package main import ( "fmt" "net" "os" "path/filepath" "regexp" "sort" "strings" ) // readResources loads every *.json in the resource dir, filename-sorted, // exactly like agent.js readResources(). Files hold the *materialized* // resource objects written by applyResource. func readResources() ([]map[string]interface{}, error) { entries, err := os.ReadDir(cfg.ResourceDir) if err != nil { return nil, err } names := []string{} for _, entry := range entries { if strings.HasSuffix(entry.Name(), ".json") { names = append(names, entry.Name()) } } sort.Strings(names) resources := []map[string]interface{}{} for _, name := range names { data, err := os.ReadFile(filepath.Join(cfg.ResourceDir, name)) if err != nil { return nil, err } decoded, err := decodeJSON(data) if err != nil { return nil, err } if m, ok := asMap(decoded); ok { resources = append(resources, m) continue } if decoded == nil { // JS would throw a TypeError on item.inbounds for a null resource. return nil, fmt.Errorf("resource %s is null", name) } // Non-object JSON: JS property access yields undefined everywhere, // which behaves like an empty resource. Mirror with an empty map. resources = append(resources, map[string]interface{}{}) } return resources, nil } var resourceIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{8,100}$`) func safeResourcePath(resourceID string) (string, error) { if !resourceIDRe.MatchString(resourceID) { return "", fmt.Errorf("Invalid resource id") } return filepath.Join(cfg.ResourceDir, resourceID+".json"), nil } func resMeta(res map[string]interface{}) map[string]interface{} { meta, _ := asMap(res["meta"]) return meta } func isLocalAddress(text string) bool { target := net.ParseIP(text) if target == nil { return false } ifaces, err := net.Interfaces() if err != nil { return false } for _, iface := range ifaces { addrs, err := iface.Addrs() if err != nil { continue } for _, addr := range addrs { var ip net.IP switch a := addr.(type) { case *net.IPNet: ip = a.IP case *net.IPAddr: ip = a.IP } if ip != nil && ip.Equal(target) { return true } } } return false } // normalizeInboundListen mirrors agent.js: a public IP that is not assigned // to a local interface (typical NAT cloud VM) cannot be bound, so fall back // to the wildcard address of the same family. func normalizeInboundListen(inbound interface{}) interface{} { m, ok := asMap(inbound) if !ok { return inbound } raw, isStr := m["listen"].(string) if !isStr { return inbound } text := strings.TrimSpace(raw) if text == "" || net.ParseIP(text) == nil || text == "0.0.0.0" || text == "::" { return inbound } if isLocalAddress(text) { return inbound } fallback := "0.0.0.0" if strings.Contains(text, ":") { fallback = "::" } logf("Listen %s 不是本机地址,回退为 %s", text, fallback) out := make(map[string]interface{}, len(m)) for k, v := range m { out[k] = v } out["listen"] = fallback return out } // Ordered structs keep the generated sing-box config's top-level field order // identical to the JS template literal in combinedSingBoxConfig (nested // resource objects keep their own keys, reserialized alphabetically by Go — // semantically irrelevant to sing-box). type combinedLog struct { Level string `json:"level"` Output string `json:"output"` Timestamp bool `json:"timestamp"` } type combinedRoute struct { Rules []interface{} `json:"rules"` Final string `json:"final"` } type v2rayAPIStats struct { Enabled bool `json:"enabled"` Inbounds []interface{} `json:"inbounds"` Users []interface{} `json:"users"` } type v2rayAPI struct { Listen string `json:"listen"` Stats v2rayAPIStats `json:"stats"` } type combinedExperimental struct { V2RayAPI v2rayAPI `json:"v2ray_api"` } type combinedConfig struct { Log combinedLog `json:"log"` Inbounds []interface{} `json:"inbounds"` Outbounds []interface{} `json:"outbounds"` Route combinedRoute `json:"route"` Experimental combinedExperimental `json:"experimental"` } // combinedSingBoxConfig merges all resources into the single sing-box config // — agent.js combinedSingBoxConfig(). func combinedSingBoxConfig(resources []map[string]interface{}) combinedConfig { inbounds := []interface{}{} statInbounds := []interface{}{} statUsers := []interface{}{} outbounds := []interface{}{map[string]interface{}{"type": "direct", "tag": "ng-fallback-direct"}} rules := []interface{}{} for _, res := range resources { resInbounds := asSlice(res["inbounds"]) for _, inbound := range resInbounds { inbounds = append(inbounds, normalizeInboundListen(inbound)) if im, ok := asMap(inbound); ok { statInbounds = append(statInbounds, im["tag"]) for _, user := range asSlice(im["users"]) { um, ok := asMap(user) if !ok { continue } name := um["name"] if !jsTruthy(name) { name = um["username"] // AnyTLS/SS use name, SOCKS5 username } if jsTruthy(name) { statUsers = append(statUsers, name) } } } } outbounds = append(outbounds, asSlice(res["outbounds"])...) rules = append(rules, asSlice(res["routingRules"])...) } return combinedConfig{ Log: combinedLog{Level: "info", Output: cfg.SingBoxLog, Timestamp: true}, Inbounds: inbounds, Outbounds: outbounds, Route: combinedRoute{Rules: rules, Final: "ng-fallback-direct"}, Experimental: combinedExperimental{V2RayAPI: v2rayAPI{ Listen: "127.0.0.1:" + cfg.APIPort, Stats: v2rayAPIStats{Enabled: true, Inbounds: statInbounds, Users: statUsers}, }}, } }