From 7cc933e073a0b7539bef70859f354e62e595d3eb Mon Sep 17 00:00:00 2001 From: kazeyukiro Date: Sat, 3 Oct 2026 07:06:14 +0000 Subject: [PATCH] feat(sub): sing-box CN geo split; harden HY2 URI for Loon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sing-box subscriptions now route geosite-cn + geoip-cn (remote rule-sets) and private IPs to direct, matching Clash CN defaults. HY2 share query params are centralized: always sni/alpn=h3, insecure+allowInsecure when skip-cert, optional mport for hop — aligned with official URI scheme and Loon parsers. --- backend/internal/converter/singbox.go | 30 +++++- backend/internal/node/uri_builders.go | 127 ++++++++++++-------------- 2 files changed, 83 insertions(+), 74 deletions(-) diff --git a/backend/internal/converter/singbox.go b/backend/internal/converter/singbox.go index 5383acc..995a32f 100644 --- a/backend/internal/converter/singbox.go +++ b/backend/internal/converter/singbox.go @@ -134,6 +134,9 @@ func GenerateUserSingboxSubscription(db *gorm.DB, pu models.ProxyUser, req *http // buildSingboxSubscriptionDoc wraps outbounds with a default TUN inbound and // route so official SFI/SFM clients create a system VPN interface. Without // inbounds, outbounds alone never capture traffic (issue #88). +// +// Default split: private + geosite-cn + geoip-cn → direct; everything else → +// selector "proxy" (same idea as Clash GEOSITE,cn / GEOIP,CN). func buildSingboxSubscriptionDoc(outbounds []map[string]interface{}) map[string]interface{} { return map[string]interface{}{ "log": map[string]interface{}{ @@ -144,9 +147,6 @@ func buildSingboxSubscriptionDoc(outbounds []map[string]interface{}) map[string] defaultSingboxTUNInbound(), }, "outbounds": outbounds, - // Route uses rule actions (sniff / hijack-dns) instead of legacy inbound fields - // (removed in 1.13). ip_is_private on *route* rules is still valid; DNS rules - // must not use bare ip_is_private without match_response (deprecated 1.14). "route": map[string]interface{}{ "rules": []map[string]interface{}{ {"action": "sniff"}, @@ -160,7 +160,10 @@ func buildSingboxSubscriptionDoc(outbounds []map[string]interface{}) map[string] }, }, {"ip_is_private": true, "action": "route", "outbound": "direct"}, + {"rule_set": "geosite-cn", "action": "route", "outbound": "direct"}, + {"rule_set": "geoip-cn", "action": "route", "outbound": "direct"}, }, + "rule_set": defaultSingboxCNRuleSets(), "final": "proxy", "auto_detect_interface": true, "default_domain_resolver": map[string]interface{}{ @@ -170,6 +173,27 @@ func buildSingboxSubscriptionDoc(outbounds []map[string]interface{}) map[string] } } +// defaultSingboxCNRuleSets downloads official binary rule-sets (sing-box 1.8+). +// download_detour=direct so first fetch works before proxy is up. +func defaultSingboxCNRuleSets() []map[string]interface{} { + return []map[string]interface{}{ + { + "tag": "geosite-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", + "download_detour": "direct", + }, + { + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": "direct", + }, + } +} + // defaultSingboxDNS uses the post-1.12 server object format (type/tag/server) // and avoids deprecated DNS rule address filters (ip_is_private / ip_cidr without // match_response — removed path in 1.16). Private destinations are handled by diff --git a/backend/internal/node/uri_builders.go b/backend/internal/node/uri_builders.go index 1a1d380..45ffc96 100644 --- a/backend/internal/node/uri_builders.go +++ b/backend/internal/node/uri_builders.go @@ -354,7 +354,56 @@ func trojanURIs(name, host, port string, cfg map[string]interface{}) ([]string, return result, nil } +// hysteria2URIParams builds query params for official hy2 / hysteria2 URIs. +// Loon accepts both schemes and expects sni + insecure=1 when skipping verify; +// allowInsecure is kept for Meta/Clash-style parsers. +func hysteria2URIParams(cfg map[string]interface{}, host string) map[string]string { + params := map[string]string{} + sni, skip := resolveURITLS(cfg, host) + if sni == "" && host != "" { + sni = strings.Trim(host, "[]") + } + if sni != "" { + params["sni"] = sni + } + if skip { + params["insecure"] = "1" + params["allowInsecure"] = "1" + } + if v, ok := firstString(cfg["alpn"]); ok { + params["alpn"] = v + } + if params["alpn"] == "" { + params["alpn"] = "h3" + } + if v, ok := cfg["obfs"].(string); ok && v != "" { + params["obfs"] = v + } + if v, ok := cfg["obfs-password"].(string); ok && v != "" { + params["obfs-password"] = v + } + if v, ok := cfg["up"].(string); ok && v != "" { + params["up"] = v + } + if v, ok := cfg["down"].(string); ok && v != "" { + params["down"] = v + } + for _, key := range []string{"mport", "ports", "port-range"} { + if v, ok := firstString(cfg[key]); ok { + params["mport"] = v + break + } + } + return params +} + func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { + build := func(password string) string { + params := hysteria2URIParams(cfg, host) + userinfo := url.User(password).String() + return addName(query("hysteria2://"+userinfo+"@"+netutil.JoinHostPort(host, port), params), name) + } + if rows := userRows(cfg); len(rows) > 0 { result := make([]string, 0, len(rows)) for _, row := range rows { @@ -362,41 +411,7 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin if password == "" { continue } - params := map[string]string{} - sni, skip := resolveURITLS(cfg, host) - if sni != "" { - params["sni"] = sni - } - if skip { - params["insecure"] = "1" - params["allowInsecure"] = "1" - } - if v, ok := firstString(cfg["alpn"]); ok { - params["alpn"] = v - } - if params["alpn"] == "" { - params["alpn"] = "h3" - } - if params["sni"] == "" && host != "" { - params["sni"] = strings.Trim(host, "[]") - } - if v, ok := cfg["obfs"].(string); ok && v != "" { - params["obfs"] = v - } - if v, ok := cfg["obfs-password"].(string); ok && v != "" { - params["obfs-password"] = v - } - // up/down are bandwidth hints; include them when set so the panel - // credential-row branch stays consistent with the config-embedded map - // users branch below (P3-6). - if v, ok := cfg["up"].(string); ok && v != "" { - params["up"] = v - } - if v, ok := cfg["down"].(string); ok && v != "" { - params["down"] = v - } - userinfo := url.User(password).String() - result = append(result, addName(query("hysteria2://"+userinfo+"@"+netutil.JoinHostPort(host, port), params), name)) + result = append(result, build(password)) } if len(result) > 0 { return result, nil @@ -404,7 +419,6 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin } users := userMap(cfg) if len(users) == 0 { - // Single password / auth string (common for panel-managed HY2). pass := "" if v, ok := cfg["password"].(string); ok { pass = strings.TrimSpace(v) @@ -422,44 +436,15 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin return nil, fmt.Errorf("hysteria2 listener requires at least one user/password for URI export") } result := make([]string, 0, len(users)) - for username, raw := range users { + for _, raw := range users { password, ok := raw.(string) if !ok || password == "" { - return nil, fmt.Errorf("hysteria2 user %q has empty password", username) + continue } - params := map[string]string{} - sni, skip := resolveURITLS(cfg, host) - if sni != "" { - params["sni"] = sni - } - if skip { - params["insecure"] = "1" - params["allowInsecure"] = "1" - } - if v, ok := firstString(cfg["alpn"]); ok { - params["alpn"] = v - } - if params["alpn"] == "" { - params["alpn"] = "h3" - } - if params["sni"] == "" && host != "" { - params["sni"] = strings.Trim(host, "[]") - } - if v, ok := cfg["obfs"].(string); ok && v != "" { - params["obfs"] = v - } - if v, ok := cfg["obfs-password"].(string); ok && v != "" { - params["obfs-password"] = v - } - if v, ok := cfg["up"].(string); ok && v != "" { - params["up"] = v - } - if v, ok := cfg["down"].(string); ok && v != "" { - params["down"] = v - } - _ = username - userinfo := url.User(password).String() - result = append(result, addName(query("hysteria2://"+userinfo+"@"+netutil.JoinHostPort(host, port), params), name)) + result = append(result, build(password)) + } + if len(result) == 0 { + return nil, fmt.Errorf("hysteria2 listener requires at least one user/password for URI export") } return result, nil }