policy/v2: SaaS-derived compat tests for nodeAttrs

Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.

tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.

testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.

The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
This commit is contained in:
Kristoffer Dalby
2026-05-11 14:51:09 +00:00
parent 3f73ed5404
commit 078b9e308f
72 changed files with 463447 additions and 183 deletions

View File

@@ -1,4 +1,4 @@
// Tests pinned against tscap captures for juanfont/headscale#3212.
// Tests pinned against captures for juanfont/headscale#3212.
//
// The captures were taken on 2026-04-28 against a live Tailscale SaaS
// tailnet. They reproduce the literal #3212 setup: an ACL granting

View File

@@ -1820,8 +1820,8 @@ func TestViaRoutesForPeer(t *testing.T) {
// `autogroup:internet` must keep the exit node visible to the source
// in BuildPeerMap so the Tailscale client surfaces it in
// `tailscale exit-node list`. Authoritative SaaS captures
// (tscap routes-b17/b18, 2026-04-28) confirm SaaS includes the exit
// node in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
// (routes-b17/b18, 2026-04-28) confirm SaaS includes the exit node
// in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
t.Parallel()

View File

@@ -0,0 +1,244 @@
package v2
// This file enumerates [tailcfg.NodeCapability] values that participate
// in [tailcfg.Node.CapMap] on the wire (both the SelfNode and the peer
// view) but where headscale's emission shape diverges from Tailscale's
// hosted control plane. The compat test in
// tailscale_nodeattrs_compat_test.go strips these from BOTH sides before
// [cmp.Diff] so the rest of the wire shape is compared in full, with no
// per-cap allowlist in the test itself.
//
// Each entry is documented with: the cap's purpose (cross-referenced to
// Tailscale source), why headscale's shape diverges, and a tracking
// issue where one exists. Entries that are unlikely to ever be modelled
// in headscale (internal magicsock or SSH server tuning) land at the
// end of the list.
//
// Coverage that this comparison loses lives elsewhere:
// - hscontrol/servertest/nodeattrs_test.go::TestNodeAttrsBaselineCapsAlwaysOn
// verifies the always-on baseline (admin, ssh, file-sharing,
// drive:share, drive:access).
// - per-feature tests in this package verify the policy compile
// output that feeds the merged CapMap.
import (
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
)
// PeerCapMap returns the subset of peerSelfCaps the Tailscale client
// reads from the peer view (rather than the self view) given the
// peer's state. Returns nil when no peer-consumed cap applies, matching
// the empirical wire shape where [tailcfg.Node.CapMap] is omitted for
// most peers.
//
// Caps the client reads from the peer view rather than the self view
// (suggest-exit-node, dns-subdomain-resolve — see
// ipn/ipnlocal/local.go:7534 and node_backend.go:745) are emitted only
// when the peer satisfies the cap's emission condition. This function
// encodes those conditions; the mapper calls it from buildTailPeers and
// the compat test calls it to compute the expected per-peer wire shape.
func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.NodeCapMap {
if len(peerSelfCaps) == 0 {
return nil
}
var out tailcfg.NodeCapMap
// suggest-exit-node — surfaced on Peer.CapMap when the peer
// advertises exit routes AND those routes are approved. Client
// reads at ipn/ipnlocal/local.go:7534. Approval gating prevents
// the suggestion from following an advertised-but-not-yet-trusted
// node.
if peer.IsExitNode() {
if v, ok := peerSelfCaps[tailcfg.NodeAttrSuggestExitNode]; ok {
if out == nil {
out = tailcfg.NodeCapMap{}
}
out[tailcfg.NodeAttrSuggestExitNode] = v
}
}
return out
}
// unmodelledTailnetStateCaps lists [tailcfg.NodeCapability] values
// stripped on both sides of the compat diff. Order:
//
// 1. Caps gated on a user-role concept headscale does not model.
// 2. Caps gated on a tailnet feature headscale does not implement.
// 3. Caps where headscale emits an always-on baseline and the hosted
// control plane emits only when policy targets them.
// 4. Caps that are tailnet-state metadata (display name, key
// duration, etc.) where the values are not derivable from
// headscale config in a way that round-trips through the
// anonymized capture.
// 5. Caps that are internal magicsock or embedded-SSH tuning with no
// headscale-side equivalent. Listed last — unlikely to be
// adopted.
var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
// --- 1. User-role gated ---
// [tailcfg.CapabilityAdmin]: the hosted control plane stamps this
// on nodes whose owning user has the admin role; tagged nodes
// inherit from a tagOwner with the role. Headscale has no
// user-role model — [types.Node.TailNode] emits it as part of
// the always-on baseline. Stripping on both sides keeps the diff
// from failing on every user-owned non-admin node in a capture.
// Long-term fix is autogroup:admin support.
tailcfg.CapabilityAdmin,
// [tailcfg.CapabilityOwner]: same shape as is-admin, conditional
// on the "owner" role rather than admin. Headscale does not emit
// this cap at all. autogroup:owner support is tracked under
// NO_USER_ROLES — see the compat skip list.
tailcfg.CapabilityOwner,
// --- 2. Feature not implemented ---
// [tailcfg.CapabilityTailnetLock]: tailnet-lock signs node keys
// with a tailnet-wide signing key so peers can detect silent
// re-keying by the control plane. Client reads at
// ipn/ipnlocal/local.go:1752 (b.capTailnetLock). Headscale has no
// tailnet-lock implementation.
tailcfg.CapabilityTailnetLock,
// [tailcfg.NodeAttrServiceHost]: marks a node as approved to host
// VIP services (Tailscale Services). Client reads via
// UnmarshalNodeCapViewJSON at ipn/ipnlocal/local.go:2704.
// Headscale does not implement Tailscale Services.
tailcfg.NodeAttrServiceHost,
// [tailcfg.NodeAttrStoreAppCRoutes]: tells an app-connector node
// to persist learned routes across restarts. Client reads via
// controlknobs:148. Headscale does not implement app connectors.
tailcfg.NodeAttrStoreAppCRoutes,
// [tailcfg.CapabilityWarnFunnelNoHTTPS]: deprecated in Tailscale
// 2023-08-09. Should not appear in fresh captures — listed
// defensively in case a stale tailnet still emits it.
tailcfg.CapabilityWarnFunnelNoHTTPS,
// --- 3. Headscale baseline; hosted control plane policy-driven ---
// [tailcfg.CapabilitySSH] and [tailcfg.CapabilityFileSharing]:
// emitted unconditionally by [types.Node.TailNode] (file-sharing
// gated on cfg.Taildrop.Enabled). The compat test compares the
// policy compile output, not the full TailNode-merged shape, so
// these baseline keys sit only on the captured side and would diff
// on every scenario without stripping. Coverage for the headscale
// emit lives in TestNodeAttrsBaselineCapsAlwaysOn.
tailcfg.CapabilitySSH,
tailcfg.CapabilityFileSharing,
// [tailcfg.NodeAttrsTaildriveShare] and
// [tailcfg.NodeAttrsTaildriveAccess]: the hosted control plane
// emits these only when policy targets them; headscale's
// [types.Node.TailNode] emits them as part of the always-on
// baseline so taildrive features work out of the box on
// self-hosted tailnets. Stripping on both sides keeps the diff
// from flagging this on every scenario; rewriting the drive
// baseline onto a policy-driven emit path is a separate
// follow-up.
tailcfg.NodeAttrsTaildriveShare,
tailcfg.NodeAttrsTaildriveAccess,
// --- 4. Tailnet-state metadata not derivable from headscale config ---
// [tailcfg.NodeAttrTailnetDisplayName]: tailnet display name
// surfaced in the client UI. The hosted control plane emits the
// tailnet admin's email; headscale would have to invent a value
// from cfg.Domain() that does not round-trip through the
// anonymized capture string. Skip rather than diverge on a value
// with no real-world equivalent.
tailcfg.NodeAttrTailnetDisplayName,
// [tailcfg.NodeAttrDefaultAutoUpdate]: tailnet-wide default for
// client auto-update behavior. Headscale has no equivalent
// tailnet setting and would emit an invented constant. Skip
// until the auto-update default lands as a real config knob.
tailcfg.NodeAttrDefaultAutoUpdate,
// [tailcfg.NodeAttrMaxKeyDuration]: tailnet-wide max key duration
// value. Headscale has cfg.Node.Expiry but does not surface it
// as a cap today; the hosted control plane emits this only when
// a non-default value is configured.
tailcfg.NodeAttrMaxKeyDuration,
// [tailcfg.NodeAttrNativeIPV4]: peer-consumed cap conditional on
// tailnet ipv4 reachability state. Out of scope for the current
// peer-cap adoption (only suggest-exit-node is wired in this
// PR).
tailcfg.NodeAttrNativeIPV4,
// --- 5. Internal tuning, no headscale equivalent ---
// [tailcfg.NodeAttrProbeUDPLifetime]: tunes magicsock's UDP
// path-lifetime probe behavior. Internal performance knob; not
// policy-driven. Client reads via controlknobs:147.
tailcfg.NodeAttrProbeUDPLifetime,
// [tailcfg.NodeAttrSSHBehaviorV1]: configures the embedded SSH
// server (no su, in-process SFTP). Internal tuning; the embedded
// server picks Tailscale-vendored defaults without the cap.
tailcfg.NodeAttrSSHBehaviorV1,
// [tailcfg.NodeAttrSSHEnvironmentVariables]: gates SendEnv
// forwarding in the embedded SSH server. Internal; default chosen
// by the server.
tailcfg.NodeAttrSSHEnvironmentVariables,
}
// strippedCapPrefixes lists URL/string prefixes for parameterized or
// pattern-named caps that should be stripped alongside
// [unmodelledTailnetStateCaps].
var strippedCapPrefixes = []string{
// "https://tailscale.com/cap/funnel-ports?…": parameterized cap
// (e.g. "?ports=80,443") issued when funnel is configured.
// Funnel is not supported.
"https://tailscale.com/cap/funnel-ports?",
}
// stripUnmodelledTailnetStateCaps returns a copy of cm with
// [unmodelledTailnetStateCaps] and [strippedCapPrefixes] removed. Used
// by the compat test on both sides before [cmp.Diff].
func stripUnmodelledTailnetStateCaps(cm tailcfg.NodeCapMap) tailcfg.NodeCapMap {
if len(cm) == 0 {
return nil
}
out := make(tailcfg.NodeCapMap, len(cm))
for k, v := range cm {
if isUnmodelledTailnetStateCap(k) {
continue
}
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func isUnmodelledTailnetStateCap(k tailcfg.NodeCapability) bool {
if slices.Contains(unmodelledTailnetStateCaps, k) {
return true
}
s := string(k)
for _, p := range strippedCapPrefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}

View File

@@ -1,7 +1,7 @@
// This file implements a data-driven test runner for ACL compatibility tests.
// It loads HuJSON golden files from testdata/acl_results/acl-*.hujson and
// compares headscale's ACL engine output against the expected packet filter
// rules captured from Tailscale SaaS by the tscap tool.
// rules captured from a Tailscale-hosted control plane by an external capture tool.
//
// Each file is a testcapture.Capture containing:
// - The full policy that was POSTed to the Tailscale SaaS API
@@ -40,8 +40,8 @@ func ptrAddr(s string) *netip.Addr {
}
// setupACLCompatUsers returns the 3 test users for ACL compatibility tests.
// Names and emails match the anonymized identifiers tscap writes into the
// capture files (see github.com/kradalby/tscap/anonymize): users get
// Names and emails match the anonymized identifiers the capture tool writes into the
// capture files users get
// norse-god names and nodes get original-151 pokémon names.
func setupACLCompatUsers() types.Users {
return types.Users{
@@ -52,7 +52,7 @@ func setupACLCompatUsers() types.Users {
}
// setupACLCompatNodes returns the 8 test nodes for ACL compatibility tests.
// Node GivenNames match tscap's anonymized pokémon naming.
// Node GivenNames match the anonymized pokémon naming.
func setupACLCompatNodes(users types.Users) types.Nodes {
return types.Nodes{
{
@@ -291,7 +291,7 @@ func TestACLCompat(t *testing.T) {
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// the capture tool uses clean-slate mode, so each scenario has
// different node IPs; using a shared topology would
// cause IP mismatches in filter rule comparisons.
users, nodes := buildACLUsersAndNodes(t, tf)

View File

@@ -1,6 +1,6 @@
// This file implements a data-driven test runner for grant compatibility
// tests. It loads HuJSON golden files from testdata/grant_results/grant-*.hujson
// and via-grant-*.hujson, captured from Tailscale SaaS by tscap, and compares
// and via-grant-*.hujson, captured from a Tailscale-hosted control plane, and compares
// headscale's grants engine output against the captured packet filter rules.
//
// Each file is a testcapture.Capture containing:
@@ -35,8 +35,8 @@ import (
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
// Users get norse-god names; nodes get original-151 pokémon names — matching
// the anonymized identifiers tscap writes into the capture files
// (see github.com/kradalby/tscap/anonymize).
// the anonymized identifiers the capture tool writes into the capture files
// .
func setupGrantsCompatUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
@@ -269,7 +269,7 @@ func findGrantsNode(nodes types.Nodes, name string) *types.Node {
}
// buildGrantsNodesFromCapture constructs types.Nodes from a capture's
// topology section. Each scenario in tscap uses clean-slate mode, so
// topology section. Each scenario in the capture tool uses clean-slate mode, so
// node IPs differ between scenarios; this builds the node set with
// the IPs that were actually present during that capture.
func buildGrantsNodesFromCapture(
@@ -332,7 +332,7 @@ func buildGrantsNodesFromCapture(
}
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
// captures anonymize the policy JSON at write time (kratail2tid -> odin,
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
// FullPolicy is already in its final form and this is a passthrough that
// just adapts the captured string value to the []byte that the policy
@@ -405,12 +405,12 @@ func TestGrantsCompat(t *testing.T) {
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// the capture tool uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is (anonymization
// in tscap already rewrote SaaS emails).
// the capture tool already rewrote SaaS emails).
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
if tf.Input.APIResponseCode == 400 || tf.Error {

View File

@@ -0,0 +1,311 @@
// This file implements a data-driven test runner for nodeAttrs
// compatibility tests. It loads HuJSON golden files from
// testdata/nodeattrs_results/nodeattrs-*.hujson, captured from a
// Tailscale-hosted control plane, and compares headscale's
// `compileNodeAttrs` output against each captured netmap's SelfNode.CapMap.
//
// Each file is a testcapture.Capture containing:
// - A full policy with a `nodeAttrs` block (and optionally `ipPool`)
// - The expected per-node netmap from SaaS, including the cap map
//
// Tests known to fail due to unimplemented features are skipped with a
// TODO comment explaining the root cause. As headscale's nodeAttrs
// implementation grows, tests should be removed from the skip list.
//
// Test data source: testdata/nodeattrs_results/nodeattrs-*.hujson
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"net/netip"
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
// nodeAttrsCompatUsers returns the three norse-god users the capture
// tool's anonymizer rewrites the SaaS users into.
func nodeAttrsCompatUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
// buildNodeAttrsNodesFromCapture mirrors the grants compat helper: each
// scenario's clean-slate run produces a different IP for the same
// hostname, so the node set comes from the capture's topology rather
// than a fixed table.
//
// [tailcfg.Hostinfo.RoutableIPs] and [types.Node.ApprovedRoutes]
// round-trip from the topology so [types.NodeView.IsExitNode] reflects
// the captured approval state — the suggest-exit-node peer-cap rule
// only fires when a peer's exit routes are approved.
func buildNodeAttrsNodesFromCapture(
t *testing.T,
users types.Users,
tf *testcapture.Capture,
) types.Nodes {
t.Helper()
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
autoID := 1
for _, nodeDef := range tf.Topology.Nodes {
node := &types.Node{
ID: types.NodeID(autoID), //nolint:gosec
GivenName: nodeDef.Hostname,
IPv4: ptrAddr(nodeDef.IPv4),
IPv6: ptrAddr(nodeDef.IPv6),
Tags: nodeDef.Tags,
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: parsePrefixes(t, nodeDef.Hostname+".RoutableIPs", nodeDef.RoutableIPs),
},
ApprovedRoutes: parsePrefixes(t, nodeDef.Hostname+".ApprovedRoutes", nodeDef.ApprovedRoutes),
}
autoID++
if len(nodeDef.Tags) == 0 && nodeDef.User != "" {
for i := range users {
if users[i].Name == nodeDef.User {
node.User = &users[i]
node.UserID = &users[i].ID
break
}
}
}
nodes = append(nodes, node)
}
return nodes
}
// parsePrefixes converts a slice of CIDR strings into [netip.Prefix].
// Bad entries fail loud through t.Fatalf — the topology files are
// authoritative routing data, so a malformed CIDR is a testdata bug
// that should surface, not silently drop the route and corrupt
// downstream IsExitNode checks.
func parsePrefixes(t *testing.T, name string, s []string) []netip.Prefix {
t.Helper()
if len(s) == 0 {
return nil
}
out := make([]netip.Prefix, 0, len(s))
for _, p := range s {
pre, err := netip.ParsePrefix(p)
if err != nil {
t.Fatalf("topology %q: malformed CIDR %q: %v", name, p, err)
}
out = append(out, pre)
}
return out
}
// nodeAttrsSkipReasons documents the captured scenarios SaaS accepts and
// headscale deliberately rejects at validate time. The rejection itself is
// covered by TestNodeAttrsValidate; this list keeps the compat diff focused
// on shapes both control planes agree on.
//
// IPPOOL_ALLOCATOR — `ipPool` is parsed but the allocator that
// consumes it is not yet implemented.
// FUNNEL_NOT_SUPPORTED — `funnel` cap is rejected pending the DNS /
// ACME machinery the feature requires.
// NO_USER_ROLES — `autogroup:admin` and `autogroup:owner` depend on
// user-role and tailnet-ownership concepts headscale does not
// model.
var nodeAttrsSkipReasons = map[string]string{
"nodeattrs-ippool-g1-admin": "IPPOOL_ALLOCATOR",
"nodeattrs-ippool-g2-group": "IPPOOL_ALLOCATOR",
"nodeattrs-ippool-g3-mixed": "IPPOOL_ALLOCATOR",
"nodeattrs-target-a10-autogroup-admin": "NO_USER_ROLES: autogroup:admin",
"nodeattrs-target-a11-autogroup-owner": "NO_USER_ROLES: autogroup:owner",
"nodeattrs-attr-c1-funnel": "FUNNEL_NOT_SUPPORTED",
"nodeattrs-funnel-f1-tag": "FUNNEL_NOT_SUPPORTED",
"nodeattrs-funnel-f2-user": "FUNNEL_NOT_SUPPORTED",
}
// TestNodeAttrsCompat is a data-driven test that loads every captured
// nodeAttrs scenario and compares headscale's compiled CapMap against
// the corresponding SaaS-rendered netmap.
func TestNodeAttrsCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "nodeattrs_results", "*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
if len(files) == 0 {
t.Skip(
"testdata/nodeattrs_results is empty — re-run the capture " +
"tool against the nodeattrs scenario set and copy the " +
"anonymized results into " +
"hscontrol/policy/v2/testdata/nodeattrs_results/",
)
}
t.Logf("Loaded %d nodeAttrs test files", len(files))
users := nodeAttrsCompatUsers()
for _, file := range files {
tf := loadGrantTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
if reason, ok := nodeAttrsSkipReasons[tf.TestID]; ok {
t.Skipf("TODO: %s", reason)
}
nodes := buildNodeAttrsNodesFromCapture(t, users, tf)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
if tf.Input.APIResponseCode == 400 || tf.Error {
testNodeAttrsError(t, policyJSON, tf)
return
}
testNodeAttrsSuccess(t, policyJSON, tf, users, nodes)
})
}
}
func testNodeAttrsError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
t.Helper()
// SaaS error wording is not stable enough to compare exactly — the
// e3-autogroup-self capture comes back as "internal server error",
// for instance. The contract this test enforces is the weaker but
// still-meaningful one: headscale must also refuse the policy at
// parse or validate time.
pol, err := unmarshalPolicy(policyJSON)
if err != nil {
return
}
err = pol.validate()
if err != nil {
return
}
wantMsg := ""
if tf.Input.APIResponseBody != nil {
wantMsg = tf.Input.APIResponseBody.Message
}
// The dispatch in TestNodeAttrsCompat fires for either
// APIResponseCode==400 or tf.Error==true; reflect the actual
// trigger in the diagnostic so a tf.Error scenario doesn't get
// reported as "saas code=0".
t.Errorf(
"%s: expected error (api_code=%d capture_error=%t msg=%q) "+
"but policy parsed and validated successfully",
tf.TestID, tf.Input.APIResponseCode, tf.Error, wantMsg,
)
}
func testNodeAttrsSuccess(
t *testing.T,
policyJSON []byte,
tf *testcapture.Capture,
users types.Users,
nodes types.Nodes,
) {
t.Helper()
pol, err := unmarshalPolicy(policyJSON)
require.NoErrorf(t, err, "%s: policy should parse", tf.TestID)
require.NoErrorf(t, pol.validate(), "%s: policy should validate", tf.TestID)
got, err := pol.compileNodeAttrs(users, nodes.ViewSlice())
require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID)
for nodeName, capture := range tf.Captures {
if capture.Netmap == nil || !capture.Netmap.SelfNode.Valid() {
continue
}
t.Run(nodeName, func(t *testing.T) {
node := findNodeByGivenName(nodes, nodeName)
require.NotNilf(t, node,
"node %q from capture not found in test setup", nodeName)
gotSelf := stripUnmodelledTailnetStateCaps(got[node.ID])
wantSelf := stripUnmodelledTailnetStateCaps(
capMapFromView(capture.Netmap.SelfNode.CapMap()),
)
if diff := cmp.Diff(wantSelf, gotSelf, cmpopts.EquateEmpty()); diff != "" {
t.Errorf(
"%s/%s: SelfNode.CapMap mismatch (-tailscale +headscale):\n%s",
tf.TestID, nodeName, diff,
)
}
for _, peer := range capture.Netmap.Peers {
peerName := peer.ComputedName()
peerNode := findNodeByGivenName(nodes, peerName)
if peerNode == nil {
// A captured peer with no matching node in the
// constructed topology is almost always topology
// drift — fail loud so the gap is visible instead
// of silently dropping the comparison.
t.Errorf("%s/%s: capture peer %q not found in topology",
tf.TestID, nodeName, peerName)
continue
}
gotPeer := stripUnmodelledTailnetStateCaps(
PeerCapMap(peerNode.View(), got[peerNode.ID]),
)
wantPeer := stripUnmodelledTailnetStateCaps(
capMapFromView(peer.CapMap()),
)
if diff := cmp.Diff(wantPeer, gotPeer, cmpopts.EquateEmpty()); diff != "" {
t.Errorf(
"%s/%s/peer=%s: Peer.CapMap mismatch (-tailscale +headscale):\n%s",
tf.TestID, nodeName, peerName, diff,
)
}
}
})
}
}
// capMapFromView materialises a captured CapMap view into the
// [tailcfg.NodeCapMap] shape headscale renders, so both sides of the
// diff have the same concrete type.
func capMapFromView(view views.MapSlice[tailcfg.NodeCapability, tailcfg.RawMessage]) tailcfg.NodeCapMap {
if view.Len() == 0 {
return nil
}
out := make(tailcfg.NodeCapMap, view.Len())
for k, v := range view.All() {
out[k] = v.AsSlice()
}
return out
}

View File

@@ -1,6 +1,6 @@
// This file implements data-driven test runners for routes compatibility tests.
// It loads HuJSON golden files from testdata/routes_results/routes-*.hujson,
// captured from Tailscale SaaS by tscap, and compares headscale's route-aware
// captured from a Tailscale-hosted control plane, and compares headscale's route-aware
// ACL engine output against the captured packet filter rules.
//
// Each capture file is a testcapture.Capture containing:
@@ -57,7 +57,7 @@ func loadRoutesTestFile(t *testing.T, path string) *testcapture.Capture {
}
// convertSaaSEmail used to map SaaS-side emails to @example.com placeholders.
// tscap now anonymizes captures at write time (norse-god names + pokémon
// captures are anonymized at write time (norse-god names + pokémon
// hostnames), so the captured topology emails are already in their final
// form and this is a passthrough.
func convertSaaSEmail(email string) string {

View File

@@ -1,7 +1,7 @@
// This file implements a data-driven test runner for SSH compatibility tests.
// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured
// from Tailscale SaaS by tscap, and compares headscale's SSH policy compilation
// against the captured SSH rules.
// from a Tailscale-hosted control plane, and compares headscale's SSH policy
// compilation against the captured SSH rules.
//
// Each file is a testcapture.Capture containing:
// - The full policy that was POSTed to Tailscale SaaS (we use tf.Input.FullPolicy
@@ -33,8 +33,8 @@ import (
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
// compatibility tests. Users get norse-god names; nodes get original-151
// pokémon names — matching the anonymized identifiers tscap writes into
// the capture files (see github.com/kradalby/tscap/anonymize).
// pokémon names — matching the anonymized identifiers the capture
// tool writes into the capture files.
//
// odin and freya live on @example.com; thor lives on @example.org so
// that "localpart:*@example.com" resolves to exactly two users
@@ -210,12 +210,12 @@ func TestSSHDataCompat(t *testing.T) {
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// the capture tool uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
// captures already rewrite SaaS emails to @example.com.
policyJSON := tf.Input.FullPolicy
pol, err := unmarshalPolicy([]byte(policyJSON))

View File

@@ -229,7 +229,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -257,7 +257,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],

View File

@@ -229,7 +229,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -257,7 +257,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -1176,7 +1176,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -1204,7 +1204,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -2126,7 +2126,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -2154,7 +2154,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -3077,7 +3077,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -3105,7 +3105,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -4204,7 +4204,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -5410,7 +5410,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -5438,7 +5438,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -6355,7 +6355,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -6383,7 +6383,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -7306,7 +7306,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -7334,7 +7334,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -8257,7 +8257,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -8285,7 +8285,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -9202,7 +9202,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -9230,7 +9230,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -10274,7 +10274,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -11535,7 +11535,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -11563,7 +11563,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -273,7 +273,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -451,7 +451,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -1496,7 +1496,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -1653,7 +1653,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -2676,7 +2676,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -2854,7 +2854,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -3881,7 +3881,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -4059,7 +4059,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -5263,7 +5263,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -6306,7 +6306,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -6463,7 +6463,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -7502,7 +7502,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -7659,7 +7659,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -8708,7 +8708,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -8864,7 +8864,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -9913,7 +9913,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -10069,7 +10069,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -11112,7 +11112,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -11269,7 +11269,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],
@@ -12320,7 +12320,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -13512,7 +13512,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -13669,7 +13669,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}],

View File

@@ -261,7 +261,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -453,7 +453,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -1463,7 +1463,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -1655,7 +1655,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -2664,7 +2664,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -2856,7 +2856,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -3870,7 +3870,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -4061,7 +4061,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -5074,7 +5074,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6273,7 +6273,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6465,7 +6465,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -7469,7 +7469,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -7661,7 +7661,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -8653,7 +8653,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -8866,7 +8866,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -9880,7 +9880,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -10071,7 +10071,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -11079,7 +11079,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -11271,7 +11271,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -12471,7 +12471,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],
@@ -13479,7 +13479,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -13671,7 +13671,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}],

View File

@@ -250,7 +250,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -407,7 +407,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -1447,7 +1447,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -1604,7 +1604,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -2643,7 +2643,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -2821,7 +2821,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -3844,7 +3844,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -4000,7 +4000,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -5199,7 +5199,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6216,7 +6216,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -6394,7 +6394,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -7428,7 +7428,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -7606,7 +7606,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -8628,7 +8628,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -8785,7 +8785,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -9829,7 +9829,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -9985,7 +9985,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -11023,7 +11023,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -11180,7 +11180,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -12226,7 +12226,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -13413,7 +13413,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -13570,7 +13570,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {

View File

@@ -302,7 +302,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -345,7 +345,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -1477,7 +1477,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -1520,7 +1520,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -2672,7 +2672,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -2715,7 +2715,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -3850,7 +3850,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -3893,7 +3893,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -5028,7 +5028,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6200,7 +6200,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6243,7 +6243,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -7348,7 +7348,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -7391,7 +7391,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -8526,7 +8526,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -8569,7 +8569,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -9725,7 +9725,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -9747,7 +9747,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -10897,7 +10897,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -10940,7 +10940,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -12093,7 +12093,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -13223,7 +13223,7 @@
"Cap": 131,
"Tags": ["tag:exit", "tag:router"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -13266,7 +13266,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {

View File

@@ -237,7 +237,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -281,7 +281,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -1439,7 +1439,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -1483,7 +1483,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -2640,7 +2640,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -2684,7 +2684,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -3846,7 +3846,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -3890,7 +3890,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -5050,7 +5050,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6249,7 +6249,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -6293,7 +6293,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -7445,7 +7445,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -7489,7 +7489,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -8651,7 +8651,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -8673,7 +8673,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -9856,7 +9856,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -9900,7 +9900,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -11055,7 +11055,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -11099,7 +11099,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -12278,7 +12278,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {
@@ -13455,7 +13455,7 @@
"Tags": ["tag:exit", "tag:router"],
"PrimaryRoutes": ["172.16.0.0/24"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "wartortle",
"ComputedNameWithHost": "wartortle"
}, {
@@ -13499,7 +13499,7 @@
"Cap": 131,
"Tags": ["tag:exit"],
"Online": true,
"CapMap": {"suggest-charmander": null},
"CapMap": {"suggest-exit-node": null},
"ComputedName": "charmander",
"ComputedNameWithHost": "charmander"
}, {

View File

@@ -0,0 +1,373 @@
package servertest_test
import (
"context"
"net/netip"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// reloadPolicy applies pol via SetPolicy and runs ReloadPolicy so that the
// state machine emits the changes the mapper consumes — same shape as every
// other servertest that exercises a policy edit.
func reloadPolicy(t *testing.T, srv *servertest.TestServer, pol string) {
t.Helper()
changed, err := srv.State().SetPolicy([]byte(pol))
require.NoError(t, err)
if !changed {
return
}
changes, err := srv.State().ReloadPolicy()
require.NoError(t, err)
srv.App.Change(changes...)
}
// hasCap reports whether the given netmap's self CapMap contains want.
func hasCap(nm *netmap.NetworkMap, want tailcfg.NodeCapability) bool {
if nm == nil || !nm.SelfNode.Valid() {
return false
}
return nm.SelfNode.CapMap().Contains(want)
}
// peerCapMapsAllEmpty reports whether every peer in nm has an empty
// [tailcfg.Node.CapMap]. The Tailscale-hosted control plane omits the
// peer-side CapMap unless the peer satisfies a peer-cap emission
// condition (e.g. suggest-exit-node on a peer with approved exit
// routes — see [policyv2.PeerCapMap]). The scenarios that call this
// helper do not advertise exit routes, so peer CapMaps stay empty;
// the test asserts that property to lock in the wire shape.
func peerCapMapsAllEmpty(nm *netmap.NetworkMap) bool {
if nm == nil {
return false
}
for _, peer := range nm.Peers {
if peer.CapMap().Len() > 0 {
return false
}
}
return true
}
func TestNodeAttrsDeliverToSelfAndPeer(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "na-user")
c1 := servertest.NewClient(t, srv, "na-node1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "na-node2", servertest.WithUser(user))
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["randomize-client-port"]
}]
}`)
c1.WaitForCondition(t, "self randomize-client-port cap on c1", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
c2.WaitForCondition(t, "self randomize-client-port cap on c2", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
// randomize-client-port is not in the peer-consumed allowlist and
// these nodes don't advertise exit routes, so peer CapMaps stay
// empty. Each client reads its own caps from SelfNode.
assert.True(t, peerCapMapsAllEmpty(c1.Netmap()),
"c1 peer CapMaps must be empty after policy edit")
assert.True(t, peerCapMapsAllEmpty(c2.Netmap()),
"c2 peer CapMaps must be empty after policy edit")
}
func TestNodeAttrsUserTargetIsolated(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
alice := srv.CreateUser(t, "alice")
bob := srv.CreateUser(t, "bob")
a := servertest.NewClient(t, srv, "alice-laptop", servertest.WithUser(alice))
b := servertest.NewClient(t, srv, "bob-laptop", servertest.WithUser(bob))
a.WaitForPeers(t, 0, 5*time.Second)
b.WaitForPeers(t, 0, 5*time.Second)
reloadPolicy(t, srv, `{
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}],
"nodeAttrs": [{
"target": ["alice@"],
"attr": ["randomize-client-port"]
}]
}`)
a.WaitForCondition(t, "alice gains randomize-client-port", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
// bob must remain free of the cap; check after alice has converged so we
// know the policy is propagated.
b.WaitForPeers(t, 1, 10*time.Second)
nmB := b.Netmap()
require.NotNil(t, nmB)
assert.False(t, hasCap(nmB, tailcfg.NodeAttrRandomizeClientPort),
"bob is not in the target set; must not receive the cap")
}
func TestNodeAttrsRevokesWhenRemoved(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "revoke-user")
c := servertest.NewClient(t, srv, "revoke-node", servertest.WithUser(user))
c.WaitForCondition(t, "node connected", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.Valid()
})
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["disable-captive-portal-detection"]
}]
}`)
c.WaitForCondition(t, "captive cap appears", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrDisableCaptivePortalDetection)
})
reloadPolicy(t, srv, `{}`)
c.WaitForCondition(t, "captive cap disappears", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return !hasCap(nm, tailcfg.NodeAttrDisableCaptivePortalDetection)
})
}
// TestNodeAttrsBaselineCapsAlwaysOn verifies that the SaaS-baseline caps
// (Admin, SSH, FileSharing, Taildrive share/access) are emitted on every
// node regardless of whether the policy mentions them. Tailscale clients
// expect these to be present, and Tailscale SaaS emits them
// unconditionally; headscale matches that shape.
func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "baseline-user")
c := servertest.NewClient(t, srv, "baseline-node", servertest.WithUser(user))
c.WaitForCondition(t, "baseline caps present without policy", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil || !nm.SelfNode.Valid() {
return false
}
for _, w := range []tailcfg.NodeCapability{
tailcfg.CapabilityAdmin,
tailcfg.CapabilitySSH,
tailcfg.CapabilityFileSharing,
tailcfg.NodeAttrsTaildriveShare,
tailcfg.NodeAttrsTaildriveAccess,
} {
if !hasCap(nm, w) {
return false
}
}
return true
})
}
// TestNodeAttrsAddsToBaseline verifies that policy nodeAttrs caps land on
// nodes alongside the always-on baseline. The baseline caps remain
// regardless of policy contents.
func TestNodeAttrsAddsToBaseline(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "addon-user")
c := servertest.NewClient(t, srv, "addon-node", servertest.WithUser(user))
c.WaitForCondition(t, "node connected", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.Valid()
})
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["randomize-client-port", "disable-captive-portal-detection"]
}]
}`)
c.WaitForCondition(t, "policy adds caps on top of baseline", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort) &&
hasCap(nm, tailcfg.NodeAttrDisableCaptivePortalDetection) &&
hasCap(nm, tailcfg.CapabilitySSH)
})
}
func TestNodeAttrsReloadingSamePolicyDoesNotChurnSelf(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "churn-user")
c := servertest.NewClient(t, srv, "churn-node", servertest.WithUser(user))
c.WaitForCondition(t, "node connected", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.Valid()
})
const pol = `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["randomize-client-port"]
}]
}`
reloadPolicy(t, srv, pol)
c.WaitForCondition(t, "policy cap arrives", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
// Reload identical bytes. Per-node CapMap diff produces an empty
// changed set, so SetPolicy returns no SelfUpdate IDs. The
// broadcast PolicyChange still fires because filter rules are
// recomputed on every reload — that's expected. The check below
// is on the wire shape: cap must still be present.
reloadPolicy(t, srv, pol)
c.WaitForCondition(t, "cap persists after no-op reload", 5*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrRandomizeClientPort)
})
}
// TestNodeAttrsSuggestExitNodeOnPeerCapMap covers the runtime peer-cap
// path: when a peer advertises exit routes, has them approved, and
// the policy targets it with `suggest-exit-node`, the cap lands on
// [tailcfg.Node.CapMap] of the *peer view* — not just on the exit
// node's own SelfNode.CapMap.
//
// The compat test in policy/v2 covers the wire shape; this test
// proves the runtime delivery path through the live mapper.
func TestNodeAttrsSuggestExitNodeOnPeerCapMap(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "see-user")
exit := servertest.NewClient(t, srv, "see-exit", servertest.WithUser(user))
viewer := servertest.NewClient(t, srv, "see-viewer", servertest.WithUser(user))
// Wait for peer visibility before advertising routes; otherwise the
// hostinfo update can race with initial registration and the
// approval below sees no advertised route to approve.
exit.WaitForPeers(t, 1, 10*time.Second)
viewer.WaitForPeers(t, 1, 10*time.Second)
exitRoutes := []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
}
// Advertise the exit routes via the live noise channel.
exit.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-see-exit",
Hostname: "see-exit",
RoutableIPs: exitRoutes,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
require.NoError(t, exit.Direct().SendUpdate(ctx))
cancel()
// Approve the routes on the control plane and fan the resulting
// change out so peers re-render.
exitID := findNodeID(t, srv, "see-exit")
_, ch, err := srv.State().SetApprovedRoutes(exitID, exitRoutes)
require.NoError(t, err)
srv.App.Change(ch)
// Stamp suggest-exit-node on every node — the peer-cap rule then
// gates the actual peer-view emission on whether the peer is an
// exit node (advertised + approved).
reloadPolicy(t, srv, `{
"nodeAttrs": [{
"target": ["*"],
"attr": ["suggest-exit-node"]
}]
}`)
// Self-side: the exit node sees the cap on its own SelfNode (the
// usual stamp; nothing special about exit nodes here).
exit.WaitForCondition(t, "self suggest-exit-node on exit", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, tailcfg.NodeAttrSuggestExitNode)
})
// Peer-side: the viewer sees the exit node in its Peers list with
// the cap on the peer entry. This is the property the new
// PeerCapMap rule guards.
viewer.WaitForCondition(t, "peer suggest-exit-node on exit's peer entry", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil {
return false
}
for _, peer := range nm.Peers {
if peer.ComputedName() != "see-exit" {
continue
}
return peer.CapMap().Contains(tailcfg.NodeAttrSuggestExitNode)
}
return false
})
// Negative side: the viewer's peer view of itself (i.e. the exit's
// peer view of the viewer) must NOT carry suggest-exit-node — only
// the actual exit-node peer view does.
exit.WaitForCondition(t, "viewer's peer entry does not carry suggest-exit-node", 5*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil {
return false
}
for _, peer := range nm.Peers {
if peer.ComputedName() != "see-viewer" {
continue
}
return !peer.CapMap().Contains(tailcfg.NodeAttrSuggestExitNode)
}
return false
})
}

View File

@@ -16,7 +16,7 @@ import (
// Nodes with filter rules: <X> of <Y> ← for non-SSH captures
// Nodes with SSH rules: <X> of <Y> ← for SSH captures
// Captured at: <RFC3339 UTC>
// tscap version: <ToolVersion>
// tool version: <ToolVersion>
// schema version: <SchemaVersion>
//
// Both `tool_version` and `schema_version` are also stored as
@@ -53,7 +53,7 @@ func CommentHeader(c *Capture) string {
}
if c.ToolVersion != "" {
fmt.Fprintf(&b, "tscap version: %s\n", c.ToolVersion)
fmt.Fprintf(&b, "tool version: %s\n", c.ToolVersion)
}
if c.SchemaVersion != 0 {

View File

@@ -1,16 +1,16 @@
// Package testcapture defines the on-disk format used by Headscale's
// policy v2 compatibility tests for golden data captured from
// Tailscale SaaS by the tscap tool.
// policy v2 compatibility tests for golden data captured from a
// Tailscale-hosted control plane by an external capture tool.
//
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
// apitype values rather than json.RawMessage so that schema drift
// between tscap and headscale becomes a compile error rather than a
// silent test failure, and so that consumers don't have to repeat
// json.Unmarshal at every read site. Storing data as json.RawMessage
// previously hid a serious capture-pipeline bug (the IPN bus initial
// notification returns a stale Peers slice — see the comment on
// Node.Netmap below) for months.
// between the capture tool and headscale becomes a compile error
// rather than a silent test failure, and so that consumers don't
// have to repeat json.Unmarshal at every read site. Storing data as
// json.RawMessage previously hid a serious capture-pipeline bug (the
// IPN bus initial notification returns a stale Peers slice — see the
// comment on Node.Netmap below) for months.
//
// All four capture types (acl, routes, grant, ssh) use the same Capture
// shape. SSH scenarios populate Captures[name].SSHRules; the others
@@ -31,7 +31,7 @@ import (
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
//
// Files written before SchemaVersion existed do not have this field; new
// captures from tscap always set it to the current value.
// captures always set it to the current value.
const SchemaVersion = 1
// Capture is one captured run of one scenario.
@@ -41,7 +41,7 @@ const SchemaVersion = 1
// Captures[name].PacketFilterRules + Captures[name].Netmap.
type Capture struct {
// SchemaVersion identifies the on-disk format version. Always set
// to testcapture.SchemaVersion when written by tscap.
// to testcapture.SchemaVersion when written.
SchemaVersion int `json:"schema_version"`
// TestID is the stable identifier of the scenario, derived from
@@ -83,7 +83,7 @@ type Capture struct {
Input Input `json:"input"`
// Topology is the users and nodes present in the tailnet at
// capture time. Always populated by tscap.
// capture time. Always populated by the capture tool.
Topology Topology `json:"topology"`
// Captures holds the per-node captured data, keyed by node
@@ -112,7 +112,7 @@ type Input struct {
// APIResponseBody is only populated when APIResponseCode != 200.
APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
// Tailnet describes the tailnet-wide settings tscap applied
// Tailnet describes the tailnet-wide settings the capture tool applied
// before pushing the policy.
Tailnet TailnetInput `json:"tailnet"`
@@ -209,7 +209,7 @@ type APIResponseBody struct {
Message string `json:"message,omitempty"`
}
// TailnetInput captures tailnet-wide settings tscap applied before
// TailnetInput captures tailnet-wide settings the capture tool applied before
// pushing the policy.
type TailnetInput struct {
DNS DNSInput `json:"dns"`
@@ -227,18 +227,25 @@ type DNSInput struct {
// SettingsInput describes tailnet settings applied via the API.
//
// Pointer fields are nil when the scenario does not override the
// reset default for that setting.
// reset default for that setting. The fields mirror the
// PATCH /tailnet/{tailnet}/settings request shape exposed by
// tailscale.com/client/tailscale/v2 — in practice the headscale
// compatibility tests use the subset that observably affects the
// captured netmap CapMap or DNSConfig.
type SettingsInput struct {
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
NetworkFlowLoggingOn *bool `json:"network_flow_logging_on,omitempty"`
RegionalRoutingOn *bool `json:"regional_routing_on,omitempty"`
PostureIdentityCollectionOn *bool `json:"posture_identity_collection_on,omitempty"`
}
// Topology describes the users and nodes present in the tailnet at
// capture time. Headscale's compat tests use this to construct
// equivalent types.User and types.Node objects.
type Topology struct {
// Users in the tailnet. Always populated by tscap.
// Users in the tailnet. Always populated by the capture tool.
Users []TopologyUser `json:"users"`
// Nodes in the tailnet, keyed by GivenName.
@@ -300,16 +307,16 @@ type Node struct {
// Netmap is the full netmap as observed by the local tailscaled.
// NEVER trimmed. Consumers extract whatever fields they need.
//
// IMPORTANT: tscap captures this by waiting for the IPN bus to
// IMPORTANT: the capture tool captures this by waiting for the IPN bus to
// settle on a fresh delta-triggered notification, NOT by reading
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
// The initial notification carries cn.NetMap() which returns
// nb.netMap as-is — the netmap.NetworkMap whose Peers slice was
// set at full-sync time and never re-synchronized from the
// authoritative nb.peers map. tscap previously used the initial
// authoritative nb.peers map. The capture tool previously used the initial
// notification and silently captured netmaps with mostly-empty
// Peers, which corrupted every via-grant compat test against the
// stale data. See tscap/tsdaemon/capture.go:NetMap for the
// stale data. See the capture tool source for the for the
// stability-wait pattern, and tailscale.com/ipn/ipnlocal/c2n.go
// :handleC2NDebugNetMap which uses netMapWithPeers() for the
// same reason.

View File

@@ -34,7 +34,7 @@ func sampleACLCapture() *testcapture.Capture {
Description: "wildcard ACL: every node sees every other node",
Category: "acl",
CapturedAt: time.Date(2026, 4, 7, 12, 34, 56, 0, time.UTC),
ToolVersion: "tscap-test-0.0.0",
ToolVersion: "capture-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
@@ -102,7 +102,7 @@ func sampleSSHCapture() *testcapture.Capture {
Description: "ssh accept autogroup:member to autogroup:self",
Category: "ssh",
CapturedAt: time.Date(2026, 4, 7, 13, 0, 0, 0, time.UTC),
ToolVersion: "tscap-test-0.0.0",
ToolVersion: "capture-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`,
@@ -238,7 +238,7 @@ func TestWrite_ProducesCommentHeader(t *testing.T) {
t.Errorf("header missing capture timestamp; got:\n%s", header)
}
if !strings.Contains(header, "tscap version:") || !strings.Contains(header, "tscap-test-0.0.0") {
if !strings.Contains(header, "tool version:") || !strings.Contains(header, "capture-test-0.0.0") {
t.Errorf("header missing tool version; got:\n%s", header)
}