diff --git a/core/testdata/clean/shared-cert-provider.json b/core/testdata/clean/shared-cert-provider.json new file mode 100644 index 0000000..0e1e990 --- /dev/null +++ b/core/testdata/clean/shared-cert-provider.json @@ -0,0 +1,24 @@ +{ + "log": {"level": "error"}, + "certificate_providers": [{ + "type": "acme", + "tag": "letsencrypt", + "domain": ["example.com"], + "email": "a@example.com", + "data_directory": "__ACMEDIR__", + "dns01_challenge": {"provider": "cloudflare", "api_token": "tok"} + }], + "inbounds": [ + { + "type": "vless", "tag": "vless-in", "listen": "127.0.0.1", "listen_port": 42445, + "users": [{"uuid": "b831381d-6324-4d53-ad4f-8cda48b30811"}], + "tls": {"enabled": true, "server_name": "example.com", "certificate_provider": "letsencrypt"} + }, + { + "type": "trojan", "tag": "trojan-in", "listen": "127.0.0.1", "listen_port": 42446, + "users": [{"password": "hunter2"}], + "tls": {"enabled": true, "server_name": "example.com", "certificate_provider": "letsencrypt"} + } + ], + "outbounds": [{"type": "direct", "tag": "direct"}] +} diff --git a/database/db.go b/database/db.go index d93b95e..e373736 100644 --- a/database/db.go +++ b/database/db.go @@ -128,6 +128,10 @@ func InitDB(dbPath string) error { if err != nil { return err } + err = migrateCertificateProviders() + if err != nil { + return err + } reportSingBox114Manual() return nil diff --git a/database/migration_cert_providers.go b/database/migration_cert_providers.go new file mode 100644 index 0000000..a729b94 --- /dev/null +++ b/database/migration_cert_providers.go @@ -0,0 +1,205 @@ +package database + +import ( + "encoding/json" + "log" + "strconv" + "strings" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +// migratedKeyCertProviders marks that inline certificate providers have already +// been hoisted into the shared certificate_providers list. +const migratedKeyCertProviders = "migratedCertProviders" + +// migrateCertificateProviders turns the inline certificate_provider object a +// TLS config may carry into an entry in the config's certificate_providers +// list, leaving the TLS config with a tag reference to it. +// +// sing-box accepts both forms, so this is not a compatibility fix: it is what +// lets the panel edit providers in one place and share them between TLS +// configs, which an inline object cannot do. +func migrateCertificateProviders() error { + var flag model.Setting + err := db.Where("key = ?", migratedKeyCertProviders).First(&flag).Error + if err == nil { + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + changed, err := hoistInlineCertificateProviders(tx) + if err != nil { + return err + } + if changed > 0 { + log.Printf("certificate providers: moved %d inline provider(s) into certificate_providers", changed) + } + return tx.Create(&model.Setting{Key: migratedKeyCertProviders, Value: "true"}).Error + }) +} + +func hoistInlineCertificateProviders(tx *gorm.DB) (int, error) { + var tlsConfigs []model.Tls + if err := tx.Find(&tlsConfigs).Error; err != nil { + return 0, err + } + + var setting model.Setting + err := tx.Where("key = ?", "config").First(&setting).Error + if err == gorm.ErrRecordNotFound { + // Without a base config there is nowhere to put shared providers, so + // the inline ones are left as they are. They still work. + return 0, nil + } + if err != nil { + return 0, err + } + var root map[string]json.RawMessage + if err = json.Unmarshal([]byte(setting.Value), &root); err != nil { + log.Printf("certificate providers: skipping, cannot parse config: %v", err) + return 0, nil + } + + providers, takenTags, err := decodeCertificateProviders(root) + if err != nil { + log.Printf("certificate providers: skipping, cannot parse certificate_providers: %v", err) + return 0, nil + } + + changed := 0 + for _, tlsConfig := range tlsConfigs { + if len(tlsConfig.Server) == 0 { + continue + } + var server map[string]json.RawMessage + if err = json.Unmarshal(tlsConfig.Server, &server); err != nil { + continue + } + inline, ok := inlineProviderObject(server["certificate_provider"]) + if !ok { + continue + } + + tag := uniqueProviderTag(providerTagBase(tlsConfig), takenTags) + takenTags[tag] = struct{}{} + inline["tag"] = json.RawMessage(strconv.Quote(tag)) + encodedProvider, err := json.Marshal(inline) + if err != nil { + return 0, err + } + providers = append(providers, encodedProvider) + + server["certificate_provider"] = json.RawMessage(strconv.Quote(tag)) + encodedServer, err := json.Marshal(server) + if err != nil { + return 0, err + } + if err = tx.Model(&model.Tls{}).Where("id = ?", tlsConfig.Id). + Update("server", json.RawMessage(encodedServer)).Error; err != nil { + return 0, err + } + changed++ + } + if changed == 0 { + return 0, nil + } + + encodedProviders, err := json.Marshal(providers) + if err != nil { + return 0, err + } + root["certificate_providers"] = encodedProviders + encodedRoot, err := json.MarshalIndent(root, "", " ") + if err != nil { + return 0, err + } + setting.Value = string(encodedRoot) + if err = tx.Save(&setting).Error; err != nil { + return 0, err + } + return changed, nil +} + +// decodeCertificateProviders reads the providers already defined in the config +// and the tags they occupy, so hoisted ones do not collide with them. +func decodeCertificateProviders(root map[string]json.RawMessage) ([]json.RawMessage, map[string]struct{}, error) { + taken := make(map[string]struct{}) + raw, ok := root["certificate_providers"] + if !ok || len(raw) == 0 { + return nil, taken, nil + } + var providers []json.RawMessage + if err := json.Unmarshal(raw, &providers); err != nil { + return nil, nil, err + } + for _, provider := range providers { + var fields struct { + Tag string `json:"tag"` + } + if err := json.Unmarshal(provider, &fields); err != nil { + continue + } + if fields.Tag != "" { + taken[fields.Tag] = struct{}{} + } + } + return providers, taken, nil +} + +// inlineProviderObject reports whether certificate_provider holds an inline +// definition rather than a tag reference to a shared one. +func inlineProviderObject(raw json.RawMessage) (map[string]json.RawMessage, bool) { + trimmed := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(trimmed, "{") { + return nil, false + } + var inline map[string]json.RawMessage + if err := json.Unmarshal(raw, &inline); err != nil { + return nil, false + } + // A provider with no type cannot be created by sing-box either way, so + // there is nothing worth moving. + if _, ok := inline["type"]; !ok { + return nil, false + } + return inline, true +} + +// providerTagBase names the hoisted provider after the TLS config it came +// from, which is how the operator will recognise it in the list. +func providerTagBase(tlsConfig model.Tls) string { + slug := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return r + case r >= 'A' && r <= 'Z': + return r + ('a' - 'A') + default: + return '-' + } + }, tlsConfig.Name) + slug = strings.Trim(slug, "-") + for strings.Contains(slug, "--") { + slug = strings.ReplaceAll(slug, "--", "-") + } + if slug == "" { + return "cert-" + strconv.FormatUint(uint64(tlsConfig.Id), 10) + } + return slug +} + +func uniqueProviderTag(base string, taken map[string]struct{}) string { + tag := base + for i := 2; ; i++ { + if _, exists := taken[tag]; !exists { + return tag + } + tag = base + "-" + strconv.Itoa(i) + } +} diff --git a/database/migration_cert_providers_test.go b/database/migration_cert_providers_test.go new file mode 100644 index 0000000..764fa0d --- /dev/null +++ b/database/migration_cert_providers_test.go @@ -0,0 +1,176 @@ +package database + +import ( + "encoding/json" + "testing" + + "github.com/alireza0/s-ui/database/model" +) + +func certificateProviders(t *testing.T, root map[string]any) []map[string]any { + t.Helper() + raw, ok := root["certificate_providers"].([]any) + if !ok { + t.Fatalf("missing certificate_providers in %v", root) + } + providers := make([]map[string]any, 0, len(raw)) + for _, entry := range raw { + provider, isObject := entry.(map[string]any) + if !isObject { + t.Fatalf("expected provider objects, got %v", entry) + } + providers = append(providers, provider) + } + return providers +} + +func tlsServer(t *testing.T, id uint) map[string]any { + t.Helper() + var stored model.Tls + if err := db.Where("id = ?", id).First(&stored).Error; err != nil { + t.Fatal(err) + } + var server map[string]any + if err := json.Unmarshal(stored.Server, &server); err != nil { + t.Fatal(err) + } + return server +} + +func TestMigrateCertificateProviders(t *testing.T) { + openTestDB(t) + if err := db.Create(&model.Setting{Key: "config", Value: `{"log":{"level":"info"}}`}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Tls{ + Name: "Main Site", + Server: json.RawMessage(`{ + "enabled": true, + "certificate_provider": {"type": "acme", "domain": ["example.com"], "email": "a@example.com"} + }`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateCertificateProviders(); err != nil { + t.Fatal(err) + } + + providers := certificateProviders(t, readConfig(t)) + if len(providers) != 1 { + t.Fatalf("expected one hoisted provider, got %v", providers) + } + if providers[0]["type"] != "acme" || providers[0]["email"] != "a@example.com" { + t.Errorf("the provider fields must carry over, got %v", providers[0]) + } + tag, _ := providers[0]["tag"].(string) + if tag != "main-site" { + t.Errorf("the tag should be named after the TLS config, got %q", tag) + } + // The TLS config must now point at the shared provider rather than repeat it. + if got := tlsServer(t, 1)["certificate_provider"]; got != tag { + t.Errorf("expected the tag %q as the reference, got %v", tag, got) + } +} + +// A tag reference is already the new form and must be left alone, as must a +// provider that was defined in the config by hand. +func TestMigrateCertificateProvidersLeavesReferences(t *testing.T) { + openTestDB(t) + if err := db.Create(&model.Setting{Key: "config", Value: `{ + "certificate_providers": [{"type": "acme", "tag": "shared", "domain": ["example.com"]}] + }`}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Tls{ + Name: "site", + Server: json.RawMessage(`{"enabled": true, "certificate_provider": "shared"}`), + }).Error; err != nil { + t.Fatal(err) + } + + if err := migrateCertificateProviders(); err != nil { + t.Fatal(err) + } + + providers := certificateProviders(t, readConfig(t)) + if len(providers) != 1 { + t.Errorf("nothing should have been added, got %v", providers) + } + if got := tlsServer(t, 1)["certificate_provider"]; got != "shared" { + t.Errorf("the reference must be untouched, got %v", got) + } +} + +// Two TLS configs whose names slugify the same must not end up sharing a tag, +// which would silently point one of them at the other's provider. +func TestMigrateCertificateProvidersDeduplicatesTags(t *testing.T) { + openTestDB(t) + if err := db.Create(&model.Setting{Key: "config", Value: `{ + "certificate_providers": [{"type": "acme", "tag": "my-site", "domain": ["taken.example"]}] + }`}).Error; err != nil { + t.Fatal(err) + } + for _, name := range []string{"My Site", "my.site"} { + if err := db.Create(&model.Tls{ + Name: name, + Server: json.RawMessage(`{"enabled": true, "certificate_provider": {"type": "acme", "domain": ["example.com"]}}`), + }).Error; err != nil { + t.Fatal(err) + } + } + + if err := migrateCertificateProviders(); err != nil { + t.Fatal(err) + } + + providers := certificateProviders(t, readConfig(t)) + if len(providers) != 3 { + t.Fatalf("expected the hand-written provider plus two hoisted ones, got %v", providers) + } + tags := make(map[string]bool) + for _, provider := range providers { + tag, _ := provider["tag"].(string) + if tag == "" { + t.Fatalf("every shared provider needs a tag, got %v", provider) + } + if tags[tag] { + t.Fatalf("duplicate tag %q in %v", tag, providers) + } + tags[tag] = true + } + for id := uint(1); id <= 2; id++ { + reference, _ := tlsServer(t, id)["certificate_provider"].(string) + if reference == "my-site" { + t.Errorf("tls %d must not claim the existing provider's tag", id) + } + if !tags[reference] { + t.Errorf("tls %d references %q, which is not defined", id, reference) + } + } +} + +// The migration runs once; a provider added inline afterwards is the operator's +// own doing and must not be moved out from under them on the next start. +func TestMigrateCertificateProvidersRunsOnce(t *testing.T) { + openTestDB(t) + if err := db.Create(&model.Setting{Key: "config", Value: `{"log":{"level":"info"}}`}).Error; err != nil { + t.Fatal(err) + } + if err := migrateCertificateProviders(); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Tls{ + Name: "later", + Server: json.RawMessage(`{"enabled": true, "certificate_provider": {"type": "acme", "domain": ["example.com"]}}`), + }).Error; err != nil { + t.Fatal(err) + } + if err := migrateCertificateProviders(); err != nil { + t.Fatal(err) + } + + if _, hoisted := readConfig(t)["certificate_providers"]; hoisted { + t.Error("the migration should not run a second time") + } +} diff --git a/frontend b/frontend index 353dea9..1da3ca9 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 353dea9d3667bbf4525ec8f4c8b0572beb07c98d +Subproject commit 1da3ca9f92f227574c8f77797aa24803d7d913c8 diff --git a/service/config.go b/service/config.go index 7cdec91..99593ee 100644 --- a/service/config.go +++ b/service/config.go @@ -33,15 +33,19 @@ type ConfigService struct { } type SingBoxConfig struct { - Log json.RawMessage `json:"log"` - Dns json.RawMessage `json:"dns"` - Ntp json.RawMessage `json:"ntp"` - Inbounds []json.RawMessage `json:"inbounds"` - Outbounds []json.RawMessage `json:"outbounds"` - Services []json.RawMessage `json:"services"` - Endpoints []json.RawMessage `json:"endpoints"` - Route json.RawMessage `json:"route"` - Experimental json.RawMessage `json:"experimental"` + Log json.RawMessage `json:"log"` + Dns json.RawMessage `json:"dns"` + Ntp json.RawMessage `json:"ntp"` + // Shared certificate providers, referenced by tag from a TLS config's + // certificate_provider. They are edited alongside the TLS configs but + // stored in the base config, so they are carried through unchanged. + CertificateProviders []json.RawMessage `json:"certificate_providers,omitempty"` + Inbounds []json.RawMessage `json:"inbounds"` + Outbounds []json.RawMessage `json:"outbounds"` + Services []json.RawMessage `json:"services"` + Endpoints []json.RawMessage `json:"endpoints"` + Route json.RawMessage `json:"route"` + Experimental json.RawMessage `json:"experimental"` } func NewConfigService(core *core.Core) *ConfigService { diff --git a/service/config_providers_test.go b/service/config_providers_test.go new file mode 100644 index 0000000..28c7fdd --- /dev/null +++ b/service/config_providers_test.go @@ -0,0 +1,90 @@ +package service + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" +) + +// TestGetConfigEmitsCertificateProviders covers the whole path the TLS page +// relies on: providers saved into the base config have to come out in the +// config handed to sing-box, and a TLS config's tag reference has to survive +// alongside them. +func TestGetConfigEmitsCertificateProviders(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { + t.Fatal(err) + } + db := database.GetDB() + + base := `{ + "log": {"level": "info"}, + "certificate_providers": [ + {"type": "acme", "tag": "letsencrypt", "domain": ["example.com"], "email": "a@example.com"} + ], + "route": {"rules": []} + }` + // The defaults are only materialised on demand, so the row may not exist yet. + if err := db.Where("key = ?", "config").Delete(&model.Setting{}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Setting{Key: "config", Value: base}).Error; err != nil { + t.Fatal(err) + } + + tlsConfig := model.Tls{ + Name: "site", + Server: json.RawMessage(`{"enabled": true, "certificate_provider": "letsencrypt"}`), + Client: json.RawMessage(`{}`), + } + if err := db.Create(&tlsConfig).Error; err != nil { + t.Fatal(err) + } + inbound := model.Inbound{ + Type: "vless", + Tag: "vless-in", + TlsId: tlsConfig.Id, + Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":42447}`), + } + if err := db.Create(&inbound).Error; err != nil { + t.Fatal(err) + } + + raw, err := (&ConfigService{}).GetConfig("") + if err != nil { + t.Fatal(err) + } + + var built struct { + CertificateProviders []struct { + Type string `json:"type"` + Tag string `json:"tag"` + Domain []string `json:"domain"` + } `json:"certificate_providers"` + Inbounds []struct { + Tag string `json:"tag"` + TLS struct { + CertificateProvider string `json:"certificate_provider"` + } `json:"tls"` + } `json:"inbounds"` + } + if err = json.Unmarshal(*raw, &built); err != nil { + t.Fatal(err) + } + + if len(built.CertificateProviders) != 1 { + t.Fatalf("the provider must reach the generated config, got %s", *raw) + } + provider := built.CertificateProviders[0] + if provider.Tag != "letsencrypt" || provider.Type != "acme" || len(provider.Domain) != 1 { + t.Errorf("the provider must be emitted unchanged, got %+v", provider) + } + if len(built.Inbounds) != 1 { + t.Fatalf("expected the inbound, got %s", *raw) + } + if built.Inbounds[0].TLS.CertificateProvider != "letsencrypt" { + t.Errorf("the inbound TLS must reference the provider, got %s", *raw) + } +} diff --git a/service/config_test.go b/service/config_test.go new file mode 100644 index 0000000..7cecad5 --- /dev/null +++ b/service/config_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +// GetConfig rebuilds the sing-box config by decoding the stored base config +// into SingBoxConfig and marshalling it back, so anything the struct does not +// model is dropped on the way through. certificate_providers is edited by the +// TLS page and has to survive that round trip. +func TestSingBoxConfigKeepsCertificateProviders(t *testing.T) { + base := `{ + "log": {"level": "info"}, + "certificate_providers": [ + {"type": "acme", "tag": "letsencrypt", "domain": ["example.com"]}, + {"type": "cloudflare-origin-ca", "tag": "cf", "domain": ["example.com"]} + ], + "route": {"rules": []} + }` + + var config SingBoxConfig + if err := json.Unmarshal([]byte(base), &config); err != nil { + t.Fatal(err) + } + rebuilt, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + + var decoded struct { + CertificateProviders []struct { + Type string `json:"type"` + Tag string `json:"tag"` + } `json:"certificate_providers"` + } + if err = json.Unmarshal(rebuilt, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.CertificateProviders) != 2 { + t.Fatalf("both providers must reach the final config, got %s", rebuilt) + } + if decoded.CertificateProviders[0].Tag != "letsencrypt" || decoded.CertificateProviders[1].Type != "cloudflare-origin-ca" { + t.Errorf("providers must carry over unchanged, got %s", rebuilt) + } +} + +// A config with no providers must not grow an empty key, which sing-box would +// read as an explicit empty list. +func TestSingBoxConfigOmitsEmptyCertificateProviders(t *testing.T) { + var config SingBoxConfig + if err := json.Unmarshal([]byte(`{"log": {"level": "info"}}`), &config); err != nil { + t.Fatal(err) + } + rebuilt, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rebuilt), "certificate_providers") { + t.Errorf("expected no certificate_providers key, got %s", rebuilt) + } +}