mapper, state: deliver nodeAttrs through MapResponse and harden nextdns DoH rewrite

WithSelfNode and buildTailPeers merge each node's policy CapMap
into the tailcfg.Node.CapMap they emit. State.NodeCapMap and
State.NodeCapMaps wrap the policy manager: NodeCapMap returns a
defensive clone per call; NodeCapMaps snapshots the full per-node
map once for batched callers, amortising pm.mu acquisition across
a peer build.

generateDNSConfig grew a per-node CapMap argument so it can apply
nodeAttr-driven DNS overlays. The nextdns DoH rewrite hardens against
policy-controlled inputs:

  - nextDNSDoHHost anchors the prefix match instead of substring,
    so a hostile resolver URL cannot smuggle a nextdns hostname in
    a path or query.
  - nextDNSProfileFromCapMap accepts only profile names matching
    [A-Za-z0-9._-]{1,64} and picks the lexicographically first when
    multiple are granted -- deterministic, no shell metacharacters
    or URL fragments through.
  - addNextDNSMetadata composes the rewritten URL via url.Parse +
    url.Values rather than fmt.Sprintf, so existing query strings
    on the resolver URL survive and metadata cannot inject a new
    component.

WithTaildropEnabled in servertest controls cfg.Taildrop.Enabled per
test so cap/file-sharing emission can be toggled in tests that need
to verify the off path.
This commit is contained in:
Kristoffer Dalby 2026-05-11 14:47:58 +00:00
parent a4f05b0962
commit 6fcff9e352
5 changed files with 312 additions and 19 deletions

View file

@ -313,6 +313,18 @@ func (s *State) ReloadPolicy() ([]change.Change, error) {
//nolint:prealloc // cs starts with one element and may grow
cs := []change.Change{change.PolicyChange()}
// Per-node selective self refresh for nodeAttrs. A broadcast
// PolicyChange() re-renders peer lists and packet filters but
// never repopulates a node's own [tailcfg.Node.CapMap]; that
// lives on the self entry only. The drain returns every node ID
// whose cap output shifted across recent updateLocked calls —
// refreshNodeAttrsLocked appends rather than overwrites so a
// concurrent SetUsers/SetNodes between SetPolicy and the drain
// cannot silently lose the policy-reload diff.
for _, id := range s.polMan.NodesWithChangedCapMap() {
cs = append(cs, change.SelfUpdate(id))
}
// Always call autoApproveNodes during policy reload, regardless of whether
// the policy content has changed. This ensures that routes are re-evaluated
// when they might have been manually disabled but could now be auto-approved
@ -1048,6 +1060,19 @@ func (s *State) MatchersForNode(node types.NodeView) ([]matcher.Match, error) {
return s.polMan.MatchersForNode(node)
}
// NodeCapMap returns the policy-derived CapMap for the given node, suitable
// for merging into tailcfg.Node.CapMap when the node is rendered as self or
// as someone else's peer.
func (s *State) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
return s.polMan.NodeCapMap(id)
}
// NodeCapMaps returns a snapshot of every node's policy CapMap so
// callers can amortise lock acquisition over a peer loop.
func (s *State) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
return s.polMan.NodeCapMaps()
}
// NodeCanHaveTag checks if a node is allowed to have a specific tag.
func (s *State) NodeCanHaveTag(node types.NodeView, tag string) bool {
return s.polMan.NodeCanHaveTag(node, tag)