mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 00:36:49 +09:00
policy/v2: revoke stale SSH access and recheck authentication
Updates #3517, #3508
(cherry picked from commit 4fd4da75f2)
This commit is contained in:
1 parent
00f670213a
commit
5fb2d4e089
21 files changed
+1202
-121
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user