package sub import ( "fmt" "maps" "strings" "github.com/goccy/go-json" yaml "github.com/goccy/go-yaml" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/web/service" ) type SubClashService struct { inboundService service.InboundService SubService *SubService directRules []string } type ClashConfig struct { Proxies []map[string]any `yaml:"proxies"` ProxyGroups []map[string]any `yaml:"proxy-groups"` Rules []string `yaml:"rules"` } func NewSubClashService(subService *SubService, rules string) *SubClashService { return &SubClashService{ SubService: subService, directRules: xrayDirectRulesToClash(rules), } } func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { // Set per-request state so resolveInboundAddress sees the node map. s.SubService.PrepareForRequest(host) inbounds, err := s.SubService.getInboundsBySubId(subId) if err != nil || len(inbounds) == 0 { return "", "", err } var proxies []map[string]any seenEmails := make(map[string]struct{}) for _, inbound := range inbounds { clients, err := s.inboundService.GetClients(inbound) if err != nil { logger.Error("SubClashService - GetClients: Unable to get clients from inbound") } if clients == nil { continue } s.SubService.projectThroughFallbackMaster(inbound) for _, client := range clients { if client.SubID == subId { seenEmails[client.Email] = struct{}{} proxies = append(proxies, s.getProxies(inbound, client, host)...) } } } if len(proxies) == 0 { return "", "", nil } ensureUniqueProxyNames(proxies) emails := make([]string, 0, len(seenEmails)) for e := range seenEmails { emails = append(emails, e) } traffic, _ := s.SubService.AggregateTrafficByEmails(emails) proxyNames := make([]string, 0, len(proxies)+1) for _, proxy := range proxies { if name, ok := proxy["name"].(string); ok && name != "" { proxyNames = append(proxyNames, name) } } proxyNames = append(proxyNames, "DIRECT") rules := make([]string, 0, len(s.directRules)+1) rules = append(rules, s.directRules...) rules = append(rules, "MATCH,PROXY") config := ClashConfig{ Proxies: proxies, ProxyGroups: []map[string]any{{ "name": "PROXY", "type": "select", "proxies": proxyNames, }}, Rules: rules, } finalYAML, err := yaml.Marshal(config) if err != nil { return "", "", err } header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) return string(finalYAML), header, nil } // ensureUniqueProxyNames keeps every proxy "name" non-empty and unique: // mihomo rejects the whole config on a duplicate name (the empty string // genRemark returns for a remark-less inbound counts), vanishing the Clash // profile on refresh. See issue #4641. func ensureUniqueProxyNames(proxies []map[string]any) { seen := make(map[string]struct{}, len(proxies)) for i, proxy := range proxies { base, _ := proxy["name"].(string) if base == "" { base = fallbackProxyName(proxy, i) } name := base for n := 2; ; n++ { if _, dup := seen[name]; !dup { break } name = fmt.Sprintf("%s-%d", base, n) } seen[name] = struct{}{} proxy["name"] = name } } func fallbackProxyName(proxy map[string]any, idx int) string { typ, _ := proxy["type"].(string) server, _ := proxy["server"].(string) if typ != "" && server != "" { return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"]) } return fmt.Sprintf("proxy-%d", idx+1) } type xrayDirectRule struct { OutboundTag string `json:"outboundTag"` Domain []string `json:"domain"` IP []string `json:"ip"` } func xrayDirectRulesToClash(raw string) []string { if strings.TrimSpace(raw) == "" { return nil } var xrayRules []xrayDirectRule if err := json.Unmarshal([]byte(raw), &xrayRules); err != nil { return nil } var rules []string for _, rule := range xrayRules { if rule.OutboundTag != "direct" { continue } for _, domain := range rule.Domain { if clashRule := xrayDomainRuleToClash(domain); clashRule != "" { rules = append(rules, clashRule) } } for _, ip := range rule.IP { rules = append(rules, xrayIPRulesToClash(ip)...) } } return dedupeClashRules(rules) } func xrayDomainRuleToClash(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } switch { case strings.HasPrefix(value, "geosite:"): tag := strings.TrimSpace(strings.TrimPrefix(value, "geosite:")) if tag == "" { return "" } return fmt.Sprintf("GEOSITE,%s,DIRECT", tag) case strings.HasPrefix(value, "domain:"): domain := strings.TrimSpace(strings.TrimPrefix(value, "domain:")) if domain == "" { return "" } return fmt.Sprintf("DOMAIN-SUFFIX,%s,DIRECT", domain) case strings.HasPrefix(value, "full:"): domain := strings.TrimSpace(strings.TrimPrefix(value, "full:")) if domain == "" { return "" } return fmt.Sprintf("DOMAIN,%s,DIRECT", domain) case strings.HasPrefix(value, "keyword:"): keyword := strings.TrimSpace(strings.TrimPrefix(value, "keyword:")) if keyword == "" { return "" } return fmt.Sprintf("DOMAIN-KEYWORD,%s,DIRECT", keyword) case strings.HasPrefix(value, "regexp:"): return "" default: return fmt.Sprintf("DOMAIN-SUFFIX,%s,DIRECT", value) } } func xrayIPRulesToClash(value string) []string { value = strings.TrimSpace(value) if value == "" { return nil } if strings.HasPrefix(value, "geoip:") { tag := strings.TrimSpace(strings.TrimPrefix(value, "geoip:")) if tag == "" { return nil } if strings.EqualFold(tag, "private") { return []string{ "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,172.16.0.0/12,DIRECT,no-resolve", "IP-CIDR,192.168.0.0/16,DIRECT,no-resolve", "IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,169.254.0.0/16,DIRECT,no-resolve", "IP-CIDR6,fc00::/7,DIRECT,no-resolve", "IP-CIDR6,fe80::/10,DIRECT,no-resolve", "IP-CIDR6,::1/128,DIRECT,no-resolve", } } return []string{fmt.Sprintf("GEOIP,%s,DIRECT", strings.ToUpper(tag))} } if strings.HasPrefix(value, "ext:") { return nil } ruleType := "IP-CIDR" if strings.Contains(value, ":") { ruleType = "IP-CIDR6" } return []string{fmt.Sprintf("%s,%s,DIRECT,no-resolve", ruleType, value)} } func dedupeClashRules(rules []string) []string { if len(rules) == 0 { return nil } seen := make(map[string]struct{}, len(rules)) deduped := make([]string, 0, len(rules)) for _, rule := range rules { if _, ok := seen[rule]; ok { continue } seen[rule] = struct{}{} deduped = append(deduped, rule) } return deduped } func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any { stream := s.streamData(inbound.StreamSettings) // For node-managed inbounds the Clash proxy "server" must be the // node's address, not the request host. resolveInboundAddress handles // the node→subscriber-host fallback chain. defaultDest := s.SubService.resolveInboundAddress(inbound) if defaultDest == "" { defaultDest = host } externalProxies, ok := stream["externalProxy"].([]any) hasExternalProxy := ok && len(externalProxies) > 0 if !hasExternalProxy { externalProxies = []any{map[string]any{ "forceTls": "same", "dest": defaultDest, "port": float64(inbound.Port), "remark": "", }} } delete(stream, "externalProxy") proxies := make([]map[string]any, 0, len(externalProxies)) for _, ep := range externalProxies { extPrxy := ep.(map[string]any) workingInbound := *inbound workingInbound.Listen = extPrxy["dest"].(string) workingInbound.Port = int(extPrxy["port"].(float64)) workingStream := cloneStreamForExternalProxy(stream) switch extPrxy["forceTls"].(string) { case "tls": if workingStream["security"] != "tls" { workingStream["security"] = "tls" workingStream["tlsSettings"] = map[string]any{} } case "none": if workingStream["security"] != "none" { workingStream["security"] = "none" delete(workingStream, "tlsSettings") delete(workingStream, "realitySettings") } } security, _ := workingStream["security"].(string) if hasExternalProxy { applyExternalProxyTLSToStream(extPrxy, workingStream, security) } proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string)) if len(proxy) > 0 { proxies = append(proxies, proxy) } } return proxies } func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any { // Hysteria has its own transport + TLS model, applyTransport / // applySecurity don't fit. if inbound.Protocol == model.Hysteria { return s.buildHysteriaProxy(inbound, client, extraRemark) } proxy := map[string]any{ "name": s.SubService.genRemark(inbound, client.Email, extraRemark), "server": inbound.Listen, "port": inbound.Port, "udp": true, } network, _ := stream["network"].(string) if !s.applyTransport(proxy, network, stream) { return nil } switch inbound.Protocol { case model.VMESS: proxy["type"] = "vmess" proxy["uuid"] = client.ID proxy["alterId"] = 0 cipher := client.Security if cipher == "" { cipher = "auto" } proxy["cipher"] = cipher case model.VLESS: proxy["type"] = "vless" proxy["uuid"] = client.ID if client.Flow != "" && network == "tcp" { proxy["flow"] = client.Flow } var inboundSettings map[string]any json.Unmarshal([]byte(inbound.Settings), &inboundSettings) if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" { proxy["packet-encoding"] = encryption } case model.Trojan: proxy["type"] = "trojan" proxy["password"] = client.Password case model.Shadowsocks: proxy["type"] = "ss" proxy["password"] = client.Password var inboundSettings map[string]any json.Unmarshal([]byte(inbound.Settings), &inboundSettings) method, _ := inboundSettings["method"].(string) if method == "" { return nil } proxy["cipher"] = method if strings.HasPrefix(method, "2022") { if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" { proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password) } } default: return nil } security, _ := stream["security"].(string) if !s.applySecurity(proxy, security, stream) { return nil } return proxy } // buildHysteriaProxy produces a mihomo-compatible Clash entry for a // Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings` // directly instead of going through streamData/tlsData, because those // helpers prune fields (like `allowInsecure` / the salamander obfs // block) that the hysteria proxy wants preserved. func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any { var inboundSettings map[string]any _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) proxyType := "hysteria2" authKey := "password" if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 { proxyType = "hysteria" authKey = "auth-str" } proxy := map[string]any{ "name": s.SubService.genRemark(inbound, client.Email, extraRemark), "type": proxyType, "server": inbound.Listen, "port": inbound.Port, "udp": true, authKey: client.Auth, } var rawStream map[string]any _ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream) // TLS details — hysteria always uses TLS. if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok { if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" { proxy["sni"] = serverName } if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 { out := make([]string, 0, len(alpnList)) for _, a := range alpnList { if s, ok := a.(string); ok && s != "" { out = append(out, s) } } if len(out) > 0 { proxy["alpn"] = out } } if inner, ok := tlsSettings["settings"].(map[string]any); ok { if insecure, ok := inner["allowInsecure"].(bool); ok && insecure { proxy["skip-cert-verify"] = true } if fp, ok := inner["fingerprint"].(string); ok && fp != "" { proxy["client-fingerprint"] = fp } } } // Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander] // block the subscription link generator uses. if finalmask, ok := rawStream["finalmask"].(map[string]any); ok { if udpMasks, ok := finalmask["udp"].([]any); ok { for _, m := range udpMasks { mask, _ := m.(map[string]any) if mask == nil || mask["type"] != "salamander" { continue } settings, _ := mask["settings"].(map[string]any) if pw, ok := settings["password"].(string); ok && pw != "" { proxy["obfs"] = "salamander" proxy["obfs-password"] = pw break } } } } // UDP port hopping. mihomo reads the range from a dedicated `ports` // field (the base `port` stays as the redirect target). if hopPorts := hysteriaHopPorts(rawStream); hopPorts != "" { proxy["ports"] = hopPorts } return proxy } func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool { switch network { case "", "tcp": proxy["network"] = "tcp" tcp, _ := stream["tcpSettings"].(map[string]any) if tcp != nil { header, _ := tcp["header"].(map[string]any) if header != nil { typeStr, _ := header["type"].(string) if typeStr != "" && typeStr != "none" { return false } } } return true case "ws": proxy["network"] = "ws" ws, _ := stream["wsSettings"].(map[string]any) wsOpts := map[string]any{} if ws != nil { if path, ok := ws["path"].(string); ok && path != "" { wsOpts["path"] = path } host := "" if v, ok := ws["host"].(string); ok && v != "" { host = v } else if headers, ok := ws["headers"].(map[string]any); ok { host = searchHost(headers) } if host != "" { wsOpts["headers"] = map[string]any{"Host": host} } } if len(wsOpts) > 0 { proxy["ws-opts"] = wsOpts } return true case "grpc": proxy["network"] = "grpc" grpc, _ := stream["grpcSettings"].(map[string]any) grpcOpts := map[string]any{} if grpc != nil { if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" { grpcOpts["grpc-service-name"] = serviceName } } if len(grpcOpts) > 0 { proxy["grpc-opts"] = grpcOpts } return true case "httpupgrade": proxy["network"] = "httpupgrade" hu, _ := stream["httpupgradeSettings"].(map[string]any) opts := map[string]any{} if hu != nil { if path, ok := hu["path"].(string); ok && path != "" { opts["path"] = path } host := "" if v, ok := hu["host"].(string); ok && v != "" { host = v } else if headers, ok := hu["headers"].(map[string]any); ok { host = searchHost(headers) } if host != "" { opts["headers"] = map[string]any{"Host": host} } } if len(opts) > 0 { proxy["http-upgrade-opts"] = opts } return true case "xhttp": proxy["network"] = "xhttp" xhttp, _ := stream["xhttpSettings"].(map[string]any) opts := map[string]any{} if xhttp != nil { if path, ok := xhttp["path"].(string); ok && path != "" { opts["path"] = path } host := "" if v, ok := xhttp["host"].(string); ok && v != "" { host = v } else if headers, ok := xhttp["headers"].(map[string]any); ok { host = searchHost(headers) } if host != "" { opts["host"] = host } if mode, ok := xhttp["mode"].(string); ok && mode != "" { opts["mode"] = mode } } if len(opts) > 0 { proxy["xhttp-opts"] = opts } return true default: return false } } func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool { switch security { case "", "none": proxy["tls"] = false return true case "tls": proxy["tls"] = true tlsSettings, _ := stream["tlsSettings"].(map[string]any) if tlsSettings != nil { if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" { proxy["servername"] = serverName switch proxy["type"] { case "trojan": proxy["sni"] = serverName } } if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" { proxy["client-fingerprint"] = fingerprint } if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok { out := make([]string, 0, len(alpn)) for _, item := range alpn { if s, ok := item.(string); ok && s != "" { out = append(out, s) } } if len(out) > 0 { proxy["alpn"] = out } } } return true case "reality": proxy["tls"] = true realitySettings, _ := stream["realitySettings"].(map[string]any) if realitySettings == nil { return false } if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" { proxy["servername"] = serverName } realityOpts := map[string]any{} if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" { realityOpts["public-key"] = publicKey } if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" { realityOpts["short-id"] = shortID } if len(realityOpts) > 0 { proxy["reality-opts"] = realityOpts } if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" { proxy["client-fingerprint"] = fingerprint } return true default: return false } } func (s *SubClashService) streamData(stream string) map[string]any { var streamSettings map[string]any json.Unmarshal([]byte(stream), &streamSettings) security, _ := streamSettings["security"].(string) switch security { case "tls": if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok { streamSettings["tlsSettings"] = s.tlsData(tlsSettings) } case "reality": if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok { streamSettings["realitySettings"] = s.realityData(realitySettings) } } delete(streamSettings, "sockopt") return streamSettings } func (s *SubClashService) tlsData(tData map[string]any) map[string]any { tlsData := make(map[string]any, 1) tlsClientSettings, _ := tData["settings"].(map[string]any) tlsData["serverName"] = tData["serverName"] tlsData["alpn"] = tData["alpn"] if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok { tlsData["fingerprint"] = fingerprint } if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 { tlsData["pin-sha256"] = pins } return tlsData } func (s *SubClashService) realityData(rData map[string]any) map[string]any { rDataOut := make(map[string]any, 1) realityClientSettings, _ := rData["settings"].(map[string]any) if publicKey, ok := realityClientSettings["publicKey"].(string); ok { rDataOut["publicKey"] = publicKey } if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok { rDataOut["fingerprint"] = fingerprint } if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 { rDataOut["serverName"] = fmt.Sprint(serverNames[0]) } if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 { rDataOut["shortId"] = fmt.Sprint(shortIDs[0]) } return rDataOut } func cloneMap(src map[string]any) map[string]any { if src == nil { return nil } dst := make(map[string]any, len(src)) maps.Copy(dst, src) return dst }