| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541 |
- package sub
- import (
- "errors"
- "fmt"
- "maps"
- "net/netip"
- "slices"
- "strings"
- "github.com/goccy/go-json"
- yaml "github.com/goccy/go-yaml"
- "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/tuic"
- wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
- )
- type SubClashService struct {
- enableRouting bool
- clashRules string
- SubService *SubService
- }
- var errNoLegacyClashProxies = errors.New("no Clash for Windows-compatible proxies found; use the Mihomo subscription for modern proxy types")
- 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) {
- return s.getClash(subId, host, false)
- }
- func (s *SubClashService) GetClashLegacy(subId string, host string) (string, string, error) {
- return s.getClash(subId, host, true)
- }
- func (s *SubClashService) getClash(subId string, host string, legacy bool) (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
- }
- if legacy {
- proxies = legacyClashProxies(proxies)
- if len(proxies) == 0 {
- return "", "", errNoLegacyClashProxies
- }
- }
- 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"},
- }
- // Custom Clash routing can inject Mihomo-only groups, rules, providers or a
- // top-level proxies key — exactly what the legacy filter just removed.
- if s.enableRouting && !legacy {
- 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
- }
- func legacyClashProxies(proxies []map[string]any) []map[string]any {
- compatible := make([]map[string]any, 0, len(proxies))
- for _, proxy := range proxies {
- if filtered := legacyClashProxy(proxy); filtered != nil {
- compatible = append(compatible, filtered)
- }
- }
- return compatible
- }
- func legacyClashProxy(proxy map[string]any) map[string]any {
- proxyType, _ := proxy["type"].(string)
- network, _ := proxy["network"].(string)
- if _, reality := proxy["reality-opts"]; reality {
- return nil
- }
- var fields []string
- var cipher string
- switch proxyType {
- case "vmess":
- if !legacyClashNetwork(network) || !legacyVmessCipher(proxy["cipher"]) {
- return nil
- }
- fields = []string{
- "name", "type", "server", "port", "uuid", "alterId", "cipher", "udp",
- "network", "tls", "skip-cert-verify", "servername", "grpc-opts", "ws-opts",
- }
- case "trojan":
- tls, _ := proxy["tls"].(bool)
- if !tls || !legacyClashNetwork(network) {
- return nil
- }
- fields = []string{
- "name", "type", "server", "port", "password", "alpn", "sni", "skip-cert-verify",
- "udp", "network", "grpc-opts", "ws-opts",
- }
- case "ss":
- tls, _ := proxy["tls"].(bool)
- cipher = legacyShadowsocksCipher(proxy["cipher"])
- if (network != "" && network != "tcp") || tls || cipher == "" {
- return nil
- }
- fields = []string{"name", "type", "server", "port", "password", "cipher", "udp", "plugin", "plugin-opts"}
- default:
- return nil
- }
- filtered := make(map[string]any, len(fields))
- for _, field := range fields {
- if value, exists := proxy[field]; exists {
- filtered[field] = value
- }
- }
- if proxyType == "ss" {
- filtered["cipher"] = cipher
- }
- return filtered
- }
- func legacyClashNetwork(network string) bool {
- switch network {
- case "", "tcp", "ws", "grpc":
- return true
- default:
- return false
- }
- }
- func legacyVmessCipher(value any) bool {
- cipher, _ := value.(string)
- switch strings.ToLower(strings.TrimSpace(cipher)) {
- case "auto", "aes-128-gcm", "chacha20-poly1305", "none":
- return true
- default:
- return false
- }
- }
- func legacyShadowsocksCipher(value any) string {
- cipher, _ := value.(string)
- cipher = strings.ToLower(strings.TrimSpace(cipher))
- switch cipher {
- case "chacha20-poly1305":
- return "chacha20-ietf-poly1305"
- case "aes-128-gcm", "aes-192-gcm", "aes-256-gcm",
- "aes-128-cfb", "aes-192-cfb", "aes-256-cfb",
- "aes-128-ctr", "aes-192-ctr", "aes-256-ctr",
- "rc4-md5", "chacha20-ietf", "xchacha20",
- "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305":
- return cipher
- default:
- return ""
- }
- }
- // 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
- // A Clash "server" is a bare host, not a URI authority, and the custom
- // share address stores IPv6 literals bracketed.
- dest, _ := extPrxy["dest"].(string)
- workingInbound.Listen = strings.Trim(dest, "[]")
- 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)
- }
- if inbound.Protocol == model.TUIC {
- return s.buildTuicProxy(subReq, inbound, client, ep)
- }
- if inbound.Protocol == model.AmneziaWG {
- return s.buildAmneziaWGProxy(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 ka := client.KeepAliveSeconds(); ka > 0 {
- proxy["persistent-keepalive"] = ka
- }
- 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
- }
- func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
- inst, ok := tuic.InstanceFromInbound(inbound)
- if !ok {
- return nil
- }
- uuid := client.ID
- password := client.Password
- for _, c := range inst.Clients {
- if c.Email == client.Email {
- if uuid == "" {
- uuid = c.UUID
- }
- if password == "" {
- password = c.Password
- }
- break
- }
- }
- if uuid == "" || password == "" {
- return nil
- }
- server := inbound.Listen
- if server == "" || server == "0.0.0.0" || server == "::" {
- server = subReq.resolveInboundAddress(inbound)
- }
- proxy := map[string]any{
- "name": subReq.endpointRemark(inbound, client.Email, ep, "tuic"),
- "type": "tuic",
- "server": server,
- "port": inbound.Port,
- "uuid": uuid,
- "password": password,
- "congestion-controller": inst.CongestionControl,
- "udp-relay-mode": inst.UDPRelayMode,
- "reduce-rtt": inst.ZeroRTTHandshake,
- }
- if len(inst.ALPN) > 0 {
- proxy["alpn"] = inst.ALPN
- }
- if inst.SNI != "" {
- proxy["sni"] = inst.SNI
- }
- if sni, ok := externalProxySNI(ep); ok {
- proxy["sni"] = sni
- }
- if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
- proxy["alpn"] = strings.Split(alpn, ",")
- }
- if ai, ok := ep["allowInsecure"].(bool); ok && ai {
- proxy["skip-cert-verify"] = true
- }
- return proxy
- }
- // amneziaWGClientAddresses prefers this inbound's own settings entry over the
- // shared clients.wg_allowed_ips column, which for an identity attached to both
- // a wireguard and an amneziawg inbound holds the other one's address.
- func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string {
- for i := range settingsClients {
- if !strings.EqualFold(settingsClients[i].Email, client.Email) {
- continue
- }
- if len(settingsClients[i].AllowedIPs) > 0 {
- return settingsClients[i].AllowedIPs
- }
- break
- }
- return client.AllowedIPs
- }
- // allBareIPs reports whether every entry is a plain IP address — no port,
- // scheme, and no zone, which mihomo brackets into a udp:// URL it then rejects.
- func allBareIPs(servers []string) bool {
- for _, s := range servers {
- addr, err := netip.ParseAddr(s)
- if err != nil || addr.Zone() != "" {
- return false
- }
- }
- return true
- }
- // buildAmneziaWGProxy emits a mihomo Clash entry for an AmneziaWG inbound:
- // type stays "wireguard", the obfuscation rides in amnezia-wg-option.
- func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
- if client.PrivateKey == "" {
- return nil
- }
- var parsed amneziawg.InboundSettings
- if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
- return nil
- }
- server := parsed.Server
- 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 server.PublicKey != "" {
- proxy["public-key"] = server.PublicKey
- }
- if client.PreSharedKey != "" {
- proxy["pre-shared-key"] = client.PreSharedKey
- }
- if ka := client.KeepAliveSeconds(); ka > 0 {
- proxy["persistent-keepalive"] = ka
- }
- for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) {
- ip := stripCIDR(addr)
- if ip == "" {
- continue
- }
- if strings.Contains(ip, ":") {
- proxy["ipv6"] = ip
- } else {
- proxy["ip"] = ip
- }
- }
- // Always emitted: mihomo's own 1408 default sits above the interface
- // amneziawgnet actually runs once s4 passes 12, so the tunnel fragments.
- proxy["mtu"] = amneziawg.EffectiveMTU(server.MTU, server.S4)
- var dns []string
- if server.PrimaryDNS != "" {
- dns = append(dns, server.PrimaryDNS)
- }
- if server.SecondaryDNS != "" {
- dns = append(dns, server.SecondaryDNS)
- }
- if len(dns) > 0 {
- proxy["dns"] = dns
- // mihomo ignores dns without this flag, but aborts the whole config on
- // a value its dns.ParseNameServer rejects, so only bare IPs opt in.
- if allBareIPs(dns) {
- proxy["remote-dns-resolve"] = true
- }
- }
- awg := map[string]any{}
- if server.Jc != 0 {
- awg["jc"] = server.Jc
- }
- if server.Jmin != 0 {
- awg["jmin"] = server.Jmin
- }
- if server.Jmax != 0 {
- awg["jmax"] = server.Jmax
- }
- if server.S1 != 0 {
- awg["s1"] = server.S1
- }
- if server.S2 != 0 {
- awg["s2"] = server.S2
- }
- if server.S3 != 0 {
- awg["s3"] = server.S3
- }
- if server.S4 != 0 {
- awg["s4"] = server.S4
- }
- if server.H1 != "" {
- awg["h1"] = server.H1
- }
- if server.H2 != "" {
- awg["h2"] = server.H2
- }
- if server.H3 != "" {
- awg["h3"] = server.H3
- }
- if server.H4 != "" {
- awg["h4"] = server.H4
- }
- for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
- if v != "" {
- awg[fmt.Sprintf("i%d", i+1)] = v
- }
- }
- needsV3 := false
- if server.HeaderProtectionKey != "" {
- awg["header-protection-key"] = server.HeaderProtectionKey
- needsV3 = true
- }
- if server.ContentPaddingAddition != "" {
- awg["content-padding-addition"] = server.ContentPaddingAddition
- needsV3 = true
- }
- if server.RekeyAfterTime != "" {
- awg["rekey-after-time"] = server.RekeyAfterTime
- needsV3 = true
- }
- if server.RekeyTimeout != "" {
- awg["rekey-timeout"] = server.RekeyTimeout
- needsV3 = true
- }
- if server.RejectAfterTime != "" {
- awg["reject-after-time"] = server.RejectAfterTime
- needsV3 = true
- }
- if server.KeepaliveTimeout != "" {
- awg["keepalive-timeout"] = server.KeepaliveTimeout
- needsV3 = true
- }
- if server.MaxHandshakeAttempts != "" {
- awg["max-handshake-attempts"] = server.MaxHandshakeAttempts
- needsV3 = true
- }
- if server.RandomTrailers {
- awg["random-trailers"] = true
- needsV3 = true
- }
- if server.DisableCookies {
- awg["disable-cookies"] = true
- needsV3 = true
- }
- if needsV3 {
- awg["version"] = 3
- }
- if len(awg) > 0 {
- proxy["amnezia-wg-option"] = awg
- }
- 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 {
- // Xray 26.9.8+ rejects REALITY handshakes without an ML-KEM key share.
- realityOpts["support-x25519mlkem768"] = true
- proxy["reality-opts"] = realityOpts
- }
- proxy["client-fingerprint"] = "chrome"
- 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
- }
|