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 <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
mrchatam
2026-09-26 23:31:04 +03:30
committed by GitHub
parent 8f1201553e
commit b3a5be9da4
4 changed files with 148 additions and 9 deletions
@@ -79,6 +79,10 @@ export default function AmneziawgFields() {
<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="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
name="contentPaddingAddition"
label={t('pages.xray.amneziawg.contentPaddingAddition')}
+30 -9
View File
@@ -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)
hasV6 := deviceHasV6(addrs)
// 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}
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}
}
}
// 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
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)
if err == nil {
return addr, nil
+107
View File
@@ -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
View File
@@ -360,6 +360,9 @@ func (t socksTarget) String() string {
// 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) {
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
}
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 {
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{
NIC: 1,
Addr: tcpip.AddrFromSlice(dst.Addr().AsSlice()),