mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-23 10:42:30 +09:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
244
hscontrol/policy/v2/tailnet_state_caps.go
Normal file
244
hscontrol/policy/v2/tailnet_state_caps.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
311
hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go
Normal file
311
hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
}],
|
||||
|
||||
@@ -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"
|
||||
}],
|
||||
|
||||
7774
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c1-funnel.hujson
vendored
Normal file
7774
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c1-funnel.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c10-disable-relay-server.hujson
vendored
Normal file
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c10-disable-relay-server.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c11-unknown.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c11-unknown.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c12-cap-with-query.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c12-cap-with-query.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c13-suggest-exit-node.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c13-suggest-exit-node.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c14-suggest-exit-node-approved.hujson
vendored
Normal file
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c14-suggest-exit-node-approved.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c2-randomize.hujson
vendored
Normal file
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c2-randomize.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c3-captive.hujson
vendored
Normal file
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c3-captive.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c4-magicdns-aaaa.hujson
vendored
Normal file
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c4-magicdns-aaaa.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7735
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c5-taildrive-share.hujson
vendored
Normal file
7735
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c5-taildrive-share.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c6-taildrive-access-only.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c6-taildrive-access-only.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8821
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c7-file-sharing.hujson
vendored
Normal file
8821
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c7-file-sharing.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c8-admin.hujson
vendored
Normal file
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c8-admin.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8813
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c9-ssh.hujson
vendored
Normal file
8813
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c9-ssh.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8818
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e1-empty.hujson
vendored
Normal file
8818
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e1-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e2-bad-target.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e2-bad-target.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e3-autogroup-self.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e3-autogroup-self.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e4-reserved-ippool.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e4-reserved-ippool.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8821
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e5-empty-target-array.hujson
vendored
Normal file
8821
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e5-empty-target-array.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e6-tag-not-defined.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e6-tag-not-defined.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8818
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e7-empty-nodeAttrs.hujson
vendored
Normal file
8818
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-error-e7-empty-nodeAttrs.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-funnel-f1-tag.hujson
vendored
Normal file
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-funnel-f1-tag.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-funnel-f2-user.hujson
vendored
Normal file
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-funnel-f2-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8816
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-interaction-h1-with-acls.hujson
vendored
Normal file
8816
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-interaction-h1-with-acls.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7308
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-interaction-h2-with-grants.hujson
vendored
Normal file
7308
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-interaction-h2-with-grants.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7408
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-interaction-h3-policy-uses-everything.hujson
vendored
Normal file
7408
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-interaction-h3-policy-uses-everything.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8821
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-ippool-g1-admin.hujson
vendored
Normal file
8821
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-ippool-g1-admin.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8826
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-ippool-g2-group.hujson
vendored
Normal file
8826
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-ippool-g2-group.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8828
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-ippool-g3-mixed.hujson
vendored
Normal file
8828
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-ippool-g3-mixed.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8825
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b1-union.hujson
vendored
Normal file
8825
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b1-union.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b2-overlap.hujson
vendored
Normal file
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b2-overlap.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8825
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b3-same-target-twice.hujson
vendored
Normal file
8825
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b3-same-target-twice.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b4-duplicate-attr.hujson
vendored
Normal file
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b4-duplicate-attr.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8824
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b5-multiple-nextdns.hujson
vendored
Normal file
8824
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-merge-b5-multiple-nextdns.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-nextdns-d1-profile.hujson
vendored
Normal file
8837
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-nextdns-d1-profile.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8852
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-nextdns-d2-no-device-info.hujson
vendored
Normal file
8852
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-nextdns-d2-no-device-info.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8817
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-nextdns-d3-per-tag.hujson
vendored
Normal file
8817
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-nextdns-d3-per-tag.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9138
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-tailnet-devices-auto-updates-on.hujson
vendored
Normal file
9138
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-tailnet-devices-auto-updates-on.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9202
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-tailnet-magicdns-on.hujson
vendored
Normal file
9202
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-tailnet-magicdns-on.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7739
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a1-wildcard.hujson
vendored
Normal file
7739
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a1-wildcard.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a10-autogroup-admin.hujson
vendored
Normal file
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a10-autogroup-admin.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a11-autogroup-owner.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a11-autogroup-owner.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a12-tag-vs-autogroup-member.hujson
vendored
Normal file
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a12-tag-vs-autogroup-member.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a2-user.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a2-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8817
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a3-tag.hujson
vendored
Normal file
8817
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a3-tag.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8828
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a4-group.hujson
vendored
Normal file
8828
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a4-group.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a5-autogroup-member.hujson
vendored
Normal file
8827
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a5-autogroup-member.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a6-autogroup-tagged.hujson
vendored
Normal file
8831
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a6-autogroup-tagged.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8825
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a7-mixed.hujson
vendored
Normal file
8825
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a7-mixed.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8824
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a8-host.hujson
vendored
Normal file
8824
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a8-host.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a9-prefix.hujson
vendored
Normal file
8823
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-target-a9-prefix.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8834
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-toplevel-randomize-client-port.hujson
vendored
Normal file
8834
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-toplevel-randomize-client-port.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8838
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-toplevel-randomize-plus-nodeattrs.hujson
vendored
Normal file
8838
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-toplevel-randomize-plus-nodeattrs.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}],
|
||||
|
||||
@@ -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"
|
||||
}],
|
||||
|
||||
@@ -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"
|
||||
}, {
|
||||
|
||||
@@ -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"
|
||||
}, {
|
||||
|
||||
@@ -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"
|
||||
}, {
|
||||
|
||||
373
hscontrol/servertest/nodeattrs_test.go
Normal file
373
hscontrol/servertest/nodeattrs_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user