mirror of
https://github.com/alireza0/s-ui.git
synced 2026-10-21 03:12:06 +00:00
other migrations to 1.14
This commit is contained in:
parent
91ff2e72d0
commit
4202af9b17
12 changed files with 771 additions and 32 deletions
19
core/testdata/clean/rule-set-http-client.json
vendored
Normal file
19
core/testdata/clean/rule-set-http-client.json
vendored
Normal 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
56
core/testdata/clean/sub-client.json
vendored
Normal 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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +132,10 @@ func InitDB(dbPath string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = repairRuleSetHTTPClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reportSingBox114Manual()
|
||||
|
||||
return nil
|
||||
|
|
|
|||
127
database/migration_http_client.go
Normal file
127
database/migration_http_client.go
Normal 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
|
||||
}
|
||||
103
database/migration_http_client_test.go
Normal file
103
database/migration_http_client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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++
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
2
frontend
2
frontend
|
|
@ -1 +1 @@
|
|||
Subproject commit 1da3ca9f92f227574c8f77797aa24803d7d913c8
|
||||
Subproject commit b5565d125aa0f4d91ee916fa946071636766a3e4
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
158
sub/jsonService_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue