diff --git a/core/endpoint_defaults_test.go b/core/endpoint_defaults_test.go index 7de035c..2986b1f 100644 --- a/core/endpoint_defaults_test.go +++ b/core/endpoint_defaults_test.go @@ -2,12 +2,19 @@ package core import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" + "os" + "path/filepath" "strconv" + "strings" "testing" + "time" "github.com/alireza0/s-ui/database/model" + singboxtls "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/option" ) @@ -82,59 +89,98 @@ func TestEndpointDefaults(t *testing.T) { } } -// TestEndpointPanelTLS builds an endpoint the way the panel does: from a stored -// model.Endpoint carrying a tls_id, whose TLS row is projected into whatever -// shape that endpoint type accepts. It proves the projection produces something -// sing-box actually takes, which a unit test over the mapping alone cannot. -func TestEndpointPanelTLS(t *testing.T) { +// TestEndpointInlineTLS starts the TLS-bearing endpoints from the `tls` object +// the panel UI now writes, in sing-box's own field names. It covers the shapes +// the OpenVPN and OpenConnect forms produce, control_wrap among them, which is +// the setting a shared panel TLS config could never express (#1253). +// +// These are started rather than only built, because the checks that made #1253 +// hard to pin down -- a server insisting on client certificates it has no CA +// for, a client with nothing to verify its peer against -- all run on start. +func TestEndpointInlineTLS(t *testing.T) { certPath, keyPath := writeTestCert(t) - - // A TLS row as the panel stores it, `enabled` and all. - tlsRow := &model.Tls{ - Name: "panel-cert", - Server: json.RawMessage(`{ - "enabled": true, - "server_name": "vpn.example.com", - "alpn": ["h2"], - "certificate_path": ` + strconv.Quote(certPath) + `, - "key_path": ` + strconv.Quote(keyPath) + ` - }`), - Client: json.RawMessage(`{ - "enabled": true, - "server_name": "vpn.example.com", - "utls": {"enabled": true, "fingerprint": "chrome"}, - "certificate_path": ` + strconv.Quote(certPath) + ` - }`), - } + quotedCert, quotedKey := strconv.Quote(certPath), strconv.Quote(keyPath) + // The form also takes the PEM itself instead of a path, which is what an + // operator pasting a .ovpn profile has. sing-box reads it as a list of + // lines, which is how the panel splits a textarea. + certLines, keyLines := pemLines(t, certPath), pemLines(t, keyPath) + generatedKey, generatedCert := generateSelfSigned(t) + staticKeyPath := writeStaticKey(t) + quotedStaticKey := strconv.Quote(staticKeyPath) testCases := []struct { name string endpoint model.Endpoint }{ + // The server presents `certificate`/`key` and verifies clients against + // `client_certificate`, which is the opposite of what those two names + // mean on the client below. {name: "openvpn-server", endpoint: model.Endpoint{ - Type: "openvpn-server", Tag: "ovs-tls", TlsId: 1, Tls: tlsRow, - Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44101,"mode":"tls","network":"udp","address":["10.8.0.1/24"]}`), + Type: "openvpn-server", Tag: "ovs-tls", + Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44101,"mode":"tls","network":"udp","address":["10.8.0.1/24"], + "tls":{"certificate_path":` + quotedCert + `,"key_path":` + quotedKey + `,"verify_client_certificate":"none"}}`), }}, - {name: "openvpn-client", endpoint: model.Endpoint{ - Type: "openvpn-client", Tag: "ovc-tls", TlsId: 1, Tls: tlsRow, - Options: json.RawMessage(`{"server":"vpn.example.com","server_port":1194,"mode":"tls","network":"udp"}`), - }}, - {name: "openconnect", endpoint: model.Endpoint{ - Type: "openconnect", Tag: "oc-tls", TlsId: 1, Tls: tlsRow, - Options: json.RawMessage(`{"server":"vpn.example.com","flavor":"anyconnect"}`), - }}, - // mutual TLS: the panel stores client CA paths as a list and names - // verification differently, so both are converted on the way in. {name: "openvpn-server-mtls", endpoint: model.Endpoint{ - Type: "openvpn-server", Tag: "ovs-mtls", TlsId: 2, - Tls: &model.Tls{Name: "panel-mtls", Server: json.RawMessage(`{ - "enabled": true, - "certificate_path": ` + strconv.Quote(certPath) + `, - "key_path": ` + strconv.Quote(keyPath) + `, - "client_authentication": "require-and-verify", - "client_certificate_path": [` + strconv.Quote(certPath) + `] - }`)}, - Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44102,"mode":"tls","network":"udp","address":["10.8.0.1/24"]}`), + Type: "openvpn-server", Tag: "ovs-mtls", + Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44102,"mode":"tls","network":"udp","address":["10.8.0.1/24"], + "tls":{"certificate_path":` + quotedCert + `,"key_path":` + quotedKey + `, + "client_certificate_path":` + quotedCert + `,"verify_client_certificate":"require"}}`), + }}, + // tls-crypt wraps the control channel in a pre-shared key. Nearly every + // real deployment uses one. + {name: "openvpn-server-control-wrap", endpoint: model.Endpoint{ + Type: "openvpn-server", Tag: "ovs-wrap", + Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44103,"mode":"tls","network":"udp","address":["10.8.0.1/24"], + "tls":{"certificate_path":` + quotedCert + `,"key_path":` + quotedKey + `, + "verify_client_certificate":"none", + "control_wrap":{"type":"tls_crypt","key_path":` + quotedStaticKey + `}}}`), + }}, + // On the client `certificate` is the CA it checks the server against. + {name: "openvpn-client", endpoint: model.Endpoint{ + Type: "openvpn-client", Tag: "ovc-tls", + Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp", + "tls":{"certificate_path":` + quotedCert + `,"version_min":"1.2"}}`), + }}, + {name: "openvpn-client-mtls", endpoint: model.Endpoint{ + Type: "openvpn-client", Tag: "ovc-mtls", + Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp", + "tls":{"certificate_path":` + quotedCert + `,"client_certificate_path":` + quotedCert + `, + "client_key_path":` + quotedKey + `,"remote_certificate_tls":"server"}}`), + }}, + // A peer fingerprint stands in for a certificate authority. + {name: "openvpn-client-fingerprint", endpoint: model.Endpoint{ + Type: "openvpn-client", Tag: "ovc-pin", + Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp", + "tls":{"peer_fingerprint":["030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dc"]}}`), + }}, + {name: "openvpn-server-inline-pem", endpoint: model.Endpoint{ + Type: "openvpn-server", Tag: "ovs-inline", + Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44104,"mode":"tls","network":"udp","address":["10.8.0.1/24"], + "tls":{"certificate":` + certLines + `,"key":` + keyLines + `,"verify_client_certificate":"none"}}`), + }}, + {name: "openvpn-client-inline-pem", endpoint: model.Endpoint{ + Type: "openvpn-client", Tag: "ovc-inline", + Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp", + "tls":{"certificate":` + certLines + `,"control_wrap":{"type":"tls_crypt","key":` + staticKeyLines(t, staticKeyPath) + `}}}`), + }}, + // What the "generate" button in the OpenVPN server form produces: the + // same self-signed pair the panel issues for inbounds, held as text + // because it exists nowhere on disk. + {name: "openvpn-server-generated-pem", endpoint: model.Endpoint{ + Type: "openvpn-server", Tag: "ovs-gen", + Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44105,"mode":"tls","network":"udp","address":["10.8.0.1/24"], + "tls":{"certificate":` + generatedCert + `,"key":` + generatedKey + `,"verify_client_certificate":"none"}}`), + }}, + // OpenConnect names the trust anchor after its role. + {name: "openconnect", endpoint: model.Endpoint{ + Type: "openconnect", Tag: "oc-tls", + Options: json.RawMessage(`{"server":"vpn.example.com","flavor":"anyconnect", + "tls":{"certificate_authority_path":` + quotedCert + `,"server_name":"vpn.example.com"}}`), + }}, + {name: "openconnect-inline-pem", endpoint: model.Endpoint{ + Type: "openconnect", Tag: "oc-inline", + Options: json.RawMessage(`{"server":"vpn.example.com","flavor":"anyconnect", + "tls":{"certificate_authority":` + certLines + `}}`), }}, } @@ -157,14 +203,154 @@ func TestEndpointPanelTLS(t *testing.T) { EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry()) var options option.Options if err = options.UnmarshalJSONContext(ctx, raw); err != nil { - t.Fatalf("parse (projected tls was %s): %v", endpointJSON, err) + t.Fatalf("parse (endpoint was %s): %v", endpointJSON, err) } instance, err := NewBox(Options{Context: ctx, Options: options}) skipIfFeatureMissing(t, err) if err != nil { - t.Fatalf("build: %v", err) + t.Fatalf("build (endpoint was %s): %v", endpointJSON, err) + } + defer instance.Close() + // A server reads its TLS credentials when it starts listening, so + // only starting it covers them. A client reads its own when it + // first dials, which needs a peer that is not there -- and + // sing-openvpn panics on the failed dial rather than returning -- + // so its credentials are covered by the build above, where + // validateTLSCredentialSet already rejects having neither a + // certificate authority nor a peer fingerprint. + if testCase.endpoint.Type != "openvpn-server" { + return + } + if err = instance.Start(); err != nil { + t.Fatalf("start (endpoint was %s): %v", endpointJSON, err) } - instance.Close() }) } } + +// pemLines reads a PEM file back as the JSON list of lines the panel's "use +// text" mode writes. +func pemLines(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return jsonLines(t, string(raw)) +} + +// generateSelfSigned mirrors ServerService.GenKeypair("tls", ...) behind the +// panel's generate button, and splits the result the way the form does, so the +// test covers what an operator actually ends up with. The service package +// cannot be imported here, since it imports this one. +func generateSelfSigned(t *testing.T) (keyLines string, certLines string) { + t.Helper() + privateKeyPem, publicKeyPem, err := singboxtls.GenerateCertificate(nil, nil, time.Now, "ovs-gen", time.Now().AddDate(0, 12, 0)) + if err != nil { + t.Fatal(err) + } + return jsonLines(t, string(privateKeyPem)), jsonLines(t, string(publicKeyPem)) +} + +func jsonLines(t *testing.T, pem string) string { + t.Helper() + encoded, err := json.Marshal(strings.Split(strings.TrimRight(pem, "\n"), "\n")) + if err != nil { + t.Fatal(err) + } + return string(encoded) +} + +// writeStaticKey writes an OpenVPN static key, which is what tls-auth and +// tls-crypt take: 256 bytes as hex between its own markers, not a PEM. +func writeStaticKey(t *testing.T) string { + t.Helper() + material := make([]byte, 256) + if _, err := rand.Read(material); err != nil { + t.Fatal(err) + } + var body strings.Builder + body.WriteString("-----BEGIN OpenVPN Static key V1-----\n") + encoded := hex.EncodeToString(material) + for offset := 0; offset < len(encoded); offset += 32 { + body.WriteString(encoded[offset:offset+32] + "\n") + } + body.WriteString("-----END OpenVPN Static key V1-----\n") + + path := filepath.Join(t.TempDir(), "static.key") + if err := os.WriteFile(path, []byte(body.String()), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func staticKeyLines(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return jsonLines(t, string(raw)) +} + +// TestOpenVPNServerClientCertificateDefault pins the behaviour that made #1253 +// so hard to place: sing-box reads an unset `verify_client_certificate` as +// "require", so a server with a perfectly good certificate and key still +// refuses to start because it has no CA for the client certificates it did not +// know it was asking for. The panel writes the choice explicitly, and the +// migration fills it in for endpoints that predate that. +// +// If this ever stops failing, the default changed upstream and the panel no +// longer has to state it. +func TestOpenVPNServerClientCertificateDefault(t *testing.T) { + certPath, keyPath := writeTestCert(t) + + start := func(t *testing.T, tlsOptions map[string]any, port int) error { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "log": map[string]any{"level": "error"}, + "endpoints": []any{map[string]any{ + "type": "openvpn-server", "tag": "ovs", "listen": "127.0.0.1", + "listen_port": port, "mode": "tls", "network": "udp", + "address": []string{"10.8.0.1/24"}, "tls": tlsOptions, + }}, + "outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}}, + }) + if err != nil { + t.Fatal(err) + } + ctx := Context(context.Background(), InboundRegistry(), OutboundRegistry(), + EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry()) + var options option.Options + if err = options.UnmarshalJSONContext(ctx, raw); err != nil { + t.Fatalf("parse: %v", err) + } + instance, err := NewBox(Options{Context: ctx, Options: options}) + skipIfFeatureMissing(t, err) + if err != nil { + t.Fatalf("build: %v", err) + } + defer instance.Close() + return instance.Start() + } + + t.Run("unstated", func(t *testing.T) { + err := start(t, map[string]any{"certificate_path": certPath, "key_path": keyPath}, 44201) + if err == nil { + t.Fatal("a server with no client CA started; the upstream default may have changed") + } + if !strings.Contains(err.Error(), "certificate-authority or peer-fingerprint") { + t.Fatalf("unexpected failure: %v", err) + } + }) + + t.Run("stated as none", func(t *testing.T) { + err := start(t, map[string]any{ + "certificate_path": certPath, "key_path": keyPath, + "verify_client_certificate": "none", + }, 44202) + if err != nil { + t.Fatalf("start: %v", err) + } + }) +} diff --git a/core/testdata/configs/12-openvpn-endpoints.json b/core/testdata/configs/12-openvpn-endpoints.json new file mode 100644 index 0000000..9527e3d --- /dev/null +++ b/core/testdata/configs/12-openvpn-endpoints.json @@ -0,0 +1,58 @@ +{ + "log": { + "level": "error" + }, + "endpoints": [ + { + "type": "openvpn-server", + "tag": "openvpn-server-in", + "listen": "127.0.0.1", + "listen_port": 42301, + "network": "udp", + "mode": "tls", + "address": ["10.8.0.1/24"], + "users": [{ "username": "u", "password": "p" }], + "tls": { + "certificate_path": "__CERT__", + "key_path": "__KEY__", + "client_certificate_path": "__CERT__", + "verify_client_certificate": "require", + "version_min": "1.2" + } + }, + { + "type": "openvpn-client", + "tag": "openvpn-client-out", + "server": "127.0.0.1", + "server_port": 42301, + "network": "udp", + "mode": "tls", + "username": "u", + "password": "p", + "tls": { + "certificate_path": "__CERT__", + "client_certificate_path": "__CERT__", + "client_key_path": "__KEY__", + "version_min": "1.2" + } + }, + { + "type": "openconnect", + "tag": "openconnect-out", + "server": "127.0.0.1:42302", + "flavor": "anyconnect", + "username": "u", + "password": "p", + "tls": { + "certificate_authority_path": "__CERT__", + "server_name": "t.example.com" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ] +} diff --git a/database/db.go b/database/db.go index 10605aa..c70c0e4 100644 --- a/database/db.go +++ b/database/db.go @@ -144,6 +144,10 @@ func InitDB(dbPath string) error { if err != nil { return err } + err = migrateEndpointTls() + if err != nil { + return err + } reportSingBox114Manual() return nil diff --git a/database/migration_endpoint_tls.go b/database/migration_endpoint_tls.go new file mode 100644 index 0000000..e1e3fcc --- /dev/null +++ b/database/migration_endpoint_tls.go @@ -0,0 +1,443 @@ +package database + +import ( + "encoding/json" + "log" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +// migratedKeyEndpointTls marks that endpoints referencing a shared TLS config +// have been given their own inline `tls` object instead. +const migratedKeyEndpointTls = "migratedEndpointTls" + +// OpenConnect and OpenVPN endpoints used to point at a panel TLS config, which +// the core projected into the shape each one accepts. The projection could only +// ever carry the handful of fields the two vocabularies share, so an OpenVPN +// endpoint set up that way never had enough to build a TLS session; those +// endpoints now hold their own `tls` object written in sing-box's own names. +// +// migrateEndpointTls runs the old projection one last time and stores the +// result inline, so an endpoint that did work keeps working and one that did +// not at least keeps the certificates the operator chose, ready to be completed +// in the endpoint's own form. +func migrateEndpointTls() error { + var flag model.Setting + err := db.Where("key = ?", migratedKeyEndpointTls).First(&flag).Error + if err == nil { + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasTable("endpoints") { + return tx.Create(&model.Setting{Key: migratedKeyEndpointTls, Value: "true"}).Error + } + // A database created after the column was dropped has nothing to move. + if tx.Migrator().HasColumn("endpoints", "tls_id") { + changed, err := inlineEndpointTls(tx) + if err != nil { + return err + } + if changed > 0 { + log.Printf("endpoint tls: moved %d shared TLS config(s) into the endpoint(s) that used them", changed) + } + } + cleaned, err := dropModeConflictingOpenVPNOptions(tx) + if err != nil { + return err + } + if cleaned > 0 { + log.Printf("endpoint tls: dropped options from %d openvpn endpoint(s) that do not belong to their mode", cleaned) + } + settled, err := settleOpenVPNClientCertificatePolicy(tx) + if err != nil { + return err + } + if settled > 0 { + log.Printf("endpoint tls: %d openvpn server(s) asked for client certificates with no CA to check them against, now set to not ask", settled) + } + return tx.Create(&model.Setting{Key: migratedKeyEndpointTls, Value: "true"}).Error + }) +} + +// The two OpenVPN modes each reject the other's options outright, and the panel +// used to offer `cipher` in both. An endpoint carrying one refuses to start, so +// the option that cannot apply is dropped rather than left to fail at runtime. +// +// static_key names a single fixed cipher; a TLS session negotiates one out of +// data_ciphers instead. The remaining names are ones a TLS-mode endpoint has no +// use for either way. +var openVPNModeConflicts = map[string][]string{ + "tls": {"cipher", "key_direction", "static_key", "static_key_path"}, + "static_key": {"data_ciphers", "data_ciphers_fallback"}, +} + +// openVPNServerTLSConflicts are rejected on a server in TLS mode on top of the +// shared list: they describe the single peer a static_key tunnel has. +var openVPNServerTLSConflicts = []string{"remote", "remote_port", "peer_address", "peer_address_ipv6"} + +func dropModeConflictingOpenVPNOptions(tx *gorm.DB) (int, error) { + var rows []endpointTlsRow + err := tx.Table("endpoints").Select("id", "type", "options"). + Where("type in ?", []string{"openvpn-server", "openvpn-client"}).Scan(&rows).Error + if err != nil { + return 0, err + } + + cleaned := 0 + for _, row := range rows { + options := make(map[string]json.RawMessage) + if len(row.Options) > 0 { + if err = json.Unmarshal(row.Options, &options); err != nil { + log.Printf("endpoint tls: skipping endpoint %d, cannot parse its options: %v", row.Id, err) + continue + } + } + // sing-box reads a missing mode as tls. + mode := "tls" + if raw, ok := options["mode"]; ok { + var stored string + if err = json.Unmarshal(raw, &stored); err == nil && stored != "" { + mode = stored + } + } + conflicting := openVPNModeConflicts[mode] + if mode == "tls" && row.Type == "openvpn-server" { + conflicting = append(append([]string{}, conflicting...), openVPNServerTLSConflicts...) + } + + dropped := false + for _, field := range conflicting { + if value, ok := options[field]; ok { + // An empty value is not what sing-box refuses, and removing it + // would rewrite rows that were fine. + if isEmptyJSON(value) || string(value) == "0" { + continue + } + delete(options, field) + dropped = true + } + } + if !dropped { + continue + } + encoded, err := json.MarshalIndent(options, "", " ") + if err != nil { + return 0, err + } + if err = tx.Table("endpoints").Where("id = ?", row.Id). + Update("options", json.RawMessage(encoded)).Error; err != nil { + return 0, err + } + cleaned++ + } + return cleaned, nil +} + +// endpointTlsRow reads the two columns the migration needs by name, since the +// endpoint model no longer declares tls_id. +type endpointTlsRow struct { + Id uint + Type string + TlsId uint + Options json.RawMessage +} + +func inlineEndpointTls(tx *gorm.DB) (int, error) { + var rows []endpointTlsRow + err := tx.Table("endpoints").Select("id", "type", "tls_id", "options"). + Where("tls_id > 0").Scan(&rows).Error + if err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + + var tlsConfigs []model.Tls + if err = tx.Find(&tlsConfigs).Error; err != nil { + return 0, err + } + byId := make(map[uint]*model.Tls, len(tlsConfigs)) + for i := range tlsConfigs { + byId[tlsConfigs[i].Id] = &tlsConfigs[i] + } + + changed := 0 + for _, row := range rows { + tlsConfig, ok := byId[row.TlsId] + if !ok { + // The config was deleted out from under the endpoint. Clearing the + // reference is all that is left to do. + if err = tx.Table("endpoints").Where("id = ?", row.Id).Update("tls_id", 0).Error; err != nil { + return 0, err + } + continue + } + projected, err := projectEndpointTLS(row.Type, tlsConfig) + if err != nil { + log.Printf("endpoint tls: skipping endpoint %d, cannot read TLS config %d: %v", row.Id, row.TlsId, err) + continue + } + + options := make(map[string]json.RawMessage) + if len(row.Options) > 0 { + if err = json.Unmarshal(row.Options, &options); err != nil { + log.Printf("endpoint tls: skipping endpoint %d, cannot parse its options: %v", row.Id, err) + continue + } + } + // An endpoint that already carries its own tls object was written after + // the change and keeps what it has. + if _, exists := options["tls"]; !exists && projected != nil { + options["tls"] = projected + changed++ + } + encoded, err := json.MarshalIndent(options, "", " ") + if err != nil { + return 0, err + } + if err = tx.Table("endpoints").Where("id = ?", row.Id). + Updates(map[string]interface{}{"options": json.RawMessage(encoded), "tls_id": 0}).Error; err != nil { + return 0, err + } + } + return changed, nil +} + +// projectEndpointTLS copies a panel TLS config into the field names the +// endpoint type uses. Whatever the endpoint has no equivalent for is left out. +func projectEndpointTLS(endpointType string, tlsConfig *model.Tls) (json.RawMessage, error) { + var source map[string]json.RawMessage + switch endpointType { + case "openvpn-server": + // A server presents a certificate, so it reads the server side. + if len(tlsConfig.Server) == 0 { + return nil, nil + } + if err := json.Unmarshal(tlsConfig.Server, &source); err != nil { + return nil, err + } + projected, err := projectTLSFields(source, openVPNServerTLSFields, openVPNServerTLSRenames) + if err != nil { + return nil, err + } + return withOpenVPNServerClientAuth(projected, source) + case "openvpn-client": + if len(tlsConfig.Client) == 0 { + return nil, nil + } + if err := json.Unmarshal(tlsConfig.Client, &source); err != nil { + return nil, err + } + return projectTLSFields(source, openVPNClientTLSFields, openVPNClientTLSRenames) + case "openconnect": + if len(tlsConfig.Client) == 0 { + return nil, nil + } + if err := json.Unmarshal(tlsConfig.Client, &source); err != nil { + return nil, err + } + return projectTLSFields(source, openConnectTLSFields, openConnectTLSRenames) + default: + return nil, nil + } +} + +// Fields carried over verbatim, keyed by the name they have in the panel's TLS +// config. Renames map a panel field onto the endpoint's name for it. +var ( + openVPNServerTLSFields = []string{ + "certificate", "certificate_path", "key", "key_path", + "client_certificate", "peer_fingerprint", "crl_path", + } + openVPNServerTLSRenames = map[string]string{ + "min_version": "version_min", + "max_version": "version_max", + } + + openVPNClientTLSFields = []string{ + "server_name", "certificate", "certificate_path", + "client_certificate", "client_certificate_path", + "client_key", "client_key_path", "peer_fingerprint", "crl_path", + } + openVPNClientTLSRenames = map[string]string{ + "min_version": "version_min", + "max_version": "version_max", + } + + openConnectTLSFields = []string{ + "server_name", "insecure", + "client_certificate", "client_certificate_path", + "client_key", "client_key_path", "peer_fingerprint", + } + // OpenConnect names the trust anchor after its role rather than reusing + // the bare `certificate` the other TLS structs use. + openConnectTLSRenames = map[string]string{ + "certificate": "certificate_authority", + "certificate_path": "certificate_authority_path", + } +) + +// withOpenVPNServerClientAuth folds in the two client-auth fields that need +// more than a rename. The panel stores client CA paths as a list, since +// sing-box inbounds accept several, while OpenVPN takes a single one; and the +// two sides describe verification with different vocabularies. +func withOpenVPNServerClientAuth(projected json.RawMessage, source map[string]json.RawMessage) (json.RawMessage, error) { + fields := make(map[string]json.RawMessage) + if projected != nil { + if err := json.Unmarshal(projected, &fields); err != nil { + return nil, err + } + } + + if raw, ok := source["client_certificate_path"]; ok && !isEmptyJSON(raw) { + var paths []string + if err := json.Unmarshal(raw, &paths); err == nil && len(paths) > 0 { + encoded, err := json.Marshal(paths[0]) + if err != nil { + return nil, err + } + fields["client_certificate_path"] = encoded + } + } + + if raw, ok := source["client_authentication"]; ok && !isEmptyJSON(raw) { + var authType string + if err := json.Unmarshal(raw, &authType); err == nil { + if verify := verifyClientCertificateFor(authType); verify != "" { + encoded, err := json.Marshal(verify) + if err != nil { + return nil, err + } + fields["verify_client_certificate"] = encoded + } + } + } + + if len(fields) == 0 { + return nil, nil + } + return json.Marshal(fields) +} + +// verifyClientCertificateFor maps sing-box's client authentication modes onto +// the three OpenVPN understands. An empty result means leave it unset. +func verifyClientCertificateFor(clientAuthentication string) string { + switch clientAuthentication { + case "require-any", "require-and-verify": + return "require" + case "request", "verify-if-given": + return "optional" + default: + return "" + } +} + +func projectTLSFields(source map[string]json.RawMessage, fields []string, renames map[string]string) (json.RawMessage, error) { + projected := make(map[string]json.RawMessage) + for _, field := range fields { + if value, ok := source[field]; ok && !isEmptyJSON(value) { + projected[field] = value + } + } + for field, renamed := range renames { + if value, ok := source[field]; ok && !isEmptyJSON(value) { + projected[renamed] = value + } + } + if len(projected) == 0 { + return nil, nil + } + return json.Marshal(projected) +} + +// isEmptyJSON reports whether a value carries nothing worth copying, so an +// unset field in the panel does not become an explicit empty one on the +// endpoint. +func isEmptyJSON(value json.RawMessage) bool { + switch string(value) { + case "", "null", `""`, "[]", "{}", "false": + return true + } + return false +} + +// sing-box reads an unset `verify_client_certificate` as "require", so an +// OpenVPN server that never mentioned client certificates still demands one and +// then refuses to start for want of a CA to check it against: +// +// tls mode: either certificate-authority or peer-fingerprint must be configured +// +// settleOpenVPNClientCertificatePolicy writes the choice such a server was +// already making by omission. A server that names a CA, pins a fingerprint, or +// states a policy of its own is left alone: only the unstated case is filled in, +// so nothing an operator actually asked for is weakened. +func settleOpenVPNClientCertificatePolicy(tx *gorm.DB) (int, error) { + var rows []endpointTlsRow + err := tx.Table("endpoints").Select("id", "type", "options"). + Where("type = ?", "openvpn-server").Scan(&rows).Error + if err != nil { + return 0, err + } + + settled := 0 + for _, row := range rows { + options := make(map[string]json.RawMessage) + if len(row.Options) > 0 { + if err = json.Unmarshal(row.Options, &options); err != nil { + continue + } + } + if mode, ok := options["mode"]; ok && !isEmptyJSON(mode) && string(mode) != `"tls"` { + continue + } + rawTLS, ok := options["tls"] + if !ok || isEmptyJSON(rawTLS) { + // No TLS block at all: the endpoint has no certificate either, and + // giving it a policy would not make it start. + continue + } + var tlsOptions map[string]json.RawMessage + if err = json.Unmarshal(rawTLS, &tlsOptions); err != nil { + continue + } + if _, stated := tlsOptions["verify_client_certificate"]; stated { + continue + } + if hasAnyOf(tlsOptions, "client_certificate", "client_certificate_path", "peer_fingerprint") { + continue + } + + tlsOptions["verify_client_certificate"] = json.RawMessage(`"none"`) + encodedTLS, err := json.Marshal(tlsOptions) + if err != nil { + return 0, err + } + options["tls"] = encodedTLS + encoded, err := json.MarshalIndent(options, "", " ") + if err != nil { + return 0, err + } + if err = tx.Table("endpoints").Where("id = ?", row.Id). + Update("options", json.RawMessage(encoded)).Error; err != nil { + return 0, err + } + settled++ + } + return settled, nil +} + +func hasAnyOf(fields map[string]json.RawMessage, names ...string) bool { + for _, name := range names { + if value, ok := fields[name]; ok && !isEmptyJSON(value) { + return true + } + } + return false +} diff --git a/database/migration_endpoint_tls_test.go b/database/migration_endpoint_tls_test.go new file mode 100644 index 0000000..41f4cb8 --- /dev/null +++ b/database/migration_endpoint_tls_test.go @@ -0,0 +1,434 @@ +package database + +import ( + "encoding/json" + "testing" + + "github.com/alireza0/s-ui/database/model" +) + +// legacyEndpoint mirrors the endpoints table as it was while endpoints pointed +// at a shared TLS config, so the migration has a column to read. +type legacyEndpoint struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Type string + Tag string + TlsId uint + Options json.RawMessage + Ext json.RawMessage +} + +func (legacyEndpoint) TableName() string { return "endpoints" } + +func openEndpointTestDB(t *testing.T) { + t.Helper() + openTestDB(t) + if err := db.AutoMigrate(&legacyEndpoint{}); err != nil { + t.Fatal(err) + } +} + +func endpointOptions(t *testing.T, id uint) map[string]any { + t.Helper() + var stored legacyEndpoint + if err := db.Where("id = ?", id).First(&stored).Error; err != nil { + t.Fatal(err) + } + if stored.TlsId != 0 { + t.Errorf("tls_id = %d, want it cleared", stored.TlsId) + } + var options map[string]any + if err := json.Unmarshal(stored.Options, &options); err != nil { + t.Fatal(err) + } + return options +} + +func TestMigrateEndpointTlsOpenVPNClient(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&model.Tls{ + Name: "vpn", + Client: json.RawMessage(`{ + "enabled": true, + "server_name": "vpn.example.com", + "certificate_path": "/etc/ssl/ca.crt", + "client_certificate_path": "/etc/ssl/client.crt", + "client_key_path": "/etc/ssl/client.key", + "min_version": "1.2" + }`), + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-client", + Tag: "ovpn", + TlsId: 1, + Options: json.RawMessage(`{"server":"vpn.example.com","server_port":1194,"mode":"tls"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + options := endpointOptions(t, 1) + if options["server"] != "vpn.example.com" { + t.Errorf("server was lost: %v", options) + } + tls, ok := options["tls"].(map[string]any) + if !ok { + t.Fatalf("no inline tls object: %v", options) + } + if tls["certificate_path"] != "/etc/ssl/ca.crt" { + t.Errorf("certificate_path = %v", tls["certificate_path"]) + } + if tls["client_key_path"] != "/etc/ssl/client.key" { + t.Errorf("client_key_path = %v", tls["client_key_path"]) + } + // The panel calls it min_version; OpenVPN calls it version_min. + if tls["version_min"] != "1.2" { + t.Errorf("version_min = %v", tls["version_min"]) + } + // `enabled` has no counterpart and sing-box rejects unknown keys. + if _, exists := tls["enabled"]; exists { + t.Errorf("enabled was carried over: %v", tls) + } +} + +func TestMigrateEndpointTlsOpenVPNServerClientAuth(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&model.Tls{ + Name: "vpn", + Server: json.RawMessage(`{ + "enabled": true, + "certificate_path": "/etc/ssl/server.crt", + "key_path": "/etc/ssl/server.key", + "client_certificate_path": ["/etc/ssl/clients-ca.crt", "/etc/ssl/other-ca.crt"], + "client_authentication": "require-and-verify" + }`), + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-server", + Tag: "ovpn-srv", + TlsId: 1, + Options: json.RawMessage(`{"listen":"::","listen_port":1194,"mode":"tls"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + tls, ok := endpointOptions(t, 1)["tls"].(map[string]any) + if !ok { + t.Fatal("no inline tls object") + } + // OpenVPN takes a single client CA where a sing-box inbound takes a list. + if tls["client_certificate_path"] != "/etc/ssl/clients-ca.crt" { + t.Errorf("client_certificate_path = %v", tls["client_certificate_path"]) + } + if tls["verify_client_certificate"] != "require" { + t.Errorf("verify_client_certificate = %v", tls["verify_client_certificate"]) + } +} + +func TestMigrateEndpointTlsOpenConnectRenamesTrustAnchor(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&model.Tls{ + Name: "oc", + Client: json.RawMessage(`{"enabled":true,"certificate_path":"/etc/ssl/ca.crt","insecure":true}`), + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&legacyEndpoint{ + Type: "openconnect", + Tag: "oc", + TlsId: 1, + Options: json.RawMessage(`{"server":"vpn.example.com"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + tls, ok := endpointOptions(t, 1)["tls"].(map[string]any) + if !ok { + t.Fatal("no inline tls object") + } + if tls["certificate_authority_path"] != "/etc/ssl/ca.crt" { + t.Errorf("certificate_authority_path = %v", tls["certificate_authority_path"]) + } + if tls["insecure"] != true { + t.Errorf("insecure = %v", tls["insecure"]) + } +} + +func TestMigrateEndpointTlsKeepsExistingInlineTls(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&model.Tls{ + Name: "vpn", + Client: json.RawMessage(`{"enabled":true,"certificate_path":"/etc/ssl/template-ca.crt"}`), + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-client", + Tag: "ovpn", + TlsId: 1, + Options: json.RawMessage(`{"server":"vpn.example.com","tls":{"certificate_path":"/etc/ssl/own-ca.crt"}}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + tls := endpointOptions(t, 1)["tls"].(map[string]any) + if tls["certificate_path"] != "/etc/ssl/own-ca.crt" { + t.Errorf("the endpoint's own tls block was overwritten: %v", tls) + } +} + +func TestMigrateEndpointTlsClearsDanglingReference(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-client", + Tag: "ovpn", + TlsId: 7, + Options: json.RawMessage(`{"server":"vpn.example.com"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + options := endpointOptions(t, 1) + if _, exists := options["tls"]; exists { + t.Errorf("a missing TLS config produced a tls block: %v", options) + } +} + +func TestMigrateEndpointTlsLeavesOtherTypesAlone(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&model.Tls{ + Name: "vpn", + Client: json.RawMessage(`{"enabled":true,"certificate_path":"/etc/ssl/ca.crt"}`), + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&legacyEndpoint{ + Type: "wireguard", + Tag: "wg", + TlsId: 1, + Options: json.RawMessage(`{"listen_port":51820}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + options := endpointOptions(t, 1) + if _, exists := options["tls"]; exists { + t.Errorf("wireguard was given a tls block: %v", options) + } +} + +func TestMigrateEndpointTlsRunsOnce(t *testing.T) { + openEndpointTestDB(t) + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + // A second run must not fail on the flag it wrote itself. + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + var flag model.Setting + if err := db.Where("key = ?", migratedKeyEndpointTls).First(&flag).Error; err != nil { + t.Fatal(err) + } +} + +// A database created after the tls_id column was dropped has no column to read, +// which must not stop the panel from starting. +func TestMigrateEndpointTlsWithoutColumn(t *testing.T) { + openTestDB(t) + if err := db.AutoMigrate(&model.Endpoint{}); err != nil { + t.Fatal(err) + } + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } +} + +func TestMigrateEndpointTlsDropsCipherInTlsMode(t *testing.T) { + openEndpointTestDB(t) + // The panel used to offer `cipher` in both modes, and sing-box rejects it + // in TLS mode, so the endpoint never started. + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-client", + Tag: "ovpn", + Options: json.RawMessage(`{"server":"vpn.example.com","mode":"tls","cipher":"AES-256-GCM","auth":"SHA256"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + options := endpointOptions(t, 1) + if _, exists := options["cipher"]; exists { + t.Errorf("cipher survived in tls mode: %v", options) + } + if options["auth"] != "SHA256" { + t.Errorf("auth was dropped with it: %v", options) + } +} + +func TestMigrateEndpointTlsKeepsCipherInStaticKeyMode(t *testing.T) { + openEndpointTestDB(t) + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-client", + Tag: "ovpn", + Options: json.RawMessage(`{"server":"vpn.example.com","mode":"static_key","cipher":"AES-256-CBC","static_key_path":"/etc/openvpn/static.key"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + options := endpointOptions(t, 1) + if options["cipher"] != "AES-256-CBC" { + t.Errorf("cipher = %v, want it kept in static_key mode", options["cipher"]) + } + if options["static_key_path"] != "/etc/openvpn/static.key" { + t.Errorf("static_key_path = %v", options["static_key_path"]) + } +} + +func TestMigrateEndpointTlsDropsPeerAddressOnTlsServer(t *testing.T) { + openEndpointTestDB(t) + // In TLS mode a server pushes addresses to whichever clients connect, so + // sing-box refuses the single peer a static_key tunnel would name. + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-server", + Tag: "ovpn-srv", + Options: json.RawMessage(`{"listen":"::","listen_port":1194,"mode":"tls","peer_address":"10.8.0.2","address":["10.8.0.1/24"]}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + options := endpointOptions(t, 1) + if _, exists := options["peer_address"]; exists { + t.Errorf("peer_address survived on a tls-mode server: %v", options) + } +} + +func TestMigrateEndpointTlsLeavesUntouchedRowsAlone(t *testing.T) { + openEndpointTestDB(t) + original := `{"server":"vpn.example.com","mode":"tls","auth":"SHA256"}` + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-client", + Tag: "ovpn", + Options: json.RawMessage(original), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + var stored legacyEndpoint + if err := db.Where("id = ?", 1).First(&stored).Error; err != nil { + t.Fatal(err) + } + if string(stored.Options) != original { + t.Errorf("a row with nothing to fix was rewritten:\n%s", stored.Options) + } +} + +func TestMigrateEndpointTlsSettlesClientCertificatePolicy(t *testing.T) { + openEndpointTestDB(t) + // sing-box reads a missing verify_client_certificate as "require", so this + // server demands certificates it has no CA to check, and refuses to start. + if err := db.Create(&legacyEndpoint{ + Type: "openvpn-server", + Tag: "ovpn-srv", + Options: json.RawMessage(`{"listen":"::","listen_port":1194,"mode":"tls","address":["10.8.0.1/24"],"tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key"}}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + tls, ok := endpointOptions(t, 1)["tls"].(map[string]any) + if !ok { + t.Fatal("tls block went missing") + } + if tls["verify_client_certificate"] != "none" { + t.Errorf("verify_client_certificate = %v, want none", tls["verify_client_certificate"]) + } + if tls["certificate_path"] != "/etc/ssl/s.crt" { + t.Errorf("the certificate was lost: %v", tls) + } +} + +func TestMigrateEndpointTlsKeepsStatedClientCertificatePolicy(t *testing.T) { + openEndpointTestDB(t) + for _, endpoint := range []legacyEndpoint{ + // Asked for client certificates in so many words. + {Type: "openvpn-server", Tag: "stated", Options: json.RawMessage( + `{"mode":"tls","tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key","verify_client_certificate":"require","client_certificate_path":"/etc/ssl/ca.crt"}}`)}, + // Has a CA, so the "require" it gets by default is what it wants. + {Type: "openvpn-server", Tag: "hasca", Options: json.RawMessage( + `{"mode":"tls","tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key","client_certificate_path":"/etc/ssl/ca.crt"}}`)}, + // Pins the peer instead of naming a CA, which satisfies sing-box too. + {Type: "openvpn-server", Tag: "pinned", Options: json.RawMessage( + `{"mode":"tls","tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key","peer_fingerprint":["ab"]}}`)}, + // static_key mode never reaches the TLS credential checks. + {Type: "openvpn-server", Tag: "static", Options: json.RawMessage( + `{"mode":"static_key","static_key_path":"/etc/openvpn/static.key","tls":{"certificate_path":"/etc/ssl/s.crt"}}`)}, + } { + if err := db.Create(&endpoint).Error; err != nil { + t.Fatal(err) + } + } + + if err := migrateEndpointTls(); err != nil { + t.Fatal(err) + } + + for id := uint(1); id <= 4; id++ { + tls, ok := endpointOptions(t, id)["tls"].(map[string]any) + if !ok { + t.Fatalf("endpoint %d lost its tls block", id) + } + want := any(nil) + if id == 1 { + want = "require" + } + if tls["verify_client_certificate"] != want { + t.Errorf("endpoint %d: verify_client_certificate = %v, want %v", id, tls["verify_client_certificate"], want) + } + } +} diff --git a/database/model/endpoint_tls.go b/database/model/endpoint_tls.go deleted file mode 100644 index 4fe5882..0000000 --- a/database/model/endpoint_tls.go +++ /dev/null @@ -1,211 +0,0 @@ -package model - -import "encoding/json" - -// OpenConnect and OpenVPN do not use sing-box's standard TLS options. They each -// define their own struct with a different field set, and sing-box rejects -// unknown fields, so a panel TLS config cannot be handed to them as-is: even -// `enabled`, which every stored config carries, fails to parse. -// -// projectEndpointTLS copies across the fields the endpoint actually accepts. -// Whatever the endpoint has no equivalent for is left out rather than failing -// the whole config: an endpoint is one row among many, and refusing to render -// the config would stop the core from starting at all. The UI is what tells the -// operator which TLS configs suit which endpoint, via unsupportedEndpointTLS. -func projectEndpointTLS(endpointType string, tlsConfig *Tls) (json.RawMessage, error) { - var source map[string]json.RawMessage - switch endpointType { - case "openvpn-server": - // A server presents a certificate, so it reads the server side. - if len(tlsConfig.Server) == 0 { - return nil, nil - } - if err := json.Unmarshal(tlsConfig.Server, &source); err != nil { - return nil, err - } - projected, err := projectTLSFields(source, openVPNServerTLSFields, openVPNServerTLSRenames) - if err != nil { - return nil, err - } - return withOpenVPNServerClientAuth(projected, source) - case "openvpn-client": - if len(tlsConfig.Client) == 0 { - return nil, nil - } - if err := json.Unmarshal(tlsConfig.Client, &source); err != nil { - return nil, err - } - return projectTLSFields(source, openVPNClientTLSFields, openVPNClientTLSRenames) - case "openconnect": - if len(tlsConfig.Client) == 0 { - return nil, nil - } - if err := json.Unmarshal(tlsConfig.Client, &source); err != nil { - return nil, err - } - return projectTLSFields(source, openConnectTLSFields, openConnectTLSRenames) - default: - return nil, nil - } -} - -// Fields carried over verbatim, keyed by the name they have in the panel's TLS -// config. Renames map a panel field onto the endpoint's name for it. -var ( - openVPNServerTLSFields = []string{ - "certificate", "certificate_path", "key", "key_path", - "client_certificate", "peer_fingerprint", "crl_path", - } - openVPNServerTLSRenames = map[string]string{ - "min_version": "version_min", - "max_version": "version_max", - } - - openVPNClientTLSFields = []string{ - "server_name", "certificate", "certificate_path", - "client_certificate", "client_certificate_path", - "client_key", "client_key_path", "peer_fingerprint", "crl_path", - } - openVPNClientTLSRenames = map[string]string{ - "min_version": "version_min", - "max_version": "version_max", - } - - openConnectTLSFields = []string{ - "server_name", "insecure", - "client_certificate", "client_certificate_path", - "client_key", "client_key_path", "peer_fingerprint", - } - // OpenConnect names the trust anchor after its role rather than reusing - // the bare `certificate` the other TLS structs use. - openConnectTLSRenames = map[string]string{ - "certificate": "certificate_authority", - "certificate_path": "certificate_authority_path", - } -) - -// withOpenVPNServerClientAuth folds in the two client-auth fields that need -// more than a rename. The panel stores client CA paths as a list, since -// sing-box inbounds accept several, while OpenVPN takes a single one; and the -// two sides describe verification with different vocabularies. -func withOpenVPNServerClientAuth(projected json.RawMessage, source map[string]json.RawMessage) (json.RawMessage, error) { - fields := make(map[string]json.RawMessage) - if projected != nil { - if err := json.Unmarshal(projected, &fields); err != nil { - return nil, err - } - } - - if raw, ok := source["client_certificate_path"]; ok && !isEmptyJSON(raw) { - var paths []string - if err := json.Unmarshal(raw, &paths); err == nil && len(paths) > 0 { - encoded, err := json.Marshal(paths[0]) - if err != nil { - return nil, err - } - fields["client_certificate_path"] = encoded - } - } - - if raw, ok := source["client_authentication"]; ok && !isEmptyJSON(raw) { - var authType string - if err := json.Unmarshal(raw, &authType); err == nil { - if verify := verifyClientCertificateFor(authType); verify != "" { - encoded, err := json.Marshal(verify) - if err != nil { - return nil, err - } - fields["verify_client_certificate"] = encoded - } - } - } - - if len(fields) == 0 { - return nil, nil - } - return json.Marshal(fields) -} - -// verifyClientCertificateFor maps sing-box's client authentication modes onto -// the three OpenVPN understands. An empty result means leave it unset. -func verifyClientCertificateFor(clientAuthentication string) string { - switch clientAuthentication { - case "require-any", "require-and-verify": - return "require" - case "request", "verify-if-given": - return "optional" - default: - return "" - } -} - -func projectTLSFields(source map[string]json.RawMessage, fields []string, renames map[string]string) (json.RawMessage, error) { - projected := make(map[string]json.RawMessage) - for _, field := range fields { - if value, ok := source[field]; ok && !isEmptyJSON(value) { - projected[field] = value - } - } - for field, renamed := range renames { - if value, ok := source[field]; ok && !isEmptyJSON(value) { - projected[renamed] = value - } - } - if len(projected) == 0 { - // sing-box treats an empty object as no TLS options at all, so there is - // nothing to gain from emitting one. - return nil, nil - } - return json.Marshal(projected) -} - -// isEmptyJSON reports whether a value carries nothing worth copying, so an -// unset field in the panel does not become an explicit empty one on the -// endpoint. -func isEmptyJSON(value json.RawMessage) bool { - switch string(value) { - case "", "null", `""`, "[]", "{}", "false": - return true - } - return false -} - -// unsupportedEndpointTLS lists the panel TLS fields that carry real behaviour -// but have no equivalent on this endpoint type, so the UI can warn before an -// operator picks a config that will not do what they expect. Fields that simply -// do not apply to a VPN tunnel (alpn, utls, fragmentation) are not listed. -func unsupportedEndpointTLS(endpointType string, tlsConfig *Tls) ([]string, error) { - var ( - source map[string]json.RawMessage - raw json.RawMessage - checked []string - ) - switch endpointType { - case "openvpn-server": - raw = tlsConfig.Server - checked = []string{"certificate_provider", "acme", "reality", "ech", "client_certificate_public_key_sha256"} - case "openvpn-client": - raw = tlsConfig.Client - // OpenVPN always verifies its peer, so a config relying on `insecure` - // will not connect without a certificate authority. - checked = []string{"insecure", "reality", "ech", "certificate_public_key_sha256"} - case "openconnect": - raw = tlsConfig.Client - checked = []string{"reality", "ech", "certificate_public_key_sha256"} - default: - return nil, nil - } - if len(raw) == 0 { - return nil, nil - } - if err := json.Unmarshal(raw, &source); err != nil { - return nil, err - } - var unsupported []string - for _, field := range checked { - if value, ok := source[field]; ok && !isEmptyJSON(value) { - unsupported = append(unsupported, field) - } - } - return unsupported, nil -} diff --git a/database/model/endpoint_tls_test.go b/database/model/endpoint_tls_test.go deleted file mode 100644 index 3bc5aa6..0000000 --- a/database/model/endpoint_tls_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package model - -import ( - "encoding/json" - "slices" - "testing" -) - -func decode(t *testing.T, raw json.RawMessage) map[string]any { - t.Helper() - if raw == nil { - return nil - } - var decoded map[string]any - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - return decoded -} - -// A panel TLS config as the UI writes it: the server side carries `enabled`, -// which is exactly the field the endpoint TLS structs reject. -func panelTLS() *Tls { - return &Tls{ - Name: "example", - Server: json.RawMessage(`{ - "enabled": true, - "server_name": "vpn.example.com", - "alpn": ["h2"], - "min_version": "1.2", - "certificate_path": "/etc/s-ui/cert.pem", - "key_path": "/etc/s-ui/key.pem", - "client_certificate_path": ["/etc/s-ui/client-ca.pem"] - }`), - Client: json.RawMessage(`{ - "enabled": true, - "server_name": "vpn.example.com", - "insecure": true, - "utls": {"enabled": true, "fingerprint": "chrome"}, - "certificate_path": "/etc/s-ui/ca.pem", - "client_certificate_path": "/etc/s-ui/client.pem", - "client_key_path": "/etc/s-ui/client.key" - }`), - } -} - -func TestProjectEndpointTLSOpenVPNServer(t *testing.T) { - projected, err := projectEndpointTLS("openvpn-server", panelTLS()) - if err != nil { - t.Fatal(err) - } - got := decode(t, projected) - - if got["certificate_path"] != "/etc/s-ui/cert.pem" || got["key_path"] != "/etc/s-ui/key.pem" { - t.Errorf("certificate and key must carry over, got %v", got) - } - if got["version_min"] != "1.2" { - t.Errorf("min_version should be renamed to version_min, got %v", got["version_min"]) - } - // These are the ones sing-box would reject outright. - for _, dropped := range []string{"enabled", "alpn", "min_version", "server_name"} { - if _, ok := got[dropped]; ok { - t.Errorf("%q must not reach an openvpn-server, got %v", dropped, got) - } - } -} - -func TestProjectEndpointTLSOpenVPNClient(t *testing.T) { - projected, err := projectEndpointTLS("openvpn-client", panelTLS()) - if err != nil { - t.Fatal(err) - } - got := decode(t, projected) - - if got["server_name"] != "vpn.example.com" { - t.Errorf("server_name must carry over, got %v", got) - } - if got["certificate_path"] != "/etc/s-ui/ca.pem" { - t.Errorf("the CA path must carry over, got %v", got) - } - if got["client_certificate_path"] != "/etc/s-ui/client.pem" || got["client_key_path"] != "/etc/s-ui/client.key" { - t.Errorf("client credentials must carry over, got %v", got) - } - // OpenVPN has no insecure switch and no use for a utls fingerprint. - for _, dropped := range []string{"enabled", "insecure", "utls"} { - if _, ok := got[dropped]; ok { - t.Errorf("%q must not reach an openvpn-client, got %v", dropped, got) - } - } -} - -// OpenConnect names the trust anchor differently from the other TLS structs. -func TestProjectEndpointTLSOpenConnect(t *testing.T) { - projected, err := projectEndpointTLS("openconnect", panelTLS()) - if err != nil { - t.Fatal(err) - } - got := decode(t, projected) - - if got["certificate_authority_path"] != "/etc/s-ui/ca.pem" { - t.Errorf("certificate_path should become certificate_authority_path, got %v", got) - } - if _, ok := got["certificate_path"]; ok { - t.Errorf("the original certificate_path must not survive, got %v", got) - } - if got["insecure"] != true { - t.Errorf("openconnect supports insecure, got %v", got) - } - for _, dropped := range []string{"enabled", "utls"} { - if _, ok := got[dropped]; ok { - t.Errorf("%q must not reach an openconnect endpoint, got %v", dropped, got) - } - } -} - -// An endpoint type that has no TLS of its own must not be handed one. -func TestProjectEndpointTLSIgnoresOtherTypes(t *testing.T) { - for _, endpointType := range []string{"wireguard", "warp", "tailscale"} { - projected, err := projectEndpointTLS(endpointType, panelTLS()) - if err != nil { - t.Fatal(err) - } - if projected != nil { - t.Errorf("%s should get no tls block, got %s", endpointType, projected) - } - } -} - -// A TLS config with nothing the endpoint can use should produce no block at -// all, since sing-box reads an empty object as absent anyway. -func TestProjectEndpointTLSEmptyResult(t *testing.T) { - tlsConfig := &Tls{Client: json.RawMessage(`{"enabled": true, "alpn": ["h2"]}`)} - projected, err := projectEndpointTLS("openvpn-client", tlsConfig) - if err != nil { - t.Fatal(err) - } - if projected != nil { - t.Errorf("expected no tls block, got %s", projected) - } -} - -func TestUnsupportedEndpointTLS(t *testing.T) { - realityTLS := &Tls{ - Server: json.RawMessage(`{"enabled": true, "reality": {"enabled": true}, "certificate_path": "/c.pem"}`), - Client: json.RawMessage(`{"enabled": true, "insecure": true, "utls": {"enabled": true}}`), - } - - serverUnsupported, err := unsupportedEndpointTLS("openvpn-server", realityTLS) - if err != nil { - t.Fatal(err) - } - if !slices.Contains(serverUnsupported, "reality") { - t.Errorf("reality should be reported for openvpn-server, got %v", serverUnsupported) - } - - clientUnsupported, err := unsupportedEndpointTLS("openvpn-client", realityTLS) - if err != nil { - t.Fatal(err) - } - if !slices.Contains(clientUnsupported, "insecure") { - t.Errorf("insecure should be reported for openvpn-client, got %v", clientUnsupported) - } - - // openconnect does support insecure, so it must not be flagged there. - openconnectUnsupported, err := unsupportedEndpointTLS("openconnect", realityTLS) - if err != nil { - t.Fatal(err) - } - if slices.Contains(openconnectUnsupported, "insecure") { - t.Errorf("openconnect supports insecure, got %v", openconnectUnsupported) - } -} - -// A referenced TLS config is an explicit choice by the operator, so it must -// replace any TLS fields the endpoint still carries inline rather than being -// quietly overwritten by them. -func TestEndpointMarshalTLSPrecedence(t *testing.T) { - endpoint := Endpoint{ - Type: "openconnect", - Tag: "oc", - TlsId: 1, - Tls: &Tls{Client: json.RawMessage(`{"enabled": true, "certificate_path": "/from-panel.pem"}`)}, - Options: json.RawMessage(`{"server":"vpn.example.com","tls":{"certificate_authority_path":"/inline.pem"}}`), - } - - raw, err := endpoint.MarshalJSON() - if err != nil { - t.Fatal(err) - } - got := decode(t, raw) - tlsBlock, ok := got["tls"].(map[string]any) - if !ok { - t.Fatalf("expected a tls block, got %v", got) - } - if tlsBlock["certificate_authority_path"] != "/from-panel.pem" { - t.Errorf("the referenced config must win over the inline one, got %v", tlsBlock) - } -} - -// Without a referenced config the inline TLS the UI wrote must survive intact. -func TestEndpointMarshalKeepsInlineTLS(t *testing.T) { - endpoint := Endpoint{ - Type: "openconnect", - Tag: "oc", - Options: json.RawMessage(`{"server":"vpn.example.com","tls":{"certificate_authority_path":"/inline.pem"}}`), - } - - raw, err := endpoint.MarshalJSON() - if err != nil { - t.Fatal(err) - } - got := decode(t, raw) - tlsBlock, ok := got["tls"].(map[string]any) - if !ok { - t.Fatalf("expected the inline tls block to survive, got %v", got) - } - if tlsBlock["certificate_authority_path"] != "/inline.pem" { - t.Errorf("inline tls must be untouched, got %v", tlsBlock) - } -} - -// The panel keeps client CA paths as a list because sing-box inbounds accept -// several, but OpenVPN takes one, and the two sides name verification modes -// differently. Both need converting, not just copying. -func TestProjectEndpointTLSOpenVPNServerClientAuth(t *testing.T) { - tlsConfig := &Tls{Server: json.RawMessage(`{ - "enabled": true, - "certificate_path": "/cert.pem", - "key_path": "/key.pem", - "client_authentication": "require-and-verify", - "client_certificate_path": ["/client-ca.pem", "/second-ca.pem"] - }`)} - - projected, err := projectEndpointTLS("openvpn-server", tlsConfig) - if err != nil { - t.Fatal(err) - } - got := decode(t, projected) - - if got["client_certificate_path"] != "/client-ca.pem" { - t.Errorf("the list should collapse to its first entry, got %v", got["client_certificate_path"]) - } - if got["verify_client_certificate"] != "require" { - t.Errorf("require-and-verify should become require, got %v", got["verify_client_certificate"]) - } - if got["client_authentication"] != nil { - t.Errorf("the sing-box spelling must not leak through, got %v", got) - } -} - -func TestVerifyClientCertificateFor(t *testing.T) { - for clientAuthentication, want := range map[string]string{ - "require-and-verify": "require", - "require-any": "require", - "verify-if-given": "optional", - "request": "optional", - "no": "", - "": "", - } { - if got := verifyClientCertificateFor(clientAuthentication); got != want { - t.Errorf("%q: want %q, got %q", clientAuthentication, want, got) - } - } -} - -// The TLS editor can hold the client credentials as inline PEM instead of -// paths, so the projection has to carry that form across too. -func TestProjectEndpointTLSInlineClientCerts(t *testing.T) { - tlsConfig := &Tls{ - Server: json.RawMessage(`{ - "enabled": true, - "certificate": ["-----BEGIN CERTIFICATE-----", "srv", "-----END CERTIFICATE-----"], - "client_certificate": ["-----BEGIN CERTIFICATE-----", "ca", "-----END CERTIFICATE-----"] - }`), - Client: json.RawMessage(`{ - "enabled": true, - "client_certificate": ["-----BEGIN CERTIFICATE-----", "cli", "-----END CERTIFICATE-----"], - "client_key": ["-----BEGIN PRIVATE KEY-----", "k", "-----END PRIVATE KEY-----"] - }`), - } - - server := decode(t, mustProject(t, "openvpn-server", tlsConfig)) - if server["client_certificate"] == nil { - t.Errorf("inline client CA must reach an openvpn-server, got %v", server) - } - - for _, endpointType := range []string{"openvpn-client", "openconnect"} { - got := decode(t, mustProject(t, endpointType, tlsConfig)) - if got["client_certificate"] == nil || got["client_key"] == nil { - t.Errorf("%s: inline client credentials must carry over, got %v", endpointType, got) - } - } -} - -func mustProject(t *testing.T, endpointType string, tlsConfig *Tls) json.RawMessage { - t.Helper() - projected, err := projectEndpointTLS(endpointType, tlsConfig) - if err != nil { - t.Fatal(err) - } - if projected == nil { - t.Fatalf("%s: expected a tls block", endpointType) - } - return projected -} diff --git a/database/model/endpoints.go b/database/model/endpoints.go index 4c717d9..8d73264 100644 --- a/database/model/endpoints.go +++ b/database/model/endpoints.go @@ -4,16 +4,18 @@ import ( "encoding/json" ) +// Endpoint has no tls_id, unlike Inbound and Service. OpenConnect and OpenVPN +// each define their own TLS options with their own field names, their own +// vocabulary (an OpenVPN server names the client CA `client_certificate`) and +// settings the panel's TLS config cannot express at all, control_wrap among +// them. A shared template projected onto them was misleading at best and, for +// OpenVPN, never produced a config sing-box would accept. Those endpoints carry +// their own `tls` object in Options instead, written in sing-box's own names. type Endpoint struct { Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Type string `json:"type" form:"type"` Tag string `json:"tag" form:"tag" gorm:"unique"` - // Foreign key to tls table. Only the endpoint types that terminate or - // present TLS themselves use it; see projectEndpointTLS. - TlsId uint `json:"tls_id" form:"tls_id"` - Tls *Tls `json:"tls" form:"tls" gorm:"foreignKey:TlsId;references:Id"` - Options json.RawMessage `json:"-" form:"-"` Ext json.RawMessage `json:"ext" form:"ext"` } @@ -34,11 +36,10 @@ func (o *Endpoint) UnmarshalJSON(data []byte) error { delete(raw, "type") o.Tag = raw["tag"].(string) delete(raw, "tag") - if val, exists := raw["tls_id"].(float64); exists { - o.TlsId = uint(val) - } + // Dropped rather than stored: an older panel sent tls_id alongside the + // endpoint, and sing-box rejects the unknown key. `tls` is kept, since it + // is now the endpoint's own. delete(raw, "tls_id") - delete(raw, "tls") o.Ext, _ = json.MarshalIndent(raw["ext"], "", " ") delete(raw, "ext") @@ -70,19 +71,5 @@ func (o Endpoint) MarshalJSON() ([]byte, error) { } } - // A referenced TLS config is the explicit choice, so it replaces whatever - // TLS fields the endpoint carries inline rather than losing to them. - if o.Tls != nil { - projected, err := projectEndpointTLS(o.Type, o.Tls) - if err != nil { - return nil, err - } - if projected != nil { - combined["tls"] = projected - } else { - delete(combined, "tls") - } - } - return json.Marshal(combined) } diff --git a/database/model/endpoints_test.go b/database/model/endpoints_test.go new file mode 100644 index 0000000..ed18fab --- /dev/null +++ b/database/model/endpoints_test.go @@ -0,0 +1,96 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestEndpointMarshalKeepsInlineTLS(t *testing.T) { + // OpenVPN and OpenConnect write their TLS options in their own field names, + // so the panel stores them as the endpoint gave them and hands them over + // untouched. + inline := `{ + "server": "example.com", + "server_port": 1194, + "mode": "tls", + "tls": { + "certificate_path": "/etc/openvpn/ca.crt", + "control_wrap": {"type": "tls_crypt", "key_path": "/etc/openvpn/tc.key"} + } + }` + var endpoint Endpoint + if err := endpoint.UnmarshalJSON([]byte(`{"type":"openvpn-client","tag":"ovpn",` + inline[1:])); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + encoded, err := endpoint.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]json.RawMessage + if err = json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("decode: %v", err) + } + + var tls struct { + CertificatePath string `json:"certificate_path"` + ControlWrap struct { + Type string `json:"type"` + KeyPath string `json:"key_path"` + } `json:"control_wrap"` + } + if err = json.Unmarshal(got["tls"], &tls); err != nil { + t.Fatalf("decode tls: %v", err) + } + if tls.CertificatePath != "/etc/openvpn/ca.crt" { + t.Errorf("certificate_path = %q", tls.CertificatePath) + } + if tls.ControlWrap.Type != "tls_crypt" || tls.ControlWrap.KeyPath != "/etc/openvpn/tc.key" { + t.Errorf("control_wrap = %+v", tls.ControlWrap) + } +} + +func TestEndpointUnmarshalDropsTlsId(t *testing.T) { + // A panel built before endpoints carried their own TLS still sends tls_id. + // Keeping it would put an unknown key in front of sing-box, which refuses + // the whole config over it. + var endpoint Endpoint + err := endpoint.UnmarshalJSON([]byte(`{"type":"openconnect","tag":"oc","tls_id":3,"server":"vpn.example.com"}`)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + encoded, err := endpoint.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]json.RawMessage + if err = json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("decode: %v", err) + } + if _, exists := got["tls_id"]; exists { + t.Errorf("tls_id survived into the rendered config: %s", encoded) + } + if _, exists := got["tls"]; exists { + t.Errorf("tls_id was turned into a tls object: %s", encoded) + } +} + +func TestEndpointMarshalWarpRendersAsWireguard(t *testing.T) { + var endpoint Endpoint + if err := endpoint.UnmarshalJSON([]byte(`{"type":"warp","tag":"warp-1","mtu":1420}`)); err != nil { + t.Fatalf("unmarshal: %v", err) + } + encoded, err := endpoint.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got struct { + Type string `json:"type"` + } + if err = json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Type != "wireguard" { + t.Errorf("type = %q, want wireguard", got.Type) + } +} diff --git a/frontend b/frontend index 0b88e90..faad006 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 0b88e90ab78e339c46e973bec31b108dee574dac +Subproject commit faad00676a658c10930719f4c866d98510db2329 diff --git a/service/endpoints.go b/service/endpoints.go index e588fd6..4e18873 100644 --- a/service/endpoints.go +++ b/service/endpoints.go @@ -25,11 +25,10 @@ func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) { var data []map[string]interface{} for _, endpoint := range endpoints { epData := map[string]interface{}{ - "id": endpoint.Id, - "type": endpoint.Type, - "tag": endpoint.Tag, - "tls_id": endpoint.TlsId, - "ext": endpoint.Ext, + "id": endpoint.Id, + "type": endpoint.Type, + "tag": endpoint.Tag, + "ext": endpoint.Ext, } if endpoint.Options != nil { var restFields map[string]json.RawMessage @@ -48,7 +47,7 @@ func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) { func (o *EndpointService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { var endpointsJson []json.RawMessage var endpoints []*model.Endpoint - err := db.Model(model.Endpoint{}).Preload("Tls").Find(&endpoints).Error + err := db.Model(model.Endpoint{}).Find(&endpoints).Error if err != nil { return nil, err } @@ -72,13 +71,6 @@ func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) er if err != nil { return err } - if endpoint.TlsId > 0 { - err = tx.Model(model.Tls{}).Where("id = ?", endpoint.TlsId).Find(&endpoint.Tls).Error - if err != nil { - return err - } - } - if endpoint.Type == "warp" { if act == "new" { err = s.WarpService.RegisterWarp(&endpoint) @@ -145,31 +137,3 @@ func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) er } return nil } - -// RestartEndpoints reloads the given endpoints in a running core, so a change -// to the TLS config they reference takes effect without restarting the panel. -func (s *EndpointService) RestartEndpoints(tx *gorm.DB, ids []uint) error { - if !corePtr.IsRunning() { - return nil - } - var endpoints []*model.Endpoint - err := tx.Model(model.Endpoint{}).Preload("Tls").Where("id in ?", ids).Find(&endpoints).Error - if err != nil { - return err - } - for _, endpoint := range endpoints { - err = corePtr.RemoveEndpoint(endpoint.Tag) - if err != nil && err != os.ErrInvalid { - return err - } - endpointConfig, err := endpoint.MarshalJSON() - if err != nil { - return err - } - err = corePtr.AddEndpoint(endpointConfig) - if err != nil { - return err - } - } - return nil -} diff --git a/service/tls.go b/service/tls.go index 9223c0e..a39ed55 100644 --- a/service/tls.go +++ b/service/tls.go @@ -14,7 +14,6 @@ import ( type TlsService struct { InboundService ServicesService - EndpointService } func (s *TlsService) GetAll() ([]model.Tls, error) { @@ -78,17 +77,6 @@ func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage, host return err } } - var endpointIds []uint - err = tx.Model(model.Endpoint{}).Where("tls_id = ?", tls.Id).Pluck("id", &endpointIds).Error - if err != nil { - return err - } - if len(endpointIds) > 0 { - err = s.EndpointService.RestartEndpoints(tx, endpointIds) - if err != nil { - return err - } - } } case "del": var id uint @@ -106,12 +94,7 @@ func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage, host if err != nil { return err } - var endpointCount int64 - err = tx.Model(model.Endpoint{}).Where("tls_id = ?", id).Count(&endpointCount).Error - if err != nil { - return err - } - if inboundCount > 0 || serviceCount > 0 || endpointCount > 0 { + if inboundCount > 0 || serviceCount > 0 { return common.NewError("tls in use") } err = tx.Where("id = ?", id).Delete(model.Tls{}).Error