diff --git a/core/testdata/clean/rule-set-http-client.json b/core/testdata/clean/rule-set-http-client.json new file mode 100644 index 0000000..6d1ab7a --- /dev/null +++ b/core/testdata/clean/rule-set-http-client.json @@ -0,0 +1,19 @@ +{ + "log": {"level": "error"}, + "http_clients": [{"tag": "default"}], + "outbounds": [ + {"type": "direct", "tag": "direct"}, + {"type": "socks", "tag": "proxy", "server": "127.0.0.1", "server_port": 1080} + ], + "route": { + "default_http_client": "default", + "rule_set": [ + {"type": "remote", "tag": "downloads-directly", "format": "binary", "url": "https://example.com/a.srs"}, + {"type": "remote", "tag": "downloads-over-proxy", "format": "binary", "url": "https://example.com/b.srs", + "http_client": {"detour": "proxy"}}, + {"type": "remote", "tag": "downloads-over-named-client", "format": "binary", "url": "https://example.com/c.srs", + "http_client": "default"} + ], + "rules": [{"rule_set": ["downloads-directly"], "action": "route", "outbound": "proxy"}] + } +} diff --git a/core/testdata/clean/sub-client.json b/core/testdata/clean/sub-client.json new file mode 100644 index 0000000..3b3eb13 --- /dev/null +++ b/core/testdata/clean/sub-client.json @@ -0,0 +1,56 @@ +{ + "log": {"level": "info", "timestamp": true}, + "dns": { + "servers": [ + {"type": "tcp", "tag": "proxy-dns", "server": "8.8.8.8", "server_port": 53, "detour": "proxy", "domain_resolver": "local-dns"}, + {"tag": "direct-dns", "type": "local"}, + {"tag": "local-dns", "type": "local"} + ], + "rules": [ + {"clash_mode": "Global", "source_ip_cidr": ["172.19.0.0/30"], "action": "route", "server": "proxy-dns"}, + {"clash_mode": "Direct", "action": "route", "server": "direct-dns"}, + {"rule_set": ["geosite-ir"], "action": "route", "server": "direct-dns"} + ], + "final": "local-dns", + "strategy": "prefer_ipv4" + }, + "http_clients": [{"tag": "default"}], + "inbounds": [ + { + "type": "tun", "address": ["172.19.0.1/30", "fdfe:dcba:9876::1/126"], "mtu": 9000, + "auto_route": true, "strict_route": false, "endpoint_independent_nat": false, "stack": "mixed", + "platform": {"http_proxy": {"enabled": true, "server": "127.0.0.1", "server_port": 2080}} + }, + {"type": "mixed", "listen": "127.0.0.1", "listen_port": 42480, "users": []} + ], + "experimental": { + "clash_api": { + "external_controller": "127.0.0.1:9090", "external_ui": "ui", "secret": "", + "external_ui_download_url": "https://example.com/ui.zip", + "external_ui_download_detour": "direct", "default_mode": "rule" + }, + "cache_file": {"enabled": true, "store_fakeip": false, "path": "__CACHEDIR__/sub.db"} + }, + "outbounds": [ + {"type": "selector", "tag": "proxy", "outbounds": ["auto", "direct", "node"]}, + {"type": "urltest", "tag": "auto", "outbounds": ["node"], "url": "http://www.gstatic.com/generate_204", "interval": "10m", "tolerance": 50}, + {"type": "direct", "tag": "direct"}, + {"type": "vless", "tag": "node", "server": "127.0.0.1", "server_port": 443, "uuid": "b831381d-6324-4d53-ad4f-8cda48b30811"} + ], + "route": { + "auto_detect_interface": true, + "final": "proxy", + "default_http_client": "default", + "default_domain_resolver": "local-dns", + "rule_set": [ + {"tag": "geosite-ir", "type": "remote", "format": "binary", "url": "https://example.com/category-ir.srs"} + ], + "rules": [ + {"protocol": "dns", "action": "hijack-dns"}, + {"action": "sniff"}, + {"clash_mode": "Direct", "action": "route", "outbound": "direct"}, + {"clash_mode": "Global", "action": "route", "outbound": "proxy"}, + {"rule_set": ["geosite-ir"], "action": "route", "outbound": "direct"} + ] + } +} diff --git a/database/db.go b/database/db.go index e373736..e0db2ca 100644 --- a/database/db.go +++ b/database/db.go @@ -132,6 +132,10 @@ func InitDB(dbPath string) error { if err != nil { return err } + err = repairRuleSetHTTPClients() + if err != nil { + return err + } reportSingBox114Manual() return nil diff --git a/database/migration_http_client.go b/database/migration_http_client.go new file mode 100644 index 0000000..5548d78 --- /dev/null +++ b/database/migration_http_client.go @@ -0,0 +1,127 @@ +package database + +import ( + "encoding/json" + "log" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +// migratedKeyHTTPClientFix marks that stored remote rule-sets have been +// checked for HTTP client options sing-box cannot parse. +const migratedKeyHTTPClientFix = "migratedHTTPClientFix" + +// repairRuleSetHTTPClients removes the two http_client settings that stop a +// config from loading: +// +// - disable_empty_direct_check, which is an internal flag with no JSON field +// of its own. sing-box rejects the whole config over the unknown key. +// - a detour to `direct`, which sing-box refuses as pointless because it +// dials exactly what it would have dialled anyway. +// +// Both come from an earlier rewrite of the deprecated download_detour option, +// which set the internal flag rather than exposing it. +func repairRuleSetHTTPClients() error { + var flag model.Setting + err := db.Where("key = ?", migratedKeyHTTPClientFix).First(&flag).Error + if err == nil { + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + changed, err := repairRuleSetHTTPClientsIn(tx) + if err != nil { + return err + } + if changed > 0 { + log.Printf("http clients: repaired %d rule-set http_client option(s)", changed) + } + return tx.Create(&model.Setting{Key: migratedKeyHTTPClientFix, Value: "true"}).Error + }) +} + +func repairRuleSetHTTPClientsIn(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 { + log.Printf("http clients: skipping, cannot parse config: %v", err) + return 0, nil + } + rawRoute, ok := root["route"] + if !ok || len(rawRoute) == 0 { + return 0, nil + } + var route map[string]any + if err = json.Unmarshal(rawRoute, &route); err != nil { + return 0, nil + } + + changed := repairRuleSetList(route) + if changed == 0 { + return 0, nil + } + + encodedRoute, err := json.Marshal(route) + if err != nil { + return 0, err + } + root["route"] = encodedRoute + 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 +} + +func repairRuleSetList(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 + } + httpClient, isObject := ruleSet["http_client"].(map[string]any) + if !isObject { + continue + } + fixed := false + if _, ok := httpClient["disable_empty_direct_check"]; ok { + delete(httpClient, "disable_empty_direct_check") + fixed = true + } + if detour, isString := httpClient["detour"].(string); isString && detour == "direct" { + delete(httpClient, "detour") + fixed = true + } + if !fixed { + continue + } + // An http_client with nothing left in it means the defaults, which is + // what leaving it out says more clearly. + if len(httpClient) == 0 { + delete(ruleSet, "http_client") + } + changed++ + } + return changed +} diff --git a/database/migration_http_client_test.go b/database/migration_http_client_test.go new file mode 100644 index 0000000..a9bac1b --- /dev/null +++ b/database/migration_http_client_test.go @@ -0,0 +1,103 @@ +package database + +import ( + "testing" + + "github.com/alireza0/s-ui/database/model" +) + +func ruleSets(t *testing.T, root map[string]any) []map[string]any { + t.Helper() + raw, ok := section(t, root, "route")["rule_set"].([]any) + if !ok { + t.Fatalf("missing rule_set in %v", root) + } + converted := make([]map[string]any, 0, len(raw)) + for _, entry := range raw { + ruleSet, isObject := entry.(map[string]any) + if !isObject { + t.Fatalf("expected rule-set objects, got %v", entry) + } + converted = append(converted, ruleSet) + } + return converted +} + +func TestRepairRuleSetHTTPClients(t *testing.T) { + openTestDB(t) + broken := `{ + "route": { + "rule_set": [ + {"type": "remote", "tag": "a", "url": "https://e.com/a.srs", + "http_client": {"detour": "direct", "disable_empty_direct_check": true}}, + {"type": "remote", "tag": "b", "url": "https://e.com/b.srs", + "http_client": {"detour": "proxy", "disable_empty_direct_check": true}}, + {"type": "remote", "tag": "c", "url": "https://e.com/c.srs", + "http_client": {"detour": "proxy"}}, + {"type": "remote", "tag": "d", "url": "https://e.com/d.srs"} + ] + } + }` + if err := db.Create(&model.Setting{Key: "config", Value: broken}).Error; err != nil { + t.Fatal(err) + } + + if err := repairRuleSetHTTPClients(); err != nil { + t.Fatal(err) + } + sets := ruleSets(t, readConfig(t)) + if len(sets) != 4 { + t.Fatalf("expected 4 rule sets, got %v", sets) + } + + // Nothing worth keeping was left, so the whole block goes. + if _, ok := sets[0]["http_client"]; ok { + t.Errorf("a direct detour with no other option must drop http_client, got %v", sets[0]) + } + // A real detour survives; only the unparseable flag is stripped. + fixed, ok := sets[1]["http_client"].(map[string]any) + if !ok { + t.Fatalf("expected http_client to survive, got %v", sets[1]) + } + if fixed["detour"] != "proxy" { + t.Errorf("the detour must be kept, got %v", fixed) + } + if _, ok = fixed["disable_empty_direct_check"]; ok { + t.Errorf("the unparseable flag must be gone, got %v", fixed) + } + // Already-valid entries are untouched. + untouched, _ := sets[2]["http_client"].(map[string]any) + if untouched["detour"] != "proxy" || len(untouched) != 1 { + t.Errorf("a valid http_client must be left alone, got %v", untouched) + } + if _, ok = sets[3]["http_client"]; ok { + t.Errorf("a rule-set without http_client must not grow one, got %v", sets[3]) + } +} + +func TestRepairRuleSetHTTPClientsRunsOnce(t *testing.T) { + openTestDB(t) + if err := db.Create(&model.Setting{Key: "config", Value: `{"route": {"rule_set": []}}`}).Error; err != nil { + t.Fatal(err) + } + if err := repairRuleSetHTTPClients(); err != nil { + t.Fatal(err) + } + + edited := `{"route": {"rule_set": [ + {"type": "remote", "tag": "a", "url": "https://e.com/a.srs", + "http_client": {"detour": "direct", "disable_empty_direct_check": true}} + ]}}` + if err := db.Model(model.Setting{}).Where("key = ?", "config"). + Update("value", edited).Error; err != nil { + t.Fatal(err) + } + if err := repairRuleSetHTTPClients(); err != nil { + t.Fatal(err) + } + + sets := ruleSets(t, readConfig(t)) + if _, ok := sets[0]["http_client"]; !ok { + t.Error("the repair should not run a second time") + } +} diff --git a/database/migration_singbox114.go b/database/migration_singbox114.go index 817edee..d6009fa 100644 --- a/database/migration_singbox114.go +++ b/database/migration_singbox114.go @@ -152,8 +152,13 @@ func migrateExperimentalSection(experimental map[string]any) int { } // migrateRouteSection converts each remote rule-set's download_detour into the -// equivalent http_client, matching what 1.14 builds internally for the legacy -// field. +// equivalent http_client. +// +// A detour to `direct` is the one case that does not carry over as a detour: +// download_detour set the internal disable_empty_direct_check flag, which has +// no JSON field of its own, and without it sing-box rejects a detour to an +// empty direct outbound as pointless. Downloading over the default outbound is +// what that detour meant anyway, so the field is simply dropped. func migrateRouteSection(route map[string]any) int { ruleSets, ok := route["rule_set"].([]any) if !ok { @@ -176,11 +181,8 @@ func migrateRouteSection(route map[string]any) int { changed++ continue } - if detour != "" { - ruleSet["http_client"] = map[string]any{ - "detour": detour, - "disable_empty_direct_check": true, - } + if detour != "" && detour != "direct" { + ruleSet["http_client"] = map[string]any{"detour": detour} } changed++ } diff --git a/database/migration_singbox114_test.go b/database/migration_singbox114_test.go index 5c7e15d..934dd55 100644 --- a/database/migration_singbox114_test.go +++ b/database/migration_singbox114_test.go @@ -52,7 +52,8 @@ func TestMigrateSingBox114Config(t *testing.T) { "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"} + {"type": "remote", "tag": "geosite-ads", "url": "https://example.com/b.srs"}, + {"type": "remote", "tag": "geosite-ir", "url": "https://example.com/c.srs", "download_detour": "proxy"} ] } }` @@ -85,23 +86,37 @@ func TestMigrateSingBox114Config(t *testing.T) { } ruleSets, ok := section(t, root, "route")["rule_set"].([]any) - if !ok || len(ruleSets) != 2 { - t.Fatalf("expected 2 rule sets, got %v", ruleSets) + if !ok || len(ruleSets) != 3 { + t.Fatalf("expected 3 rule sets, got %v", ruleSets) } - migrated, _ := ruleSets[0].(map[string]any) - if _, ok = migrated["download_detour"]; ok { + // A direct detour is what sing-box does anyway, and it cannot be expressed + // as an http_client detour, so it just goes away. + direct, _ := ruleSets[0].(map[string]any) + if _, ok = direct["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 _, ok = direct["http_client"]; ok { + t.Errorf("a direct detour must not become an http_client, got %v", direct) } if untouched, _ := ruleSets[1].(map[string]any); untouched["http_client"] != nil { t.Error("rule sets without download_detour must be left alone") } + proxied, _ := ruleSets[2].(map[string]any) + if _, ok = proxied["download_detour"]; ok { + t.Error("download_detour should have been replaced") + } + httpClient, ok := proxied["http_client"].(map[string]any) + if !ok { + t.Fatalf("expected http_client, got %v", proxied) + } + if httpClient["detour"] != "proxy" { + t.Errorf("unexpected http_client: %v", httpClient) + } + // disable_empty_direct_check has no JSON field; emitting it makes the whole + // config unparseable. + if _, ok = httpClient["disable_empty_direct_check"]; ok { + t.Errorf("disable_empty_direct_check is not a real option, got %v", httpClient) + } } func TestMigrateSingBox114Tls(t *testing.T) { diff --git a/frontend b/frontend index 1da3ca9..b5565d1 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 1da3ca9f92f227574c8f77797aa24803d7d913c8 +Subproject commit b5565d125aa0f4d91ee916fa946071636766a3e4 diff --git a/service/config.go b/service/config.go index 99593ee..6dfe29c 100644 --- a/service/config.go +++ b/service/config.go @@ -32,20 +32,30 @@ type ConfigService struct { EndpointService } +// SingBoxConfig is the shape GetConfig decodes the stored base config into +// before filling in the objects held in the database. Every top-level sing-box +// key has to be listed here: anything missing is silently dropped on the way +// through, even when the operator wrote it by hand. type SingBoxConfig struct { - 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. + Schema string `json:"$schema,omitempty"` + Log json.RawMessage `json:"log"` + Dns json.RawMessage `json:"dns"` + Ntp json.RawMessage `json:"ntp"` + // Global certificate store settings, and the shared certificate providers + // referenced by tag from a TLS config's certificate_provider. Providers are + // edited alongside the TLS configs but stored in the base config. + Certificate json.RawMessage `json:"certificate,omitempty"` 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"` + // Named HTTP clients, referenced by remote rule-sets and by + // route.default_http_client. + HTTPClients []json.RawMessage `json:"http_clients,omitempty"` + NetworkNamespaces []json.RawMessage `json:"network_namespaces,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 { @@ -83,6 +93,9 @@ func (s *ConfigService) GetConfig(data string) (*[]byte, error) { if err != nil { return nil, err } + if err = ensureDefaultHTTPClient(&singboxConfig); err != nil { + return nil, err + } rawConfig, err := json.MarshalIndent(singboxConfig, "", " ") if err != nil { return nil, err @@ -90,6 +103,81 @@ func (s *ConfigService) GetConfig(data string) (*[]byte, error) { return &rawConfig, nil } +// defaultHTTPClientTag names the HTTP client remote rule-sets download over +// when the operator has not set one up. +const defaultHTTPClientTag = "default" + +// ensureDefaultHTTPClient declares an HTTP client for remote rule-sets that +// name none. Left implicit, sing-box 1.14 downloads them over the default +// outbound and reports that fallback as deprecated; declaring a client with no +// detour says exactly the same thing. It is added to the generated config +// rather than to the stored one, so it also covers rule-sets added later. +// +// An operator who configured http_clients themselves is left alone. +func ensureDefaultHTTPClient(config *SingBoxConfig) error { + if len(config.HTTPClients) > 0 || len(config.Route) == 0 { + return nil + } + var route map[string]json.RawMessage + if err := json.Unmarshal(config.Route, &route); err != nil { + // A route section the panel cannot read is passed through untouched. + return nil + } + if raw, ok := route["default_http_client"]; ok && !isEmptyRawJSON(raw) { + return nil + } + if !hasImplicitHTTPClientRuleSet(route["rule_set"]) { + return nil + } + + client, err := json.Marshal(map[string]string{"tag": defaultHTTPClientTag}) + if err != nil { + return err + } + tag, err := json.Marshal(defaultHTTPClientTag) + if err != nil { + return err + } + route["default_http_client"] = tag + encodedRoute, err := json.Marshal(route) + if err != nil { + return err + } + config.HTTPClients = []json.RawMessage{client} + config.Route = encodedRoute + return nil +} + +// hasImplicitHTTPClientRuleSet reports whether any remote rule-set would fall +// back to the implicit default HTTP client. +func hasImplicitHTTPClientRuleSet(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var ruleSets []map[string]json.RawMessage + if err := json.Unmarshal(raw, &ruleSets); err != nil { + return false + } + for _, ruleSet := range ruleSets { + var ruleSetType string + if err := json.Unmarshal(ruleSet["type"], &ruleSetType); err != nil || ruleSetType != "remote" { + continue + } + if httpClient, ok := ruleSet["http_client"]; !ok || isEmptyRawJSON(httpClient) { + return true + } + } + return false +} + +func isEmptyRawJSON(raw json.RawMessage) bool { + switch string(raw) { + case "", "null", `""`, "{}", "[]": + return true + } + return false +} + func (s *ConfigService) StartCore() error { if corePtr.IsRunning() { return nil diff --git a/service/config_test.go b/service/config_test.go index 7cecad5..008a5e5 100644 --- a/service/config_test.go +++ b/service/config_test.go @@ -61,3 +61,88 @@ func TestSingBoxConfigOmitsEmptyCertificateProviders(t *testing.T) { t.Errorf("expected no certificate_providers key, got %s", rebuilt) } } + +func routeOf(t *testing.T, config SingBoxConfig) map[string]any { + t.Helper() + var route map[string]any + if len(config.Route) == 0 { + return route + } + if err := json.Unmarshal(config.Route, &route); err != nil { + t.Fatal(err) + } + return route +} + +func decodeConfig(t *testing.T, raw string) SingBoxConfig { + t.Helper() + var config SingBoxConfig + if err := json.Unmarshal([]byte(raw), &config); err != nil { + t.Fatal(err) + } + if err := ensureDefaultHTTPClient(&config); err != nil { + t.Fatal(err) + } + return config +} + +// A remote rule-set with no client of its own downloads over the implicit +// default, which sing-box 1.14 deprecates. +func TestEnsureDefaultHTTPClient(t *testing.T) { + config := decodeConfig(t, `{ + "route": {"rule_set": [ + {"type": "remote", "tag": "a", "url": "https://e.com/a.srs"} + ]} + }`) + + if len(config.HTTPClients) != 1 { + t.Fatalf("expected one declared http client, got %v", config.HTTPClients) + } + var client map[string]any + if err := json.Unmarshal(config.HTTPClients[0], &client); err != nil { + t.Fatal(err) + } + // A detour to a plain direct outbound is rejected by sing-box, and the + // default outbound is what the implicit client used anyway. + if client["tag"] != defaultHTTPClientTag || len(client) != 1 { + t.Errorf("unexpected client: %v", client) + } + if routeOf(t, config)["default_http_client"] != defaultHTTPClientTag { + t.Errorf("the route must point at it, got %v", routeOf(t, config)) + } +} + +func TestEnsureDefaultHTTPClientLeavesConfigsAlone(t *testing.T) { + for name, raw := range map[string]string{ + "rule-set names its own client": `{"route": {"rule_set": [ + {"type": "remote", "tag": "a", "url": "https://e.com/a.srs", "http_client": {"detour": "proxy"}} + ]}}`, + "local rule-set only": `{"route": {"rule_set": [ + {"type": "local", "tag": "a", "path": "/a.srs"} + ]}}`, + "no rule-set": `{"route": {"rules": []}}`, + "no route": `{"log": {"level": "info"}}`, + "operator set": `{"http_clients": [{"tag": "mine"}], "route": {"rule_set": [ + {"type": "remote", "tag": "a", "url": "https://e.com/a.srs"} + ]}}`, + "default already set": `{"route": {"default_http_client": "mine", "rule_set": [ + {"type": "remote", "tag": "a", "url": "https://e.com/a.srs"} + ]}}`, + } { + t.Run(name, func(t *testing.T) { + config := decodeConfig(t, raw) + for _, client := range config.HTTPClients { + var decoded map[string]any + if err := json.Unmarshal(client, &decoded); err != nil { + t.Fatal(err) + } + if decoded["tag"] == defaultHTTPClientTag { + t.Errorf("no client should have been declared, got %v", decoded) + } + } + if got := routeOf(t, config)["default_http_client"]; got != nil && got != "mine" { + t.Errorf("unexpected default_http_client: %v", got) + } + }) + } +} diff --git a/sub/jsonService.go b/sub/jsonService.go index 7e38ab2..4905f24 100644 --- a/sub/jsonService.go +++ b/sub/jsonService.go @@ -296,11 +296,18 @@ func (j *JsonService) addOthers(jsonConfig *map[string]interface{}) error { if _, ok := othersJson["rule_set"]; ok { route["rule_set"] = othersJson["rule_set"] } + j.addHTTPClients(jsonConfig, route, othersJson) if settingRules, ok := othersJson["rules"].([]interface{}); ok { route["rules"] = settingRules } - if defaultDomainResolver, ok := othersJson["default_domain_resolver"].(string); ok { + if defaultDomainResolver, ok := othersJson["default_domain_resolver"].(string); ok && defaultDomainResolver != "" { route["default_domain_resolver"] = defaultDomainResolver + } else if fallback := fallbackDomainResolver(othersJson); fallback != "" { + // With more than one DNS server and no resolver named for dial fields, + // sing-box has to guess which one resolves outbound server domains and + // reports the guess as deprecated. The template's final server is the + // one it would have to fall back to anyway. + route["default_domain_resolver"] = fallback } if v, ok := othersJson["override_android_vpn"]; ok { route["override_android_vpn"] = v @@ -313,6 +320,81 @@ func (j *JsonService) addOthers(jsonConfig *map[string]interface{}) error { return nil } +// fallbackDomainResolver returns the DNS server dial fields should resolve +// through when the template names none, or "" when the config has too few +// servers for the choice to matter. +func fallbackDomainResolver(othersJson map[string]interface{}) string { + dns, ok := othersJson["dns"].(map[string]interface{}) + if !ok { + return "" + } + servers, ok := dns["servers"].([]interface{}) + if !ok || len(servers) < 2 { + return "" + } + if final, ok := dns["final"].(string); ok && final != "" { + return final + } + // No final server either: the first one is what sing-box treats as default. + if first, ok := servers[0].(map[string]interface{}); ok { + tag, _ := first["tag"].(string) + return tag + } + return "" +} + +// defaultHTTPClientTag names the HTTP client the generated config declares for +// downloading remote rule-sets. +const defaultHTTPClientTag = "default" + +// addHTTPClients carries the template's HTTP clients across and, when the +// config downloads remote rule-sets without naming a client for them, declares +// one. Left implicit, sing-box 1.14 falls back to the default outbound and +// reports the fallback as deprecated; an explicit client says the same thing +// and keeps the client's log clean. +func (j *JsonService) addHTTPClients(jsonConfig *map[string]interface{}, route map[string]interface{}, othersJson map[string]interface{}) { + if clients, ok := othersJson["http_clients"]; ok { + (*jsonConfig)["http_clients"] = clients + } + if defaultClient, ok := othersJson["default_http_client"].(string); ok && defaultClient != "" { + route["default_http_client"] = defaultClient + return + } + // A template that brings its own clients decides for itself. + if _, ok := (*jsonConfig)["http_clients"]; ok { + return + } + if !needsDefaultHTTPClient(route) { + return + } + (*jsonConfig)["http_clients"] = []interface{}{ + map[string]interface{}{"tag": defaultHTTPClientTag}, + } + route["default_http_client"] = defaultHTTPClientTag +} + +// needsDefaultHTTPClient reports whether any remote rule-set would fall back to +// the implicit default HTTP client. +func needsDefaultHTTPClient(route map[string]interface{}) bool { + ruleSets, ok := route["rule_set"].([]interface{}) + if !ok { + return false + } + for _, entry := range ruleSets { + ruleSet, isObject := entry.(map[string]interface{}) + if !isObject { + continue + } + if ruleSetType, _ := ruleSet["type"].(string); ruleSetType != "remote" { + continue + } + if _, hasClient := ruleSet["http_client"]; !hasClient { + return true + } + } + return false +} + func (j *JsonService) pushMixed(outbounds *[]map[string]interface{}, outTags *[]string, out map[string]interface{}) { socksOut := make(map[string]interface{}, 1) httpOut := make(map[string]interface{}, 1) diff --git a/sub/jsonService_test.go b/sub/jsonService_test.go new file mode 100644 index 0000000..e569925 --- /dev/null +++ b/sub/jsonService_test.go @@ -0,0 +1,158 @@ +package sub + +import ( + "encoding/json" + "testing" +) + +func runAddHTTPClients(t *testing.T, template string) (map[string]interface{}, map[string]interface{}) { + t.Helper() + var othersJson map[string]interface{} + if err := json.Unmarshal([]byte(template), &othersJson); err != nil { + t.Fatal(err) + } + route := map[string]interface{}{"final": "proxy"} + if ruleSet, ok := othersJson["rule_set"]; ok { + route["rule_set"] = ruleSet + } + jsonConfig := map[string]interface{}{} + (&JsonService{}).addHTTPClients(&jsonConfig, route, othersJson) + return jsonConfig, route +} + +// A remote rule-set with no client of its own would use the implicit default, +// which sing-box 1.14 reports as deprecated in the client's log. +func TestAddHTTPClientsDeclaresDefault(t *testing.T) { + jsonConfig, route := runAddHTTPClients(t, `{ + "rule_set": [ + {"tag": "geosite-ir", "type": "remote", "format": "binary", "url": "https://e.com/a.srs"} + ] + }`) + + clients, ok := jsonConfig["http_clients"].([]interface{}) + if !ok || len(clients) != 1 { + t.Fatalf("expected one declared http client, got %v", jsonConfig["http_clients"]) + } + client, _ := clients[0].(map[string]interface{}) + if client["tag"] != defaultHTTPClientTag { + t.Errorf("unexpected client: %v", client) + } + // A detour would be wrong here: sing-box refuses a detour to a plain direct + // outbound, and downloading over the default outbound is the intent. + if _, hasDetour := client["detour"]; hasDetour { + t.Errorf("the default download client must carry no detour, got %v", client) + } + if route["default_http_client"] != defaultHTTPClientTag { + t.Errorf("the route must point at it, got %v", route["default_http_client"]) + } +} + +// Rule-sets that name their own client need no default, and a config with no +// remote rule-set at all must not grow one. +func TestAddHTTPClientsLeavesConfigsAlone(t *testing.T) { + for name, template := range map[string]string{ + "every rule-set has a client": `{ + "rule_set": [ + {"tag": "a", "type": "remote", "format": "binary", "url": "https://e.com/a.srs", + "http_client": {"detour": "proxy"}} + ] + }`, + "local rule-set only": `{ + "rule_set": [{"tag": "a", "type": "local", "format": "binary", "path": "/a.srs"}] + }`, + "no rule-set": `{}`, + } { + t.Run(name, func(t *testing.T) { + jsonConfig, route := runAddHTTPClients(t, template) + if _, ok := jsonConfig["http_clients"]; ok { + t.Errorf("no client should have been declared, got %v", jsonConfig["http_clients"]) + } + if _, ok := route["default_http_client"]; ok { + t.Errorf("no default should have been set, got %v", route["default_http_client"]) + } + }) + } +} + +// A template that declares its own clients decides for itself. +func TestAddHTTPClientsKeepsTemplateClients(t *testing.T) { + jsonConfig, route := runAddHTTPClients(t, `{ + "http_clients": [{"tag": "over-proxy", "detour": "proxy"}], + "default_http_client": "over-proxy", + "rule_set": [ + {"tag": "a", "type": "remote", "format": "binary", "url": "https://e.com/a.srs"} + ] + }`) + + clients, ok := jsonConfig["http_clients"].([]interface{}) + if !ok || len(clients) != 1 { + t.Fatalf("the template's clients must be carried over, got %v", jsonConfig["http_clients"]) + } + client, _ := clients[0].(map[string]interface{}) + if client["tag"] != "over-proxy" || client["detour"] != "proxy" { + t.Errorf("the template's client must be unchanged, got %v", client) + } + if route["default_http_client"] != "over-proxy" { + t.Errorf("the template's default must win, got %v", route["default_http_client"]) + } +} + +func runAddOthersRoute(t *testing.T, template string) map[string]interface{} { + t.Helper() + var othersJson map[string]interface{} + if err := json.Unmarshal([]byte(template), &othersJson); err != nil { + t.Fatal(err) + } + route := map[string]interface{}{"final": "proxy"} + if defaultDomainResolver, ok := othersJson["default_domain_resolver"].(string); ok && defaultDomainResolver != "" { + route["default_domain_resolver"] = defaultDomainResolver + } else if fallback := fallbackDomainResolver(othersJson); fallback != "" { + route["default_domain_resolver"] = fallback + } + return route +} + +// With two or more DNS servers and nothing naming the resolver for dial +// fields, sing-box guesses and reports the guess as deprecated. +func TestFallbackDomainResolver(t *testing.T) { + for name, expect := range map[string]struct { + template string + want string + }{ + "final server wins": {`{"dns": {"servers": [ + {"tag": "proxy-dns", "type": "tcp", "server": "8.8.8.8"}, + {"tag": "local-dns", "type": "local"} + ], "final": "local-dns"}}`, "local-dns"}, + "first server without a final": {`{"dns": {"servers": [ + {"tag": "a", "type": "local"}, {"tag": "b", "type": "local"} + ]}}`, "a"}, + "single server needs no choice": {`{"dns": {"servers": [ + {"tag": "a", "type": "local"} + ], "final": "a"}}`, ""}, + "no dns section": {`{}`, ""}, + } { + t.Run(name, func(t *testing.T) { + var othersJson map[string]interface{} + if err := json.Unmarshal([]byte(expect.template), &othersJson); err != nil { + t.Fatal(err) + } + if got := fallbackDomainResolver(othersJson); got != expect.want { + t.Errorf("want %q, got %q", expect.want, got) + } + }) + } +} + +// An explicit choice in the template must not be second-guessed. +func TestDefaultDomainResolverFromTemplate(t *testing.T) { + route := runAddOthersRoute(t, `{ + "default_domain_resolver": "direct-dns", + "dns": {"servers": [ + {"tag": "proxy-dns", "type": "tcp", "server": "8.8.8.8"}, + {"tag": "direct-dns", "type": "local"} + ], "final": "proxy-dns"} + }`) + if route["default_domain_resolver"] != "direct-dns" { + t.Errorf("the template's choice must win, got %v", route["default_domain_resolver"]) + } +}