Просмотр исходного кода

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) <[email protected]>

* 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) <[email protected]>

* 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).
VibeProgramm 11 часов назад
Родитель
Сommit
876497db6e
2 измененных файлов с 584 добавлено и 0 удалено
  1. 185 0
      internal/sub/clash_service.go
  2. 399 0
      internal/sub/clash_service_test.go

+ 185 - 0
internal/sub/clash_service.go

@@ -4,12 +4,14 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"maps"
 	"maps"
+	"net/netip"
 	"slices"
 	"slices"
 	"strings"
 	"strings"
 
 
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
 	yaml "github.com/goccy/go-yaml"
 	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/database/model"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	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 {
 	if inbound.Protocol == model.WireGuard {
 		return s.buildWireguardProxy(subReq, inbound, client, ep)
 		return s.buildWireguardProxy(subReq, inbound, client, ep)
 	}
 	}
+	if inbound.Protocol == model.AmneziaWG {
+		return s.buildAmneziaWGProxy(subReq, inbound, client, ep)
+	}
 
 
 	network, _ := stream["network"].(string)
 	network, _ := stream["network"].(string)
 
 
@@ -492,6 +497,186 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
 	return proxy
 	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
 // buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
 // storage into the kebab-case map that Mihomo expects under xhttp-opts.
 // storage into the kebab-case map that Mihomo expects under xhttp-opts.
 //
 //

+ 399 - 0
internal/sub/clash_service_test.go

@@ -1,9 +1,11 @@
 package sub
 package sub
 
 
 import (
 import (
+	"fmt"
 	"reflect"
 	"reflect"
 	"testing"
 	"testing"
 
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	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
 // TestGetProxies_CustomIPv6ShareAddrIsUnbracketed pins that a Clash "server" is a
 // bare host: the custom share address stores IPv6 literals bracketed, and mihomo
 // bare host: the custom share address stores IPv6 literals bracketed, and mihomo
 // rejects "[2001:db8::1]" there.
 // rejects "[2001:db8::1]" there.
@@ -908,3 +1257,53 @@ func TestGetProxies_CustomIPv6ShareAddrIsUnbracketed(t *testing.T) {
 		t.Fatalf("server = %v, want 2001:db8::1", got)
 		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"])
+		}
+	})
+}