From fad8f2a729c941cab03d52bff9c7939b6512f921 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 8 Jun 2026 11:58:05 +0000 Subject: [PATCH] mapper: test incremental visibility matches full map Pin that NodeOnline patches, NodeAdded peers, and cross-user profiles expose exactly the full-map ACL-visible set across the four policy shapes. --- hscontrol/mapper/mapper_test.go | 166 ++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index b8baefc6..a740da72 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -351,6 +351,172 @@ func TestBuildFromChangeFiltersUserProfilesByVisibility(t *testing.T) { } } +// TestBuildFromChangeVisibilityMatchesFullMap is the consolidation guard for +// PR #3304: the incremental change paths (peer patches via NodeOnline, changed +// peers via NodeAdded) must expose exactly the same ACL-visible peer set as the +// full-map path under every policy shape, and a cross-user UserProfile must not +// leak. If a future refactor lets one path drift from another, this fails. +// +// It pins two behaviours the scattered per-path filters get wrong today and the +// consolidation onto the snapshot peer map must fix: deny-all (empty matchers) +// must hide every peer on the incremental path rather than fall open to "no +// matchers => all visible", and per-node policies (autogroup:self) must agree +// across paths. +func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) { + tmp := t.TempDir() + p4 := netip.MustParsePrefix("100.64.0.0/10") + p6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") + cfg := &types.Config{ + Database: types.DatabaseConfig{ + Type: types.DatabaseSqlite, + Sqlite: types.SqliteConfig{Path: tmp + "/h.db"}, + }, + PrefixV4: &p4, + PrefixV6: &p6, + IPAllocation: types.IPAllocationStrategySequential, + BaseDomain: "headscale.test", + Policy: types.PolicyConfig{Mode: types.PolicyModeDB}, + DERP: types.DERPConfig{ + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}}, + }, + }, + Tuning: types.Tuning{ + NodeStoreBatchSize: state.TestBatchSize, + NodeStoreBatchTimeout: state.TestBatchTimeout, + }, + } + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + user1 := database.CreateUserForTest("u1") + user2 := database.CreateUserForTest("u2") + n1 := database.CreateRegisteredNodeForTest(user1, "n1") + n1b := database.CreateRegisteredNodeForTest(user1, "n1b") + n2 := database.CreateRegisteredNodeForTest(user2, "n2") + require.NoError(t, database.Close()) + + s, err := state.NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + m := &mapper{state: s, cfg: cfg} + capVer := tailcfg.CurrentCapabilityVersion + + // fullVisible returns the peer IDs n1 sees in the full map. + fullVisible := func(t *testing.T) map[tailcfg.NodeID]bool { + t.Helper() + resp, err := m.fullMapResponse(n1.ID, capVer) + require.NoError(t, err) + got := map[tailcfg.NodeID]bool{} + for _, p := range resp.Peers { + got[p.ID] = true + } + return got + } + // patchReaches reports whether a NodeOnline patch for id is delivered to n1. + patchReaches := func(t *testing.T, id types.NodeID) bool { + t.Helper() + c := change.NodeOnline(id) + resp, err := m.buildFromChange(n1.ID, capVer, &c) + require.NoError(t, err) + if resp == nil { + return false + } + for _, p := range resp.PeersChangedPatch { + if p.NodeID == id.NodeID() { + return true + } + } + return false + } + // changedReaches reports whether a NodeAdded changed-peer for id reaches n1. + changedReaches := func(t *testing.T, id types.NodeID) bool { + t.Helper() + c := change.NodeAdded(id) + resp, err := m.buildFromChange(n1.ID, capVer, &c) + require.NoError(t, err) + if resp == nil { + return false + } + for _, p := range resp.PeersChanged { + if p.ID == id.NodeID() { + return true + } + } + return false + } + // profileReaches reports whether userID's profile is delivered to n1 when n + // is added. Use a cross-user node so the result is not masked by n1's own + // always-present user profile. + profileReaches := func(t *testing.T, n *types.Node, userID uint) bool { + t.Helper() + c := change.NodeAdded(n.ID) + resp, err := m.buildFromChange(n1.ID, capVer, &c) + require.NoError(t, err) + if resp == nil { + return false + } + for _, up := range resp.UserProfiles { + if up.ID == tailcfg.UserID(userID) { + return true + } + } + return false + } + + // wantFull pins the actual peer-visibility semantics so the invariant below + // cannot pass vacuously (e.g. if every path broke to zero identically). + // Note deny_all: an empty ACL set compiles to zero matchers, which headscale + // treats as "no visibility restriction" — all peers are visible on every + // path (the packet filter denies traffic separately). user_isolation and + // autogroup_self are the discriminating cases that prove filtering works. + tests := []struct { + name string + policy string + wantFull int + }{ + {"allow_all", `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`, 2}, + { + "user_isolation", + `{"acls":[ + {"action":"accept","src":["u1@"],"dst":["u1@:*"]}, + {"action":"accept","src":["u2@"],"dst":["u2@:*"]} + ]}`, + 1, + }, + {"deny_all", `{"acls":[]}`, 2}, + { + "autogroup_self", + `{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}`, + 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := s.SetPolicy([]byte(tt.policy)) + require.NoError(t, err) + + full := fullVisible(t) + require.Lenf(t, full, tt.wantFull, + "%s: unexpected full-map visible peer count", tt.name) + for _, peer := range []*types.Node{n1b, n2} { + want := full[peer.ID.NodeID()] + assert.Equalf(t, want, patchReaches(t, peer.ID), + "%s: NodeOnline patch for %s must reach n1 iff full-map shows it", + tt.name, peer.Hostname) + assert.Equalf(t, want, changedReaches(t, peer.ID), + "%s: NodeAdded changed-peer for %s must reach n1 iff full-map shows it", + tt.name, peer.Hostname) + } + // Cross-user profile (user2) must appear iff n2 is visible to n1. + assert.Equalf(t, full[n2.ID.NodeID()], profileReaches(t, n2, user2.ID), + "%s: user2 profile must be sent iff n2 is visible to n1", tt.name) + }) + } +} + // TestGenerateDNSConfigNilHostinfoNoPanic proves generateDNSConfig does not // panic when a node's Hostinfo is nil (e.g. a legacy DB row with a NULL // host_info column). addNextDNSMetadata dereferenced node.Hostinfo().OS()