diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bdaea8aa..cdd75a72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -129,8 +129,13 @@ jobs: ./librespeed-cli --local-json config_librespeed.json --server 2 --json --share --telemetry-level disabled | python3 -m json.tool ping 10.66.0.2 -w 20 -c 10 - # TODO: remove this temporal hack for linux - ping awl-tester.awl -w 20 -c 10 || true + IPV6=$(./awl cli peers status -f p | grep -A 3 awl-tester | grep -o 'fd00:[0-9a-f:]*' || true) + if [ -n "$IPV6" ]; then + echo "IPv6 detected: $IPV6. Running ping6..." + ping6 awl-tester.awl -w 20 -c 10 + else + echo "awl-tester does not have IPv6 enabled yet, skipping IPv6 ping test." + fi # ---- VPN gateway server (exit-node) mode: runtime enable/disable round-trips OS state ---- # awl runs as root here, so enabling the server installs real NAT @@ -231,7 +236,7 @@ jobs: ./librespeed-cli --local-json config_librespeed.json --server 2 --json --share --telemetry-level disabled | python3 -m json.tool ping 10.66.0.2 -c 10 - ping awl-tester.awl -c 10 + echo "IPv6 VPN gateway is currently supported on Linux only, skipping IPv6 ping test on macOS." sleep 1 sudo kill -SIGINT $awl_pid @@ -251,7 +256,7 @@ jobs: ./librespeed-cli.exe --local-json config_librespeed.json --server 2 --json --share --telemetry-level disabled | python3 -m json.tool ping -w 20000 -n 10 10.66.0.2 - ping -w 20000 -n 10 -a awl-tester.awl + echo "IPv6 VPN gateway is currently supported on Linux only, skipping IPv6 ping test on Windows." # ---- VPN gateway server (exit-node) mode: runtime enable/disable round-trips OS state ---- # Diagnostic first: what the runner already holds in WinNAT (a diff --git a/api/peers.go b/api/peers.go index d8d8788e..873e3194 100644 --- a/api/peers.go +++ b/api/peers.go @@ -48,6 +48,7 @@ func (h *Handler) getKnownPeers() []entity.KnownPeersResponse { Alias: knownPeer.Alias, Version: config.VersionFromUserAgent(h.p2p.PeerUserAgent(id)), IpAddr: knownPeer.IPAddr, + IpAddrV6: knownPeer.IPAddrV6, DomainName: knownPeer.DomainName, Connected: h.p2p.IsConnected(id), Confirmed: knownPeer.Confirmed, diff --git a/api/settings.go b/api/settings.go index 01b35fb9..668b8a58 100644 --- a/api/settings.go +++ b/api/settings.go @@ -96,6 +96,10 @@ func (h *Handler) GetMyPeerInfo(c echo.Context) (err error) { }(), } + if ipV6, _ := h.conf.VPNLocalIPMaskV6(); ipV6 != nil { + peerInfo.VPN.IPv6Addr = ipV6.String() + } + return c.JSON(http.StatusOK, peerInfo) } diff --git a/application.go b/application.go index 053b0274..0c408528 100644 --- a/application.go +++ b/application.go @@ -110,7 +110,7 @@ type NetManager interface { EnableClientRoutes(tunIfName string) error DisableClientRoutes() error ClientRoutesActive() bool - EnableServerNAT(awlSubnet, tunIfName string) error + EnableServerNAT(awlSubnet, awlSubnet6, tunIfName string) error DisableServerNAT() error ServerNATActive() bool } @@ -147,12 +147,16 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error { a.logger.Info("VPN interface is disabled from config") } else { localIP, netMask := a.Conf.VPNLocalIPMask() + localIPv6, netMaskv6 := a.Conf.VPNLocalIPMaskV6() interfaceName := a.Conf.VPNConfig.InterfaceName - a.vpnDevice, err = vpn.NewDevice(tunDevice, interfaceName, localIP, netMask) + a.vpnDevice, err = vpn.NewDevice(tunDevice, interfaceName, localIP, netMask, localIPv6, netMaskv6) if err != nil { return fmt.Errorf("failed to init vpn: %v", err) } a.logger.Infof("VPN interface created. Name: %s CIDR: %s", interfaceName, &net.IPNet{IP: localIP, Mask: netMask}) + if localIPv6 != nil { + a.logger.Infof("VPN interface IPv6: %s", &net.IPNet{IP: localIPv6, Mask: netMaskv6}) + } a.Tunnel = service.NewTunnel(a.P2p, a.vpnDevice, a.Conf, a.Eventbus) go a.vpnDevice.ReadTUNPackets(a.Tunnel.HandleReadPackets) @@ -614,7 +618,8 @@ func (a *DNSService) refreshDNSConfigLocked() { if runtime.GOOS != "android" { dnsNamesMapping[config.AdminHttpServerDomainName] = config.AdminHttpServerIP } - a.dnsResolver.ReceiveConfiguration(a.upstreamDNS, dnsNamesMapping) + dnsNamesMappingV6 := a.conf.DNSNamesMappingV6() + a.dnsResolver.ReceiveConfiguration(a.upstreamDNS, dnsNamesMapping, dnsNamesMappingV6) } func (a *DNSService) Close() { diff --git a/application_gateway_test.go b/application_gateway_test.go index cecd3919..70811f11 100644 --- a/application_gateway_test.go +++ b/application_gateway_test.go @@ -951,6 +951,7 @@ func TestGatewayServeAsVPNGatewayPropagatesViaStatus(t *testing.T) { // the same peer must let SetGatewayPeer succeed again and resume packet flow. // HandleReadPackets must not panic on packets sent during the gap. func TestGatewayRebindOnPeerReadd(t *testing.T) { + skipIfVPNGatewayUnsupported(t) ts := NewTestSuite(t) client, exitNode, _ := setupGatewayPeers(ts) diff --git a/application_test.go b/application_test.go index 6bb4f752..af7f2767 100644 --- a/application_test.go +++ b/application_test.go @@ -845,15 +845,18 @@ func testSOCKS5Proxy(ts *TestSuite, proxyAddr string, expectSocksErr string) { func testSOCKS5ProxyWithAuth(ts *TestSuite, proxyAddr string, auth *proxy.Auth, iterations int, expectSocksErr string) { // setup mock server expectedBody := strings.Repeat("test text", 10_000) - addr := pickFreeAddr(ts.t) + l, err := net.Listen("tcp", "127.0.0.1:0") + ts.NoError(err) + addr := l.Addr().String() + mux := http.NewServeMux() mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, expectedBody) }) //nolint - httpServer := &http.Server{Addr: addr, Handler: mux} + httpServer := &http.Server{Handler: mux} go func() { - _ = httpServer.ListenAndServe() + _ = httpServer.Serve(l) }() defer func() { httpServer.Shutdown(context.Background()) @@ -937,6 +940,32 @@ func TestTunnelPackets(t *testing.T) { received2 := peer2.tun.InboundCount() ts.EqualValues(packetsCount, received1) ts.EqualValues(packetsCount, received2) + + // --- IPv6 Routing Test --- + peer2ConfigInPeer1, _ := peer1.app.Conf.GetPeer(peer2.PeerID()) + peer1ConfigInPeer2, _ := peer2.app.Conf.GetPeer(peer1.PeerID()) + + peer1IPv6Str := peer1ConfigInPeer2.IPAddrV6 + peer2IPv6Str := peer2ConfigInPeer1.IPAddrV6 + + ts.t.Logf("DEBUG: peer1 IPNetV6: %v", peer1.app.Conf.VPNConfig.IPNetV6) + ts.t.Logf("DEBUG: peer1IPv6 calculated: %s, peer2IPv6 calculated: %s", peer1IPv6Str, peer2IPv6Str) + + peer1.tun.ClearInboundCount() + peer2.tun.ClearInboundCount() + + // Send IPv6 packets from peer1 to peer2 + const ipv6PacketsCount = 10 + ipv6Packet := testPacketWithSrcDestV6(packetSize, peer1IPv6Str, peer2IPv6Str) + + for i := 0; i < ipv6PacketsCount; i++ { + peer1.tun.Outbound <- [][]byte{ipv6Packet} + time.Sleep(10 * time.Millisecond) + } + + time.Sleep(1 * time.Second) + receivedIPv6 := peer2.tun.InboundCount() + ts.EqualValues(ipv6PacketsCount, receivedIPv6, "peer2 should receive exactly %d IPv6 packets", ipv6PacketsCount) } func BenchmarkTunnelPackets(b *testing.B) { diff --git a/awldns/awldns.go b/awldns/awldns.go index c372ec28..51515c28 100644 --- a/awldns/awldns.go +++ b/awldns/awldns.go @@ -2,6 +2,7 @@ package awldns import ( "net" + "strconv" "strings" "sync/atomic" "time" @@ -17,6 +18,7 @@ const ( defaultTTL = 60 * time.Second defaultTTLSeconds = uint32(defaultTTL / time.Second) ptrV4Suffix = ".in-addr.arpa." + ptrV6Suffix = ".ip6.arpa." ) const ( @@ -44,9 +46,10 @@ type Resolver struct { } type config struct { - upstreamDNS string - directMapping map[string]string - reverseMapping map[string]string + upstreamDNS string + directMapping map[string]string + directMappingV6 map[string]string + reverseMapping map[string]string } // NewResolver creates a resolver that binds its own UDP and TCP sockets on @@ -91,7 +94,8 @@ func newResolver(dnsAddress string) *Resolver { mux := dns.NewServeMux() mux.HandleFunc(LocalDomain, r.dnsLocalDomainHandler) - mux.HandleFunc(strings.TrimPrefix(ptrV4Suffix, "."), r.ptrv4Handler) + mux.HandleFunc(strings.TrimPrefix(ptrV4Suffix, "."), r.ptrHandler) + mux.HandleFunc(strings.TrimPrefix(ptrV6Suffix, "."), r.ptrHandler) mux.HandleFunc(".", r.dnsProxyHandler) r.udpServer = &dns.Server{ @@ -138,9 +142,11 @@ func serveDNSServer(srv *dns.Server) error { return srv.ListenAndServe() } -func (r *Resolver) ReceiveConfiguration(upstreamDNS string, namesMapping map[string]string) { - reverseMapping := make(map[string]string, len(namesMapping)) +func (r *Resolver) ReceiveConfiguration(upstreamDNS string, namesMapping map[string]string, namesMappingV6 map[string]string) { + reverseMapping := make(map[string]string, len(namesMapping)+len(namesMappingV6)) directMapping := make(map[string]string, len(namesMapping)) + directMappingV6 := make(map[string]string, len(namesMappingV6)) + for key, ip := range namesMapping { canonicalName := dns.CanonicalName(key + "." + LocalDomain) directMapping[canonicalName] = ip @@ -154,10 +160,22 @@ func (r *Resolver) ReceiveConfiguration(upstreamDNS string, namesMapping map[str } } + for key, ip := range namesMappingV6 { + canonicalName := dns.CanonicalName(key + "." + LocalDomain) + directMappingV6[canonicalName] = ip + existedName, exists := reverseMapping[ip] + if !exists { + reverseMapping[ip] = canonicalName + } else if exists && len(canonicalName) < len(existedName) { + reverseMapping[ip] = canonicalName + } + } + cfg := config{ - upstreamDNS: upstreamDNS, - directMapping: directMapping, - reverseMapping: reverseMapping, + upstreamDNS: upstreamDNS, + directMapping: directMapping, + directMappingV6: directMappingV6, + reverseMapping: reverseMapping, } r.cfg.Store(&cfg) } @@ -201,10 +219,14 @@ func (r *Resolver) dnsLocalDomainHandler(resp dns.ResponseWriter, req *dns.Msg) qtype := question.Qtype hostnameLower := strings.ToLower(hostname) mappedIP, found := cfg.directMapping[hostnameLower] + mappedIPv6, foundV6 := cfg.directMappingV6[hostnameLower] switch qtype { - case dns.TypeA, dns.TypeANY: + case dns.TypeA: if !found { + if foundV6 { + continue // domain exists but no A record, return NOERROR with 0 answers (NODATA) + } m.SetRcode(req, dns.RcodeNameError) continue } @@ -221,11 +243,55 @@ func (r *Resolver) dnsLocalDomainHandler(resp dns.ResponseWriter, req *dns.Msg) }) } case dns.TypeAAAA: - if !found { + if !foundV6 { + if found { + continue // domain exists but no AAAA record, return NOERROR with 0 answers (NODATA) + } + m.SetRcode(req, dns.RcodeNameError) + continue + } + if ip := net.ParseIP(mappedIPv6).To16(); ip != nil { + m.Answer = append(m.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: hostname, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: defaultTTLSeconds, + }, + AAAA: ip, + }) + } + case dns.TypeANY: + if !found && !foundV6 { m.SetRcode(req, dns.RcodeNameError) continue } - // TODO: support IPv6 addresses in cfg.directMapping. + if found { + if ip := net.ParseIP(mappedIP).To4(); ip != nil { + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{ + Name: hostname, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: defaultTTLSeconds, + }, + A: ip, + }) + } + } + if foundV6 { + if ip := net.ParseIP(mappedIPv6).To16(); ip != nil { + m.Answer = append(m.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: hostname, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: defaultTTLSeconds, + }, + AAAA: ip, + }) + } + } } } @@ -234,7 +300,7 @@ func (r *Resolver) dnsLocalDomainHandler(resp dns.ResponseWriter, req *dns.Msg) _ = resp.WriteMsg(m) } -func (r *Resolver) ptrv4Handler(resp dns.ResponseWriter, req *dns.Msg) { +func (r *Resolver) ptrHandler(resp dns.ResponseWriter, req *dns.Msg) { metrics.DNSQueriesTotal.WithLabelValues("awl_ptr").Inc() start := time.Now() defer func() { @@ -249,7 +315,13 @@ func (r *Resolver) ptrv4Handler(resp dns.ResponseWriter, req *dns.Msg) { name := req.Question[0].Name cfg := r.loadConfig() - ip := ptrV4NameToIP(name) + var ip net.IP + if strings.HasSuffix(strings.ToLower(name), ptrV6Suffix) { + ip = ptrV6NameToIP(name) + } else { + ip = ptrV4NameToIP(name) + } + if ip == nil { r.dnsProxyHandler(resp, req) return @@ -350,7 +422,7 @@ func IsValidDomainName(domain string) bool { } func ptrV4NameToIP(name string) net.IP { - s := strings.TrimSuffix(name, ptrV4Suffix) + s := strings.TrimSuffix(strings.ToLower(name), ptrV4Suffix) revIp := net.ParseIP(s) revIp = revIp.To4() if revIp == nil { @@ -358,3 +430,21 @@ func ptrV4NameToIP(name string) net.IP { } return net.IP{revIp[3], revIp[2], revIp[1], revIp[0]} } + +func ptrV6NameToIP(name string) net.IP { + s := strings.TrimSuffix(strings.ToLower(name), ptrV6Suffix) + parts := strings.Split(s, ".") + if len(parts) != 32 { + return nil + } + ip := make(net.IP, 16) + for i := 0; i < 16; i++ { + high, err1 := strconv.ParseUint(parts[31-(i*2)], 16, 8) + low, err2 := strconv.ParseUint(parts[31-(i*2)-1], 16, 8) + if err1 != nil || err2 != nil { + return nil + } + ip[i] = byte((high << 4) | low) + } + return ip +} diff --git a/awldns/awldns_test.go b/awldns/awldns_test.go index 8193ec38..ca9da754 100644 --- a/awldns/awldns_test.go +++ b/awldns/awldns_test.go @@ -34,7 +34,7 @@ func TestDNS(t *testing.T) { name1: addr1, name2: addr2, } - resolver.ReceiveConfiguration("", namesMapping) + resolver.ReceiveConfiguration("", namesMapping, nil) client := NewResolverClient(addr) @@ -80,7 +80,7 @@ func TestDNSAAAAQueryDoesNotReturnARecord(t *testing.T) { resolver.ReceiveConfiguration("", map[string]string{ "admin": "127.0.0.66", - }) + }, nil) req := new(dns.Msg) req.SetQuestion("admin.awl.", dns.TypeAAAA) @@ -102,7 +102,7 @@ func TestDNSAQueryReturnsARecord(t *testing.T) { resolver.ReceiveConfiguration("", map[string]string{ "admin": "127.0.0.66", - }) + }, nil) req := new(dns.Msg) req.SetQuestion("admin.awl.", dns.TypeA) @@ -129,7 +129,7 @@ func TestDNSUnknownAddressReturnsNameError(t *testing.T) { resolver.ReceiveConfiguration("", map[string]string{ "admin": "127.0.0.66", - }) + }, nil) req := new(dns.Msg) req.SetQuestion("unknown.awl.", dns.TypeA) @@ -155,7 +155,7 @@ func TestResolverFromListeners(t *testing.T) { resolver.ReceiveConfiguration("", map[string]string{ "admin": "127.0.0.66", - }) + }, nil) // DNSAddress reports the passed address once both servers are serving. a.Eventually(func() bool { return resolver.DNSAddress() == dnsAddress }, diff --git a/cli/peers.go b/cli/peers.go index 076916f3..88a0c9b5 100644 --- a/cli/peers.go +++ b/cli/peers.go @@ -83,6 +83,9 @@ func printPeersStatus(api *apiclient.Client, format string, w io.Writer) error { if peer.DomainName != "" { info = append(info, fmt.Sprintf("%s.%s", peer.DomainName, awldns.LocalDomain)) } + if peer.IpAddrV6 != "" { + info = append(info, peer.IpAddrV6) + } info = append(info, peer.IpAddr) row = append(row, strings.Join(info, "\n")) diff --git a/config/config.go b/config/config.go index c642d24e..29baa80d 100644 --- a/config/config.go +++ b/config/config.go @@ -98,6 +98,7 @@ type ( DisableVPNInterface bool `json:"disableVPNInterface"` InterfaceName string `json:"interfaceName"` IPNet string `json:"ipNet"` + IPNetV6 string `json:"ipNetV6"` } // VPNGatewayConfig configures full-tunnel VPN gateway mode. // @@ -145,6 +146,8 @@ type ( Alias string `json:"alias"` // IPAddr used for forwarding IPAddr string `json:"ipAddr"` + // IPAddrV6 used for IPv6 overlay forwarding (derived from peerID) + IPAddrV6 string `json:"ipAddrV6"` // DomainName without zone suffix (.awl) DomainName string `json:"domainName"` // Time of adding to config (accept/invite) @@ -377,6 +380,7 @@ func (c *Config) SetIdentity(key crypto.PrivKey, id peer.ID) { c.P2pNode.Identity = identity c.P2pNode.PeerID = id.String() + c.ensureIPv6AddressLocked() c.Save() c.Unlock() } @@ -466,6 +470,24 @@ func (c *Config) DNSNamesMapping() map[string]string { return mapping } +func (c *Config) DNSNamesMappingV6() map[string]string { + mapping := make(map[string]string) + c.RLock() + defer c.RUnlock() + + for _, knownPeer := range c.KnownPeers { + if knownPeer.IPAddrV6 == "" { + continue + } + mapping[knownPeer.PeerID] = knownPeer.IPAddrV6 + if knownPeer.DomainName != "" { + mapping[knownPeer.DomainName] = knownPeer.IPAddrV6 + } + } + + return mapping +} + func (c *Config) PeerstoreDir() string { dir := filepath.Join(c.dataDir, DhtPeerstoreDataDirectory) return dir diff --git a/config/ipv6.go b/config/ipv6.go new file mode 100644 index 00000000..b48e7cf9 --- /dev/null +++ b/config/ipv6.go @@ -0,0 +1,55 @@ +package config + +import ( + "crypto/sha256" + "net" + + "github.com/libp2p/go-libp2p/core/peer" +) + +// deriveIPv6FromPeerID deterministically maps a libp2p peer ID to its overlay +// IPv6 address inside the given awl IPv6 subnet prefix. The result is a stable, +// network-wide identity: every node derives the same address for a given peer — +// no allocation, no coordination, no conflict checks. +// +// addr = prefix || SHA-256(rawPeerID)[...] // fills remaining host bits +// +// rawPeerID is the *binary* multihash ([]byte(id)). For ed25519 keys, this provides +// a stable entropy source. Truncating SHA-256 is a standard construction, making +// the host bits collision-safe. +func DeriveIPv6FromPeerID(id peer.ID, prefix *net.IPNet) net.IP { + if prefix == nil || len(prefix.Mask) != net.IPv6len { + return nil + } + + sum := sha256.Sum256([]byte(id)) + + // Get the prefix network address as a 16-byte array. + base := prefix.IP.Mask(prefix.Mask).To16() + if base == nil { + return nil + } + + addr := make(net.IP, net.IPv6len) + copy(addr, base) + + // Dynamically embed the hash into the host portion of the address using the inverted mask. + // This works flawlessly for ANY valid prefix length (e.g. /48, /64, /120, etc.) + for i := 0; i < net.IPv6len; i++ { + addr[i] |= sum[i] & ^prefix.Mask[i] + } + + // Never hand out the all-zero host (Subnet-Router Anycast, RFC 4291 §2.6.1). + allZero := true + for i := 0; i < net.IPv6len; i++ { + if (addr[i] & ^prefix.Mask[i]) != 0 { + allZero = false + break + } + } + if allZero { + addr[15] |= 1 + } + + return addr +} diff --git a/config/ipv6_test.go b/config/ipv6_test.go new file mode 100644 index 00000000..300c4e28 --- /dev/null +++ b/config/ipv6_test.go @@ -0,0 +1,26 @@ +package config + +import ( + "net" + "testing" + + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/assert" +) + +func TestDeriveIPv6FromPeerID(t *testing.T) { + _, sub6, _ := net.ParseCIDR("fd00:66:0:7915:10ba:fb1c:ef80:180c/48") + pid, _ := peer.Decode("12D3KooWBG3PFoGRgbr8ckoRPCpWQjdFj5tME2XBUot5s4uGZkiL") + + addr := DeriveIPv6FromPeerID(pid, sub6) + assert.NotNil(t, addr) + + // Ensure the address has the correct prefix + assert.True(t, sub6.Contains(addr)) + + // Ensure it's not the exact network address + assert.NotEqual(t, sub6.IP, addr) + + // Ensure the last bit logic works for all-zero hashes (hard to mock hash, but we test nil case) + assert.Nil(t, DeriveIPv6FromPeerID(pid, nil)) +} diff --git a/config/network_addr.go b/config/network_addr.go index 829861d2..f036b87f 100644 --- a/config/network_addr.go +++ b/config/network_addr.go @@ -10,7 +10,8 @@ import ( const ( DefaultVPNInterfaceName = "awl0" // TODO: generate subnets if this has already taken - DefaultVPNNetworkSubnet = "10.66.0.1/16" + DefaultVPNNetworkSubnet = "10.66.0.1/16" + DefaultVPNNetworkSubnet6 = "fd00:66:0::/48" ) func (c *Config) VPNLocalIPMask() (net.IP, net.IPMask) { @@ -29,6 +30,26 @@ func (c *Config) VPNLocalIPMaskUnlocked() (net.IP, net.IPMask) { return localIP.To4(), ipNet.Mask } +func (c *Config) VPNLocalIPMaskV6() (net.IP, net.IPMask) { + c.RLock() + defer c.RUnlock() + + return c.VPNLocalIPMaskV6Unlocked() +} + +func (c *Config) VPNLocalIPMaskV6Unlocked() (net.IP, net.IPMask) { + if c.VPNConfig.IPNetV6 == "" { + return nil, nil + } + localIP, ipNet, err := net.ParseCIDR(c.VPNConfig.IPNetV6) + if err != nil { + logger.Errorf("parse CIDR %s: %v", c.VPNConfig.IPNetV6, err) + return nil, nil + } + + return localIP.To16(), ipNet.Mask +} + // NetstackDNSIP returns the in-subnet IP reserved for the awl DNS server, // computed once in setDefaults and fixed for the session (see the // netstackDNSIP field). nil when the subnet has no free address, in which case @@ -42,7 +63,7 @@ func (c *Config) NetstackDNSIP() net.IP { } // computeNetstackDNSIP derives the reserved DNS server IP from the current -// config snapshot: broadcast−1, shifted down until an address is free to +// config snapshot: broadcast-1, shifted down until an address is free to // assign (CheckIPUnique rejects our own IP, the broadcast address and peers). // Deterministic; returns nil when the subnet has no free address. Not thread // safe — called from setDefaults at construction only, where netstackDNSIP is diff --git a/config/other.go b/config/other.go index 45fd1945..fce08a4b 100644 --- a/config/other.go +++ b/config/other.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "net" "net/url" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( "time" "github.com/ipfs/go-log/v2" + "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/p2p/host/eventbus" "github.com/moby/sys/atomicwriter" "github.com/multiformats/go-multiaddr" @@ -231,9 +233,19 @@ func setDefaults(conf *Config, bus awlevent.Bus) { if conf.VPNConfig.IPNet == "" { conf.VPNConfig.IPNet = DefaultVPNNetworkSubnet } + + // IPv6 support is a significant change, so it's opt-in for existing users + // to ensure a safe upgrade path. We only set the default for new configs. + if isEmptyConfig && conf.VPNConfig.IPNetV6 == "" { + conf.VPNConfig.IPNetV6 = DefaultVPNNetworkSubnet6 + } + conf.ensureIPv6AddressLocked() if ip, _ := conf.VPNLocalIPMask(); ip == nil { conf.VPNConfig.IPNet = DefaultVPNNetworkSubnet } + if ip, _ := conf.VPNLocalIPMaskV6(); conf.VPNConfig.IPNetV6 != "" && ip == nil { + conf.VPNConfig.IPNetV6 = DefaultVPNNetworkSubnet6 + } if conf.VPNConfig.InterfaceName == "" { if runtime.GOOS == "darwin" { conf.VPNConfig.InterfaceName = "utun" @@ -346,3 +358,18 @@ func writeFileAtomic(path string, data []byte) error { ChownFileIfNeeded(path) return nil } + +func (c *Config) ensureIPv6AddressLocked() { + if c.VPNConfig.IPNetV6 == "" || c.P2pNode.PeerID == "" { + return + } + localIP, ipNet, err := net.ParseCIDR(c.VPNConfig.IPNetV6) + if err == nil && localIP.Equal(ipNet.IP) { + if pid, err := peer.Decode(c.P2pNode.PeerID); err == nil { + if derived := DeriveIPv6FromPeerID(pid, ipNet); derived != nil { + maskLen, _ := ipNet.Mask.Size() + c.VPNConfig.IPNetV6 = fmt.Sprintf("%s/%d", derived.String(), maskLen) + } + } + } +} diff --git a/entity/api.go b/entity/api.go index 190a0951..001b388d 100644 --- a/entity/api.go +++ b/entity/api.go @@ -58,6 +58,7 @@ type ( Alias string Version string IpAddr string + IpAddrV6 string DomainName string Connected bool Confirmed bool @@ -93,6 +94,7 @@ type ( VPNInterfaceEnabled bool InterfaceName string IPNet string + IPv6Addr string } SOCKS5Info struct { diff --git a/protocol/protocol.go b/protocol/protocol.go index 10b1282e..13a7c6b5 100644 --- a/protocol/protocol.go +++ b/protocol/protocol.go @@ -28,6 +28,7 @@ type ( // and uses KnownPeer.CanUseAsVPNGateway() to decide whether the // peer is a valid VPN gateway target. VPNGatewayServerEnabled bool + IPv6Addr string `json:"ipv6Addr,omitempty"` } ) diff --git a/service/auth_status.go b/service/auth_status.go index b8fa09b7..44f6c1ed 100644 --- a/service/auth_status.go +++ b/service/auth_status.go @@ -27,6 +27,7 @@ const ( type P2p interface { ConnectPeer(ctx context.Context, peerID peer.ID) error + PeerID() peer.ID IsConnected(peerID peer.ID) bool NewStream(ctx context.Context, id peer.ID, proto libp2pProtocol.ID) (network.Stream, error) NewStreamMulti(ctx context.Context, id peer.ID, protos ...libp2pProtocol.ID) (network.Stream, error) @@ -165,13 +166,17 @@ func (s *AuthStatus) createPeerInfo(peer config.KnownPeer, myPeerName string, de vpnGatewayServerEnabled := s.conf.VPNGateway.ServerEnabled s.conf.RUnlock() - myPeerInfo := protocol.PeerStatusInfo{ + var ipv6Addr string + if ipV6, _ := s.conf.VPNLocalIPMaskV6(); ipV6 != nil { + ipv6Addr = ipV6.String() + } + + return protocol.PeerStatusInfo{ Name: myPeerName, AllowUsingAsExitNode: peer.WeAllowUsingAsExitNode, VPNGatewayServerEnabled: vpnGatewayServerEnabled, + IPv6Addr: ipv6Addr, } - - return myPeerInfo } // processPeerStatusInfo merges the status info received from peerID into the @@ -210,6 +215,9 @@ func (s *AuthStatus) processPeerStatusInfo(peerID string, peerInfo protocol.Peer if peer.Alias == "" { peer.Alias = s.conf.GenUniqPeerAliasUnlocked(peer.Name, peer.Alias) } + if peerInfo.IPv6Addr != "" && peer.IPAddrV6 == "" { + peer.IPAddrV6 = peerInfo.IPv6Addr + } peer.AllowedUsingAsExitNode = peerInfo.AllowUsingAsExitNode peer.RemoteVPNGatewayServerEnabled = peerInfo.VPNGatewayServerEnabled allowedUsingAsExitNode = peer.AllowedUsingAsExitNode diff --git a/service/tunnel.go b/service/tunnel.go index fb5ebdbe..de7cec37 100644 --- a/service/tunnel.go +++ b/service/tunnel.go @@ -32,9 +32,10 @@ type Tunnel struct { device *vpn.Device logger *log.ZapEventLogger - isClosed atomic.Bool - peersLock sync.RWMutex - peerIDToPeer map[peer.ID]*VpnPeer + isClosed atomic.Bool + peersLock sync.RWMutex + peerIDToPeer map[peer.ID]*VpnPeer + // netIPToPeer maps both IPv4 and IPv6 string representations to a VpnPeer. netIPToPeer map[string]*VpnPeer udpBroadcastAddr net.IP @@ -44,7 +45,8 @@ type Tunnel struct { vpnGatewayPeer *VpnPeer // resolved VpnPeer for outbound gateway traffic; rebound on RefreshPeersList vpnGatewayServerEnabled bool // server side: we serve as a VPN gateway for others // awlSubnet is set once in NewTunnel and never mutated afterwards. - awlSubnet *net.IPNet + awlSubnet *net.IPNet + awlSubnet6 *net.IPNet // gatewayConnEmitter emits awlevent.VPNGatewayConnectivityChanged. May be // nil if the event bus had no emitter. vpnGatewayConnected holds the last @@ -75,6 +77,12 @@ func NewTunnel(p2pService P2p, device *vpn.Device, conf *config.Config, eventbus awlSubnet := &net.IPNet{IP: localIP, Mask: netMask} udpBroadcastAddr := vpn.GetIPv4BroadcastAddress(awlSubnet) + var awlSubnet6 *net.IPNet + localIPV6, netMaskV6 := conf.VPNLocalIPMaskV6() + if localIPV6 != nil { + awlSubnet6 = &net.IPNet{IP: localIPV6, Mask: netMaskV6} + } + emitter, err := eventbus.Emitter(new(awlevent.VPNGatewayConnectivityChanged)) if err != nil { panic(err) @@ -90,6 +98,7 @@ func NewTunnel(p2pService P2p, device *vpn.Device, conf *config.Config, eventbus udpBroadcastAddr: udpBroadcastAddr, vpnGatewayServerEnabled: conf.VPNGateway.ServerEnabled, awlSubnet: awlSubnet, + awlSubnet6: awlSubnet6, gatewayConnEmitter: emitter, } tunnel.RefreshPeersList() @@ -175,33 +184,54 @@ func (t *Tunnel) RefreshPeersList() { t.logger.Errorf("Known peer %q has invalid IP %s in conf", knownPeer.DisplayName(), knownPeer.IPAddr) continue } + newLocalIPv6 := net.ParseIP(knownPeer.IPAddrV6) prevPeer, exists := t.peerIDToPeer[peerID] - if exists { - oldLocalIP := *prevPeer.localIP.Load() - if oldLocalIP.Equal(newLocalIP) { - // no changes - continue + if !exists { + // add new peer + vpnPeer := NewVpnPeer(peerID, newLocalIP, newLocalIPv6) + t.peerIDToPeer[peerID] = vpnPeer + t.netIPToPeer[newLocalIP.String()] = vpnPeer + if newLocalIPv6 != nil { + t.netIPToPeer[newLocalIPv6.String()] = vpnPeer + t.logger.Debugf("mapping peer %s (%s) to IPv6 %s", peerID, newLocalIP, newLocalIPv6) } + vpnPeer.Start(t) + continue + } - if !oldLocalIP.Equal(newLocalIP) { - // changed IP - delete(t.netIPToPeer, string(oldLocalIP)) - prevPeer.localIP.Store(&newLocalIP) - t.netIPToPeer[string(newLocalIP)] = prevPeer + oldLocalIP := *prevPeer.localIP.Load() + var oldLocalIPv6 net.IP + if p := prevPeer.localIPv6.Load(); p != nil { + oldLocalIPv6 = *p + } - continue - } + ipChanged := !oldLocalIP.Equal(newLocalIP) + ipv6Changed := !oldLocalIPv6.Equal(newLocalIPv6) - // impossible case + if !ipChanged && !ipv6Changed { + // no changes continue } - // add new peer - vpnPeer := NewVpnPeer(peerID, newLocalIP) - t.peerIDToPeer[peerID] = vpnPeer - t.netIPToPeer[string(newLocalIP)] = vpnPeer - vpnPeer.Start(t) + // IP changed: update both IPv4 and IPv6 mappings + if ipChanged { + delete(t.netIPToPeer, oldLocalIP.String()) + prevPeer.localIP.Store(&newLocalIP) + t.netIPToPeer[newLocalIP.String()] = prevPeer + } + + if ipv6Changed { + if oldLocalIPv6 != nil { + delete(t.netIPToPeer, oldLocalIPv6.String()) + } + if newLocalIPv6 != nil { + prevPeer.localIPv6.Store(&newLocalIPv6) + t.netIPToPeer[newLocalIPv6.String()] = prevPeer + } else { + prevPeer.localIPv6.Store(nil) + } + } } // delete unknown peers @@ -211,9 +241,16 @@ func (t *Tunnel) RefreshPeersList() { continue } localIP := *vpnPeer.localIP.Load() + var localIPv6 net.IP + if p := vpnPeer.localIPv6.Load(); p != nil { + localIPv6 = *p + } vpnPeer.Close(t) delete(t.peerIDToPeer, vpnPeer.peerID) - delete(t.netIPToPeer, string(localIP)) + delete(t.netIPToPeer, localIP.String()) + if localIPv6 != nil { + delete(t.netIPToPeer, localIPv6.String()) + } } // Rebind gateway pointer to the (possibly new) VpnPeer for the configured gateway peer. @@ -249,9 +286,16 @@ func (t *Tunnel) Close() { for _, vpnPeer := range t.peerIDToPeer { localIP := *vpnPeer.localIP.Load() + var localIPv6 net.IP + if p := vpnPeer.localIPv6.Load(); p != nil { + localIPv6 = *p + } vpnPeer.Close(t) delete(t.peerIDToPeer, vpnPeer.peerID) - delete(t.netIPToPeer, string(localIP)) + delete(t.netIPToPeer, localIP.String()) + if localIPv6 != nil { + delete(t.netIPToPeer, localIPv6.String()) + } } } @@ -269,10 +313,6 @@ func (t *Tunnel) HandleReadPackets(packets []*vpn.Packet) { if packet == nil { continue } - // TODO: ipv6 support - if packet.IsIPv6 { - continue - } // DNS queries to the in-tunnel DNS IP (Android interceptor). Must come // before the broadcast and gateway branches. @@ -281,38 +321,18 @@ func (t *Tunnel) HandleReadPackets(packets []*vpn.Packet) { continue } - // TODO: ipv6 support - if packet.Dst.Equal(t.udpBroadcastAddr) || packet.Dst.Equal(net.IPv4bcast) { - // udp broadcast - - for _, vpnPeer := range t.netIPToPeer { - // TODO: replace with event-based check OnConnected/OnDisconnected to improve performance - if !t.p2p.IsConnected(vpnPeer.peerID) { - continue - } - - copyPacket := t.device.GetTempPacket() - packet.CopyTo(copyPacket) - - select { - case vpnPeer.outboundCh <- copyPacket: - default: - t.device.PutTempPacket(copyPacket) - } - } - - continue - } - - vpnPeer, ok := t.netIPToPeer[string(packet.Dst)] - if ok { + // P2P broadcast/unicast lookup + vpnPeer, isP2P := t.netIPToPeer[packet.Dst.String()] + if isP2P { // VPN gateway server: tag NAT-returned packets so the client peer // applies a dst-only rewrite on receive. Discriminator: peer is // our gateway client AND src is outside our awl subnet (i.e. came // from the internet via NAT, not our own p2p initiative to the // same peer). Subnet check is local to this side — no cross-side // dependency on the client's awl subnet. - if vpnPeer.weAllowUsingAsExitNode.Load() && t.vpnGatewayServerEnabled && !t.awlSubnet.Contains(packet.Src) { + srcFromInternet := !t.isAWLSubnet(packet.Src, packet.IsIPv6) + + if vpnPeer.weAllowUsingAsExitNode.Load() && t.vpnGatewayServerEnabled && srcFromInternet { packet.GatewayDir = vpn.GatewayDirReturn } select { @@ -324,12 +344,28 @@ func (t *Tunnel) HandleReadPackets(packets []*vpn.Packet) { continue } + // IPv4 broadcast + if !packet.IsIPv6 && (packet.Dst.Equal(t.udpBroadcastAddr) || packet.Dst.Equal(net.IPv4bcast)) { + for _, vpnPeer := range t.peerIDToPeer { + if !t.p2p.IsConnected(vpnPeer.peerID) { + continue + } + copyPacket := t.device.GetTempPacket() + packet.CopyTo(copyPacket) + select { + case vpnPeer.outboundCh <- copyPacket: + default: + t.device.PutTempPacket(copyPacket) + } + } + continue + } + // VPN gateway client mode: forward non-local packets to the gateway peer. - // Subnet check is local to this side — it picks which packets go through - // the gateway vs. drop. The Forward tag carries the intent on the wire - // so the server doesn't have to re-derive it from packet IPs. if t.vpnGatewayClientEnabled && t.vpnGatewayPeer != nil { - if isNonRoutableIP(packet.Dst) || t.awlSubnet.Contains(packet.Dst) { + isAWLSubnet := t.isAWLSubnet(packet.Dst, packet.IsIPv6) + + if isNonRoutableIP(packet.Dst) || isAWLSubnet { continue } packet.GatewayDir = vpn.GatewayDirForward @@ -339,6 +375,7 @@ func (t *Tunnel) HandleReadPackets(packets []*vpn.Packet) { default: metrics.VPNPacketsDroppedTotal.WithLabelValues("gateway_channel_full").Inc() } + continue } } } @@ -362,9 +399,20 @@ func (t *Tunnel) makeTunnelStream(ctx context.Context, peerID peer.ID) (network. return stream, nil } +func (t *Tunnel) isAWLSubnet(ip net.IP, isIPv6 bool) bool { + if isIPv6 { + if t.awlSubnet6 != nil { + return t.awlSubnet6.Contains(ip) + } + return false + } + return t.awlSubnet.Contains(ip) +} + type VpnPeer struct { peerID peer.ID localIP atomic.Pointer[net.IP] + localIPv6 atomic.Pointer[net.IP] weAllowUsingAsExitNode atomic.Bool inboundCh chan *vpn.Packet // from remote peer to us @@ -374,7 +422,7 @@ type VpnPeer struct { ctxCancel context.CancelFunc } -func NewVpnPeer(peerID peer.ID, localIP net.IP) *VpnPeer { +func NewVpnPeer(peerID peer.ID, localIP net.IP, localIPv6 net.IP) *VpnPeer { ctx, cancel := context.WithCancel(context.Background()) p := &VpnPeer{ peerID: peerID, @@ -385,6 +433,9 @@ func NewVpnPeer(peerID peer.ID, localIP net.IP) *VpnPeer { } p.localIP.Store(&localIP) + if localIPv6 != nil { + p.localIPv6.Store(&localIPv6) + } return p } @@ -730,16 +781,29 @@ func (t *Tunnel) writeInboundBatch(packets []*vpn.Packet, bufs [][]byte, senderI isOurGateway := t.vpnGatewayClientEnabled && vp.peerID == t.vpnGatewayPeerID t.peersLock.RUnlock() - localIP := t.device.LocalIP() + localIPv4 := t.awlSubnet.IP + var localIPv6 net.IP + if t.awlSubnet6 != nil { + localIPv6 = t.awlSubnet6.IP + } allowGateway := vp.weAllowUsingAsExitNode.Load() for _, packet := range packets { + var localIP net.IP + var senderIPv6 net.IP if packet.IsIPv6 { - // TODO: IPv6 — currently dropped at TUN write. Both the regular - // awl rewrite and the gateway rewrites need IPv6 support. - continue + localIP = localIPv6 + if p := vp.localIPv6.Load(); p != nil { + senderIPv6 = *p + } + } else { + localIP = localIPv4 } + if localIP == nil { + continue // No local IP for this family + } + switch packet.GatewayDir { case vpn.GatewayDirForward: if !serverEnabled { @@ -750,8 +814,15 @@ func (t *Tunnel) writeInboundBatch(packets []*vpn.Packet, bufs [][]byte, senderI metrics.VPNPacketsDroppedTotal.WithLabelValues("gateway_not_allowed").Inc() continue } - copy(packet.Src, senderIP) - // dst preserved + if packet.IsIPv6 { + if senderIPv6 == nil { + continue + } + copy(packet.Src, senderIPv6) + } else { + copy(packet.Src, senderIP) + } + // dst preserved (internet destination) case vpn.GatewayDirReturn: if !isOurGateway { metrics.VPNPacketsDroppedTotal.WithLabelValues("gateway_return_from_non_gateway").Inc() @@ -759,8 +830,15 @@ func (t *Tunnel) writeInboundBatch(packets []*vpn.Packet, bufs [][]byte, senderI } copy(packet.Dst, localIP) // src preserved - default: - copy(packet.Src, senderIP) + default: // P2P + if packet.IsIPv6 { + if senderIPv6 == nil { + continue + } + copy(packet.Src, senderIPv6) + } else { + copy(packet.Src, senderIP) + } copy(packet.Dst, localIP) } packet.RecalculateChecksum() diff --git a/service/vpn_gateway.go b/service/vpn_gateway.go index 810f214f..8bdada6c 100644 --- a/service/vpn_gateway.go +++ b/service/vpn_gateway.go @@ -34,7 +34,7 @@ type NetManager interface { EnableClientRoutes(tunIfName string) error DisableClientRoutes() error ClientRoutesActive() bool - EnableServerNAT(awlSubnet, tunIfName string) error + EnableServerNAT(awlSubnet, awlSubnet6, tunIfName string) error DisableServerNAT() error ServerNATActive() bool } @@ -319,10 +319,16 @@ func (g *VPNGateway) applyServer() error { localIP, netMask := g.conf.VPNLocalIPMask() awlSubnet := (&net.IPNet{IP: localIP.Mask(netMask), Mask: netMask}).String() - if err := g.netManager.EnableServerNAT(awlSubnet, tunName); err != nil { + // Derive the IPv6 awl subnet for NAT6 (may be empty if IPv6 is unconfigured). + awlSubnet6 := "" + if localIPv6, netMaskv6 := g.conf.VPNLocalIPMaskV6(); localIPv6 != nil { + awlSubnet6 = (&net.IPNet{IP: localIPv6.Mask(netMaskv6), Mask: netMaskv6}).String() + } + + if err := g.netManager.EnableServerNAT(awlSubnet, awlSubnet6, tunName); err != nil { return err } - g.logger.Infof("VPN gateway server NAT configured for subnet %s on %s", awlSubnet, tunName) + g.logger.Infof("VPN gateway server NAT configured for subnet %s (IPv6: %q) on %s", awlSubnet, awlSubnet6, tunName) return nil } diff --git a/socks5/server_test.go b/socks5/server_test.go index 62773abf..adfc5424 100644 --- a/socks5/server_test.go +++ b/socks5/server_test.go @@ -122,26 +122,29 @@ func pickFreeAddr(t testing.TB) string { t.Fatal(err) } defer l.Close() - return l.Addr().String() } // startUpstreamServer starts an HTTP server that responds with "test text" on /test. func startUpstreamServer(t testing.TB) string { - addr := pickFreeAddr(t) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "test text") }) //nolint - httpServer := &http.Server{Addr: addr, Handler: mux} + httpServer := &http.Server{Handler: mux} go func() { - _ = httpServer.ListenAndServe() + _ = httpServer.Serve(l) }() t.Cleanup(func() { httpServer.Shutdown(context.Background()) }) - return addr + return l.Addr().String() } // newSOCKS5HttpClient creates an HTTP client that routes through a SOCKS5 proxy. diff --git a/test_suite_test.go b/test_suite_test.go index 1f4d43ff..f4dd3134 100644 --- a/test_suite_test.go +++ b/test_suite_test.go @@ -163,7 +163,7 @@ func (ts *TestSuite) newTestPeerWithConfig(disableLogging bool, listenAddrs []mu } // NewTestPeerExpectingInitError builds a peer with the same defaults as -// NewTestPeerWithAppConfig but does NOT assert that Init succeeded — it +// NewTestPeerWithAppConfig but does NOT assert that Init succeeded ?it // returns the Init error to the caller. The Application is registered for // cleanup either way, so callers do not need to call Close themselves. // @@ -542,7 +542,6 @@ func pickFreeAddr(t testing.TB) string { t.Fatal(err) } defer l.Close() - return l.Addr().String() } @@ -550,8 +549,8 @@ func testPacket(length int) []byte { return testPacketWithDest(length, "10.66.0.2") } -func testPacketWithSrcDest(length int, srcIP, destIP string) []byte { - data, err := hex.DecodeString("4500002828f540004011fd490a4200010a420002a9d0238200148bfd68656c6c6f20776f726c6421") +func buildPacketFromHex(length int, srcIP, destIP net.IP, hexStr string) []byte { + data, err := hex.DecodeString(hexStr) if err != nil { panic(err) } @@ -573,27 +572,59 @@ func testPacketWithSrcDest(length int, srcIP, destIP string) []byte { } vpnPacket.Parse() + if srcIP != nil { + copy(vpnPacket.Src, srcIP) + } + if destIP != nil { + copy(vpnPacket.Dst, destIP) + } + + vpnPacket.RecalculateChecksum() + + return vpnPacket.Packet +} + +func testPacketWithSrcDest(length int, srcIP, destIP string) []byte { srcIPParsed := net.ParseIP(srcIP).To4() if srcIPParsed == nil { panic(fmt.Sprintf("invalid source IP: %s", srcIP)) } - copy(vpnPacket.Src, srcIPParsed) - destIPParsed := net.ParseIP(destIP).To4() if destIPParsed == nil { panic(fmt.Sprintf("invalid destination IP: %s", destIP)) } - copy(vpnPacket.Dst, destIPParsed) - - vpnPacket.RecalculateChecksum() - - return vpnPacket.Packet + return buildPacketFromHex(length, srcIPParsed, destIPParsed, "4500002828f540004011fd490a4200010a420002a9d0238200148bfd68656c6c6f20776f726c6421") } func testPacketWithDest(length int, destIP string) []byte { return testPacketWithSrcDest(length, "10.66.0.1", destIP) } +func testPacketWithSrcDestV6(length int, srcIP, destIP string) []byte { + srcIPParsed := net.ParseIP(srcIP).To16() + if srcIPParsed == nil { + panic(fmt.Sprintf("invalid source IPv6: %s", srcIP)) + } + destIPParsed := net.ParseIP(destIP).To16() + if destIPParsed == nil { + panic(fmt.Sprintf("invalid destination IPv6: %s", destIP)) + } + packet := buildPacketFromHex(length, srcIPParsed, destIPParsed, "6000000000141140fd000000000000000000000000000001fd00000000000000000000000000000204d2162e0014000068656c6c6f20776f726c6421") + if length > 40 { + payloadLen := uint16(length - 40) + binary.BigEndian.PutUint16(packet[4:], payloadLen) + binary.BigEndian.PutUint16(packet[44:], payloadLen) + + // Recalculate checksum after modifying lengths + vpnPacket := vpn.Packet{} + vpnPacket.Packet = packet + if vpnPacket.Parse() { + vpnPacket.RecalculateChecksum() + } + } + return packet +} + // testUDPPacket builds an IPv4/UDP packet with the given payload and correct // checksums (e.g. a DNS query for the interceptor tests). func testUDPPacket(srcIP, dstIP net.IP, srcPort, dstPort uint16, payload []byte) []byte { @@ -677,7 +708,7 @@ func (m *testNetManager) ClientRoutesActive() bool { return m.clientActive } -func (m *testNetManager) EnableServerNAT(_, _ string) error { +func (m *testNetManager) EnableServerNAT(_, _, _ string) error { m.mu.Lock() defer m.mu.Unlock() if !m.serverActive { diff --git a/vpn/iface_android.go b/vpn/iface_android.go index c8a9efa1..cac352bb 100644 --- a/vpn/iface_android.go +++ b/vpn/iface_android.go @@ -32,7 +32,7 @@ func NewAndroidTUNFromFD(fd int) (tun.Device, error) { // newTUN is the nil-device path of NewDevice. On Android the TUN device must be // supplied externally via NewAndroidTUNFromFD (the host owns the fd), so being // asked to create one here is a programming error. -func newTUN(_ string, _ int, _ net.IP, _ net.IPMask) (tun.Device, error) { +func newTUN(_ string, _ int, _ net.IP, _ net.IPMask, _ net.IP, _ net.IPMask) (tun.Device, error) { return nil, fmt.Errorf("android requires an externally-supplied tun device (use NewAndroidTUNFromFD)") } diff --git a/vpn/iface_darwin.go b/vpn/iface_darwin.go index 55fe30fb..c403b801 100644 --- a/vpn/iface_darwin.go +++ b/vpn/iface_darwin.go @@ -12,7 +12,7 @@ import ( "golang.zx2c4.com/wireguard/tun" ) -func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask) (tun.Device, error) { +func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask, localIPv6 net.IP, ipMaskv6 net.IPMask) (tun.Device, error) { ipNet := &net.IPNet{ IP: localIP, Mask: ipMask, @@ -48,6 +48,18 @@ func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask) (tun.Devi return nil, fmt.Errorf("unable to setup interface route: %v: %s", err, strings.TrimSpace(string(out))) } + if localIPv6 != nil { + ipNet6 := &net.IPNet{ + IP: localIPv6, + Mask: ipMaskv6, + } + prefixLen, _ := ipMaskv6.Size() + if out, err := exec.Command("ifconfig", realIfname, "inet6", localIPv6.String(), + "prefixlen", fmt.Sprintf("%d", prefixLen)).CombinedOutput(); err != nil { + return nil, fmt.Errorf("unable to set IPv6 (%s) on interface: %v: %s", ipNet6, err, strings.TrimSpace(string(out))) + } + } + success = true return tunDevice, nil } diff --git a/vpn/iface_linux.go b/vpn/iface_linux.go index 40d62057..db3607c8 100644 --- a/vpn/iface_linux.go +++ b/vpn/iface_linux.go @@ -11,7 +11,7 @@ import ( "golang.zx2c4.com/wireguard/tun" ) -func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask) (tun.Device, error) { +func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask, localIPv6 net.IP, ipMaskv6 net.IPMask) (tun.Device, error) { tunDevice, err := tun.CreateTUN(ifname, mtu) if err != nil { return nil, fmt.Errorf("create tun: %v", err) @@ -40,6 +40,18 @@ func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask) (tun.Devi return nil, fmt.Errorf("unable to set IP (%s) to (%v on interface): %v", localIP, addr.IPNet, err) } + if localIPv6 != nil { + addr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: localIPv6, + Mask: ipMaskv6, + }, + } + if err := netlink.AddrAdd(link, addr); err != nil { + return nil, fmt.Errorf("unable to set IPv6 (%s) to (%v on interface): %v", localIPv6, addr.IPNet, err) + } + } + if err := netlink.LinkSetUp(link); err != nil { return nil, fmt.Errorf("unable to UP interface: %v", err) } diff --git a/vpn/iface_other.go b/vpn/iface_other.go index e1434b32..2c658d62 100644 --- a/vpn/iface_other.go +++ b/vpn/iface_other.go @@ -11,7 +11,7 @@ import ( "golang.zx2c4.com/wireguard/tun/tuntest" ) -func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask) (tun.Device, error) { +func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask, _ net.IP, _ net.IPMask) (tun.Device, error) { fmt.Println("WARN: TUN is unimplemented for !linux,!windows,!darwin") tt := tuntest.NewChannelTUN() diff --git a/vpn/iface_windows.go b/vpn/iface_windows.go index 7b662bfc..ed36212e 100644 --- a/vpn/iface_windows.go +++ b/vpn/iface_windows.go @@ -29,7 +29,7 @@ func init() { tun.WintunStaticRequestedGUID = &guid } -func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask) (tun.Device, error) { +func newTUN(ifname string, mtu int, localIP net.IP, ipMask net.IPMask, localIPv6 net.IP, ipMaskv6 net.IPMask) (tun.Device, error) { logger := log.Logger("awl/vpn") var tunDevice tun.Device diff --git a/vpn/netstate/manager_android.go b/vpn/netstate/manager_android.go index 2203ed05..1af8f78d 100644 --- a/vpn/netstate/manager_android.go +++ b/vpn/netstate/manager_android.go @@ -113,7 +113,7 @@ func (m *Manager) ClientRoutesActive() bool { // EnableServerNAT only records the enabled state: Android exit node support // requires root or special system configuration, so no OS state is touched. -func (m *Manager) EnableServerNAT(_, _ string) error { +func (m *Manager) EnableServerNAT(_, _, _ string) error { m.mu.Lock() defer m.mu.Unlock() m.serverNATActive = true diff --git a/vpn/netstate/manager_linux.go b/vpn/netstate/manager_linux.go index f61d5fe7..7d1b04d7 100644 --- a/vpn/netstate/manager_linux.go +++ b/vpn/netstate/manager_linux.go @@ -145,14 +145,14 @@ func (m *Manager) ClientRoutesActive() bool { // EnableServerNAT configures the exit-node data path for the awl subnet // (ip_forward + iptables chain + MASQUERADE). Idempotent: a second call while // NAT is configured is a no-op. -func (m *Manager) EnableServerNAT(awlSubnet, tunIfName string) error { +func (m *Manager) EnableServerNAT(awlSubnet, awlSubnet6, tunIfName string) error { m.mu.Lock() defer m.mu.Unlock() if m.natState != nil { return nil } - state, err := m.setupNAT(awlSubnet, tunIfName) + state, err := m.setupNAT(awlSubnet, awlSubnet6, tunIfName) if err != nil { return fmt.Errorf("setup NAT: %w", err) } diff --git a/vpn/netstate/manager_other.go b/vpn/netstate/manager_other.go index bc30c549..228073a7 100644 --- a/vpn/netstate/manager_other.go +++ b/vpn/netstate/manager_other.go @@ -38,7 +38,7 @@ func (m *Manager) DisableClientRoutes() error { return nil } func (m *Manager) ClientRoutesActive() bool { return false } // EnableServerNAT is not supported on this platform. -func (m *Manager) EnableServerNAT(_, _ string) error { +func (m *Manager) EnableServerNAT(_, _, _ string) error { return errors.New("setup NAT: NAT setup not supported on this platform") } diff --git a/vpn/netstate/manager_windows.go b/vpn/netstate/manager_windows.go index c210a981..f1934c74 100644 --- a/vpn/netstate/manager_windows.go +++ b/vpn/netstate/manager_windows.go @@ -243,14 +243,14 @@ func (m *Manager) ClientRoutesActive() bool { // EnableServerNAT configures the exit-node data path for the awl subnet // (WFP filter + per-interface forwarding + WinNAT). Idempotent: a second // call while NAT is configured is a no-op. -func (m *Manager) EnableServerNAT(awlSubnet, tunIfName string) error { +func (m *Manager) EnableServerNAT(awlSubnet, awlSubnet6, tunIfName string) error { m.mu.Lock() defer m.mu.Unlock() if m.natState != nil { return nil } - state, err := m.setupNAT(awlSubnet, tunIfName) + state, err := m.setupNAT(awlSubnet, awlSubnet6, tunIfName) if err != nil { return fmt.Errorf("setup NAT: %w", err) } diff --git a/vpn/netstate/nat_linux.go b/vpn/netstate/nat_linux.go index 4dc47f17..e4c0ea0e 100644 --- a/vpn/netstate/nat_linux.go +++ b/vpn/netstate/nat_linux.go @@ -11,7 +11,10 @@ import ( "github.com/coreos/go-iptables/iptables" ) -const awlForwardChain = "AWL-FORWARD" +const ( + awlForwardChain = "AWL-FORWARD" + awlForwardChain6 = "AWL6-FORWARD" +) // privateSubnets (the destination set we refuse to forward) is shared across // platforms — see private_subnets.go. @@ -26,6 +29,14 @@ type natState struct { awlSubnet string tunIfName string origIPForward string + + // IPv6 NAT state. awlSubnet6 is empty when the IPv6 awl subnet is not + // configured. ip6tablesOK is set to false when the kernel lacks NAT6 + // support (missing nf_nat_ipv6 module), so we degrade gracefully to + // IPv4-only without failing the entire server-side enable. + awlSubnet6 string + origIPv6Forward string + ip6tablesOK bool } // setupNAT enables IP forwarding and configures iptables MASQUERADE for the exit node. @@ -36,10 +47,11 @@ type natState struct { // state (AWL-FORWARD chain, MASQUERADE rule, ip_forward=1) would otherwise // cause this function to fail at NewChain. We pre-clean any such leftovers // best-effort so the new setup gets a clean slate. -func (m *Manager) setupNAT(awlSubnet, tunIfName string) (*natState, error) { +func (m *Manager) setupNAT(awlSubnet, awlSubnet6, tunIfName string) (*natState, error) { state := &natState{ - awlSubnet: awlSubnet, - tunIfName: tunIfName, + awlSubnet: awlSubnet, + awlSubnet6: awlSubnet6, + tunIfName: tunIfName, } ipt, err := iptables.New() @@ -84,9 +96,59 @@ func (m *Manager) setupNAT(awlSubnet, tunIfName string) (*natState, error) { return nil, err } + // IPv6 NAT — optional, degrades gracefully when the kernel has no NAT6 + // support (nf_nat_ipv6 module missing, common on minimised kernels). + if awlSubnet6 != "" { + if err := m.setupNATv6(state); err != nil { + // Log and continue: IPv4 gateway still works. + logger.Warnf("IPv6 NAT setup failed (IPv4 gateway still active, IPv6 will not be tunnelled): %v", err) + } + } + return state, nil } +// setupNATv6 configures ip6tables MASQUERADE for the IPv6 awl subnet. +// It mirrors setupIptables but for IPv6. Errors are not fatal to the overall +// NAT setup — they are logged and the gateway continues with IPv4 only. +func (m *Manager) setupNATv6(state *natState) error { + // Enable IPv6 forwarding (mirrors ip_forward logic above). + origVal, err := os.ReadFile("/proc/sys/net/ipv6/conf/all/forwarding") + if err != nil { + return fmt.Errorf("read ipv6 forwarding: %w", err) + } + state.origIPv6Forward = strings.TrimSpace(string(origVal)) + if state.origIPv6Forward == "0" { + if err := os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0600); err != nil { + return fmt.Errorf("enable ipv6 forwarding: %w", err) + } + } + + // ip6tables with nat table — will fail if the kernel module is absent. + ipt6, err := iptables.NewWithProtocol(iptables.ProtocolIPv6) + if err != nil { + return fmt.Errorf("init ip6tables: %w", err) + } + + // Pre-clean any stale ip6tables state from a previous run. + if staleCleaned, err := cleanupStaleNAT6(ipt6, state.awlSubnet6, state.tunIfName); err != nil { + return fmt.Errorf("pre-clean stale NAT6: %w", err) + } else if staleCleaned { + logger.Warnf("recovered from leftover gateway NAT6 state") + } + + if err := setupIptables6(ipt6, state); err != nil { + // Roll back ip6tables partial state, but leave ipv6 forwarding as-is + // (same hands-off policy as IPv4: if it was already on, keep it). + _ = teardownIptablesRules6(state) + return err + } + + state.ip6tablesOK = true + logger.Infof("IPv6 NAT (MASQUERADE) configured for subnet %s on %s", state.awlSubnet6, state.tunIfName) + return nil +} + func setupIptables(ipt *iptables.IPTables, state *natState) error { if err := ipt.NewChain("filter", awlForwardChain); err != nil { return fmt.Errorf("create chain %s: %w", awlForwardChain, err) @@ -131,6 +193,40 @@ func setupIptables(ipt *iptables.IPTables, state *natState) error { return nil } +// setupIptables6 mirrors setupIptables for IPv6 using the AWL6-FORWARD chain. +func setupIptables6(ipt6 *iptables.IPTables, state *natState) error { + if err := ipt6.NewChain("filter", awlForwardChain6); err != nil { + return fmt.Errorf("create chain %s: %w", awlForwardChain6, err) + } + + if err := ipt6.Append("filter", awlForwardChain6, conntrackArgs()...); err != nil { + return fmt.Errorf("add conntrack rule to %s: %w", awlForwardChain6, err) + } + + for _, priv := range privateSubnetsV6 { + if err := ipt6.Append("filter", awlForwardChain6, "-d", priv, "-j", "DROP"); err != nil { + return fmt.Errorf("add IPv6 DROP rule for %s to %s: %w", priv, awlForwardChain6, err) + } + } + + if err := ipt6.Append("filter", awlForwardChain6, "-j", "ACCEPT"); err != nil { + return fmt.Errorf("add ACCEPT rule to %s: %w", awlForwardChain6, err) + } + + if err := ipt6.Insert("filter", "FORWARD", 1, outboundJumpArgs6(state.tunIfName, state.awlSubnet6)...); err != nil { + return fmt.Errorf("insert outbound jump to %s: %w", awlForwardChain6, err) + } + if err := ipt6.Insert("filter", "FORWARD", 1, returnJumpArgs6(state.tunIfName, state.awlSubnet6)...); err != nil { + return fmt.Errorf("insert return jump to %s: %w", awlForwardChain6, err) + } + + if err := ipt6.Append("nat", "POSTROUTING", masqueradeArgs6(state.awlSubnet6, state.tunIfName)...); err != nil { + return fmt.Errorf("add IPv6 MASQUERADE: %w", err) + } + + return nil +} + // teardownNAT reverses the changes made by setupNAT. Safe to call on partially // set up state. func (m *Manager) teardownNAT(state *natState) error { @@ -140,14 +236,24 @@ func (m *Manager) teardownNAT(state *natState) error { errs := teardownIptablesRules(state) - // Mirror of setupNAT: we only enabled forwarding if it was off, so we only - // restore in that case. If it was already on, leave the kernel value alone. + if state.ip6tablesOK { + errs = append(errs, teardownIptablesRules6(state)...) + } + + // Restore IPv4 forwarding if we changed it. if state.origIPForward == "0" { if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("0"), 0600); err != nil { errs = append(errs, fmt.Errorf("restore ip_forward: %w", err)) } } + // Restore IPv6 forwarding if we changed it. + if state.ip6tablesOK && state.origIPv6Forward == "0" { + if err := os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("0"), 0600); err != nil { + errs = append(errs, fmt.Errorf("restore ipv6 forwarding: %w", err)) + } + } + return errors.Join(errs...) } @@ -184,6 +290,39 @@ func teardownIptablesRules(state *natState) []error { return errs } +func teardownIptablesRules6(state *natState) []error { + var errs []error + ipt6, err := iptables.NewWithProtocol(iptables.ProtocolIPv6) + if err != nil { + return []error{fmt.Errorf("init ip6tables for teardown: %w", err)} + } + + if err := ipt6.DeleteIfExists("nat", "POSTROUTING", masqueradeArgs6(state.awlSubnet6, state.tunIfName)...); err != nil { + errs = append(errs, fmt.Errorf("del IPv6 MASQUERADE: %w", err)) + } + if err := ipt6.DeleteIfExists("filter", "FORWARD", returnJumpArgs6(state.tunIfName, state.awlSubnet6)...); err != nil { + errs = append(errs, fmt.Errorf("del IPv6 return jump: %w", err)) + } + if err := ipt6.DeleteIfExists("filter", "FORWARD", outboundJumpArgs6(state.tunIfName, state.awlSubnet6)...); err != nil { + errs = append(errs, fmt.Errorf("del IPv6 outbound jump: %w", err)) + } + + exists, err := ipt6.ChainExists("filter", awlForwardChain6) + if err != nil { + errs = append(errs, fmt.Errorf("check %s chain: %w", awlForwardChain6, err)) + return errs + } + if exists { + if err := ipt6.ClearChain("filter", awlForwardChain6); err != nil { + errs = append(errs, fmt.Errorf("flush chain %s: %w", awlForwardChain6, err)) + } + if err := ipt6.DeleteChain("filter", awlForwardChain6); err != nil { + errs = append(errs, fmt.Errorf("del chain %s: %w", awlForwardChain6, err)) + } + } + return errs +} + // cleanupStaleNAT removes leftover NAT state from a previous setupNAT call // that did not get a clean teardown (kill -9, OOM, etc). Detection key is the // presence of the AWL-FORWARD chain — if it exists, we assume the rest of the @@ -216,6 +355,28 @@ func cleanupStaleNAT(ipt *iptables.IPTables, awlSubnet, tunIfName string) (bool, return true, nil } +// cleanupStaleNAT6 mirrors cleanupStaleNAT for IPv6. Detection key is the +// AWL6-FORWARD chain. +func cleanupStaleNAT6(ipt6 *iptables.IPTables, awlSubnet6, tunIfName string) (bool, error) { + chainExists, err := ipt6.ChainExists("filter", awlForwardChain6) + if err != nil { + return false, fmt.Errorf("check %s chain: %w", awlForwardChain6, err) + } + if !chainExists { + return false, nil + } + + _ = ipt6.DeleteIfExists("nat", "POSTROUTING", masqueradeArgs6(awlSubnet6, tunIfName)...) + _ = ipt6.DeleteIfExists("filter", "FORWARD", returnJumpArgs6(tunIfName, awlSubnet6)...) + _ = ipt6.DeleteIfExists("filter", "FORWARD", outboundJumpArgs6(tunIfName, awlSubnet6)...) + _ = ipt6.ClearChain("filter", awlForwardChain6) + _ = ipt6.DeleteChain("filter", awlForwardChain6) + + return true, nil +} + +// ─── iptables rule argument builders (IPv4) ───────────────────────────────── + func conntrackArgs() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } @@ -231,3 +392,17 @@ func returnJumpArgs(tunIfName, awlSubnet string) []string { func masqueradeArgs(awlSubnet, tunIfName string) []string { return []string{"-s", awlSubnet, "!", "-o", tunIfName, "-j", "MASQUERADE"} } + +// ─── ip6tables rule argument builders (IPv6) ───────────────────────────────── + +func outboundJumpArgs6(tunIfName, awlSubnet6 string) []string { + return []string{"-i", tunIfName, "-s", awlSubnet6, "-j", awlForwardChain6} +} + +func returnJumpArgs6(tunIfName, awlSubnet6 string) []string { + return []string{"-o", tunIfName, "-d", awlSubnet6, "-j", awlForwardChain6} +} + +func masqueradeArgs6(awlSubnet6, tunIfName string) []string { + return []string{"-s", awlSubnet6, "!", "-o", tunIfName, "-j", "MASQUERADE"} +} diff --git a/vpn/netstate/nat_windows.go b/vpn/netstate/nat_windows.go index f9ddba1a..9aea19b9 100644 --- a/vpn/netstate/nat_windows.go +++ b/vpn/netstate/nat_windows.go @@ -78,7 +78,7 @@ type natState struct { // Order matters and is fail-closed: the WFP BLOCK filter is installed before // forwarding/NAT create any technical possibility of transit. Any failure // after the first step rolls back via teardownNAT (all steps are idempotent). -func (m *Manager) setupNAT(awlSubnet, tunIfName string) (*natState, error) { +func (m *Manager) setupNAT(awlSubnet, awlSubnet6, tunIfName string) (*natState, error) { awlPrefix, err := netip.ParsePrefix(awlSubnet) if err != nil { return nil, fmt.Errorf("parse awl subnet %q: %w", awlSubnet, err) diff --git a/vpn/netstate/private_subnets.go b/vpn/netstate/private_subnets.go index 5f639601..7c3e1da2 100644 --- a/vpn/netstate/private_subnets.go +++ b/vpn/netstate/private_subnets.go @@ -22,16 +22,42 @@ var privateSubnets = []string{ "169.254.0.0/16", // RFC 3927 — link-local } +// privateSubnetsV6 covers the IPv6 equivalents of LAN and link-local space. +// Exit nodes must drop forwarding to these to prevent local IPv6 network exposure. +var privateSubnetsV6 = []string{ + "fc00::/7", // RFC 4193 — Unique Local Address (ULA), equivalent to RFC 1918 LANs + "fe80::/10", // RFC 4291 — Link-local address, equivalent to 169.254.0.0/16 +} + // privateSubnetPrefixes returns privateSubnets parsed into netip.Prefix. // Panics on a malformed entry — the list is a compile-time constant and is // verified by a unit test, so a panic here means a broken edit, not runtime // input. func privateSubnetPrefixes() []netip.Prefix { - prefixes := make([]netip.Prefix, 0, len(privateSubnets)) - for _, s := range privateSubnets { + return parsePrefixList(privateSubnets, "privateSubnets") +} + +// privateSubnetPrefixesV6 returns privateSubnetsV6 parsed into netip.Prefix. +// Panics on a malformed entry for the same reasons as privateSubnetPrefixes. +func privateSubnetPrefixesV6() []netip.Prefix { + return parsePrefixList(privateSubnetsV6, "privateSubnetsV6") +} + +// AllPrivateSubnetPrefixes returns a combined slice of both IPv4 and IPv6 private prefixes. +// Useful for cross-platform engines (like Windows WFP or Go-native packet matchers) +// that handle both IP families in a single pass. +func AllPrivateSubnetPrefixes() []netip.Prefix { + v4 := privateSubnetPrefixes() + v6 := privateSubnetPrefixesV6() + return append(v4, v6...) +} + +func parsePrefixList(subnets []string, name string) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(subnets)) + for _, s := range subnets { p, err := netip.ParsePrefix(s) if err != nil { - panic(fmt.Sprintf("privateSubnets contains malformed prefix %q: %v", s, err)) + panic(fmt.Sprintf("%s contains malformed prefix %q: %v", name, s, err)) } prefixes = append(prefixes, p) } diff --git a/vpn/netstate/routes_linux.go b/vpn/netstate/routes_linux.go index d4efb2db..6cf3c513 100644 --- a/vpn/netstate/routes_linux.go +++ b/vpn/netstate/routes_linux.go @@ -5,11 +5,11 @@ package netstate import ( "errors" "fmt" + "golang.org/x/sys/unix" "net" "syscall" "github.com/vishvananda/netlink" - "golang.org/x/sys/unix" ) const ( @@ -47,15 +47,15 @@ type routeState struct { origDefaults []netlink.Route tunRouteAdded bool - // IPv6 fail-closed state. The gateway only tunnels IPv4; to stop IPv6 from - // leaking past the exit node on a dual-stack host we fence it with an - // `unreachable ::/0` route, and exempt marked libp2p sockets via a v6 - // fwmark rule + a copy of the host's IPv6 default(s) into tableID. Mirrors - // the IPv4 fields above. origDefaultsV6 may be empty (host has no IPv6) and, - // like origDefaults, is kept current by the monitor. - origDefaultsV6 []netlink.Route - v6RuleAdded bool - v6UnreachAdded bool + // IPv6 gateway state. origDefaultsV6 are the host IPv6 default routes copied + // into tableID for the libp2p exemption path (mirrors origDefaults). + // tunRouteV6Added tracks whether we installed a ::/0 via TUN route so IPv6 + // traffic flows through the tunnel (as opposed to the old fail-closed + // `unreachable ::/0` that merely blocked IPv6). + // v6RuleAdded tracks whether the fwmark→tableID rule for IPv6 was installed. + origDefaultsV6 []netlink.Route + v6RuleAdded bool + tunRouteV6Added bool } // setupGatewayRoutes configures the system to route all traffic through the @@ -153,10 +153,14 @@ func (m *Manager) setupGatewayRoutes(tunIfName string) (*routeState, error) { } state.tunRouteAdded = true - // IPv6 fail-closed fence (`unreachable ::/0` + libp2p exemption). Installed - // unconditionally — see setupIPv6Fence for why that is safe even when IPv6 is - // disabled via sysctl, and how a genuinely absent IPv6 stack is tolerated. - if err := setupIPv6Fence(state); err != nil { + // IPv6 gateway route: a ::/0 default route via TUN so IPv6 traffic is + // captured by the tunnel and forwarded to the exit node. This replaces the + // old `unreachable ::/0` fence — we now actually tunnel IPv6 instead of + // merely blocking it. + // Installed UNCONDITIONALLY even when the host has no IPv6 default right + // now (same rationale as the former fence: IPv6 may appear later via RA). + // Graceful on EAFNOSUPPORT (kernel-level ipv6.disable=1). + if err := setupIPv6TunRoute(state); err != nil { _ = m.teardownGatewayRoutes(state) return nil, err } @@ -164,33 +168,23 @@ func (m *Manager) setupGatewayRoutes(tunIfName string) (*routeState, error) { return state, nil } -// setupIPv6Fence installs the IPv6 fail-closed fencing onto state: a v6 -// fwmark->tableID rule, a copy of the host's current IPv6 default(s) into -// tableID (the libp2p exemption path), and an `unreachable ::/0` route that wins -// LPM over any host default so locally generated IPv6 connect()s fail fast with -// EHOSTUNREACH and apps fall back to IPv4 through the tunnel (Happy Eyeballs, -// RFC 8305). Without it a dual-stack host egresses IPv6 straight out its -// physical interface, exposing the real address past the exit node. +// setupIPv6TunRoute installs the IPv6 gateway capture route: a v6 fwmark→tableID +// rule, a copy of the host's current IPv6 default(s) into tableID (the libp2p +// exemption path), and a ::/0 default route via TUN so IPv6 flows through the +// tunnel to the exit node. // -// It is applied UNCONDITIONALLY, even when the host has no IPv6 default right -// now. The unreachable route and the rule install fine even with IPv6 -// administratively disabled via sysctl (`disable_ipv6=1` only blocks address -// assignment, not route/rule additions — verified on Linux 6.8). Installing it -// regardless means IPv6 that appears later — a hot-plugged uplink, a runtime -// sysctl flip, a fresh RA — is already fenced and loses LPM to our metric-5 -// unreachable, rather than leaking. An empty IPv6 default set is therefore not -// an error (unlike the IPv4 default above). +// This replaces the former `unreachable ::/0` fence: IPv6 is now FORWARDED +// through the tunnel (exit node does NAT6) rather than dropped. The libp2p +// exemption works identically to the IPv4 path. // -// The one case where the IPv6 stack genuinely isn't there is a kernel-level -// disable (`ipv6.disable=1` on the cmdline): the module is absent, AF_INET6 ops -// fail with EAFNOSUPPORT, and there is nothing to leak. We detect that from the -// netlink ops and skip the fence (leaving state.v6* unset) rather than failing -// the otherwise-working IPv4 gateway setup. -func setupIPv6Fence(state *routeState) error { +// Graceful degradation: if the kernel has no IPv6 stack (ipv6.disable=1 on the +// cmdline), EAFNOSUPPORT is returned from the first netlink op and we skip the +// whole setup, leaving v6* fields unset. IPv4 gateway continues normally. +func setupIPv6TunRoute(state *routeState) error { origDefaultsV6, err := getDefaultRoutesV6() if err != nil { if ipv6Unavailable(err) { - logger.Infof("IPv6 stack unavailable (%v); skipping IPv6 leak fence", err) + logger.Infof("IPv6 stack unavailable (%v); skipping IPv6 gateway route", err) return nil } return fmt.Errorf("get IPv6 default routes: %w", err) @@ -198,7 +192,7 @@ func setupIPv6Fence(state *routeState) error { if err := netlink.RuleAdd(buildFwmarkRuleV6()); err != nil { if ipv6Unavailable(err) { - logger.Infof("IPv6 stack unavailable (%v); skipping IPv6 leak fence", err) + logger.Infof("IPv6 stack unavailable (%v); skipping IPv6 gateway route", err) return nil } return fmt.Errorf("add IPv6 ip rule: %w", err) @@ -214,16 +208,16 @@ func setupIPv6Fence(state *routeState) error { } } - if err := netlink.RouteAdd(buildV6UnreachableRoute()); err != nil { + // ::/0 via TUN — captures all IPv6 traffic into the tunnel. + if err := netlink.RouteAdd(buildTunDefaultRouteV6(state.tunLinkIndex)); err != nil { if errors.Is(err, syscall.EEXIST) { - return fmt.Errorf("add IPv6 unreachable default route: %w — a ::/0 route at metric %d "+ - "already exists (likely another VPN or a manual route, not awl: cleanupStaleRoutes "+ - "already removed any of ours); inspect with `ip -6 route show` and resolve the conflict", + return fmt.Errorf("add IPv6 TUN default route: %w — a ::/0 route at metric %d "+ + "already exists; inspect with `ip -6 route show` and resolve the conflict", err, tunRouteMetric) } - return fmt.Errorf("add IPv6 unreachable default route: %w", err) + return fmt.Errorf("add IPv6 TUN default route: %w", err) } - state.v6UnreachAdded = true + state.tunRouteV6Added = true return nil } @@ -253,6 +247,10 @@ func ipv6Unavailable(err error) bool { // or a system administrator's static route. Better to surface a clear // error from RouteAdd's EEXIST than silently delete someone else's // traffic path. +// +// The same logic applies to the IPv6 TUN default route: it is also bound to +// the TUN interface and dies with the process. The fwmark rules for both +// families ARE cleaned (they are not bound to the TUN fd). func cleanupStaleRoutes() bool { cleaned := false @@ -280,18 +278,12 @@ func cleanupStaleRoutes() bool { } } - // 3. Stale IPv6 fwmark rule and the `unreachable ::/0` fence. Unlike the - // IPv4 TUN default route (which the kernel auto-removes when the TUN fd dies - // with the process), the v6 unreachable route is not bound to any interface, - // so a SIGKILL'd run leaves it behind — fencing off IPv6 host-wide until it - // is removed. It IS owner-tagged (its low metric + ::/0 + RTN_UNREACHABLE - // shape is ours), so we clean it here rather than surfacing an EEXIST. + // 3. Stale IPv6 fwmark rule. The IPv6 TUN default route (::/0 via TUN) is + // NOT cleaned here — like the IPv4 TUN route, it is bound to the TUN + // interface fd and disappears when the process dies. if err := netlink.RuleDel(buildFwmarkRuleV6()); err == nil { cleaned = true } - if err := netlink.RouteDel(buildV6UnreachableRoute()); err == nil { - cleaned = true - } return cleaned } @@ -328,12 +320,11 @@ func (m *Manager) teardownGatewayRoutes(state *routeState) error { errs = append(errs, fmt.Errorf("del ip rule: %w", err)) } - // IPv6 fail-closed teardown, reverse order of setup: unreachable fence, - // copied defaults, then the v6 fwmark rule. Guarded by the per-step flags so - // a rollback from a partially-applied setup doesn't generate spurious errors. - if state.v6UnreachAdded { - if err := netlink.RouteDel(buildV6UnreachableRoute()); err != nil { - errs = append(errs, fmt.Errorf("del IPv6 unreachable default route: %w", err)) + // IPv6 gateway teardown, reverse order of setup: TUN default route, + // copied defaults, then the v6 fwmark rule. Guarded by the per-step flags. + if state.tunRouteV6Added { + if err := netlink.RouteDel(buildTunDefaultRouteV6(state.tunLinkIndex)); err != nil { + errs = append(errs, fmt.Errorf("del IPv6 TUN default route: %w", err)) } } for i := range state.origDefaultsV6 { @@ -401,21 +392,21 @@ func buildTunDefaultRoute(tunLinkIndex int) *netlink.Route { } } -// buildV6UnreachableRoute constructs the `unreachable ::/0` fence installed -// while the gateway is on. RTN_UNREACHABLE (not RTN_BLACKHOLE) so locally -// generated IPv6 connect()s fail fast with EHOSTUNREACH and apps fall back to -// IPv4 through the tunnel (Happy Eyeballs, RFC 8305) instead of timing out. -// Same low metric as the IPv4 TUN default so it wins LPM over the host's -// RA/DHCPv6 default. The identical shape is used for Add, stale-cleanup Del and -// teardown Del so they can't drift. No LinkIndex: an unreachable route is not -// attached to any interface. -func buildV6UnreachableRoute() *netlink.Route { +// buildTunDefaultRouteV6 constructs the IPv6 default route via the TUN. This +// is the IPv6 analogue of buildTunDefaultRoute: a ::/0 route via the TUN so +// all IPv6 traffic is captured into the tunnel and forwarded to the exit node. +// Scope is SCOPE_LINK (point-to-point TUN, no gateway address). Same low +// metric as the IPv4 TUN default so it wins LPM over any existing host +// RA/DHCPv6 default. The identical shape is used for RouteAdd and RouteDel so +// they can't drift. +func buildTunDefaultRouteV6(tunLinkIndex int) *netlink.Route { return &netlink.Route{ - Type: unix.RTN_UNREACHABLE, + LinkIndex: tunLinkIndex, Dst: &net.IPNet{ IP: net.IPv6zero, Mask: net.CIDRMask(0, 128), }, + Scope: netlink.SCOPE_LINK, Priority: tunRouteMetric, Family: netlink.FAMILY_V6, } @@ -455,10 +446,10 @@ func isIPv4DefaultDst(dst *net.IPNet) bool { } // getDefaultRoutesV6 returns every IPv6 default route (::/0) currently in the -// main routing table, to be copied into tableID as the libp2p exemption path. -// Unlike getDefaultRoutes, an empty result is NOT an error: the gateway installs -// the `unreachable ::/0` fence unconditionally, so a host with no IPv6 uplink is -// simply fenced against IPv6 that may appear later via RA. +// main routing table, to be copied into the policy-routing table as the libp2p +// exemption path. Unlike getDefaultRoutes, an empty result is NOT an error: the +// gateway installs the `unreachable ::/0` fence unconditionally, so a host with +// no IPv6 uplink is simply fenced against IPv6 that may appear later via RA. func getDefaultRoutesV6() ([]netlink.Route, error) { allRoutes, err := netlink.RouteList(nil, netlink.FAMILY_V6) if err != nil { diff --git a/vpn/netstate/vpn_hostnet_integration_test.go b/vpn/netstate/vpn_hostnet_integration_test.go index 84d063f1..a0a35915 100644 --- a/vpn/netstate/vpn_hostnet_integration_test.go +++ b/vpn/netstate/vpn_hostnet_integration_test.go @@ -49,9 +49,10 @@ import ( ) const ( - testTunIf = "awl0" - testAwlSubnet = "10.66.0.0/16" - ipForwardPath = "/proc/sys/net/ipv4/ip_forward" + testTunIf = "awl0" + testAwlSubnet = "10.66.0.0/16" + testAwlSubnet6 = "fd00:66::/48" + ipForwardPath = "/proc/sys/net/ipv4/ip_forward" ) // ---- N1: NAT apply/teardown lifecycle ---- @@ -65,7 +66,7 @@ func TestGatewayHostNetNATLifecycle(t *testing.T) { before := snapshotNet(t) mgr := NewManager() - require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, testTunIf)) + require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, testAwlSubnet6, testTunIf)) require.True(t, mgr.ServerNATActive()) assertNATApplied(t) @@ -97,13 +98,13 @@ func TestGatewayHostNetNATIdempotentResetup(t *testing.T) { before := snapshotNet(t) mgr1 := NewManager() - require.NoError(t, mgr1.EnableServerNAT(testAwlSubnet, testTunIf)) + require.NoError(t, mgr1.EnableServerNAT(testAwlSubnet, testAwlSubnet6, testTunIf)) applied1 := snapshotNet(t) // Second manager over the live state of the first — simulates a leftover // from a process that was killed before teardown ran. mgr2 := NewManager() - require.NoError(t, mgr2.EnableServerNAT(testAwlSubnet, testTunIf), + require.NoError(t, mgr2.EnableServerNAT(testAwlSubnet, testAwlSubnet6, testTunIf), "re-setup over leftover state must succeed (cleanupStaleNAT)") applied2 := snapshotNet(t) @@ -126,7 +127,7 @@ func TestGatewayHostNetNATPreservesExistingIPForward(t *testing.T) { } mgr := NewManager() - require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, testTunIf)) + require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, testAwlSubnet6, testTunIf)) require.Equal(t, "1", readForward(t)) require.NoError(t, mgr.DisableServerNAT()) @@ -293,9 +294,9 @@ func TestGatewayHostNetRoutesStalenessReconcile(t *testing.T) { // The IPv6 counterpart of R4. RA re-advertising a new router/prefix changes the // v6 default far more often than IPv4 changes, so the monitor must mirror v6 // default changes into tableID too (marked libp2p v6 sockets otherwise fall -// through to the `unreachable ::/0` fence and get EHOSTUNREACH). We simulate a +// through to the TUN default and exit the VPN). We simulate a // new v6 uplink with a high-metric default via a dummy interface and assert the -// awl table follows both edges without disturbing the fence or the TUN default. +// awl table follows both edges without disturbing the TUN default. func TestGatewayHostNetRoutesStalenessReconcileV6(t *testing.T) { verifyNoLeaks(t) requireRoot(t) @@ -331,7 +332,7 @@ func TestGatewayHostNetRoutesStalenessReconcileV6(t *testing.T) { }, 5*time.Second, 100*time.Millisecond, "the monitor must copy the new host IPv6 default into the awl exemption table") - // The unreachable fence and TUN default must be untouched by the reconcile. + // The IPv6 TUN route must be untouched by the reconcile. assertRoutesApplied(t) // Remove the second v6 default; the awl table copy must follow. @@ -478,11 +479,11 @@ func assertRoutesApplied(t *testing.T) { require.Contains(t, rules6, fmt.Sprintf("fwmark 0x%x", awlMark), "v6 fwmark ip rule") require.Contains(t, rules6, fmt.Sprintf("lookup %d", tableID), "v6 ip rule must steer to the awl table") - // Anchor the metric to the unreachable line so an unrelated host route can't + // Anchor the metric to the awl0 route so an unrelated host route can't // satisfy it. main6 := cmdOut(t, "ip", "-6", "route", "show") - require.Regexp(t, fmt.Sprintf(`unreachable default.*metric %d`, tunRouteMetric), main6, - "IPv6 unreachable fence present at the expected metric") + require.Regexp(t, fmt.Sprintf(`default dev %s.*metric %d`, testTunIf, tunRouteMetric), main6, + "IPv6 TUN route present at the expected metric") } // --------------------------------------------------------------------------- diff --git a/vpn/netstate/vpn_hostnet_windows_integration_test.go b/vpn/netstate/vpn_hostnet_windows_integration_test.go index 8f3c20f8..9ef21e44 100644 --- a/vpn/netstate/vpn_hostnet_windows_integration_test.go +++ b/vpn/netstate/vpn_hostnet_windows_integration_test.go @@ -281,7 +281,7 @@ func TestGatewayHostNetNATLifecycle(t *testing.T) { require.False(t, awlNATInstalled(t), "pre-existing awl-gateway NetNat; clean the host before running") mgr := NewManager() - require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, nicGUID)) + require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, "", nicGUID)) require.True(t, mgr.ServerNATActive()) require.True(t, awlNATInstalled(t), "NetNat must exist while NAT is up") @@ -317,7 +317,7 @@ func TestGatewayHostNetNATPreservesExistingForwarding(t *testing.T) { } mgr := NewManager() - require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, nicGUID)) + require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, "", nicGUID)) require.True(t, forwardingEnabled(t, nicLUID)) require.NoError(t, mgr.DisableServerNAT()) @@ -338,7 +338,7 @@ func TestGatewayHostNetNATStaleRecovery(t *testing.T) { require.True(t, awlNATInstalled(t)) mgr := NewManager() - require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, nicGUID), + require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, "", nicGUID), "EnableServerNAT must recover from a stale awl-gateway NetNat") require.True(t, awlNATInstalled(t)) @@ -370,7 +370,7 @@ func TestGatewayHostNetNATRollback(t *testing.T) { }) mgr := NewManager() - err = mgr.EnableServerNAT(testAwlSubnet, nicGUID) + err = mgr.EnableServerNAT(testAwlSubnet, "", nicGUID) if err == nil { // If even overlapping-prefix instances are tolerated, there is no // failure to roll back from. Clean up and skip rather than fail. @@ -603,7 +603,7 @@ func TestGatewayHostNetClientFenceAllowsClientServerCoexist(t *testing.T) { _, nicGUID := pickServerTestNIC(t) require.NoError(t, mgr.EnableClientRoutes(tunGUID)) - require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, nicGUID)) + require.NoError(t, mgr.EnableServerNAT(testAwlSubnet, "", nicGUID)) t.Cleanup(func() { _ = mgr.DisableServerNAT() }) require.Equal(t, 8, clientFenceRuleCount(t), "client fence rules present with both roles on") diff --git a/vpn/packet.go b/vpn/packet.go index 88511017..61f0692a 100644 --- a/vpn/packet.go +++ b/vpn/packet.go @@ -12,8 +12,9 @@ import ( ) const ( - IPProtocolTCP = 6 - IPProtocolUDP = 17 + IPProtocolTCP = 6 + IPProtocolUDP = 17 + IPProtocolICMPv6 = 58 ipv4offsetChecksum = 10 ) @@ -122,7 +123,8 @@ func (data *Packet) Parse() bool { data.IsIPv6 = true data.setAddrs() - // TODO: set data.IPProtocol + // IPv6 Next Header field is at offset 6. + data.IPProtocol = packet[6] default: return false } @@ -132,7 +134,7 @@ func (data *Packet) Parse() bool { func (data *Packet) RecalculateChecksum() { if data.IsIPv6 { - // TODO + data.recalculateChecksumIPv6() return } // Guard against malformed lengths so a bad packet can't slice past bounds and @@ -165,6 +167,45 @@ func (data *Packet) RecalculateChecksum() { } } +// recalculateChecksumIPv6 updates TCP/UDP transport-layer checksums after an +// IPv6 src/dst rewrite. IPv6 has no IP-header checksum (unlike IPv4), but the +// TCP/UDP pseudo-header includes the 128-bit src and dst addresses, so any +// address rewrite invalidates the transport checksum and must be followed by +// this call. +// +// IPv6 extension headers are NOT walked: awl tunnels ordinary TCP/UDP/ICMP6 +// packets and never generates fragments or options, so the Next Header at +// offset 6 always names the transport protocol directly. +func (data *Packet) recalculateChecksumIPv6() { + if len(data.Packet) < ipv6.HeaderLen { + return + } + payload := data.Packet[ipv6.HeaderLen:] + switch data.IPProtocol { + case IPProtocolTCP: + if len(payload) < 18 { + return + } + copy(payload[16:18], []byte{0, 0}) + checksum := checksumIPv6TCPUDP(payload, uint32(data.IPProtocol), data.Src, data.Dst) + binary.BigEndian.PutUint16(payload[16:], checksum) + case IPProtocolUDP: + if len(payload) < 8 { + return + } + copy(payload[6:8], []byte{0, 0}) + checksum := checksumIPv6TCPUDP(payload, uint32(data.IPProtocol), data.Src, data.Dst) + binary.BigEndian.PutUint16(payload[6:], checksum) + case IPProtocolICMPv6: + if len(payload) < 4 { + return + } + copy(payload[2:4], []byte{0, 0}) + checksum := checksumIPv6TCPUDP(payload, uint32(data.IPProtocol), data.Src, data.Dst) + binary.BigEndian.PutUint16(payload[2:], checksum) + } +} + func (data *Packet) setAddrs() { if data.IsIPv6 { data.Src = data.Packet[device.IPv6offsetSrc : device.IPv6offsetSrc+net.IPv6len] @@ -206,6 +247,37 @@ func checksumIPv4TCPUDP(headerAndPayload []byte, protocol uint32, srcIP net.IP, return tcpipChecksum(headerAndPayload, csum) } +// checksumIPv6TCPUDP computes the TCP/UDP transport checksum using the IPv6 +// pseudo-header as defined in RFC 2460 §8.1. The pseudo-header fields are: +// +// source address (16 bytes) +// destination address (16 bytes) +// upper-layer length (4 bytes, big-endian) +// zero padding (3 bytes) +// next header (1 byte) +// +// headerAndPayload is the transport segment (TCP/UDP header + payload). +// srcIP and dstIP must each be 16 bytes (net.IPv6len). +func checksumIPv6TCPUDP(headerAndPayload []byte, protocol uint32, srcIP net.IP, dstIP net.IP) uint16 { + var csum uint32 + // Source address + for i := 0; i < net.IPv6len; i += 2 { + csum += uint32(srcIP[i])<<8 + uint32(srcIP[i+1]) + } + // Destination address + for i := 0; i < net.IPv6len; i += 2 { + csum += uint32(dstIP[i])<<8 + uint32(dstIP[i+1]) + } + // Upper-layer packet length (same as transport segment length) + totalLen := uint32(len(headerAndPayload)) + csum += totalLen >> 16 + csum += totalLen & 0xffff + // Next Header (protocol) + csum += protocol + + return tcpipChecksum(headerAndPayload, csum) +} + // Calculate the TCP/IP checksum defined in rfc1071. The passed-in csum is any // initial checksum data that's already been computed. // Borrowed from google/gopacket diff --git a/vpn/packet_test.go b/vpn/packet_test.go index 84d3cbb3..09ed1ec2 100644 --- a/vpn/packet_test.go +++ b/vpn/packet_test.go @@ -21,6 +21,20 @@ func TestPacket_RecalculateChecksum(t *testing.T) { a.Equal(rawData, packet.Packet) } +func TestPacket_RecalculateChecksum_IPv6(t *testing.T) { + a := require.New(t) + packet, rawData := testUDPPacketIPv6() + // testUDPPacketIPv6 has 0000 for checksum, calling RecalculateChecksum will update it + packet.RecalculateChecksum() + // Just verify it doesn't crash and actually modifies the checksum if it was 0000 + a.NotEqual(rawData, packet.Packet) + + // Test idempotency + firstRecalculate := append([]byte(nil), packet.Packet...) + packet.RecalculateChecksum() + a.Equal(firstRecalculate, packet.Packet) +} + // TODO: bench with bigger packet func BenchmarkPacket_RecalculateChecksum(b *testing.B) { packet, _ := testUDPPacket() @@ -76,6 +90,31 @@ func TestPacket_Parse_RejectsMalformedIPv4(t *testing.T) { } } +func TestPacket_Parse_RejectsMalformedIPv6(t *testing.T) { + newRawIPv6 := func(size int, nextHeader byte) []byte { + raw := make([]byte, size) + raw[0] = 0x60 // version 6 + if len(raw) > 6 { + raw[6] = nextHeader + } + return raw + } + + cases := []struct { + name string + raw []byte + }{ + {"length less than ipv6 header", newRawIPv6(20, 17)}, // ipv6 header is 40 + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := new(Packet) + _, _ = p.ReadFrom(bytes.NewReader(tc.raw)) + require.False(t, p.Parse(), "malformed IPv6 packet must be rejected by Parse") + }) + } +} + // defense in depth: RecalculateChecksum must be panic-safe on malformed or // truncated packets, including when called without a prior successful Parse. func TestPacket_RecalculateChecksum_MalformedNoPanic(t *testing.T) { @@ -186,6 +225,19 @@ func testUDPPacket() (*Packet, []byte) { return packet, data } +func testUDPPacketIPv6() (*Packet, []byte) { + data, err := hex.DecodeString("6000000000141140fd000000000000000000000000000001fd00000000000000000000000000000204d2162e0014000068656c6c6f20776f726c6421") + if err != nil { + panic(err) + } + + packet := new(Packet) + _, _ = packet.ReadFrom(bytes.NewReader(data)) + packet.Parse() + + return packet, data +} + func TestGetIPv4BroadcastAddress(t *testing.T) { tests := []struct { name string diff --git a/vpn/swappable_tun_test.go b/vpn/swappable_tun_test.go index c2da7c41..3c62fa50 100644 --- a/vpn/swappable_tun_test.go +++ b/vpn/swappable_tun_test.go @@ -105,7 +105,7 @@ func TestSwappableTUN_ReadContinuesAcrossSwap(t *testing.T) { fake1 := newFakeTUN() sw := NewSwappableTUN(fake1) - dev, err := NewDevice(sw, "awl0", net.IPv4(10, 66, 0, 1), net.CIDRMask(24, 32)) + dev, err := NewDevice(sw, "awl0", net.IPv4(10, 66, 0, 1), net.CIDRMask(24, 32), nil, nil) a.NoError(err) var mu sync.Mutex diff --git a/vpn/vpn.go b/vpn/vpn.go index f94f29e7..2399d88f 100644 --- a/vpn/vpn.go +++ b/vpn/vpn.go @@ -28,19 +28,20 @@ const ( ) type Device struct { - tun tun.Device - mtu int64 - localIP net.IP + tun tun.Device + mtu int64 + localIP net.IP + localIP6 net.IP // may be nil when IPv6 is not configured packetsPool sync.Pool logger *log.ZapEventLogger } -func NewDevice(existingTun tun.Device, interfaceName string, localIP net.IP, ipMask net.IPMask) (*Device, error) { +func NewDevice(existingTun tun.Device, interfaceName string, localIP net.IP, ipMask net.IPMask, localIPv6 net.IP, ipMaskv6 net.IPMask) (*Device, error) { var tunDevice tun.Device var err error if existingTun == nil { - tunDevice, err = newTUN(interfaceName, InterfaceMTU, localIP, ipMask) + tunDevice, err = newTUN(interfaceName, InterfaceMTU, localIP, ipMask, localIPv6, ipMaskv6) if err != nil { return nil, fmt.Errorf("failed to create TUN device: %v", err) } @@ -54,9 +55,10 @@ func NewDevice(existingTun tun.Device, interfaceName string, localIP net.IP, ipM } dev := &Device{ - tun: tunDevice, - mtu: int64(realMtu), - localIP: localIP, + tun: tunDevice, + mtu: int64(realMtu), + localIP: localIP, + localIP6: localIPv6, packetsPool: sync.Pool{ New: func() interface{} { return new(Packet) @@ -77,11 +79,17 @@ func (d *Device) PutTempPacket(data *Packet) { d.packetsPool.Put(data) } -// LocalIP returns the awl IP assigned to this device. Set once in NewDevice. +// LocalIP returns the awl IPv4 address assigned to this device. Set once in NewDevice. func (d *Device) LocalIP() net.IP { return d.localIP } +// LocalIP6 returns the awl IPv6 address assigned to this device, or nil if +// IPv6 is not configured. Set once in NewDevice. +func (d *Device) LocalIP6() net.IP { + return d.localIP6 +} + // WriteRawPacket writes a single ready-made IP packet (raw bytes, not a // *Packet) to the TUN device, framing it with the internal TUN header offset. // The slice is not retained. Used by the DNS bridge to inject netstack-emitted packets.