| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178 |
- package sub
- import (
- "errors"
- "fmt"
- "maps"
- "slices"
- "strings"
- "github.com/goccy/go-json"
- yaml "github.com/goccy/go-yaml"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
- )
- type SubClashService struct {
- enableRouting bool
- clashRules string
- SubService *SubService
- }
- func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
- return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
- }
- func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
- subReq := s.SubService.ForRequest(host)
- subReq.subscriptionBody = true
- inbounds, err := subReq.getInboundsBySubId(subId)
- if err != nil {
- return "", "", err
- }
- externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
- if err != nil {
- return "", "", err
- }
- if len(inbounds) == 0 && len(externalLinks) == 0 {
- return "", "", nil
- }
- var proxies []map[string]any
- var hasInactiveExternal bool
- var hasEnabledClient bool
- seenEmails := make(map[string]struct{})
- for _, inbound := range inbounds {
- clients := subReq.matchingClients(inbound, subId)
- if len(clients) == 0 {
- continue
- }
- subReq.projectThroughFallbackMaster(inbound)
- if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
- injectExternalProxy(inbound, hostEps)
- }
- for _, client := range clients {
- if client.Enable {
- hasEnabledClient = true
- }
- seenEmails[client.Email] = struct{}{}
- proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
- }
- }
- for _, ext := range externalLinks {
- if ext.Enable {
- hasEnabledClient = true
- }
- if !ext.Active {
- seenEmails[ext.Email] = struct{}{}
- hasInactiveExternal = true
- continue
- }
- for _, el := range expandEntry(ext) {
- name := el.Name
- if name == "" {
- name = ext.Email
- }
- if proxy := s.clashProxyFromExternal(el.Link, name); proxy != nil {
- seenEmails[ext.Email] = struct{}{}
- proxies = append(proxies, proxy)
- }
- }
- }
- if len(proxies) == 0 && !hasInactiveExternal {
- return "", "", nil
- }
- emails := make([]string, 0, len(seenEmails))
- for e := range seenEmails {
- emails = append(emails, e)
- }
- slices.Sort(emails)
- traffic, _ := subReq.AggregateTrafficByEmails(emails)
- traffic.Enable = hasEnabledClient
- header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
- if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone {
- dummyProxy := map[string]any{
- "name": remark,
- "type": "socks5",
- "server": "127.0.0.1",
- "port": 1080,
- }
- if mode == infoNodeExpired || mode == infoNodeDepleted {
- proxies = []map[string]any{dummyProxy}
- } else {
- proxies = append([]map[string]any{dummyProxy}, proxies...)
- }
- }
- if len(proxies) == 0 {
- return "", header, nil
- }
- ensureUniqueProxyNames(proxies)
- proxyNames := make([]string, 0, len(proxies)+1)
- for _, proxy := range proxies {
- if isDummyProxy(proxy) && len(proxies) > 1 {
- continue
- }
- if name, ok := proxy["name"].(string); ok && name != "" {
- proxyNames = append(proxyNames, name)
- }
- }
- proxyNames = append(proxyNames, "DIRECT")
- config := map[string]any{
- "proxies": proxies,
- "proxy-groups": []map[string]any{{
- "name": "PROXY",
- "type": "select",
- "proxies": proxyNames,
- }},
- "rules": []string{"MATCH,PROXY"},
- }
- if s.enableRouting {
- resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
- if resolveErr == nil && strings.TrimSpace(resolved) != "" {
- if remote {
- if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
- return "", "", err
- }
- } else if err := mergeClashRulesYAML(config, resolved); err != nil {
- return "", "", err
- }
- }
- }
- finalYAML, err := marshalClashYAML(config)
- if err != nil {
- return "", "", err
- }
- 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 isDummyProxy(proxy map[string]any) bool {
- typ, _ := proxy["type"].(string)
- server, _ := proxy["server"].(string)
- var port int
- switch p := proxy["port"].(type) {
- case int:
- port = p
- case float64:
- port = int(p)
- }
- return typ == "socks5" && server == "127.0.0.1" && port == 1080
- }
- 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)
- }
- func (s *SubClashService) getProxies(subReq *SubService, 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 := subReq.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")
- network, _ := stream["network"].(string)
- proxies := make([]map[string]any, 0, len(externalProxies))
- for _, ep := range externalProxies {
- extPrxy, ok := ep.(map[string]any)
- if !ok {
- continue
- }
- // Expand the host's {{VAR}} remark template for this client (no-op for
- // the synthetic/legacy entry) before it becomes the proxy name.
- subReq.renderHostRemark(inbound, client, extPrxy, network)
- workingInbound := *inbound
- workingInbound.Listen, _ = extPrxy["dest"].(string)
- if port, ok := extPrxy["port"].(float64); ok {
- workingInbound.Port = int(port)
- }
- workingStream := cloneStreamForExternalProxy(stream)
- forceTls, _ := extPrxy["forceTls"].(string)
- switch forceTls {
- 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)
- }
- applyHostStreamOverrides(extPrxy, workingStream)
- proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy)
- if len(proxy) > 0 {
- // Host-only mihomo knob: ip-version is a top-level proxy field, set
- // last so it cannot be clobbered. Absent for legacy externalProxy.
- if v, _ := extPrxy["mihomoIpVersion"].(string); v != "" {
- proxy["ip-version"] = v
- }
- proxies = append(proxies, proxy)
- }
- }
- return proxies
- }
- func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, ep map[string]any) map[string]any {
- // Hysteria has its own transport + TLS model, applyTransport /
- // applySecurity don't fit.
- if inbound.Protocol == model.Hysteria {
- return s.buildHysteriaProxy(subReq, inbound, client, ep)
- }
- if inbound.Protocol == model.WireGuard {
- return s.buildWireguardProxy(subReq, inbound, client, ep)
- }
- network, _ := stream["network"].(string)
- proxy := map[string]any{
- "name": subReq.endpointRemark(inbound, client.Email, ep, network),
- "server": inbound.Listen,
- "port": inbound.Port,
- "udp": true,
- }
- if !s.applyTransport(proxy, network, stream) {
- return nil
- }
- switch inbound.Protocol {
- case model.VMESS:
- proxy["type"] = "vmess"
- proxy["uuid"] = client.ID
- proxy["alterId"] = 0
- proxy["cipher"] = normalizeVmessSecurity(client.Security)
- case model.VLESS:
- proxy["type"] = "vless"
- proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
- inboundSettings := subReq.linkSettings(inbound)
- streamSecurity, _ := stream["security"].(string)
- if client.Flow != "" && !inbound.DisableFlow && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
- proxy["flow"] = client.Flow
- }
- if encryption, ok := inboundSettings["encryption"].(string); ok {
- encryption = strings.TrimSpace(encryption)
- if encryption != "" && encryption != "none" {
- proxy["encryption"] = encryption
- }
- }
- case model.Trojan:
- proxy["type"] = "trojan"
- proxy["password"] = client.Password
- case model.Shadowsocks:
- proxy["type"] = "ss"
- proxy["password"] = client.Password
- inboundSettings := subReq.linkSettings(inbound)
- 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(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
- inboundSettings := subReq.linkSettings(inbound)
- proxyType := "hysteria2"
- authKey := "password"
- if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
- proxyType = "hysteria"
- authKey = "auth-str"
- }
- proxy := map[string]any{
- "name": subReq.endpointRemark(inbound, client.Email, ep, "quic"),
- "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
- }
- }
- }
- if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
- proxy["skip-cert-verify"] = true
- }
- // 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
- }
- // buildWireguardProxy produces a mihomo-compatible Clash entry for a native
- // WireGuard inbound, mirroring genWireguardLink: the peer public key is derived
- // from the inbound secretKey, while the private key, tunnel address, and
- // pre-shared key come from the client. Returns nil when the client has no key.
- func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
- if client.PrivateKey == "" {
- return nil
- }
- var inboundSettings map[string]any
- _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
- secretKey, _ := inboundSettings["secretKey"].(string)
- proxy := map[string]any{
- "name": subReq.endpointRemark(inbound, client.Email, ep, ""),
- "type": "wireguard",
- "server": inbound.Listen,
- "port": inbound.Port,
- "udp": true,
- "private-key": client.PrivateKey,
- }
- if secretKey != "" {
- if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
- proxy["public-key"] = pub
- }
- }
- if client.PreSharedKey != "" {
- proxy["pre-shared-key"] = client.PreSharedKey
- }
- if client.KeepAlive > 0 {
- proxy["persistent-keepalive"] = client.KeepAlive
- }
- for _, addr := range client.AllowedIPs {
- ip := stripCIDR(addr)
- if ip == "" {
- continue
- }
- if strings.Contains(ip, ":") {
- proxy["ipv6"] = ip
- } else {
- proxy["ip"] = ip
- }
- }
- if mtu, ok := inboundSettings["mtu"].(float64); ok && mtu > 0 {
- proxy["mtu"] = int(mtu)
- }
- if dns, _ := inboundSettings["dns"].(string); dns != "" {
- servers := make([]string, 0)
- for server := range strings.SplitSeq(dns, ",") {
- if server = strings.TrimSpace(server); server != "" {
- servers = append(servers, server)
- }
- }
- if len(servers) > 0 {
- proxy["dns"] = servers
- }
- }
- return proxy
- }
- // buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
- // storage into the kebab-case map that Mihomo expects under xhttp-opts.
- //
- // Only client-relevant fields are included (allowlist approach).
- // Server-only fields (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
- // serverMaxHeaderBytes) are automatically excluded because they are not in
- // the mapping. This is intentional — when Mihomo adds new fields, the mapping
- // must be updated explicitly rather than leaking unverified fields to clients.
- //
- // Returns nil if no non-trivial fields are present.
- func buildXhttpClashOpts(xhttp map[string]any) map[string]any {
- if xhttp == nil {
- return nil
- }
- opts := map[string]any{}
- // Direct fields: path, mode
- if v, ok := xhttp["path"].(string); ok && v != "" {
- opts["path"] = v
- }
- if v, ok := xhttp["mode"].(string); ok && v != "" {
- opts["mode"] = v
- }
- // Host: explicit host field wins, then fall back to headers.Host
- 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
- }
- type xhttpStringField struct{ src, dst, skipValue string }
- stringFields := []xhttpStringField{
- {"xPaddingBytes", "x-padding-bytes", ""},
- {"uplinkHTTPMethod", "uplink-http-method", ""},
- {"sessionIDPlacement", "session-id-placement", ""},
- {"sessionIDKey", "session-id-key", ""},
- {"sessionIDTable", "session-id-table", ""},
- {"sessionIDLength", "session-id-length", ""},
- {"seqPlacement", "seq-placement", ""},
- {"seqKey", "seq-key", ""},
- {"uplinkDataPlacement", "uplink-data-placement", ""},
- {"uplinkDataKey", "uplink-data-key", ""},
- {"scMaxEachPostBytes", "sc-max-each-post-bytes", "1000000"},
- {"scMinPostsIntervalMs", "sc-min-posts-interval-ms", "30"},
- }
- for _, f := range stringFields {
- if v, ok := xhttp[f.src].(string); ok && v != "" && (f.skipValue == "" || v != f.skipValue) {
- opts[f.dst] = v
- }
- }
- // Legacy inbounds (pre xray-core #6258) stored sessionPlacement/sessionKey.
- // Fall back to them so not-yet-resaved configs still map. Mirrors the
- // frontend migration.
- for _, f := range []xhttpStringField{
- {"sessionPlacement", "session-id-placement", ""},
- {"sessionKey", "session-id-key", ""},
- } {
- if _, exists := opts[f.dst]; exists {
- continue
- }
- if v, ok := xhttp[f.src].(string); ok && v != "" {
- opts[f.dst] = v
- }
- }
- // Bool fields (truthy only)
- if v, ok := xhttp["noGRPCHeader"].(bool); ok && v {
- opts["no-grpc-header"] = true
- }
- if v, ok := xhttp["xPaddingObfsMode"].(bool); ok && v {
- opts["x-padding-obfs-mode"] = true
- // Padding obfs gated fields
- for _, field := range []struct{ src, dst string }{
- {"xPaddingKey", "x-padding-key"},
- {"xPaddingHeader", "x-padding-header"},
- {"xPaddingPlacement", "x-padding-placement"},
- {"xPaddingMethod", "x-padding-method"},
- } {
- if v, ok := xhttp[field.src].(string); ok && v != "" {
- opts[field.dst] = v
- }
- }
- }
- // Non-zero value fields
- if v, ok := nonZeroShareValue(xhttp["uplinkChunkSize"]); ok {
- opts["uplink-chunk-size"] = v
- }
- // Nested object: xmux → reuse-settings
- if xmux, ok := xhttp["xmux"].(map[string]any); ok && len(xmux) > 0 {
- reuse := map[string]any{}
- for _, f := range []struct{ src, dst string }{
- {"maxConcurrency", "max-concurrency"},
- {"maxConnections", "max-connections"},
- {"cMaxReuseTimes", "c-max-reuse-times"},
- {"hMaxRequestTimes", "h-max-request-times"},
- {"hMaxReusableSecs", "h-max-reusable-secs"},
- } {
- if v, ok := xmux[f.src].(string); ok && v != "" {
- reuse[f.dst] = v
- }
- }
- if v, ok := nonZeroShareValue(xmux["hKeepAlivePeriod"]); ok {
- reuse["h-keep-alive-period"] = v
- }
- if len(reuse) > 0 {
- opts["reuse-settings"] = reuse
- }
- }
- // Headers (drop Host key)
- if rawHeaders, ok := xhttp["headers"].(map[string]any); ok && len(rawHeaders) > 0 {
- out := map[string]any{}
- for k, v := range rawHeaders {
- if strings.EqualFold(k, "host") {
- continue
- }
- out[k] = v
- }
- if len(out) > 0 {
- opts["headers"] = out
- }
- }
- if len(opts) == 0 {
- return nil
- }
- return opts
- }
- 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 := buildXhttpClashOpts(xhttp)
- if opts != nil {
- 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
- }
- }
- if inner, ok := tlsSettings["settings"].(map[string]any); ok {
- if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
- proxy["skip-cert-verify"] = true
- }
- }
- if pins, ok := tlsSettings["pin-sha256"].([]any); ok && len(pins) > 0 {
- proxy["pin-sha256"] = pins
- }
- }
- 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
- }
- func mergeClashRulesYAML(base map[string]any, raw string) error {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return nil
- }
- var custom any
- if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
- mergeClashRules(base, linesToClashRules(raw))
- return nil
- }
- switch typed := custom.(type) {
- case []any:
- mergeClashRules(base, typed)
- case map[string]any:
- for key, value := range typed {
- if key == "rules" {
- if ruleList, ok := asAnySlice(value); ok {
- mergeClashRules(base, ruleList)
- }
- continue
- }
- base[key] = value
- }
- default:
- mergeClashRules(base, linesToClashRules(raw))
- }
- return nil
- }
- // mergeRemoteClashRules lets remote update only the route graph (see
- // remoteClashAllowedKey) and never mutates remote: cached documents are shared.
- func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
- if len(remote) == 0 {
- return fmt.Errorf("remote Clash routing source must be a YAML map")
- }
- for key, value := range remote {
- if !remoteClashAllowedKey(key) {
- continue
- }
- if err := validateRemoteClashValue(key, value); err != nil {
- return err
- }
- switch key {
- case "rules":
- rules, _ := asAnySlice(value)
- mergeClashRules(base, rules)
- case "proxy-groups":
- groups, _ := asAnySlice(value)
- base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups)
- default:
- base[key] = value
- }
- }
- return validateClashRouteGraph(base)
- }
- func validateRemoteClashValue(key string, value any) error {
- switch key {
- case "rules":
- rules, ok := asAnySlice(value)
- if !ok {
- return fmt.Errorf("remote Clash rules must be a list")
- }
- for _, rule := range rules {
- text, ok := rule.(string)
- if !ok || strings.TrimSpace(text) == "" {
- return fmt.Errorf("remote Clash rules must contain non-empty strings")
- }
- }
- case "proxy-groups":
- groups, ok := asAnySlice(value)
- if !ok {
- return fmt.Errorf("remote Clash proxy-groups must be a list")
- }
- seen := make(map[string]struct{}, len(groups))
- for _, groupValue := range groups {
- group, ok := groupValue.(map[string]any)
- if !ok {
- return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
- }
- name, nameOK := group["name"].(string)
- groupType, typeOK := group["type"].(string)
- if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" {
- return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
- }
- name = strings.TrimSpace(name)
- if _, duplicate := seen[name]; duplicate {
- return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name)
- }
- seen[name] = struct{}{}
- if useValue, exists := group["use"]; exists {
- use, ok := asAnySlice(useValue)
- if !ok || len(use) > 0 {
- return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name)
- }
- }
- }
- case "rule-providers":
- providers, ok := value.(map[string]any)
- if !ok {
- return fmt.Errorf("remote Clash rule-providers must be a map")
- }
- for name, provider := range providers {
- if strings.TrimSpace(name) == "" {
- return fmt.Errorf("remote Clash rule-provider name must not be empty")
- }
- if _, ok := provider.(map[string]any); !ok {
- return fmt.Errorf("remote Clash rule-provider %q must be a map", name)
- }
- }
- }
- return nil
- }
- func remoteClashAllowedKey(key string) bool {
- switch key {
- case "proxy-groups", "rule-providers", "rules":
- return true
- default:
- return false
- }
- }
- func validateClashRouteGraph(config map[string]any) error {
- known := map[string]struct{}{
- "DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
- }
- if proxies, ok := asAnySlice(config["proxies"]); ok {
- for _, value := range proxies {
- proxy, ok := value.(map[string]any)
- if !ok {
- continue
- }
- if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" {
- known[strings.TrimSpace(name)] = struct{}{}
- }
- }
- }
- groups, _ := asAnySlice(config["proxy-groups"])
- for _, value := range groups {
- if name := clashProxyGroupName(value); name != "" {
- known[name] = struct{}{}
- }
- }
- for _, value := range groups {
- group, ok := value.(map[string]any)
- if !ok {
- continue
- }
- name := clashProxyGroupName(group)
- refs, exists := group["proxies"]
- if !exists {
- continue
- }
- proxies, ok := asAnySlice(refs)
- if !ok {
- return fmt.Errorf("Clash proxy-group %q proxies must be a list", name)
- }
- for _, refValue := range proxies {
- ref, ok := refValue.(string)
- if !ok || strings.TrimSpace(ref) == "" {
- return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name)
- }
- ref = strings.TrimSpace(ref)
- if _, exists := known[ref]; !exists {
- return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref)
- }
- }
- }
- providers, _ := config["rule-providers"].(map[string]any)
- for providerName, value := range providers {
- provider, ok := value.(map[string]any)
- if !ok {
- continue
- }
- via, ok := provider["proxy"].(string)
- if !ok || strings.TrimSpace(via) == "" {
- continue
- }
- via = strings.TrimSpace(via)
- if _, exists := known[via]; !exists {
- return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via)
- }
- }
- rules, _ := asAnySlice(config["rules"])
- for _, value := range rules {
- rule, ok := value.(string)
- if !ok || strings.TrimSpace(rule) == "" {
- return errors.New("Clash rules must contain non-empty strings")
- }
- parts := strings.Split(rule, ",")
- for i := range parts {
- parts[i] = strings.TrimSpace(parts[i])
- }
- if len(parts) < 2 {
- return fmt.Errorf("invalid Clash rule %q", rule)
- }
- if strings.EqualFold(parts[0], "RULE-SET") {
- if len(parts) < 3 {
- return fmt.Errorf("invalid Clash RULE-SET rule %q", rule)
- }
- if _, exists := providers[parts[1]]; !exists {
- return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1])
- }
- }
- targetIndex := len(parts) - 1
- // Mihomo IP rules may carry trailing no-resolve / src option flags.
- for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) {
- targetIndex--
- }
- if targetIndex < 1 {
- return fmt.Errorf("invalid Clash rule target in %q", rule)
- }
- target := parts[targetIndex]
- if _, exists := known[target]; !exists {
- return fmt.Errorf("Clash rule references unknown proxy or group %q", target)
- }
- }
- return nil
- }
- func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any {
- baseGroups, _ := asAnySlice(baseValue)
- baseByName := make(map[string]any, len(baseGroups))
- baseOrder := make([]string, 0, len(baseGroups))
- for _, group := range baseGroups {
- name := clashProxyGroupName(group)
- if name == "" {
- continue
- }
- baseByName[name] = group
- baseOrder = append(baseOrder, name)
- }
- merged := make([]any, 0, len(remoteGroups)+len(baseGroups))
- seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups))
- for _, group := range remoteGroups {
- name := clashProxyGroupName(group)
- if name == "" {
- continue
- }
- if _, duplicate := seen[name]; duplicate {
- continue
- }
- seen[name] = struct{}{}
- merged = append(merged, group)
- }
- for _, name := range baseOrder {
- if _, replaced := seen[name]; replaced {
- continue
- }
- merged = append(merged, baseByName[name])
- }
- return merged
- }
- func clashProxyGroupName(value any) string {
- group, ok := value.(map[string]any)
- if !ok {
- return ""
- }
- name, _ := group["name"].(string)
- return strings.TrimSpace(name)
- }
- func mergeClashRules(base map[string]any, customRules []any) {
- if len(customRules) == 0 {
- return
- }
- baseRules, _ := asAnySlice(base["rules"])
- if hasClashMatchRule(customRules) {
- base["rules"] = customRules
- return
- }
- merged := make([]any, 0, len(customRules)+len(baseRules))
- merged = append(merged, customRules...)
- merged = append(merged, baseRules...)
- base["rules"] = merged
- }
- func asAnySlice(value any) ([]any, bool) {
- switch typed := value.(type) {
- case []any:
- return typed, true
- case []string:
- out := make([]any, 0, len(typed))
- for _, item := range typed {
- out = append(out, item)
- }
- return out, true
- case []map[string]any:
- out := make([]any, 0, len(typed))
- for _, item := range typed {
- out = append(out, item)
- }
- return out, true
- default:
- return nil, false
- }
- }
- func hasClashMatchRule(rules []any) bool {
- for _, rule := range rules {
- ruleText, ok := rule.(string)
- if !ok {
- continue
- }
- parts := strings.SplitN(ruleText, ",", 2)
- if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
- return true
- }
- }
- return false
- }
- func linesToClashRules(raw string) []any {
- lines := strings.Split(raw, "\n")
- rules := make([]any, 0, len(lines))
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- rules = append(rules, line)
- }
- return rules
- }
|