mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
7163c6e521
genTuicLink and genAmneziaWGLink, and their Clash and JSON siblings, parsed the inbound's whole settings, every client included, for each link they built, bypassing the per-request caches the other generators use. Building the links of n clients of one inbound, as the export of all links does, cost n full parses of an n-client document. Typed per-request caches next to settingsByInbound now hold the decoded TUIC instance, indexed by email, and the AmneziaWG server block, and every generator reads through them.
1726 lines
48 KiB
Go
1726 lines
48 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"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"
|
|
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
|
)
|
|
|
|
type SubClashService struct {
|
|
enableRouting bool
|
|
clashRules string
|
|
clashRemoteDNS bool
|
|
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, clashRemoteDNS bool, subService *SubService) *SubClashService {
|
|
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, clashRemoteDNS: clashRemoteDNS, 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 ordered []orderedSubEntry[func() []map[string]any]
|
|
var hasInactiveExternal bool
|
|
var hasBoundClosed bool
|
|
var hasEnabledClient bool
|
|
|
|
seenEmails := make(map[string]struct{})
|
|
for _, inbound := range inbounds {
|
|
clients := subReq.matchingClients(inbound, subId)
|
|
if len(clients) == 0 {
|
|
continue
|
|
}
|
|
if inbound.ExcludeFromSub {
|
|
if countHiddenClients(clients, seenEmails) {
|
|
hasEnabledClient = true
|
|
}
|
|
continue
|
|
}
|
|
subReq.projectThroughFallbackMaster(inbound)
|
|
var baseEps []map[string]any
|
|
baseStream := inbound.StreamSettings
|
|
if !subReq.hostsFirst {
|
|
if baseEps = subReq.hostEndpoints(inbound, "clash"); len(baseEps) > 0 {
|
|
injectExternalProxy(inbound, baseEps)
|
|
}
|
|
}
|
|
for _, client := range clients {
|
|
if client.Enable {
|
|
hasEnabledClient = true
|
|
}
|
|
seenEmails[client.Email] = struct{}{}
|
|
if subReq.hostsFirst {
|
|
variants, closed := subReq.clientHostVariants(inbound, client, "clash")
|
|
hasBoundClosed = hasBoundClosed || closed
|
|
for _, variant := range variants {
|
|
render := func() []map[string]any {
|
|
return s.budgetedProxies(subReq, variant.inbound(subReq, inbound), client, host)
|
|
}
|
|
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
|
|
}
|
|
continue
|
|
}
|
|
effective := inbound
|
|
if effectiveEps, bound := subReq.clientHostEndpoints(inbound, client, "clash", baseEps); bound {
|
|
if len(effectiveEps) == 0 {
|
|
hasBoundClosed = true
|
|
continue
|
|
}
|
|
clone := *inbound
|
|
clone.StreamSettings = baseStream
|
|
injectExternalProxy(&clone, effectiveEps)
|
|
effective = &clone
|
|
}
|
|
proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...)
|
|
}
|
|
}
|
|
if subReq.hostsFirst {
|
|
for _, render := range orderedSubValues(ordered) {
|
|
proxies = append(proxies, render()...)
|
|
}
|
|
}
|
|
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 && !hasBoundClosed {
|
|
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 := subReq.subscriptionUserinfo(traffic)
|
|
|
|
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, s.clashRemoteDNS); 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
|
|
}
|
|
// Legacy endpoints directly block obfs nodes.
|
|
if plugin, _ := proxy["plugin"].(string); plugin == "obfs" {
|
|
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
|
|
}
|
|
// A pre-26.9.8 core accepts a plain X25519 hello, which some DPI lets
|
|
// through where it drops the two-segment ML-KEM one.
|
|
if hostX25519Only(extPrxy) {
|
|
if opts, ok := proxy["reality-opts"].(map[string]any); ok {
|
|
opts["support-x25519mlkem768"] = false
|
|
}
|
|
}
|
|
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,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if !s.applyTransport(proxy, network, stream) {
|
|
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 certFingerprint := mihomoCertFingerprint(inner["pinnedPeerCertSha256"]); certFingerprint != "" {
|
|
proxy["fingerprint"] = certFingerprint
|
|
}
|
|
}
|
|
}
|
|
if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
|
|
proxy["skip-cert-verify"] = true
|
|
}
|
|
if certFingerprint := mihomoCertFingerprint(ep["pinnedPeerCertSha256"]); certFingerprint != "" {
|
|
proxy["fingerprint"] = certFingerprint
|
|
}
|
|
|
|
// Salamander obfs (Hysteria2), read from the same finalmask.udp[salamander]
|
|
// block as the link generator; a packetSize range is mihomo's gecko obfs.
|
|
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
|
|
packetSize, _ := settings["packetSize"].(string)
|
|
if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
|
|
minStr, maxStr := splitHysteriaPacketSize(gecko)
|
|
proxy["obfs"] = "gecko"
|
|
proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
|
|
proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Mihomo supports only one certificate fingerprint, so mihomoCertFingerprint
|
|
// converts the first valid SHA-256 pin to its colon-separated TLS form.
|
|
func mihomoCertFingerprint(value any) string {
|
|
var pins []string
|
|
switch typed := value.(type) {
|
|
case []any:
|
|
for _, item := range typed {
|
|
if pin, ok := item.(string); ok {
|
|
pins = append(pins, pin)
|
|
}
|
|
}
|
|
case []string:
|
|
pins = typed
|
|
case string:
|
|
pins = strings.Split(typed, ",")
|
|
}
|
|
|
|
for _, pin := range pins {
|
|
normalized := hysteriaPinHex(pin)
|
|
if len(normalized) != 64 {
|
|
continue
|
|
}
|
|
if _, err := hex.DecodeString(normalized); err != nil {
|
|
continue
|
|
}
|
|
normalized = strings.ToUpper(normalized)
|
|
var out strings.Builder
|
|
out.Grow(95)
|
|
for i := 0; i < len(normalized); i += 2 {
|
|
if i > 0 {
|
|
out.WriteByte(':')
|
|
}
|
|
out.WriteString(normalized[i : i+2])
|
|
}
|
|
return out.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
inboundSettings := subReq.linkSettings(inbound)
|
|
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, ok := keepAliveForScalarExport(client); ok {
|
|
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 := subReq.tuicInstanceForLink(inbound)
|
|
if inst == nil {
|
|
return nil
|
|
}
|
|
uuid := client.ID
|
|
password := client.Password
|
|
if c := inst.byEmail[client.Email]; c != nil {
|
|
if uuid == "" {
|
|
uuid = c.UUID
|
|
}
|
|
if password == "" {
|
|
password = c.Password
|
|
}
|
|
}
|
|
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(subReq *SubService, inbound *model.Inbound, client model.Client) []string {
|
|
if own, ok := subReq.clientForLink(inbound, client.Email); ok && len(own.AllowedIPs) > 0 {
|
|
return own.AllowedIPs
|
|
}
|
|
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
|
|
}
|
|
|
|
server := subReq.amneziaWGServerForLink(inbound)
|
|
if server == nil {
|
|
return nil
|
|
}
|
|
|
|
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, ok := keepAliveForScalarExport(client); ok {
|
|
proxy["persistent-keepalive"] = ka
|
|
}
|
|
|
|
for _, addr := range amneziaWGClientAddresses(subReq, inbound, 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"},
|
|
} {
|
|
v, ok := nonZeroShareValue(xmux[f.src])
|
|
if !ok {
|
|
continue
|
|
}
|
|
if n, isFloat := v.(float64); isFloat {
|
|
// JSON numbers decode as float64, and %v would render 1000000 as "1e+06".
|
|
reuse[f.dst] = strconv.FormatFloat(n, 'f', -1, 64)
|
|
} else {
|
|
reuse[f.dst] = fmt.Sprint(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" {
|
|
// Mihomo's simple-obfs always sends GET /, and xray-core rejects a request whose
|
|
// path is not one of the header's paths, so only a "/" header maps to obfs.
|
|
if proxy["type"] == "ss" && typeStr == "http" && tcpHeaderAcceptsRootPath(header) {
|
|
proxy["plugin"] = "obfs"
|
|
opts := map[string]any{
|
|
"mode": "http",
|
|
}
|
|
if host := extractTCPHeaderHost(header); host != "" {
|
|
opts["host"] = host
|
|
}
|
|
proxy["plugin-opts"] = opts
|
|
return true
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
// tcpHeaderAcceptsRootPath reports whether the inbound's HTTP header accepts "/"; xray-core
|
|
// defaults an absent request path to "/".
|
|
func tcpHeaderAcceptsRootPath(header map[string]any) bool {
|
|
request, _ := header["request"].(map[string]any)
|
|
paths, _ := request["path"].([]any)
|
|
if len(paths) == 0 {
|
|
return true
|
|
}
|
|
for _, p := range paths {
|
|
if p == "/" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func extractTCPHeaderHost(header map[string]any) string {
|
|
request, _ := header["request"].(map[string]any)
|
|
reqHeaders, _ := request["headers"].(map[string]any)
|
|
return findHostInHeaders(reqHeaders)
|
|
}
|
|
|
|
func findHostInHeaders(headers map[string]any) string {
|
|
for k, v := range headers {
|
|
if strings.EqualFold(k, "host") {
|
|
switch val := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(val)
|
|
case []string:
|
|
if len(val) > 0 {
|
|
return strings.TrimSpace(val[0])
|
|
}
|
|
case []any:
|
|
if len(val) > 0 {
|
|
if h, ok := val[0].(string); ok {
|
|
return strings.TrimSpace(h)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type remoteClashKeyClass int
|
|
|
|
const (
|
|
remoteClashRouteGraph remoteClashKeyClass = iota + 1
|
|
remoteClashClientConfig
|
|
)
|
|
|
|
// Client config keys steer the client's own resolver, so a remote URL may set
|
|
// them only when the admin opts in (subClashRemoteDNS).
|
|
var remoteClashKeyClasses = map[string]remoteClashKeyClass{
|
|
"proxy-groups": remoteClashRouteGraph,
|
|
"rule-providers": remoteClashRouteGraph,
|
|
"rules": remoteClashRouteGraph,
|
|
"dns": remoteClashClientConfig,
|
|
"sniffer": remoteClashClientConfig,
|
|
}
|
|
|
|
func remoteClashKeyAllowed(key string, allowClientConfig bool) bool {
|
|
switch remoteClashKeyClasses[key] {
|
|
case remoteClashRouteGraph:
|
|
return true
|
|
case remoteClashClientConfig:
|
|
return allowClientConfig
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// mergeRemoteClashRules copies only keys listed in remoteClashKeyClasses and
|
|
// never mutates remote: cached documents are shared.
|
|
func mergeRemoteClashRules(base map[string]any, remote map[string]any, allowClientConfig bool) error {
|
|
if len(remote) == 0 {
|
|
return fmt.Errorf("remote Clash routing source must be a YAML map")
|
|
}
|
|
|
|
for key, value := range remote {
|
|
if !remoteClashKeyAllowed(key, allowClientConfig) {
|
|
continue
|
|
}
|
|
if remoteClashKeyClasses[key] == remoteClashClientConfig {
|
|
// A malformed dns or sniffer must not cost the client its routing.
|
|
if _, ok := value.(map[string]any); !ok {
|
|
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 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
|
|
}
|