Jelajahi Sumber

fix(amneziawg): stop AAAA fallback on v4-only tunnels and expose I2–I5 (#6611)

* fix(amneziawg): stop AAAA fallback on v4-only tunnels and expose I2–I5

Gate tunnel DNS queries to address families the device can actually dial,
reject undialable literal IPs early, and surface I2–I5 on the outbound form.

Fixes #6570

* ci: retrigger frontend after npm registry maintenance

The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.

* style(amneziawg): keep the tunnel DNS family comments to two lines

CLAUDE.md caps a comment block at two lines.

---------

Co-authored-by: mrchatam <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
mrchatam 9 jam lalu
induk
melakukan
b3a5be9da4

+ 4 - 0
frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx

@@ -79,6 +79,10 @@ export default function AmneziawgFields() {
       <ObfText name="h3" label={t('pages.xray.amneziawg.h3')} placeholder="1700-2400" />
       <ObfText name="h3" label={t('pages.xray.amneziawg.h3')} placeholder="1700-2400" />
       <ObfText name="h4" label={t('pages.xray.amneziawg.h4')} placeholder="2500-3200" />
       <ObfText name="h4" label={t('pages.xray.amneziawg.h4')} placeholder="2500-3200" />
       <ObfText name="i1" label={t('pages.xray.amneziawg.i1')} placeholder="<r 64>" />
       <ObfText name="i1" label={t('pages.xray.amneziawg.i1')} placeholder="<r 64>" />
+      <ObfText name="i2" label={t('pages.xray.amneziawg.i2')} placeholder="<r 64>" />
+      <ObfText name="i3" label={t('pages.xray.amneziawg.i3')} placeholder="<r 64>" />
+      <ObfText name="i4" label={t('pages.xray.amneziawg.i4')} placeholder="<r 64>" />
+      <ObfText name="i5" label={t('pages.xray.amneziawg.i5')} placeholder="<r 64>" />
       <ObfText
       <ObfText
         name="contentPaddingAddition"
         name="contentPaddingAddition"
         label={t('pages.xray.amneziawg.contentPaddingAddition')}
         label={t('pages.xray.amneziawg.contentPaddingAddition')}

+ 29 - 8
internal/amneziawgnet/dns.go

@@ -135,21 +135,42 @@ func flushTunnelDNSCacheForTag(tag string) {
 	}
 	}
 }
 }
 
 
-// exchangeTunnelDNSWithFallback queries A and/or AAAA depending on the local
-// address families configured on the device stack.
-func exchangeTunnelDNSWithFallback(ctx context.Context, conn *gonet.UDPConn, addrs []netip.Addr, host string) (netip.Addr, error) {
+// dnsQueryTypesFor asks only for families the tunnel can dial, so a v4-only tunnel
+// never caches an unroutable AAAA answer (#6570). No addresses keeps A then AAAA.
+func dnsQueryTypesFor(addrs []netip.Addr) []dnsmessage.Type {
 	hasV4 := deviceHasV4(addrs)
 	hasV4 := deviceHasV4(addrs)
 	hasV6 := deviceHasV6(addrs)
 	hasV6 := deviceHasV6(addrs)
+	switch {
+	case hasV4 && hasV6:
+		return []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
+	case hasV6:
+		return []dnsmessage.Type{dnsmessage.TypeAAAA}
+	case hasV4:
+		return []dnsmessage.Type{dnsmessage.TypeA}
+	default:
+		return []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
+	}
+}
 
 
-	// If the tunnel is IPv6-only, query AAAA first; else query A first.
-	types := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
-	if hasV6 && !hasV4 {
-		types = []dnsmessage.Type{dnsmessage.TypeAAAA, dnsmessage.TypeA}
+// tunnelSupportsAddr reports whether the device stack has a local address in
+// the same family as ip (IPv4-mapped IPv6 counts as IPv4).
+func tunnelSupportsAddr(addrs []netip.Addr, ip netip.Addr) bool {
+	if !ip.IsValid() {
+		return false
+	}
+	if ip.Is4() || ip.Is4In6() {
+		return deviceHasV4(addrs)
 	}
 	}
+	return deviceHasV6(addrs)
+}
+
+// exchangeTunnelDNSWithFallback returns the first answer among the families the
+// device stack can route.
+func exchangeTunnelDNSWithFallback(ctx context.Context, conn *gonet.UDPConn, addrs []netip.Addr, host string) (netip.Addr, error) {
+	types := dnsQueryTypesFor(addrs)
 
 
 	var firstErr error
 	var firstErr error
 	for _, qType := range types {
 	for _, qType := range types {
-		// Skip AAAA if device has no IPv6 capability and has IPv4, unless A failed.
 		addr, err := exchangeTunnelDNSQuery(ctx, conn, host, qType)
 		addr, err := exchangeTunnelDNSQuery(ctx, conn, host, qType)
 		if err == nil {
 		if err == nil {
 			return addr, nil
 			return addr, nil

+ 107 - 0
internal/amneziawgnet/dns_family_test.go

@@ -0,0 +1,107 @@
+package amneziawgnet
+
+import (
+	"net/netip"
+	"testing"
+
+	"golang.org/x/net/dns/dnsmessage"
+)
+
+func TestDNSQueryTypesFor(t *testing.T) {
+	v4 := netip.MustParseAddr("10.8.0.2")
+	v6 := netip.MustParseAddr("2001:db8::2")
+	mapped := netip.MustParseAddr("::ffff:10.8.0.2")
+
+	cases := []struct {
+		name  string
+		addrs []netip.Addr
+		want  []dnsmessage.Type
+	}{
+		{
+			name:  "v4-only",
+			addrs: []netip.Addr{v4},
+			want:  []dnsmessage.Type{dnsmessage.TypeA},
+		},
+		{
+			name:  "v6-only",
+			addrs: []netip.Addr{v6},
+			want:  []dnsmessage.Type{dnsmessage.TypeAAAA},
+		},
+		{
+			name:  "dual-stack prefers A then AAAA",
+			addrs: []netip.Addr{v4, v6},
+			want:  []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA},
+		},
+		{
+			name:  "empty falls back to A then AAAA",
+			addrs: nil,
+			want:  []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA},
+		},
+		{
+			name:  "v4-mapped alone is not dual-stack",
+			addrs: []netip.Addr{mapped},
+			want:  []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := dnsQueryTypesFor(tc.addrs)
+			if len(got) != len(tc.want) {
+				t.Fatalf("dnsQueryTypesFor(%v) = %v, want %v", tc.addrs, got, tc.want)
+			}
+			for i := range got {
+				if got[i] != tc.want[i] {
+					t.Fatalf("dnsQueryTypesFor(%v) = %v, want %v", tc.addrs, got, tc.want)
+				}
+			}
+		})
+	}
+}
+
+func TestTunnelSupportsAddr(t *testing.T) {
+	v4 := netip.MustParseAddr("10.8.0.2")
+	v6 := netip.MustParseAddr("2001:db8::2")
+	dest4 := netip.MustParseAddr("8.8.8.8")
+	dest6 := netip.MustParseAddr("2001:4860:4860::8888")
+	mappedDest := netip.MustParseAddr("::ffff:8.8.8.8")
+
+	if !tunnelSupportsAddr([]netip.Addr{v4}, dest4) {
+		t.Error("v4 tunnel should dial IPv4")
+	}
+	if tunnelSupportsAddr([]netip.Addr{v4}, dest6) {
+		t.Error("v4-only tunnel must not dial IPv6")
+	}
+	if !tunnelSupportsAddr([]netip.Addr{v6}, dest6) {
+		t.Error("v6 tunnel should dial IPv6")
+	}
+	if tunnelSupportsAddr([]netip.Addr{v6}, dest4) {
+		t.Error("v6-only tunnel must not dial IPv4")
+	}
+	if !tunnelSupportsAddr([]netip.Addr{v4, v6}, dest4) || !tunnelSupportsAddr([]netip.Addr{v4, v6}, dest6) {
+		t.Error("dual-stack tunnel should dial both families")
+	}
+	if !tunnelSupportsAddr([]netip.Addr{v4}, mappedDest) {
+		t.Error("v4 tunnel should treat IPv4-mapped destinations as IPv4")
+	}
+	if tunnelSupportsAddr(nil, dest4) {
+		t.Error("empty address list should not claim support")
+	}
+}
+
+func TestSocksTargetResolveTunnelVia_RejectsWrongFamilyLiteral(t *testing.T) {
+	dev := &Device{localAddrs: []netip.Addr{netip.MustParseAddr("10.8.0.2")}}
+	target := socksTarget{ip: netip.MustParseAddr("2001:4860:4860::8888"), port: 443}
+	_, err := target.resolveTunnelVia("", "awg", dev)
+	if err == nil {
+		t.Fatal("expected error dialing IPv6 literal on v4-only tunnel")
+	}
+
+	okTarget := socksTarget{ip: netip.MustParseAddr("8.8.8.8"), port: 443}
+	got, err := okTarget.resolveTunnelVia("", "awg", dev)
+	if err != nil {
+		t.Fatalf("v4 literal on v4 tunnel: %v", err)
+	}
+	if got.String() != "8.8.8.8:443" {
+		t.Fatalf("got %s", got)
+	}
+}

+ 7 - 0
internal/amneziawgnet/egress.go

@@ -360,6 +360,9 @@ func (t socksTarget) String() string {
 // Domain targets resolve via the tunnel; reply-side helper must not be used here.
 // Domain targets resolve via the tunnel; reply-side helper must not be used here.
 func (t socksTarget) resolveTunnelVia(dnsServer, tag string, dev *Device) (netip.AddrPort, error) {
 func (t socksTarget) resolveTunnelVia(dnsServer, tag string, dev *Device) (netip.AddrPort, error) {
 	if t.ip.IsValid() {
 	if t.ip.IsValid() {
+		if !tunnelSupportsAddr(dev.LocalAddresses(), t.ip) {
+			return netip.AddrPort{}, fmt.Errorf("tunnel has no route for %s (device addresses %v)", t.ip, dev.LocalAddresses())
+		}
 		return netip.AddrPortFrom(t.ip, t.port), nil
 		return netip.AddrPortFrom(t.ip, t.port), nil
 	}
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
@@ -523,6 +526,10 @@ func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPC
 	if sess, ok := s.m[dst]; ok {
 	if sess, ok := s.m[dst]; ok {
 		return sess
 		return sess
 	}
 	}
+	if !tunnelSupportsAddr(dev.LocalAddresses(), dst.Addr()) {
+		logger.Warningf("amneziawgnet: egress %q: dial udp %s: tunnel has no route for address family (device addresses %v)", tag, dst, dev.LocalAddresses())
+		return nil
+	}
 	raddr := tcpip.FullAddress{
 	raddr := tcpip.FullAddress{
 		NIC:  1,
 		NIC:  1,
 		Addr: tcpip.AddrFromSlice(dst.Addr().AsSlice()),
 		Addr: tcpip.AddrFromSlice(dst.Addr().AsSlice()),