feat(sub): sing-box CN geo split; harden HY2 URI for Loon

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.
This commit is contained in:
kazeyukiro committed 2026-10-03 07:06:14 +00:00
1 parent b289523823
commit 7cc933e073
2 files changed
+83 -74

No files matched your search

+27 -3
View File
@@ -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
+56 -71
View File
@@ -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
}