policy/v2: revoke stale SSH access and recheck authentication

Updates #3517, #3508

(cherry picked from commit 4fd4da75f2)
This commit is contained in:
Kristoffer Dalby committed 2026-10-13 14:35:52 +02:00
1 parent 00f670213a
commit 5fb2d4e089
21 files changed
+1202 -121

No files matched your search

+3
View File
@@ -348,6 +348,9 @@ func (b *Batcher) AddNode(
// and we want to avoid the race condition where the receiver isn't ready yet
select {
case c <- initialMap:
// Still pendingInitial, so no broadcast can race this.
newEntry.lastSSHPolicy.Store(initialMap.SSHPolicy)
// Record sent peers only after confirmed delivery, mirroring the async
// path, and under workMu so a concurrent async bundle for this node
// cannot interleave its own lastSentPeers update.
+152
View File
@@ -1,6 +1,7 @@
package mapper
import (
"encoding/json"
"errors"
"fmt"
"net/netip"
@@ -13,6 +14,7 @@ import (
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/derp"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
@@ -2337,6 +2339,55 @@ func TestAddWorkFullUpdateUnaffectedByEmpty(t *testing.T) {
}
}
var errInjectedSSHPolicy = errors.New("injected SSH policy failure")
type failingSSHPolicyManager struct {
policy.PolicyManager
}
func (failingSSHPolicyManager) SSHPolicy(string, types.NodeView) (*tailcfg.SSHPolicy, error) {
return nil, errInjectedSSHPolicy
}
// TestSSHPolicyEmptyOnWire pins that a node without SSH rules, or whose SSH
// policy fails to compile, gets "SSHPolicy":{"rules":[]}. A nil SSHPolicy
// leaves the client's previous rules in force.
// https://github.com/juanfont/headscale/issues/3508
func TestSSHPolicyEmptyOnWire(t *testing.T) {
for name, failCompile := range map[string]bool{
"no ssh rules": false,
"compile error": true,
} {
t.Run(name, func(t *testing.T) {
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, normalBufferSize)
defer cleanup()
if failCompile {
testData.State.WrapPolicyManagerForTest(
func(pm policy.PolicyManager) policy.PolicyManager {
return failingSSHPolicyManager{PolicyManager: pm}
},
)
}
self := testData.Nodes[0].n.ID
mc := newMockNodeConnection(self)
require.NoError(t, handleNodeChange(mc, testData.Batcher.mapper, change.PolicyChange()))
require.NoError(t, handleNodeChange(mc, testData.Batcher.mapper, change.FullSelf(self)))
sent := mc.getSent()
require.Len(t, sent, 2)
for _, resp := range sent {
wire, err := json.Marshal(resp)
require.NoError(t, err)
assert.Contains(t, string(wire), `"SSHPolicy":{"rules":[]}`)
}
})
}
}
// TestAddWorkPeersRemovedNotTreatedAsEmpty ensures the empty filter does
// not swallow a PeersRemoved change — deletion cleanup must still run, and
// surviving recipients must still see the removal.
@@ -2702,3 +2753,104 @@ func TestDNSConfigOnlyWithSelfRefresh(t *testing.T) {
assert.Nil(t, sent[0].DNSConfig, "policy response must not carry DNSConfig")
assert.NotNil(t, sent[1].DNSConfig, "self refresh must carry DNSConfig")
}
// TestSSHPolicySentOnlyWhenChanged pins that policy frames drop an SSHPolicy
// the client already holds, since any non-nil SSHPolicy forces a full client
// netmap rebuild, while initial maps and real changes still carry it.
// https://github.com/juanfont/headscale/issues/3508
func TestSSHPolicySentOnlyWhenChanged(t *testing.T) {
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, normalBufferSize)
defer cleanup()
b := testData.Batcher.Batcher
self := &testData.Nodes[0]
policy := func(sshUser, group string) []byte {
ssh := ""
if sshUser != "" {
ssh = fmt.Sprintf(`, "ssh": [{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": [%q]
}]`, sshUser)
}
return fmt.Appendf(nil, `{
"groups": {%q: []},
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]%s
}`, "group:"+group, ssh)
}
_, err := testData.State.SetPolicy(policy("root", "a"))
require.NoError(t, err)
require.NoError(t, b.AddNode(self.n.ID, self.ch, tailcfg.CapabilityVersion(100), nil))
initial := expectReceive(t, self.ch, "initial map")
require.NotNil(t, initial.SSHPolicy)
require.NotEmpty(t, initial.SSHPolicy.Rules)
nc, ok := b.nodes.Load(self.n.ID)
require.True(t, ok)
policyFrame := func(chs ...chan *tailcfg.MapResponse) []*tailcfg.MapResponse {
nc.workMu.Lock()
defer nc.workMu.Unlock()
require.NoError(t, handleNodeChange(nc, b.mapper, change.PolicyChange()))
frames := make([]*tailcfg.MapResponse, 0, len(chs))
for _, ch := range chs {
frames = append(frames, expectReceive(t, ch, "policy frame"))
}
return frames
}
steps := []struct {
name string
policy []byte // nil: no policy change
wantSent bool
wantSSH bool
}{
{"nothing changed", nil, false, false},
{"acl-only change", policy("root", "b"), false, false},
{"ssh users changed", policy("alice", "b"), true, true},
{"ssh removed", policy("", "b"), true, false},
{"acl-only change without ssh", policy("", "c"), false, false},
}
for _, step := range steps {
if step.policy != nil {
_, err := testData.State.SetPolicy(step.policy)
require.NoError(t, err)
}
frame := policyFrame(self.ch)[0]
if !step.wantSent {
assert.Nil(t, frame.SSHPolicy, "%s: unchanged SSHPolicy must be dropped", step.name)
_, delta := netmap.MutationsFromMapResponse(frame, time.Now())
assert.True(t, delta, "%s: frame must apply as a delta", step.name)
continue
}
require.NotNil(t, frame.SSHPolicy, "%s: changed SSHPolicy must be sent", step.name)
assert.Equal(t, step.wantSSH, len(frame.SSHPolicy.Rules) > 0, step.name)
}
// A new connection gets the policy in its initial map; afterwards both
// connections hold it and drop it from the next policy frame.
ch2 := make(chan *tailcfg.MapResponse, normalBufferSize)
require.NoError(t, b.AddNode(self.n.ID, ch2, tailcfg.CapabilityVersion(100), nil))
initial2 := expectReceive(t, ch2, "second connection's initial map")
require.NotNil(t, initial2.SSHPolicy, "every initial map must carry SSHPolicy")
for i, frame := range policyFrame(self.ch, ch2) {
assert.Nil(t, frame.SSHPolicy, "connection %d already holds the policy", i)
}
}
+83
View File
@@ -1237,3 +1237,86 @@ func TestRemoveConnectionAtIndex_NilsTrailingSlot(t *testing.T) {
mc.mutex.Unlock()
}
// ============================================================================
// SSHPolicy delta Tests
// ============================================================================
func sshPolicyForUser(user string) *tailcfg.SSHPolicy {
return &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.1"}},
SSHUsers: map[string]string{user: user},
Action: &tailcfg.SSHAction{Accept: true},
}}}
}
func emptySSHPolicy() *tailcfg.SSHPolicy {
return &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{}}
}
func TestMultiChannelSend_SSHPolicyOnlyWhenChanged(t *testing.T) {
tests := []struct {
name string
last, next *tailcfg.SSHPolicy
wantSent bool
}{
{"unchanged empty", emptySSHPolicy(), emptySSHPolicy(), false},
{"unchanged rules", sshPolicyForUser("root"), sshPolicyForUser("root"), false},
{"rules removed", sshPolicyForUser("root"), emptySSHPolicy(), true},
{"rules changed", sshPolicyForUser("root"), sshPolicyForUser("alice"), true},
{"none delivered yet", nil, emptySSHPolicy(), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mc := newMultiChannelNodeConn(1, nil)
ch := make(chan *tailcfg.MapResponse, 1)
entry := makeConnectionEntry("conn", ch)
entry.lastSSHPolicy.Store(tt.last)
mc.addConnection(entry)
data := testMapResponse()
data.PacketFilters = map[string][]tailcfg.FilterRule{"base": nil}
data.SSHPolicy = tt.next
require.NoError(t, mc.send(data))
got := expectReceive(t, ch, "connection should receive the response")
assert.Equal(t, data.PacketFilters, got.PacketFilters, "other fields must be kept")
if tt.wantSent {
assert.Same(t, tt.next, got.SSHPolicy)
} else {
assert.Nil(t, got.SSHPolicy)
}
assert.Same(t, tt.next, entry.lastSSHPolicy.Load(), "latest copy must be stored")
assert.Same(t, tt.next, data.SSHPolicy, "shared response must not be modified")
})
}
}
// TestMultiChannelSend_SSHPolicyPerConnection pins per-connection tracking:
// connections of one node can hold different policies, e.g. one that missed
// deltas while its initial map was in flight.
func TestMultiChannelSend_SSHPolicyPerConnection(t *testing.T) {
mc := newMultiChannelNodeConn(1, nil)
chA := make(chan *tailcfg.MapResponse, 1)
chB := make(chan *tailcfg.MapResponse, 1)
a := makeConnectionEntry("a", chA)
b := makeConnectionEntry("b", chB)
a.lastSSHPolicy.Store(sshPolicyForUser("alice"))
b.lastSSHPolicy.Store(sshPolicyForUser("root"))
mc.addConnection(a)
mc.addConnection(b)
data := testMapResponse()
data.SSHPolicy = sshPolicyForUser("alice")
require.NoError(t, mc.send(data))
assert.Nil(t, expectReceive(t, chA, "a").SSHPolicy, "a already holds this policy")
assert.Same(t, data.SSHPolicy, expectReceive(t, chB, "b").SSHPolicy, "b holds an older policy")
}
+7 -6
View File
@@ -146,16 +146,15 @@ func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder {
sshPolicy, err := b.mapper.state.SSHPolicy(node)
if err != nil {
// SSH policy is optional for a node to function. Rather than fail the
// whole map (leaving the node unable to connect), log and continue
// without it; the node still receives a usable netmap.
log.Warn().Caller().
// Don't fail the whole map over SSH, but fail closed: a nil
// SSHPolicy would leave the client's previous rules in force.
log.Error().Caller().
Err(err).
Uint64(zf.NodeID, node.ID().Uint64()).
Str(zf.NodeHostname, node.Hostname()).
Msg("building map response: skipping SSH policy for node; node will receive a map without SSH rules")
Msg("building map response: compiling SSH policy failed; sending empty SSH policy")
return b
sshPolicy = &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{}}
}
b.resp.SSHPolicy = sshPolicy
@@ -346,6 +345,8 @@ func (b *MapResponseBuilder) Build() (*tailcfg.MapResponse, error) {
return nil, multierr.New(b.errs...)
}
// Dumps show the generated response; an unchanged SSHPolicy is
// dropped later, per connection, before it reaches the wire.
if debugDumpMapResponsePath != "" {
writeDebugMapResponse(b.resp, b.debugType, b.nodeID)
}
+2 -1
View File
@@ -322,7 +322,8 @@ func (m *mapper) selfMapResponse(
// [handleNodeChange]. It sends:
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
// - Updated PacketFilters
// - Updated SSHPolicy (SSH rules may reference users/groups that changed)
// - Updated SSHPolicy, dropped per connection when unchanged;
// see [connectionEntry.withSSHPolicyDelta]
// - Optionally, the node's own self info (when includeSelf is true)
//
// DNSConfig is left out: it forces clients into a full netmap rebuild, and
+24 -1
View File
@@ -3,6 +3,7 @@ package mapper
import (
"errors"
"fmt"
"reflect"
"strconv"
"sync"
"sync/atomic"
@@ -55,6 +56,9 @@ type connectionEntry struct {
// established connection must go through [multiChannelNodeConn.send]
// to keep it current.
lastSelf atomic.Pointer[tailcfg.Node]
// lastSSHPolicy is the last non-nil policy delivered to this connection.
lastSSHPolicy atomic.Pointer[tailcfg.SSHPolicy]
}
// withSelfDelta returns data without its Node when this client already
@@ -70,6 +74,21 @@ func (entry *connectionEntry) withSelfDelta(data *tailcfg.MapResponse) *tailcfg.
return &stripped
}
// withSSHPolicyDelta returns data without its SSHPolicy when this client
// already holds an equal one: any non-nil SSHPolicy forces a full client
// netmap rebuild. Equal content arrives in fresh pointers after every
// policy reload, so compare deeply.
func (entry *connectionEntry) withSSHPolicyDelta(data *tailcfg.MapResponse) *tailcfg.MapResponse {
if data.SSHPolicy == nil || !reflect.DeepEqual(entry.lastSSHPolicy.Load(), data.SSHPolicy) {
return data
}
stripped := *data
stripped.SSHPolicy = nil
return &stripped
}
// multiChannelNodeConn manages multiple concurrent connections for a single node.
type multiChannelNodeConn struct {
id types.NodeID
@@ -342,7 +361,7 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
)
for _, conn := range snapshot {
err := conn.send(conn.withSelfDelta(data))
err := conn.send(conn.withSSHPolicyDelta(conn.withSelfDelta(data)))
if err != nil {
lastErr = err
@@ -357,6 +376,10 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
if data.Node != nil {
conn.lastSelf.Store(data.Node)
}
if data.SSHPolicy != nil {
conn.lastSSHPolicy.Store(data.SSHPolicy)
}
}
}