From 8d830599b142c236588c18ee3e6103b8e0c16077 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 12 Jun 2026 17:43:56 -0700 Subject: [PATCH] ipn/ipnstate,tailcfg: define IsRouter for PeerStatus and Node Add consistent definitions and tests so that watchers of the IPN bus can keep track of routers when listening for NotifyInitialStatus and NotifyPeerChanges. Updates #17366 Updates tailscale/corp#33033 Signed-off-by: Simon Law --- ipn/ipnstate/ipnstate.go | 18 +++ ipn/ipnstate/ipnstate_test.go | 164 ++++++++++++++++++++++ tailcfg/tailcfg.go | 16 +++ tailcfg/tailcfg_test.go | 248 ++++++++++++++++++++++++++++++++++ 4 files changed, 446 insertions(+) create mode 100644 ipn/ipnstate/ipnstate_test.go diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index 55702a64c..be532259f 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -360,6 +360,24 @@ func (ps *PeerStatus) HasCap(cap tailcfg.NodeCapability) bool { return ps.CapMap.Contains(cap) } +// IsRouter reports whether ps describes a router: +// a node that routes addresses besides its own. +// Examples: an exit node, a subnet router, an app connector, etc. +// It is the analogue of [tailcfg.Node.IsRouter]. +func (ps *PeerStatus) IsRouter() bool { + // TODO(sfllaw): Keep this aligned with dbx.Node.IsSubnetRouter. + if ps.AllowedIPs == nil { + return false + } + + for _, r := range ps.AllowedIPs.All() { + if !r.IsSingleIP() || !slices.Contains(ps.TailscaleIPs, r.Addr()) { + return true + } + } + return false +} + // IsTagged reports whether ps is tagged. func (ps *PeerStatus) IsTagged() bool { return ps.Tags != nil && ps.Tags.Len() > 0 diff --git a/ipn/ipnstate/ipnstate_test.go b/ipn/ipnstate/ipnstate_test.go new file mode 100644 index 000000000..a6ea28b8d --- /dev/null +++ b/ipn/ipnstate/ipnstate_test.go @@ -0,0 +1,164 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package ipnstate_test + +import ( + "net/netip" + "testing" + + "tailscale.com/ipn/ipnstate" + "tailscale.com/types/views" +) + +func TestPeerStatusIsRouter(t *testing.T) { + for _, tc := range []struct { + name string + status ipnstate.PeerStatus + want bool + }{ + { + name: "empty", + status: ipnstate.PeerStatus{}, + want: false, + }, + { + name: "invalid", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{})), + }, + want: false, + }, + { + name: "plain-ipv4", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + })), + }, + want: false, + }, + { + name: "plain-ipv6", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("fd7a:115c:a1e0::1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + })), + }, + want: false, + }, + { + name: "plain-ipv4-ipv6", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + netip.MustParseAddr("fd7a:115c:a1e0::1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + })), + }, + want: false, + }, + { + name: "exit-node-ipv4", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("0.0.0.0/0"), + })), + }, + want: true, + }, + { + name: "exit-node-ipv6", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("fd7a:115c:a1e0::1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("::/0"), + })), + }, + want: true, + }, + { + name: "exit-node-ipv4-ipv6", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + netip.MustParseAddr("fd7a:115c:a1e0::1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("0.0.0.0/0"), + netip.MustParsePrefix("::/0"), + })), + }, + want: true, + }, + { + name: "subnet-router-ipv4", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("192.0.2.0/24"), + })), + }, + want: true, + }, + { + name: "subnet-router-ipv6", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("fd7a:115c:a1e0::1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("2001:db8::/32"), + })), + }, + want: true, + }, + { + name: "subnet-router-ipv4-ipv6", + status: ipnstate.PeerStatus{ + TailscaleIPs: []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), + netip.MustParseAddr("fd7a:115c:a1e0::1"), + }, + AllowedIPs: new(views.SliceOf([]netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("2001:db8::/32"), + })), + }, + want: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.status.IsRouter(); got != tc.want { + t.Errorf("got %t, want %t", got, tc.want) + } + }) + } +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 55df5938f..bc9efd744 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -593,6 +593,18 @@ func (n *Node) DisplayNames(forOwner bool) (name, hostIfDifferent string) { return n.ComputedName, "" } +// IsRouter reports whether n is a router: it routes addresses besides its own. +// Examples: an exit node, a subnet router, an app connector, etc. +func (n *Node) IsRouter() bool { + // TODO(sfllaw): Keep this aligned with dbx.Node.IsSubnetRouter. + for _, r := range n.AllowedIPs { + if !slices.Contains(n.Addresses, r) { + return true + } + } + return false +} + // IsTagged reports whether the node has any tags. func (n *Node) IsTagged() bool { return len(n.Tags) > 0 @@ -603,6 +615,10 @@ func (n *Node) SharerOrUser() UserID { return cmp.Or(n.Sharer, n.User) } +// IsRouter reports whether n is a router: it routes addresses besides its own. +// Examples: an exit node, a subnet router, an app connector, etc. +func (n NodeView) IsRouter() bool { return n.ж.IsRouter() } + // IsTagged reports whether the node has any tags. func (n NodeView) IsTagged() bool { return n.ж.IsTagged() } diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index ab486197e..989bf9c19 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -5,14 +5,17 @@ package tailcfg_test import ( "encoding/json" + "log" "net/netip" "os" "reflect" "regexp" "strconv" + "strings" "testing" "time" + "tailscale.com/ipn/ipnstate" . "tailscale.com/tailcfg" "tailscale.com/tstest/deptest" "tailscale.com/types/key" @@ -614,6 +617,251 @@ func TestNodeEqual(t *testing.T) { } } +var nodeIsRouterCases = []struct { + name string + node Node + want bool +}{ + { + name: "empty", + node: Node{}, + want: false, + }, + { + name: "too-few-allowedips", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + }, + AllowedIPs: []netip.Prefix{}, + }, + want: false, + }, + { + name: "duplicates", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("100.64.0.1/32"), + }, + }, + want: false, + }, + { + name: "plain-ipv4", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + }, + }, + want: false, + }, + { + name: "plain-ipv6", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + }, + want: false, + }, + { + name: "plain-ipv4-ipv6", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + }, + want: false, + }, + { + name: "exit-node-ipv4", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("0.0.0.0/0"), + }, + }, + want: true, + }, + { + name: "exit-node-ipv6", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("::/0"), + }, + }, + want: true, + }, + { + name: "exit-node-ipv4-ipv6", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("0.0.0.0/0"), + netip.MustParsePrefix("::/0"), + }, + }, + want: true, + }, + { + name: "subnet-router-ipv4", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("192.0.2.0/24"), + }, + }, + want: true, + }, + { + name: "subnet-router-ipv6", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("2001:db8::/32"), + }, + }, + want: true, + }, + { + name: "subnet-router-ipv4-ipv6", + node: Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + }, + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("2001:db8::/32"), + }, + }, + want: true, + }, +} + +func TestNodeIsRouter(t *testing.T) { + for _, tc := range nodeIsRouterCases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.node.IsRouter(); got != tc.want { + t.Errorf("node: got %t, want %t", got, tc.want) + } + + nv := tc.node.View() + if got := nv.IsRouter(); got != tc.want { + t.Errorf("view: got %t, want %t", got, tc.want) + } + + // Check that [ipnstate.PeerStatus.IsRouter] matches. + ps := peerStatusFromNode(nv) + if got := ps.IsRouter(); got != tc.want { + t.Errorf("peer status: got %t, want %t", got, tc.want) + } + }) + } +} + +func FuzzNodeIsRouter(f *testing.F) { + encodePrefixes := func(f *testing.F, prefixes ...netip.Prefix) string { + f.Helper() + out := make([]string, len(prefixes)) + for i, p := range prefixes { + out[i] = p.String() + } + return strings.Join(out, " ") + } + decodePrefixes := func(t *testing.T, prefixes string) []netip.Prefix { + t.Helper() + var out []netip.Prefix + for _, p := range strings.Fields(prefixes) { + pfx, err := netip.ParsePrefix(p) + if err != nil { + log.Printf("skipping %q: %v", prefixes, err) + t.Skipf("%q: %v", prefixes, err) + } + out = append(out, pfx) + } + return out + } + + for _, tc := range nodeIsRouterCases { + addresses := encodePrefixes(f, tc.node.Addresses...) + allowedIPs := encodePrefixes(f, tc.node.AllowedIPs...) + f.Logf("addresses=%q allowedIPs=%q", addresses, allowedIPs) + f.Add(addresses, allowedIPs) + } + f.Fuzz(func(t *testing.T, addresses, allowedIPs string) { + n := Node{ + Addresses: decodePrefixes(t, addresses), + AllowedIPs: decodePrefixes(t, allowedIPs), + } + ps := peerStatusFromNode(n.View()) + t.Logf("%v %v", n.Addresses, n.AllowedIPs) + + if len(n.Addresses) != len(ps.TailscaleIPs) || + len(n.AllowedIPs) != ps.AllowedIPs.Len() { + t.Skip("n and ps are not equivalent") + } + + gotN := n.IsRouter() + gotPS := ps.IsRouter() + if gotN != gotPS { + t.Errorf("mismatched node %t, peer status %t; addresses=%q allowedIPs=%q", + gotN, gotPS, addresses, allowedIPs) + } + }) +} + +func peerStatusFromNode(n NodeView) *ipnstate.PeerStatus { + ps := &ipnstate.PeerStatus{ + ID: n.StableID(), + NodeID: n.ID(), + PublicKey: n.Key(), + DNSName: n.Name(), + } + for _, p := range n.Addresses().All() { + if p.IsSingleIP() { + ps.TailscaleIPs = append(ps.TailscaleIPs, p.Addr()) + } + } + ps.AllowedIPs = new(n.AllowedIPs()) + return ps +} + func TestNetInfoFields(t *testing.T) { handled := []string{ "MappingVariesByDestIP",