package main import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "regexp" "sort" "strconv" "strings" ) // ---------- ordered map (JS object insertion order for Clash proxies) ---------- type OrderedKV struct { Key string Value any } type OrderedMap []OrderedKV func (m OrderedMap) Get(key string) (any, bool) { for _, kv := range m { if kv.Key == key { return kv.Value, true } } return nil, false } func (m *OrderedMap) Set(key string, value any) { for i := range *m { if (*m)[i].Key == key { (*m)[i].Value = value return } } *m = append(*m, OrderedKV{Key: key, Value: value}) } func (m OrderedMap) Str(key string) string { v, ok := m.Get(key) if !ok || v == nil { return "" } s, _ := v.(string) return s } func (m OrderedMap) Bool(key string) bool { v, _ := m.Get(key) b, _ := v.(bool) return b } // ---------- entries ---------- type SubEntry struct { NodeID string `json:"nodeId"` ClientURI string `json:"clientUri"` } type SubResult struct { Body string ContentType string } func decodeURIComponent(s string) string { out, err := url.PathUnescape(s) if err != nil { return s } return out } func NodeClientURI(d *Data, nc *NodeClient) string { var node *Node for _, item := range d.Nodes { if item.ID == nc.NodeID { node = item break } } if node == nil || node.Status != "active" { return "" } var server *Server for _, item := range d.Servers { if item.ID == node.ServerID { server = item break } } if server == nil { return "" } publicKey := "" if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 { var s string if err := json.Unmarshal(raw, &s); err == nil { publicKey = s } } networkMode := node.NetworkMode if networkMode == "" { networkMode = "ipv4" } creds := Credentials{} if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil { creds = *customer.Credentials } uri, ok := BuildClientURI(BuildClientURIParams{ Protocol: node.Protocol, RelayServer: server, RelayPort: node.Port, ClientID: creds.ClientID, RelayPassword: creds.RelayPassword, ShortID: creds.ShortID, WSPath: xbDeref(node.WSPath), SocksUser: NodeEmail(nc.CustomerID), Reality: node.Reality, PublicKey: publicKey, Name: node.Name, NetworkMode: networkMode, TLSDomain: server.TLSDomain, SSServerKey: xbDeref(node.SSServerKey), }) if !ok { return "" } return uri } // sortIndexOf mirrors sortIndexOf in lib/subscriptions.js. func sortIndexOf(d *Data, nodeID string) int { for _, item := range d.Nodes { if item.ID == nodeID { return item.SortIndex } } return 1 << 30 } func Entries(d *Data, customerID string) []SubEntry { seen := map[string]int{} items := []SubEntry{} bindings := []*NodeClient{} for _, nc := range d.NodeClients { if nc.CustomerID == customerID && nc.Status == "active" { bindings = append(bindings, nc) } } sort.SliceStable(bindings, func(i, j int) bool { return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID) }) for _, nc := range bindings { uri := NodeClientURI(d, nc) if uri == "" { continue } nodeName := nc.NodeID for _, item := range d.Nodes { if item.ID == nc.NodeID { nodeName = item.Name break } } index := seen[nodeName] + 1 seen[nodeName] = index name := nodeName if index != 1 { name = fmt.Sprintf("%s (%d)", nodeName, index) } separator := strings.LastIndex(uri, "#") prefix := uri if separator >= 0 { prefix = uri[:separator] } items = append(items, SubEntry{ NodeID: nc.NodeID, ClientURI: prefix + "#" + encodeURIComponent(name), }) } return items } // ---------- Clash ---------- func q(value any) string { // JSON.stringify(String(value)); String(null) is "null". b, err := marshalJSON(toJSString(value)) if err != nil { panic(err) } return string(b) } func toJSString(value any) string { switch v := value.(type) { case string: return v case bool: if v { return "true" } return "false" case int: return strconv.Itoa(v) case int64: return strconv.FormatInt(v, 10) case float64: return strconv.FormatFloat(v, 'g', -1, 64) case nil: return "null" default: return fmt.Sprintf("%v", v) } } func ClashProxy(item SubEntry) (OrderedMap, error) { uri := item.ClientURI hashIdx := strings.LastIndex(uri, "#") name := decodeURIComponent(uri[hashIdx+1:]) u, err := url.Parse(uri) if err != nil { return nil, err } port := 0 if p := u.Port(); p != "" { if n, err := strconv.Atoi(p); err == nil { port = n } } base := OrderedMap{ {Key: "name", Value: name}, {Key: "server", Value: u.Hostname()}, {Key: "port", Value: port}, } query := u.Query() switch u.Scheme + ":" { case "hysteria2:": password := "" if u.User != nil { password = u.User.Username() } out := append(OrderedMap{}, base...) out = append(out, OrderedKV{Key: "type", Value: "hysteria2"}, OrderedKV{Key: "password", Value: password}, OrderedKV{Key: "sni", Value: queryGetOrNil(query, "sni")}, OrderedKV{Key: "alpn", Value: []string{"h3"}}, ) return out, nil case "anytls:": password := "" if u.User != nil { password = u.User.Username() } out := append(OrderedMap{}, base...) out = append(out, OrderedKV{Key: "type", Value: "anytls"}, OrderedKV{Key: "password", Value: password}, OrderedKV{Key: "sni", Value: queryGetOrNil(query, "sni")}, OrderedKV{Key: "udp", Value: true}, ) return out, nil case "vless:": security := query.Get("security") uuid := "" if u.User != nil { uuid = u.User.Username() } isTLS := security == "reality" || security == "tls" network := "tcp" if query.Get("type") == "ws" { network = "ws" } out := append(OrderedMap{}, base...) out = append(out, OrderedKV{Key: "type", Value: "vless"}, OrderedKV{Key: "uuid", Value: uuid}, OrderedKV{Key: "tls", Value: isTLS}, OrderedKV{Key: "network", Value: network}, ) if network == "ws" { path := query.Get("path") if path == "" { path = "/" } out = append(out, OrderedKV{Key: "ws-opts", Value: OrderedMap{{Key: "path", Value: path}}}) } if isTLS { out = append(out, OrderedKV{Key: "servername", Value: queryGetOrNil(query, "sni")}) if security == "reality" { fp := query.Get("fp") if fp == "" { fp = "chrome" } out = append(out, OrderedKV{Key: "client-fingerprint", Value: fp}, OrderedKV{Key: "reality-opts", Value: OrderedMap{ {Key: "public-key", Value: queryGetOrNil(query, "pbk")}, {Key: "short-id", Value: queryGetOrNil(query, "sid")}, }}, ) } if _, has := query["flow"]; has { out = append(out, OrderedKV{Key: "flow", Value: query.Get("flow")}) } } return out, nil case "ss:": username := "" if u.User != nil { username = u.User.Username() } raw, err := decodeBase64URL(username) if err != nil { return nil, errors.New("Invalid Shadowsocks URI") } decoded := string(raw) separator := strings.Index(decoded, ":") if separator < 0 { return nil, errors.New("Invalid Shadowsocks URI") } out := append(OrderedMap{}, base...) out = append(out, OrderedKV{Key: "type", Value: "ss"}, OrderedKV{Key: "cipher", Value: decoded[:separator]}, OrderedKV{Key: "password", Value: decoded[separator+1:]}, ) return out, nil case "socks5:": username, password := "", "" if u.User != nil { username = u.User.Username() password, _ = u.User.Password() } out := append(OrderedMap{}, base...) out = append(out, OrderedKV{Key: "type", Value: "socks5"}, OrderedKV{Key: "username", Value: username}, OrderedKV{Key: "password", Value: password}, ) return out, nil } return nil, errors.New("Unsupported client URI") } func queryGetOrNil(query url.Values, key string) any { vals, ok := query[key] if !ok || len(vals) == 0 { return nil } return vals[0] } func decodeBase64URL(s string) ([]byte, error) { if raw, err := base64.RawURLEncoding.DecodeString(s); err == nil { return raw, nil } padded := s for len(padded)%4 != 0 { padded += "=" } return base64.URLEncoding.DecodeString(padded) } func yamlValue(value any, indent int) string { switch v := value.(type) { case []string: raw, err := marshalJSON(v) if err != nil { panic(err) } return string(raw) case []any: raw, err := marshalJSON(v) if err != nil { panic(err) } return string(raw) case OrderedMap: parts := make([]string, 0, len(v)) pad := strings.Repeat(" ", indent) for _, kv := range v { if child, isObj := kv.Value.(OrderedMap); isObj { parts = append(parts, pad+kv.Key+": "+"\n"+yamlValue(child, indent+2)) } else { parts = append(parts, pad+kv.Key+": "+yamlValue(kv.Value, 0)) } } return strings.Join(parts, "\n") case bool: if v { return "true" } return "false" case int: return strconv.Itoa(v) case int64: return strconv.FormatInt(v, 10) case float64: return strconv.FormatFloat(v, 'g', -1, 64) case json.Number: return v.String() default: return q(value) } } func MakeClash(items []SubEntry, smart bool) string { reserved := []string{"节点选择", "DIRECT", "REJECT"} if smart { reserved = []string{"节点选择", "自动选择", "流媒体", "AI 服务", "Meta 服务", "DIRECT", "REJECT"} } used := map[string]bool{} for _, r := range reserved { used[r] = true } proxies := make([]OrderedMap, 0, len(items)) for _, item := range items { proxy, err := ClashProxy(item) if err != nil { panic(err) } name := proxy.Str("name") finalName := name if used[finalName] { index := 1 for { suffix := "" if index != 1 { suffix = fmt.Sprintf(" %d", index) } finalName = fmt.Sprintf("%s (节点%s)", name, suffix) index++ if !used[finalName] { break } } } used[finalName] = true proxy.Set("name", finalName) proxies = append(proxies, proxy) } names := make([]string, 0, len(proxies)) yamlParts := make([]string, 0, len(proxies)) for _, proxy := range proxies { names = append(names, proxy.Str("name")) y := yamlValue(proxy, 4) if strings.HasPrefix(y, " ") { y = " - " + y[4:] } yamlParts = append(yamlParts, y) } yaml := strings.Join(yamlParts, "\n") group := func(name, typ string, members []string) string { var sb strings.Builder sb.WriteString(" - name: " + q(name) + "\n") sb.WriteString(" type: " + typ + "\n") if typ == "url-test" { sb.WriteString(" url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n") } sb.WriteString(" proxies:\n") lines := make([]string, len(members)) for i, m := range members { lines[i] = " - " + q(m) } sb.WriteString(strings.Join(lines, "\n")) sb.WriteString("\n") return sb.String() } var groups string if smart { groups = group("节点选择", "select", append([]string{"自动选择"}, append(append([]string{}, names...), "DIRECT")...)) + group("自动选择", "url-test", names) + group("流媒体", "select", append([]string{"节点选择", "自动选择"}, names...)) + group("AI 服务", "select", append([]string{"节点选择", "自动选择"}, names...)) + group("Meta 服务", "select", append([]string{"节点选择", "自动选择"}, names...)) } else { groups = group("节点选择", "select", names) } rules := []string{"MATCH,节点选择"} if smart { rules = []string{ "GEOSITE,category-ads-all,REJECT", "GEOSITE,private,DIRECT", "GEOSITE,openai,AI 服务", "GEOSITE,netflix,流媒体", "GEOSITE,facebook,Meta 服务", "DOMAIN-SUFFIX,fbcdn.net,Meta 服务", "DOMAIN-SUFFIX,instagram.com,Meta 服务", "DOMAIN-SUFFIX,whatsapp.net,Meta 服务", "GEOSITE,geolocation-!cn,节点选择", "GEOSITE,cn,DIRECT", "GEOIP,CN,DIRECT", "MATCH,节点选择", } } ruleLines := make([]string, len(rules)) for i, r := range rules { ruleLines[i] = " - " + q(r) } return "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n" + yaml + "\nproxy-groups:\n" + groups + "rules:\n" + strings.Join(ruleLines, "\n") + "\n" } // ---------- Surge ---------- // MakeSurge mirrors makeSurge; ok=false mirrors JS null. func MakeSurge(items []SubEntry) (string, bool) { proxies := []OrderedMap{} for _, item := range items { proxy, err := ClashProxy(item) if err != nil { panic(err) } typ := proxy.Str("type") if typ == "ss" || typ == "socks5" { proxies = append(proxies, proxy) } } if len(proxies) == 0 { return "", false } safeReplacer := strings.NewReplacer("\r", "_", "\n", "_", ",", "_", "=", "_") safe := func(v string) string { return safeReplacer.Replace(v) } policies := make([]string, 0, len(proxies)) names := make([]string, 0, len(proxies)) for _, proxy := range proxies { prefix := safe(proxy.Str("name")) + " = " server, _ := proxy.Get("server") port, _ := proxy.Get("port") if proxy.Str("type") == "ss" { policies = append(policies, fmt.Sprintf("%sss, %s, %s, encrypt-method=%s, password=%s", prefix, toJSString(server), toJSString(port), proxy.Str("cipher"), safe(proxy.Str("password")))) } else { policies = append(policies, fmt.Sprintf("%ssocks5, %s, %s, %s, %s", prefix, toJSString(server), toJSString(port), safe(proxy.Str("username")), safe(proxy.Str("password")))) } names = append(names, safe(proxy.Str("name"))) } joined := strings.Join(names, ", ") body := "[General]\nloglevel = notify\n[Proxy]\n" + strings.Join(policies, "\n") + "\n[Proxy Group]\n节点选择 = select, " + joined + ", DIRECT\n自动选择 = url-test, " + joined + ", url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n" return body, true } // ---------- sing-box ---------- type sbTransport struct { Type string `json:"type"` Path string `json:"path"` } type sbReality struct { Enabled bool `json:"enabled"` PublicKey string `json:"public_key"` ShortID string `json:"short_id"` } type sbTLS struct { Enabled bool `json:"enabled"` ServerName string `json:"server_name"` Alpn []string `json:"alpn,omitempty"` Reality *sbReality `json:"reality,omitempty"` } type sbOutbound struct { Type string `json:"type"` Tag string `json:"tag"` Server string `json:"server,omitempty"` ServerPort int `json:"server_port,omitempty"` Method string `json:"method,omitempty"` Version string `json:"version,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` UUID string `json:"uuid,omitempty"` Flow string `json:"flow,omitempty"` Transport *sbTransport `json:"transport,omitempty"` TLS *sbTLS `json:"tls,omitempty"` Outbounds []string `json:"outbounds,omitempty"` } type sbInbound struct { Type string `json:"type"` Tag string `json:"tag"` Listen string `json:"listen"` ListenPort int `json:"listen_port"` } type sbConfig struct { Log struct { Level string `json:"level"` } `json:"log"` Inbounds []sbInbound `json:"inbounds"` Outbounds []sbOutbound `json:"outbounds"` Route struct { Final string `json:"final"` } `json:"route"` } func subMarshalIndentJSON(v any) string { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { panic(err) } return buf.String() } func MakeSingBox(items []SubEntry) string { proxies := make([]sbOutbound, 0, len(items)) tags := make([]string, 0, len(items)) for i, item := range items { proxy, err := ClashProxy(item) if err != nil { panic(err) } tag := fmt.Sprintf("node-%d", i+1) tags = append(tags, tag) server := proxy.Str("server") portVal, _ := proxy.Get("port") port, _ := portVal.(int) base := sbOutbound{Tag: tag, Server: server, ServerPort: port} switch proxy.Str("type") { case "ss": base.Type = "shadowsocks" base.Method = proxy.Str("cipher") base.Password = proxy.Str("password") case "hysteria2": base.Type = "hysteria2" base.Password = proxy.Str("password") base.TLS = &sbTLS{Enabled: true, ServerName: proxy.Str("sni"), Alpn: []string{"h3"}} case "anytls": base.Type = "anytls" base.Password = proxy.Str("password") base.TLS = &sbTLS{Enabled: true, ServerName: proxy.Str("sni")} case "socks5": base.Type = "socks" base.Version = "5" base.Username = proxy.Str("username") base.Password = proxy.Str("password") default: // vless base.Type = "vless" base.UUID = proxy.Str("uuid") if flow := proxy.Str("flow"); flow != "" { base.Flow = flow } if proxy.Str("network") == "ws" { path := "/" if wsOpts, ok := proxy.Get("ws-opts"); ok { if om, isOM := wsOpts.(OrderedMap); isOM { if p := om.Str("path"); p != "" { path = p } } } base.Transport = &sbTransport{Type: "ws", Path: path} } if proxy.Bool("tls") { tls := &sbTLS{Enabled: true, ServerName: proxy.Str("servername")} if ro, ok := proxy.Get("reality-opts"); ok && ro != nil { if om, isOM := ro.(OrderedMap); isOM { tls.Reality = &sbReality{Enabled: true, PublicKey: om.Str("public-key"), ShortID: om.Str("short-id")} } } base.TLS = tls } } proxies = append(proxies, base) } cfg := sbConfig{} cfg.Log.Level = "warn" cfg.Inbounds = []sbInbound{{Type: "mixed", Tag: "local", Listen: "127.0.0.1", ListenPort: 2080}} cfg.Outbounds = append([]sbOutbound{{Type: "selector", Tag: "proxy", Outbounds: tags}}, proxies...) cfg.Outbounds = append(cfg.Outbounds, sbOutbound{Type: "direct", Tag: "direct"}) cfg.Route.Final = "proxy" return subMarshalIndentJSON(cfg) } // ---------- format dispatch ---------- var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`) // AggregateEntries mirrors aggregateEntries in lib/subscriptions.js: // own entries (sortIndex order) → manual links → external sources, // deduped by scheme+host+port+credential fingerprint, first wins. func AggregateEntries(d *Data, customer *Customer) []SubEntry { own := Entries(d, customer.ID) out := []SubEntry{} seenKeys := map[string]bool{} seenNames := map[string]int{} push := func(uri, name string) { if uri == "" { return } key := DedupKey(uri) if key != "" && seenKeys[key] { return } if key != "" { seenKeys[key] = true } index := seenNames[name] + 1 seenNames[name] = index finalName := name if index != 1 { finalName = fmt.Sprintf("%s (%d)", name, index) } out = append(out, SubEntry{ClientURI: WithName(uri, finalName)}) } for _, entry := range own { key := DedupKey(entry.ClientURI) if key != "" { seenKeys[key] = true } seenNames[NameFromURI(entry.ClientURI)]++ out = append(out, entry) } for i, link := range customer.ManualLinks { if !link.Enabled { continue } name := link.Remark if name == "" { name = NameFromURI(link.URI) } if name == "" { name = fmt.Sprintf("手动节点 %d", i+1) } push(link.URI, name) } for _, source := range customer.ExternalSources { if !source.Enabled { continue } entry := d.SubSourceCache[source.ID] if entry == nil || entry.OkAt == nil { continue } for _, item := range entry.Items { name := item.Name if name == "" { name = "未命名节点" } if source.Remark != "" { name = "[" + source.Remark + "] " + name } push(item.URI, name) } } return out } func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) { items := AggregateEntries(d, customer) if len(items) == 0 { return nil, false } selected := format if format == "auto" { if clashUARe.MatchString(userAgent) { selected = "clash" } else { selected = "base64" } } joined := func() string { uris := make([]string, len(items)) for i, item := range items { uris[i] = item.ClientURI } return strings.Join(uris, "\n") + "\n" } switch selected { case "raw": return &SubResult{Body: joined(), ContentType: "text/plain; charset=utf-8"}, true case "base64": return &SubResult{Body: base64.StdEncoding.EncodeToString([]byte(joined())), ContentType: "text/plain; charset=utf-8"}, true case "clash", "clash-smart": return &SubResult{Body: MakeClash(items, selected == "clash-smart"), ContentType: "text/yaml; charset=utf-8"}, true case "singbox": return &SubResult{Body: MakeSingBox(items), ContentType: "application/json; charset=utf-8"}, true case "surge": body, ok := MakeSurge(items) if !ok { return nil, false } return &SubResult{Body: body, ContentType: "text/plain; charset=utf-8"}, true } return nil, false }