mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-15 19:02:18 +03:00
* fix(sub): gate external Clash shadowsocks links like the inbound path clashProxyFromExternal returned as soon as it had built the ss proxy, so an ss:// link skipped applyTransport/applySecurity: a node whose tcp/http obfuscation Clash cannot express was emitted anyway (mihomo then opens a plain shadowsocks stream at a server that requires the header, and the node silently never connects), and security=tls was silently stripped. The inbound path runs both helpers for every protocol, so the two Clash importers disagreed about the same node. * fix(sub): count a dropped external link in the quota header The client email that feeds AggregateTrafficByEmails was recorded only when a proxy came out of the link, so a node Clash cannot represent also vanished from the Subscription-Userinfo header of every other node in the same subscription — the header reported another client's numbers as the whole subscription's. The inactive-link branch already counted an email without a proxy; make that unconditional so the header describes the subscribers, not the representable subset of their nodes. * docs(sub): describe clashProxyFromExternal by what it does, not by protocol The protocol list in the doc comment went stale the moment the shadowsocks branch stopped returning early, and it restated what the switch already says.
1543 lines
43 KiB
Go
1543 lines
43 KiB
Go
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
|
|
}
|
|
// Count the client even when no proxy comes out of this link, so the
|
|
// quota header does not shrink because a node is unrepresentable in Clash.
|
|
seenEmails[ext.Email] = struct{}{}
|
|
if !ext.Active {
|
|
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 {
|
|
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
|
|
}
|