mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-14 11:59:19 +09:00
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.
This commit is contained in:
committed by
Kristoffer Dalby
parent
0121083b53
commit
fad8f2a729
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user