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"]) + } + }) +}