From 876497db6e0facc94bd3c016984b0e55072f6871 Mon Sep 17 00:00:00 2001 From: VibeProgramm <36777218+VibeProgramm@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:20:18 +0300 Subject: [PATCH] feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326) * feat(sub): add AmneziaWG proxy generation for Clash subscriptions Add buildAmneziaWGProxy to generate mihomo-compatible wireguard proxy entries with amnezia-wg-option sub-block for AmneziaWG inbounds. Previously, AmneziaWG inbounds were silently skipped in Clash subscriptions (buildProxy returned nil), making them unusable with mihomo/Clash clients. The new function reuses the wireguard proxy base structure and adds: - v1.0 obfuscation fields (jc/jmin/jmax/s1-s4/h1-h4/i1-i5) - v1.5 fields (s3/s4/i1-i5) - v3 fields (header-protection-key, content-padding-addition, timing, random-trailers, disable-cookies) with automatic version: 3 tagging Closes #6310 * fix review nits: doc comment and test call * fix(sub): use the AmneziaWG inbound's own tunnel address in Clash proxies buildAmneziaWGProxy took the peer address from model.Client.AllowedIPs, which matchingClients resolves out of the shared clients.wg_allowed_ips column. That column holds one address per identity, so a client attached to both a WireGuard and an AmneziaWG inbound gets the other protocol's address written into its Clash proxy - an unroutable peer, since the running interface accepts only the AllowedIPs InstanceFromInbound derives from the inbound's own settings JSON. Read the address from settings.clients[] and fall back to the shared column. Also emit remote-dns-resolve alongside dns: mihomo gates its whole `dns` list on that flag (adapter/outbound/wireguard.go, NewWireGuard), so the panel's primaryDns/secondaryDns were inert in Clash while the vpn:// .conf turned them into a real DNS line. Restricted to bare IPs - mihomo aborts the entire config when dns.ParseNameServer rejects an entry, and nothing validates those fields. Co-Authored-By: Claude Opus 5 (1M context) * fix(sub): emit the AmneziaWG effective MTU in Clash proxies main's EffectiveMTU landed while this branch was open, so the Clash builder was the one AmneziaWG emitter left omitting mtu when the operator set none. The running interface uses EffectiveMTU (internal/amneziawgnet/device.go), as do the vpn:// .conf and both TS builders; mihomo instead falls back to its own 1408, which sits above the tunnel once s4 passes 12 and fragments every packet the client sends. GenerateObfuscation31 draws s4 from 12..27, so that is the default. Co-Authored-By: Claude Opus 5 (1M context) * fix(sub): reject a zoned DNS address before opting into remote-dns-resolve netip.ParseAddr accepts "fe80::1%eth0", but mihomo's parsePureDNSServer brackets it into "udp://[fe80::1%eth0]" and url.Parse rejects "%et" as a bad escape, so parseNameServer errors and parseProxies aborts the entire config - the whole subscription's Clash profile, not just this proxy (#4641 class). --- internal/sub/clash_service.go | 185 +++++++++++++ internal/sub/clash_service_test.go | 399 +++++++++++++++++++++++++++++ 2 files changed, 584 insertions(+) diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index df52ab9bf..dc6529d1a 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -4,12 +4,14 @@ import ( "errors" "fmt" "maps" + "net/netip" "slices" "strings" "github.com/goccy/go-json" yaml "github.com/goccy/go-yaml" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database/model" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -285,6 +287,9 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, if inbound.Protocol == model.WireGuard { return s.buildWireguardProxy(subReq, inbound, client, ep) } + if inbound.Protocol == model.AmneziaWG { + return s.buildAmneziaWGProxy(subReq, inbound, client, ep) + } network, _ := stream["network"].(string) @@ -492,6 +497,186 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model 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 client.KeepAlive > 0 { + proxy["persistent-keepalive"] = client.KeepAlive + } + + 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. // diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index e785e7164..4dbf9a431 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -1,9 +1,11 @@ package sub import ( + "fmt" "reflect" "testing" + "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" ) @@ -884,6 +886,353 @@ func TestBuildWireguardProxyForClashNoKey(t *testing.T) { } } +func TestBuildAmneziaWGProxyForClash(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1420,"primaryDns":"8.8.8.8","secondaryDns":"8.8.4.4","jc":3,"jmin":66,"jmax":150,"s1":147,"s2":146,"s3":28,"s4":27,"h1":"364198942-470015235","h2":"1041963382-1068354159","h3":"1313106728-1361756201","h4":"1801896583-1875457201","i1":"10-20","i2":"30-40"}}` + + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Remark: "amneziawg", + Settings: settings, + } + client := model.Client{ + Email: "user", + PrivateKey: clientPriv, + PreSharedKey: "psk-value", + KeepAlive: 25, + AllowedIPs: []string{"10.8.1.2/32", "fd00::2/128"}, + } + + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid amneziawg client") + } + if proxy["type"] != "wireguard" { + t.Fatalf("type = %v, want wireguard", proxy["type"]) + } + if proxy["server"] != "203.0.113.7" { + t.Fatalf("server = %v, want 203.0.113.7", proxy["server"]) + } + if proxy["port"] != 51820 { + t.Fatalf("port = %v, want 51820", proxy["port"]) + } + if proxy["private-key"] != clientPriv { + t.Fatalf("private-key = %v, want %v", proxy["private-key"], clientPriv) + } + if proxy["public-key"] != serverPub { + t.Fatalf("public-key = %v, want %v", proxy["public-key"], serverPub) + } + if proxy["pre-shared-key"] != "psk-value" { + t.Fatalf("pre-shared-key = %v, want psk-value", proxy["pre-shared-key"]) + } + if proxy["persistent-keepalive"] != 25 { + t.Fatalf("persistent-keepalive = %v, want 25", proxy["persistent-keepalive"]) + } + if proxy["ip"] != "10.8.1.2" { + t.Fatalf("ip = %v, want 10.8.1.2", proxy["ip"]) + } + if proxy["ipv6"] != "fd00::2" { + t.Fatalf("ipv6 = %v, want fd00::2", proxy["ipv6"]) + } + if proxy["mtu"] != 1420 { + t.Fatalf("mtu = %v, want 1420", proxy["mtu"]) + } + if proxy["udp"] != true { + t.Fatalf("udp = %v, want true", proxy["udp"]) + } + if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"8.8.8.8", "8.8.4.4"}) { + t.Fatalf("dns = %v, want [8.8.8.8 8.8.4.4]", proxy["dns"]) + } + + awg, ok := proxy["amnezia-wg-option"].(map[string]any) + if !ok { + t.Fatal("amnezia-wg-option missing") + } + if awg["jc"] != 3 { + t.Fatalf("jc = %v, want 3", awg["jc"]) + } + if awg["jmin"] != 66 { + t.Fatalf("jmin = %v, want 66", awg["jmin"]) + } + if awg["jmax"] != 150 { + t.Fatalf("jmax = %v, want 150", awg["jmax"]) + } + if awg["s1"] != 147 { + t.Fatalf("s1 = %v, want 147", awg["s1"]) + } + if awg["s2"] != 146 { + t.Fatalf("s2 = %v, want 146", awg["s2"]) + } + if awg["s3"] != 28 { + t.Fatalf("s3 = %v, want 28", awg["s3"]) + } + if awg["s4"] != 27 { + t.Fatalf("s4 = %v, want 27", awg["s4"]) + } + if awg["h1"] != "364198942-470015235" { + t.Fatalf("h1 = %v, want 364198942-470015235", awg["h1"]) + } + if awg["h2"] != "1041963382-1068354159" { + t.Fatalf("h2 = %v, want 1041963382-1068354159", awg["h2"]) + } + if awg["h3"] != "1313106728-1361756201" { + t.Fatalf("h3 = %v, want 1313106728-1361756201", awg["h3"]) + } + if awg["h4"] != "1801896583-1875457201" { + t.Fatalf("h4 = %v, want 1801896583-1875457201", awg["h4"]) + } + if awg["i1"] != "10-20" { + t.Fatalf("i1 = %v, want 10-20", awg["i1"]) + } + if awg["i2"] != "30-40" { + t.Fatalf("i2 = %v, want 30-40", awg["i2"]) + } + // v1.0 fields must NOT set version + if _, ok := awg["version"]; ok { + t.Fatalf("version should not be set for v1.0 obfuscation fields") + } +} + +func TestBuildAmneziaWGProxyForClashV3(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1280,"primaryDns":"1.1.1.1","jc":3,"jmin":66,"jmax":150,"s1":147,"s2":146,"s3":28,"s4":27,"h1":"364198942-470015235","h2":"1041963382-1068354159","h3":"1313106728-1361756201","h4":"1801896583-1875457201","headerProtectionKey":"DmVT7JtmJM8YoHiA2Wp3xPKI5dTXFx83y2JUQkKg1p8=","contentPaddingAddition":"9-31","rekeyAfterTime":"105-125","rekeyTimeout":"3-5","rejectAfterTime":"176-239","keepaliveTimeout":"11-16","maxHandshakeAttempts":"24-41","randomTrailers":true,"disableCookies":true}}` + + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Remark: "amneziawg", + Settings: settings, + } + client := model.Client{ + Email: "user", + PrivateKey: clientPriv, + AllowedIPs: []string{"10.8.1.2/32"}, + } + + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid amneziawg v3 client") + } + + awg, ok := proxy["amnezia-wg-option"].(map[string]any) + if !ok { + t.Fatal("amnezia-wg-option missing") + } + if awg["version"] != 3 { + t.Fatalf("version = %v, want 3", awg["version"]) + } + if awg["header-protection-key"] != "DmVT7JtmJM8YoHiA2Wp3xPKI5dTXFx83y2JUQkKg1p8=" { + t.Fatalf("header-protection-key = %v", awg["header-protection-key"]) + } + if awg["content-padding-addition"] != "9-31" { + t.Fatalf("content-padding-addition = %v", awg["content-padding-addition"]) + } + if awg["rekey-after-time"] != "105-125" { + t.Fatalf("rekey-after-time = %v", awg["rekey-after-time"]) + } + if awg["rekey-timeout"] != "3-5" { + t.Fatalf("rekey-timeout = %v", awg["rekey-timeout"]) + } + if awg["reject-after-time"] != "176-239" { + t.Fatalf("reject-after-time = %v", awg["reject-after-time"]) + } + if awg["keepalive-timeout"] != "11-16" { + t.Fatalf("keepalive-timeout = %v", awg["keepalive-timeout"]) + } + if awg["max-handshake-attempts"] != "24-41" { + t.Fatalf("max-handshake-attempts = %v", awg["max-handshake-attempts"]) + } + if awg["random-trailers"] != true { + t.Fatalf("random-trailers = %v, want true", awg["random-trailers"]) + } + if awg["disable-cookies"] != true { + t.Fatalf("disable-cookies = %v, want true", awg["disable-cookies"]) + } +} + +func TestBuildAmneziaWGProxyForClashNoKey(t *testing.T) { + svc := &SubClashService{SubService: &SubService{}} + settings := `{"server":{"privateKey":"abc","publicKey":"def","jc":3,"jmin":66,"jmax":150}}` + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Settings: settings, + } + client := model.Client{Email: "user"} + + if proxy := svc.buildAmneziaWGProxy(svc.SubService, inbound, client, nil); proxy != nil { + t.Fatalf("buildAmneziaWGProxy = %v, want nil for a keyless amneziawg client", proxy) + } +} + +// TestBuildAmneziaWGProxyForClashPerInboundAddress pins the tunnel address to +// this inbound's own settings.clients[] entry, the one InstanceFromInbound +// turns into the running peer's AllowedIPs. model.Client here is what +// matchingClients hands buildProxy: the shared clients.wg_allowed_ips column, +// which for an identity attached to both wireguard and amneziawg holds the +// other protocol's address. +func TestBuildAmneziaWGProxyForClashPerInboundAddress(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + + `","jc":3,"jmin":66,"jmax":150},"clients":[{"email":"dual@x","publicKey":"` + clientPub + + `","allowedIPs":["10.8.1.5/32","fd00::5/128"],"enable":true}]}` + + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Remark: "amneziawg", + Settings: settings, + } + client := model.Client{ + Email: "dual@x", + PrivateKey: clientPriv, + AllowedIPs: []string{"10.0.0.5/32"}, + } + + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid amneziawg client") + } + if proxy["ip"] != "10.8.1.5" { + t.Fatalf("ip = %v, want 10.8.1.5 (this inbound's own address, not the shared column's 10.0.0.5)", proxy["ip"]) + } + if proxy["ipv6"] != "fd00::5" { + t.Fatalf("ipv6 = %v, want fd00::5", proxy["ipv6"]) + } +} + +// TestBuildAmneziaWGProxyForClashFallsBackToClientAddress covers an inbound +// whose settings.clients[] has no entry for this email: the shared column is +// then the only address there is. +func TestBuildAmneziaWGProxyForClashFallsBackToClientAddress(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + + `","jc":3,"jmin":66,"jmax":150},"clients":[{"email":"someone-else@x","allowedIPs":["10.8.1.9/32"]}]}` + + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Settings: settings, + } + client := model.Client{Email: "user@x", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}} + + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid amneziawg client") + } + if proxy["ip"] != "10.8.1.2" { + t.Fatalf("ip = %v, want 10.8.1.2", proxy["ip"]) + } +} + +// TestBuildAmneziaWGProxyForClashRemoteDNSResolve pins the flag mihomo gates +// its `dns` list on, and the guard that keeps a non-IP entry from turning an +// inert key into a whole-config parse abort. +func TestBuildAmneziaWGProxyForClashRemoteDNSResolve(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + build := func(t *testing.T, primary, secondary string) map[string]any { + t.Helper() + settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + + `","primaryDns":"` + primary + `","secondaryDns":"` + secondary + `"}}` + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Settings: settings, + } + client := model.Client{Email: "user", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}} + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid amneziawg client") + } + return proxy + } + + t.Run("bare IPs", func(t *testing.T) { + proxy := build(t, "8.8.8.8", "fd00::1") + if proxy["remote-dns-resolve"] != true { + t.Fatalf("remote-dns-resolve = %v, want true: mihomo ignores dns without it", proxy["remote-dns-resolve"]) + } + }) + + // netip.ParseAddr accepts a zone, but mihomo brackets the address into a + // udp:// URL whose url.Parse then rejects "%eth0" as a bad escape. + t.Run("zoned IPv6", func(t *testing.T) { + proxy := build(t, "8.8.8.8", "fe80::1%eth0") + if _, ok := proxy["remote-dns-resolve"]; ok { + t.Fatalf("remote-dns-resolve must stay unset for a zoned address, got %v", proxy["remote-dns-resolve"]) + } + }) + + t.Run("non-IP entry", func(t *testing.T) { + proxy := build(t, "8.8.8.8", "dns.example.com") + if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"8.8.8.8", "dns.example.com"}) { + t.Fatalf("dns = %v, want both entries kept", proxy["dns"]) + } + if _, ok := proxy["remote-dns-resolve"]; ok { + t.Fatalf("remote-dns-resolve must stay unset when an entry is not a bare IP, got %v", proxy["remote-dns-resolve"]) + } + }) + + t.Run("no DNS", func(t *testing.T) { + proxy := build(t, "", "") + if _, ok := proxy["remote-dns-resolve"]; ok { + t.Fatal("remote-dns-resolve must stay unset when there is no dns list") + } + }) +} + // TestGetProxies_CustomIPv6ShareAddrIsUnbracketed pins that a Clash "server" is a // bare host: the custom share address stores IPv6 literals bracketed, and mihomo // rejects "[2001:db8::1]" there. @@ -908,3 +1257,53 @@ func TestGetProxies_CustomIPv6ShareAddrIsUnbracketed(t *testing.T) { t.Fatalf("server = %v, want 2001:db8::1", got) } } + +// TestBuildAmneziaWGProxyForClashEffectiveMTU pins the Clash mtu to the same +// amneziawg.EffectiveMTU every other emitter uses -- the running interface +// (amneziawgnet), the vpn:// .conf and both TS builders. Omitting the key +// leaves mihomo on its own 1408 default, above the tunnel once s4 > 12. +func TestBuildAmneziaWGProxyForClashEffectiveMTU(t *testing.T) { + serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + clientPriv, _, err := wgutil.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + build := func(t *testing.T, mtu, s4 int) map[string]any { + t.Helper() + settings := fmt.Sprintf( + `{"server":{"privateKey":%q,"publicKey":%q,"mtu":%d,"s4":%d}}`, + serverPriv, serverPub, mtu, s4) + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.7", + Port: 51820, + Protocol: model.AmneziaWG, + Settings: settings, + } + client := model.Client{Email: "user", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}} + proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil) + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid amneziawg client") + } + return proxy + } + + t.Run("unset MTU falls back to 1420-s4", func(t *testing.T) { + proxy := build(t, 0, 27) + want := amneziawg.EffectiveMTU(0, 27) + if proxy["mtu"] != want { + t.Fatalf("mtu = %v, want %d (amneziawg.EffectiveMTU)", proxy["mtu"], want) + } + }) + + t.Run("explicit MTU wins", func(t *testing.T) { + proxy := build(t, 1380, 27) + if proxy["mtu"] != 1380 { + t.Fatalf("mtu = %v, want 1380", proxy["mtu"]) + } + }) +}