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:
1 parent
b289523823
commit
7cc933e073
2 files changed
+83
-74
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user