mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
41bafb3fd5
The per-host Mihomo X25519 switch has been stored since managed hosts landed, but no renderer read it, while every REALITY proxy was forced to support-x25519mlkem768: true. On some networks DPI drops the second segment of an ML-KEM ClientHello and blackholes the server for that client. A host with the switch on now gets support-x25519mlkem768: false and no ML-KEM hint in its vless link; switches saved while they had no effect are cleared once, so upgrading does not silently change existing subscriptions. A REALITY host's fingerprint now reaches the Clash and JSON outputs, and every host endpoint gets its own deep copy of the stream, so one host's blank SNI, fingerprint, Host header or path no longer inherits another host's value.
1643 lines
46 KiB
Go
1643 lines
46 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"
|
|
"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
|
|
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 hasInactiveExternal 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)
|
|
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 := 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
|
|
}
|
|
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,
|
|
}
|
|
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 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
|
|
}
|
|
|
|
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"},
|
|
} {
|
|
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" {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|