other migrations to 1.14

This commit is contained in:
Alireza Ahmadi 2026-08-30 14:04:15 +02:00
parent 91ff2e72d0
commit 4202af9b17
12 changed files with 771 additions and 32 deletions

View file

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

56
core/testdata/clean/sub-client.json vendored Normal file
View file

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

View file

@ -132,6 +132,10 @@ func InitDB(dbPath string) error {
if err != nil {
return err
}
err = repairRuleSetHTTPClients()
if err != nil {
return err
}
reportSingBox114Manual()
return nil

View file

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

View file

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

View file

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

View file

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

@ -1 +1 @@
Subproject commit 1da3ca9f92f227574c8f77797aa24803d7d913c8
Subproject commit b5565d125aa0f4d91ee916fa946071636766a3e4

View file

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

View file

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

View file

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

158
sub/jsonService_test.go Normal file
View file

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