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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user