diff --git a/core/acme_built_stub_test.go b/core/acme_built_stub_test.go new file mode 100644 index 0000000..99c4f83 --- /dev/null +++ b/core/acme_built_stub_test.go @@ -0,0 +1,5 @@ +//go:build !with_acme + +package core + +const acmeBuilt = false diff --git a/core/acme_built_test.go b/core/acme_built_test.go new file mode 100644 index 0000000..c73ef05 --- /dev/null +++ b/core/acme_built_test.go @@ -0,0 +1,7 @@ +//go:build with_acme + +package core + +// acmeBuilt reports whether this build includes ACME, so tests over configs +// that declare an ACME certificate provider can skip when it is absent. +const acmeBuilt = true diff --git a/core/config_compat_test.go b/core/config_compat_test.go index bcdf13c..c38b82b 100644 --- a/core/config_compat_test.go +++ b/core/config_compat_test.go @@ -123,3 +123,55 @@ func TestConfigCompat(t *testing.T) { }) } } + +// TestConfigCompatClean asserts that the post-migration shapes produced by +// database.migrateSingBox114 start without any deprecation warning, so the +// migration is known to actually resolve what it claims to. +// +// These configs are only constructed, not started: an ACME provider would try +// to reach Let's Encrypt on start. Every deprecation these shapes could raise +// is reported while the box is being built. +func TestConfigCompatClean(t *testing.T) { + logger.InitLogger(logging.ERROR) + acmeDir := t.TempDir() + + files, err := filepath.Glob(filepath.Join("testdata", "clean", "*.json")) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no clean test configs found") + } + + for _, file := range files { + t.Run(filepath.Base(file), func(t *testing.T) { + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + data := strings.ReplaceAll(string(raw), "__ACMEDIR__", acmeDir) + if !acmeBuilt && strings.Contains(data, `"type": "acme"`) { + t.Skip("build does not include ACME") + } + + ctx := Context(context.Background(), InboundRegistry(), OutboundRegistry(), + EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry()) + notes := &collectManager{} + ctx = service.ContextWith[deprecated.Manager](ctx, notes) + + var opts option.Options + if err = opts.UnmarshalJSONContext(ctx, []byte(data)); err != nil { + t.Fatalf("parse: %v", err) + } + instance, err := NewBox(Options{Context: ctx, Options: opts}) + if err != nil { + t.Fatalf("create box: %v", err) + } + instance.Close() + + for _, note := range notes.notes { + t.Errorf("migrated config still reports deprecation %q: %s", note.Name, note.Description) + } + }) + } +} diff --git a/core/testdata/clean/migrated-acme.json b/core/testdata/clean/migrated-acme.json new file mode 100644 index 0000000..055c2d4 --- /dev/null +++ b/core/testdata/clean/migrated-acme.json @@ -0,0 +1,18 @@ +{ + "log": {"level": "error"}, + "inbounds": [{ + "type": "vless", "tag": "vless-in", "listen": "127.0.0.1", "listen_port": 42444, + "users": [{"uuid": "b831381d-6324-4d53-ad4f-8cda48b30811"}], + "tls": { + "enabled": true, "server_name": "example.com", + "certificate_provider": { + "type": "acme", + "domain": ["example.com"], + "email": "a@example.com", + "data_directory": "__ACMEDIR__", + "dns01_challenge": {"provider": "cloudflare", "api_token": "tok"} + } + } + }], + "outbounds": [{"type": "direct", "tag": "direct"}] +} diff --git a/core/testdata/clean/migrated-cache.json b/core/testdata/clean/migrated-cache.json new file mode 100644 index 0000000..509d758 --- /dev/null +++ b/core/testdata/clean/migrated-cache.json @@ -0,0 +1,6 @@ +{ + "log": {"level": "error"}, + "dns": {"servers": [{"type": "udp", "tag": "google", "server": "8.8.8.8"}], "rules": [], "cache_capacity": 4096}, + "experimental": {"cache_file": {"enabled": true, "store_dns": true}}, + "outbounds": [{"type": "direct", "tag": "direct"}] +} diff --git a/database/db.go b/database/db.go index 01eb006..d93b95e 100644 --- a/database/db.go +++ b/database/db.go @@ -124,6 +124,12 @@ func InitDB(dbPath string) error { return err } + err = migrateSingBox114() + if err != nil { + return err + } + reportSingBox114Manual() + return nil } diff --git a/database/migration_singbox114.go b/database/migration_singbox114.go new file mode 100644 index 0000000..817edee --- /dev/null +++ b/database/migration_singbox114.go @@ -0,0 +1,300 @@ +package database + +import ( + "encoding/json" + "log" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +// migratedKeySingBox114 marks that the one-shot sing-box 1.14 option migration +// has already run, so it is not attempted again on every start. +const migratedKeySingBox114 = "migratedSingBox114" + +// migrateSingBox114 rewrites the options sing-box deprecated in 1.14.0 into +// their replacements. Only the lossless rewrites are done automatically: +// +// - dns.independent_cache is dropped; 1.14 ignores the field entirely +// - experimental.cache_file.store_rdrc becomes store_dns +// - route.rule_set[].download_detour becomes http_client +// - an inline tls.acme block becomes tls.certificate_provider +// +// The two remaining 1.14 deprecations, legacy DNS address filters and the +// `strategy` DNS rule action option, are deliberately left alone: migrating +// them means reordering DNS rules and inserting evaluate actions, which +// changes how names resolve. Those are reported by reportSingBox114Manual so +// the operator can convert them by hand. +func migrateSingBox114() error { + var flag model.Setting + err := db.Where("key = ?", migratedKeySingBox114).First(&flag).Error + if err == nil { + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + changed, err := migrateSingBox114Config(tx) + if err != nil { + return err + } + tlsChanged, err := migrateSingBox114Tls(tx) + if err != nil { + return err + } + changed += tlsChanged + if changed > 0 { + log.Printf("sing-box 1.14: migrated %d deprecated option(s)", changed) + } + return tx.Create(&model.Setting{Key: migratedKeySingBox114, Value: "true"}).Error + }) +} + +// migrateSingBox114Config migrates the dns, experimental and route sections +// held in the `config` setting, returning how many options it rewrote. +func migrateSingBox114Config(tx *gorm.DB) (int, error) { + var setting model.Setting + err := tx.Where("key = ?", "config").First(&setting).Error + if err == gorm.ErrRecordNotFound { + 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 { + // A config we cannot parse is left untouched rather than discarded. + log.Printf("sing-box 1.14: skipping config migration, cannot parse: %v", err) + return 0, nil + } + + changed := 0 + sections := []struct { + key string + migrate func(map[string]any) int + }{ + {"dns", migrateDNSSection}, + {"experimental", migrateExperimentalSection}, + {"route", migrateRouteSection}, + } + for _, section := range sections { + if err = migrateSection(root, section.key, section.migrate, &changed); err != nil { + return 0, err + } + } + if changed == 0 { + return 0, nil + } + + encoded, err := json.MarshalIndent(root, "", " ") + if err != nil { + return 0, err + } + setting.Value = string(encoded) + if err = tx.Save(&setting).Error; err != nil { + return 0, err + } + return changed, nil +} + +// migrateSection decodes one top-level config section, hands it to migrate and +// writes it back when migrate reports a change. +func migrateSection(root map[string]json.RawMessage, key string, migrate func(map[string]any) int, changed *int) error { + raw, ok := root[key] + if !ok || len(raw) == 0 { + return nil + } + var section map[string]any + if err := json.Unmarshal(raw, §ion); err != nil { + return nil + } + count := migrate(section) + if count == 0 { + return nil + } + encoded, err := json.Marshal(section) + if err != nil { + return err + } + root[key] = encoded + *changed += count + return nil +} + +// migrateDNSSection drops independent_cache, which 1.14 reads only to warn on. +func migrateDNSSection(dns map[string]any) int { + if _, ok := dns["independent_cache"]; !ok { + return 0 + } + delete(dns, "independent_cache") + return 1 +} + +// migrateExperimentalSection renames cache_file.store_rdrc to store_dns. +func migrateExperimentalSection(experimental map[string]any) int { + cacheFile, ok := experimental["cache_file"].(map[string]any) + if !ok { + return 0 + } + storeRDRC, ok := cacheFile["store_rdrc"] + if !ok { + return 0 + } + delete(cacheFile, "store_rdrc") + if enabled, isBool := storeRDRC.(bool); isBool && enabled { + cacheFile["store_dns"] = true + } + return 1 +} + +// migrateRouteSection converts each remote rule-set's download_detour into the +// equivalent http_client, matching what 1.14 builds internally for the legacy +// field. +func migrateRouteSection(route map[string]any) int { + ruleSets, ok := route["rule_set"].([]any) + if !ok { + return 0 + } + changed := 0 + for _, entry := range ruleSets { + ruleSet, isObject := entry.(map[string]any) + if !isObject { + continue + } + detour, hasDetour := ruleSet["download_detour"].(string) + if !hasDetour { + continue + } + delete(ruleSet, "download_detour") + // http_client and download_detour conflict in 1.14, so an existing + // http_client wins and the legacy field is simply dropped. + if _, hasClient := ruleSet["http_client"]; hasClient { + changed++ + continue + } + if detour != "" { + ruleSet["http_client"] = map[string]any{ + "detour": detour, + "disable_empty_direct_check": true, + } + } + changed++ + } + return changed +} + +// migrateSingBox114Tls moves an inline acme block on every stored TLS config +// into certificate_provider, which carries the same fields under a type. +func migrateSingBox114Tls(tx *gorm.DB) (int, error) { + var configs []model.Tls + if err := tx.Find(&configs).Error; err != nil { + return 0, err + } + changed := 0 + for _, tlsConfig := range configs { + if len(tlsConfig.Server) == 0 { + continue + } + var server map[string]any + if err := json.Unmarshal(tlsConfig.Server, &server); err != nil { + continue + } + acme, ok := server["acme"].(map[string]any) + if !ok { + continue + } + delete(server, "acme") + // certificate_provider and acme cannot both apply, so an existing + // provider wins and the legacy block is dropped. + if _, hasProvider := server["certificate_provider"]; !hasProvider { + provider := make(map[string]any, len(acme)+1) + for key, value := range acme { + provider[key] = value + } + provider["type"] = "acme" + server["certificate_provider"] = provider + } + encoded, err := json.Marshal(server) + if err != nil { + return 0, err + } + if err = tx.Model(&model.Tls{}).Where("id = ?", tlsConfig.Id). + Update("server", json.RawMessage(encoded)).Error; err != nil { + return 0, err + } + changed++ + } + return changed, nil +} + +// reportSingBox114Manual logs the 1.14 deprecations that need a human decision. +// Both change how DNS names resolve, so they are reported rather than rewritten. +func reportSingBox114Manual() { + var setting model.Setting + if err := db.Where("key = ?", "config").First(&setting).Error; err != nil { + return + } + var root struct { + DNS struct { + Rules []map[string]any `json:"rules"` + } `json:"dns"` + } + if err := json.Unmarshal([]byte(setting.Value), &root); err != nil { + return + } + + addressFilter, strategy := 0, 0 + for _, rule := range root.DNS.Rules { + if dnsRuleUsesAddressFilter(rule) { + addressFilter++ + } + if _, ok := rule["strategy"]; ok { + strategy++ + } + } + if addressFilter > 0 { + log.Printf("sing-box 1.14: %d DNS rule(s) use legacy address filters (ip_cidr, ip_is_private, "+ + "ip_accept_any or an IP rule-set). They still work but are removed in 1.16; migrate them to "+ + "match_response: https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", + addressFilter) + } + if strategy > 0 { + log.Printf("sing-box 1.14: %d DNS rule(s) use the deprecated `strategy` action option. "+ + "They still work but are removed in 1.16; migrate them to rule items: "+ + "https://sing-box.sagernet.org/migration/#migrate-dns-rule-action-strategy-to-rule-items", + strategy) + } +} + +// dnsRuleUsesAddressFilter reports whether a DNS rule matches on the resolved +// address without opting into response matching. +func dnsRuleUsesAddressFilter(rule map[string]any) bool { + if matchResponse, ok := rule["match_response"].(bool); ok && matchResponse { + return false + } + for _, field := range []string{"ip_cidr", "rule_set", "ip_is_private", "ip_accept_any", "rule_set_ip_cidr_accept_empty"} { + value, ok := rule[field] + if !ok { + continue + } + switch typed := value.(type) { + case bool: + if typed { + return true + } + case []any: + if len(typed) > 0 { + return true + } + case string: + if typed != "" { + return true + } + } + } + return false +} diff --git a/database/migration_singbox114_test.go b/database/migration_singbox114_test.go new file mode 100644 index 0000000..5c7e15d --- /dev/null +++ b/database/migration_singbox114_test.go @@ -0,0 +1,187 @@ +package database + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/alireza0/s-ui/database/model" +) + +// openTestDB gives each test its own on-disk database, since OpenDB stores the +// handle in the package-level db used by the migration. +func openTestDB(t *testing.T) { + t.Helper() + if err := OpenDB(filepath.Join(t.TempDir(), "test.db")); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Setting{}, &model.Tls{}); err != nil { + t.Fatal(err) + } +} + +func readConfig(t *testing.T) map[string]any { + t.Helper() + var setting model.Setting + if err := db.Where("key = ?", "config").First(&setting).Error; err != nil { + t.Fatal(err) + } + var root map[string]any + if err := json.Unmarshal([]byte(setting.Value), &root); err != nil { + t.Fatal(err) + } + return root +} + +func section(t *testing.T, root map[string]any, key string) map[string]any { + t.Helper() + value, ok := root[key].(map[string]any) + if !ok { + t.Fatalf("missing %q section in %v", key, root) + } + return value +} + +func TestMigrateSingBox114Config(t *testing.T) { + openTestDB(t) + legacy := `{ + "log": {"level": "info"}, + "dns": {"servers": [], "rules": [], "independent_cache": true, "cache_capacity": 4096}, + "experimental": {"cache_file": {"enabled": true, "store_rdrc": true}}, + "route": { + "rules": [{"action": "sniff"}], + "rule_set": [ + {"type": "remote", "tag": "geoip-cn", "url": "https://example.com/a.srs", "download_detour": "direct"}, + {"type": "remote", "tag": "geosite-ads", "url": "https://example.com/b.srs"} + ] + } + }` + if err := db.Create(&model.Setting{Key: "config", Value: legacy}).Error; err != nil { + t.Fatal(err) + } + + if err := migrateSingBox114(); err != nil { + t.Fatal(err) + } + root := readConfig(t) + + dns := section(t, root, "dns") + if _, ok := dns["independent_cache"]; ok { + t.Error("independent_cache should have been dropped") + } + if dns["cache_capacity"] != float64(4096) { + t.Errorf("unrelated dns options must be preserved, got %v", dns["cache_capacity"]) + } + if root["log"] == nil { + t.Error("unrelated top-level sections must be preserved") + } + + cacheFile := section(t, section(t, root, "experimental"), "cache_file") + if _, ok := cacheFile["store_rdrc"]; ok { + t.Error("store_rdrc should have been renamed") + } + if cacheFile["store_dns"] != true { + t.Errorf("store_rdrc should become store_dns, got %v", cacheFile["store_dns"]) + } + + ruleSets, ok := section(t, root, "route")["rule_set"].([]any) + if !ok || len(ruleSets) != 2 { + t.Fatalf("expected 2 rule sets, got %v", ruleSets) + } + migrated, _ := ruleSets[0].(map[string]any) + if _, ok = migrated["download_detour"]; ok { + t.Error("download_detour should have been replaced") + } + httpClient, ok := migrated["http_client"].(map[string]any) + if !ok { + t.Fatalf("expected http_client, got %v", migrated) + } + if httpClient["detour"] != "direct" || httpClient["disable_empty_direct_check"] != true { + t.Errorf("unexpected http_client: %v", httpClient) + } + if untouched, _ := ruleSets[1].(map[string]any); untouched["http_client"] != nil { + t.Error("rule sets without download_detour must be left alone") + } +} + +func TestMigrateSingBox114Tls(t *testing.T) { + openTestDB(t) + server := `{"enabled": true, "server_name": "example.com", "acme": {"domain": ["example.com"], "email": "a@example.com", "dns01_challenge": {"provider": "cloudflare", "api_token": "tok"}}}` + if err := db.Create(&model.Tls{Name: "cert", Server: json.RawMessage(server)}).Error; err != nil { + t.Fatal(err) + } + + if err := migrateSingBox114(); err != nil { + t.Fatal(err) + } + + var stored model.Tls + if err := db.First(&stored).Error; err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(stored.Server, &decoded); err != nil { + t.Fatal(err) + } + if _, ok := decoded["acme"]; ok { + t.Error("inline acme should have been removed") + } + if decoded["server_name"] != "example.com" { + t.Error("unrelated tls options must be preserved") + } + provider, ok := decoded["certificate_provider"].(map[string]any) + if !ok { + t.Fatalf("expected certificate_provider, got %v", decoded) + } + if provider["type"] != "acme" { + t.Errorf("expected type acme, got %v", provider["type"]) + } + if provider["email"] != "a@example.com" { + t.Errorf("acme fields must carry over, got %v", provider) + } + if _, ok = provider["dns01_challenge"].(map[string]any); !ok { + t.Errorf("nested acme options must carry over, got %v", provider) + } +} + +// TestMigrateSingBox114RunsOnce guards that a config edited back to a legacy +// shape after the migration is not silently rewritten again. +func TestMigrateSingBox114RunsOnce(t *testing.T) { + openTestDB(t) + if err := db.Create(&model.Setting{Key: "config", Value: `{"dns": {"independent_cache": true}}`}).Error; err != nil { + t.Fatal(err) + } + if err := migrateSingBox114(); err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Setting{}).Where("key = ?", "config"). + Update("value", `{"dns": {"independent_cache": true}}`).Error; err != nil { + t.Fatal(err) + } + if err := migrateSingBox114(); err != nil { + t.Fatal(err) + } + if _, ok := section(t, readConfig(t), "dns")["independent_cache"]; !ok { + t.Error("migration should not run a second time") + } +} + +// TestMigrateSingBox114Idempotent guards the common case: a config that has +// nothing to migrate must come through byte-identical. +func TestMigrateSingBox114Idempotent(t *testing.T) { + openTestDB(t) + clean := `{"log":{"level":"info"},"dns":{"servers":[],"rules":[]},"experimental":{}}` + if err := db.Create(&model.Setting{Key: "config", Value: clean}).Error; err != nil { + t.Fatal(err) + } + if err := migrateSingBox114(); err != nil { + t.Fatal(err) + } + var setting model.Setting + if err := db.Where("key = ?", "config").First(&setting).Error; err != nil { + t.Fatal(err) + } + if setting.Value != clean { + t.Errorf("config with nothing to migrate was rewritten:\n got %s\nwant %s", setting.Value, clean) + } +}