database: migrate options deprecated in sing-box 1.14

One-shot migration over the stored config and TLS rows, guarded by a setting
so it runs once:

- dns.independent_cache is dropped (1.14 reads it only to warn; the value is
  never passed to the DNS client)
- experimental.cache_file.store_rdrc becomes store_dns
- route.rule_set[].download_detour becomes the http_client 1.14 builds for it
- inline tls.acme becomes tls.certificate_provider with type "acme"

Legacy DNS address filters and the `strategy` DNS rule action are NOT migrated
automatically. Converting them means inserting evaluate rules and reordering,
which changes how names resolve, so they are logged with the upstream
migration link for the operator to handle.

Tests cover each rewrite, that unrelated options survive, that a config with
nothing to migrate is left byte-identical, and that the migrated shapes build
a box with no deprecation warning.
This commit is contained in:
Alireza Ahmadi 2026-08-29 12:47:59 +02:00
parent 8089e95a5e
commit f41da0dfdd
8 changed files with 581 additions and 0 deletions

View file

@ -0,0 +1,5 @@
//go:build !with_acme
package core
const acmeBuilt = false

7
core/acme_built_test.go Normal file
View file

@ -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

View file

@ -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)
}
})
}
}

18
core/testdata/clean/migrated-acme.json vendored Normal file
View file

@ -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"}]
}

View file

@ -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"}]
}

View file

@ -124,6 +124,12 @@ func InitDB(dbPath string) error {
return err
}
err = migrateSingBox114()
if err != nil {
return err
}
reportSingBox114Manual()
return nil
}

View file

@ -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, &section); 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
}

View file

@ -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)
}
}