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 <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2026-07-02 20:26:27 -07:00
committed by Simon Law
parent 7b2432abae
commit 8d830599b1
4 changed files with 446 additions and 0 deletions
+18
View File
@@ -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
+164
View File
@@ -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)
}
})
}
}
+16
View File
@@ -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() }
+248
View File
@@ -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",