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)
}
})
}
}