Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
133f004
Add IPv6 support to ACL manager, USP filter, and forwarder
lixmal Mar 24, 2026
780fd66
Fix review findings: v6 block rule, PMTUD, eBPF typed IP, tracer vali…
lixmal Mar 25, 2026
569244a
Merge branch 'client-ipv6-ssh-netflow' into client-ipv6-acl-usp
lixmal Mar 25, 2026
7fe417c
Merge branch 'client-ipv6-ssh-netflow' into client-ipv6-acl-usp
lixmal Mar 25, 2026
5e1cdd7
Fix localip bitmap aliasing and bench test indentation
lixmal Mar 25, 2026
aebf3ce
Merge branch 'client-ipv6-ssh-netflow' into client-ipv6-acl-usp
lixmal Mar 25, 2026
76414a1
Merge branch 'client-ipv6-ssh-netflow' into client-ipv6-acl-usp
lixmal Mar 26, 2026
fcf8c4b
Handle ICMP directly in forwarder, bypassing gVisor network layer
lixmal Mar 27, 2026
ed5cfa6
Fix CodeRabbit findings: fragment guard, v6 raw socket probe, v6 echo…
lixmal Mar 27, 2026
974ea1f
Merge remote-tracking branch 'origin/proto-ipv6-overlay' into client-…
lixmal Apr 7, 2026
01068dd
Merge remote-tracking branch 'origin/proto-ipv6-overlay' into client-…
lixmal Apr 7, 2026
b7c8c5b
Use raw socket for ICMPv6 echo when available, recompute checksum for…
lixmal Apr 8, 2026
5ccf521
Merge branch 'proto-ipv6-overlay' into client-ipv6-acl-usp
lixmal Apr 8, 2026
c3fb0f9
Add ICMPv6 echo ID/Seq to tracer packet builder and conntrack messages
lixmal Apr 8, 2026
dcd8562
Merge remote-tracking branch 'origin/proto-ipv6-overlay' into client-…
lixmal Apr 9, 2026
5063fea
[client] Add IPv6 routing, fake IPs, and DNS bind selection (#5706)
lixmal Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 60 additions & 19 deletions client/android/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,41 +238,82 @@ func (c *Client) Networks() *NetworkArray {
return nil
}

routesMap := routeManager.GetClientRoutesWithNetID()
v6Merged := route.V6ExitMergeSet(routesMap)
resolvedDomains := c.recorder.GetResolvedDomainsStates()

networkArray := &NetworkArray{
items: make([]Network, 0),
}

resolvedDomains := c.recorder.GetResolvedDomainsStates()

for id, routes := range routeManager.GetClientRoutesWithNetID() {
for id, routes := range routesMap {
if len(routes) == 0 {
continue
}
if _, skip := v6Merged[id]; skip {
continue
}

network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged)
if network == nil {
continue
}
networkArray.Add(*network)
}
return networkArray
}

r := routes[0]
domains := c.getNetworkDomainsFromRoute(r, resolvedDomains)
netStr := r.Network.String()
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network {
r := routes[0]
netStr := r.Network.String()
if r.IsDynamic() {
netStr = r.Domains.SafeString()
}

if r.IsDynamic() {
netStr = r.Domains.SafeString()
routePeer, err := c.findBestRoutePeer(routes)
if err != nil {
log.Errorf("could not get peer info for route %s: %v", id, err)
return nil
}

network := &Network{
Name: string(id),
Network: netStr,
Peer: routePeer.FQDN,
Status: routePeer.ConnStatus.String(),
IsSelected: selected,
Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains),
}

if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) {
network.Network = "0.0.0.0/0, ::/0"
}

return network
}

// findBestRoutePeer returns the peer actively routing traffic for the given
// HA route group. Falls back to the first connected peer, then the first peer.
func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) {
netStr := routes[0].Network.String()

fullStatus := c.recorder.GetFullStatus()
for _, p := range fullStatus.Peers {
if _, ok := p.GetRoutes()[netStr]; ok {
return p, nil
}
}

routePeer, err := c.recorder.GetPeer(routes[0].Peer)
for _, r := range routes {
p, err := c.recorder.GetPeer(r.Peer)
if err != nil {
log.Errorf("could not get peer info for %s: %v", routes[0].Peer, err)
continue
}
network := Network{
Name: string(id),
Network: netStr,
Peer: routePeer.FQDN,
Status: routePeer.ConnStatus.String(),
IsSelected: routeSelector.IsSelected(id),
Domains: domains,
if p.ConnStatus == peer.StatusConnected {
return p, nil
}
networkArray.Add(network)
}
return networkArray
return c.recorder.GetPeer(routes[0].Peer)
}

// OnUpdatedHostDNS update the DNS servers addresses for root zones
Expand Down
7 changes: 5 additions & 2 deletions client/android/route_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ func executeRouteToggle(id string, manager routemanager.Manager,
netID := route.NetID(id)
routes := []route.NetID{netID}

log.Debugf("%s with id: %s", operationName, id)
routesMap := manager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)

if err := routeOperation(routes, maps.Keys(manager.GetClientRoutesWithNetID())); err != nil {
log.Debugf("%s with ids: %v", operationName, routes)

if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
log.Debugf("error when %s: %s", operationName, err)
return fmt.Errorf("error %s: %w", operationName, err)
}
Expand Down
11 changes: 9 additions & 2 deletions client/anonymize/anonymize.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/url"
"regexp"
"slices"
"strconv"
"strings"
)

Expand All @@ -26,8 +27,9 @@ type Anonymizer struct {
}

func DefaultAddresses() (netip.Addr, netip.Addr) {
// 198.51.100.0, 100::
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.AddrFrom16([16]byte{0x01})
// 198.51.100.0 (RFC 5737 TEST-NET-2), 2001:db8:ffff:: (RFC 3849 documentation, last /48)
// The old start 100:: (discard, RFC 6666) is now used for fake IPs on Android.
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
}

func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
Expand Down Expand Up @@ -96,6 +98,11 @@ func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
}

func (a *Anonymizer) AnonymizeIPString(ip string) string {
// Handle CIDR notation (e.g. "2001:db8::/32")
if prefix, err := netip.ParsePrefix(ip); err == nil {
return a.AnonymizeIP(prefix.Addr()).String() + "/" + strconv.Itoa(prefix.Bits())
}

addr, err := netip.ParseAddr(ip)
if err != nil {
return ip
Expand Down
14 changes: 7 additions & 7 deletions client/anonymize/anonymize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

func TestAnonymizeIP(t *testing.T) {
startIPv4 := netip.MustParseAddr("198.51.100.0")
startIPv6 := netip.MustParseAddr("100::")
startIPv6 := netip.MustParseAddr("2001:db8:ffff::")
anonymizer := anonymize.NewAnonymizer(startIPv4, startIPv6)

tests := []struct {
Expand All @@ -26,9 +26,9 @@ func TestAnonymizeIP(t *testing.T) {
{"Second Public IPv4", "4.3.2.1", "198.51.100.1"},
{"Repeated IPv4", "1.2.3.4", "198.51.100.0"},
{"Private IPv4", "192.168.1.1", "192.168.1.1"},
{"First Public IPv6", "2607:f8b0:4005:805::200e", "100::"},
{"Second Public IPv6", "a::b", "100::1"},
{"Repeated IPv6", "2607:f8b0:4005:805::200e", "100::"},
{"First Public IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
{"Second Public IPv6", "a::b", "2001:db8:ffff::1"},
{"Repeated IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
{"Private IPv6", "fe80::1", "fe80::1"},
{"In Range IPv4", "198.51.100.2", "198.51.100.2"},
}
Expand Down Expand Up @@ -274,17 +274,17 @@ func TestAnonymizeString_IPAddresses(t *testing.T) {
{
name: "IPv6 Address",
input: "Access attempted from 2001:db8::ff00:42",
expect: "Access attempted from 100::",
expect: "Access attempted from 2001:db8:ffff::",
},
{
name: "IPv6 Address with Port",
input: "Access attempted from [2001:db8::ff00:42]:8080",
expect: "Access attempted from [100::]:8080",
expect: "Access attempted from [2001:db8:ffff::]:8080",
},
{
name: "Both IPv4 and IPv6",
input: "IPv4: 142.108.0.1 and IPv6: 2001:db8::ff00:43",
expect: "IPv4: 198.51.100.1 and IPv6: 100::1",
expect: "IPv4: 198.51.100.1 and IPv6: 2001:db8:ffff::1",
},
}

Expand Down
4 changes: 2 additions & 2 deletions client/cmd/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,10 +787,10 @@ func isUnixSocket(path string) bool {
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./")
}

// normalizeLocalHost converts "*" to "0.0.0.0" for binding to all interfaces.
// normalizeLocalHost converts "*" to "" for binding to all interfaces (dual-stack).
func normalizeLocalHost(host string) string {
if host == "*" {
return "0.0.0.0"
return ""
}
return host
}
Expand Down
4 changes: 2 additions & 2 deletions client/cmd/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,10 +527,10 @@ func TestParsePortForward(t *testing.T) {
{
name: "wildcard bind all interfaces",
spec: "*:8080:localhost:80",
expectedLocal: "0.0.0.0:8080",
expectedLocal: ":8080",
expectedRemote: "localhost:80",
expectError: false,
description: "Wildcard * should bind to all interfaces (0.0.0.0)",
description: "Wildcard * should bind to all interfaces (dual-stack)",
},
{
name: "wildcard for port only",
Expand Down
31 changes: 28 additions & 3 deletions client/firewall/iptables/acl_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type aclManager struct {
entries aclEntries
optionalEntries map[string][]entry
ipsetStore *ipsetStore
v6 bool

stateManager *statemanager.Manager
}
Expand All @@ -47,6 +48,7 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl
entries: make(map[string][][]string),
optionalEntries: make(map[string][]entry),
ipsetStore: newIpsetStore(),
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
}, nil
}

Expand Down Expand Up @@ -81,7 +83,11 @@ func (m *aclManager) AddPeerFiltering(
chain := chainNameInputRules

ipsetName = transformIPsetName(ipsetName, sPort, dPort, action)
specs := filterRuleSpecs(ip, string(protocol), sPort, dPort, action, ipsetName)
if m.v6 && ipsetName != "" {
ipsetName += "-v6"
}
proto := protoForFamily(protocol, m.v6)
specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName)

mangleSpecs := slices.Clone(specs)
mangleSpecs = append(mangleSpecs,
Expand All @@ -105,6 +111,7 @@ func (m *aclManager) AddPeerFiltering(
ip: ip.String(),
chain: chain,
specs: specs,
v6: m.v6,
}}, nil
}

Expand Down Expand Up @@ -157,6 +164,7 @@ func (m *aclManager) AddPeerFiltering(
ipsetName: ipsetName,
ip: ip.String(),
chain: chain,
v6: m.v6,
}

m.updateState()
Expand Down Expand Up @@ -376,15 +384,29 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()

currentState.ACLEntries = m.entries
currentState.ACLIPsetStore = m.ipsetStore
if m.v6 {
currentState.ACLEntries6 = m.entries
currentState.ACLIPsetStore6 = m.ipsetStore
} else {
currentState.ACLEntries = m.entries
currentState.ACLIPsetStore = m.ipsetStore
}

if err := m.stateManager.UpdateState(currentState); err != nil {
log.Errorf("failed to update state: %v", err)
}
}

// filterRuleSpecs returns the specs of a filtering rule
// protoForFamily translates ICMP to ICMPv6 for ip6tables.
// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp".
func protoForFamily(protocol firewall.Protocol, v6 bool) string {
if v6 && protocol == firewall.ProtocolICMP {
return "ipv6-icmp"
}
return string(protocol)
}

func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) {
// don't use IP matching if IP is 0.0.0.0
matchByIP := !ip.IsUnspecified()
Expand Down Expand Up @@ -437,6 +459,9 @@ func (m *aclManager) createIPSet(name string) error {
opts := ipset.CreateOptions{
Replace: true,
}
if m.v6 {
opts.Family = ipset.FamilyIPV6
}

if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil {
return fmt.Errorf("create ipset %s: %w", name, err)
Expand Down
Loading
Loading