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
|
// and we want to avoid the race condition where the receiver isn't ready yet
|
||||||
select {
|
select {
|
||||||
case c <- initialMap:
|
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
|
// Record sent peers only after confirmed delivery, mirroring the async
|
||||||
// path, and under workMu so a concurrent async bundle for this node
|
// path, and under workMu so a concurrent async bundle for this node
|
||||||
// cannot interleave its own lastSentPeers update.
|
// cannot interleave its own lastSentPeers update.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package mapper
|
package mapper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/db"
|
"github.com/juanfont/headscale/hscontrol/db"
|
||||||
"github.com/juanfont/headscale/hscontrol/derp"
|
"github.com/juanfont/headscale/hscontrol/derp"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/policy"
|
||||||
"github.com/juanfont/headscale/hscontrol/state"
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
"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
|
// TestAddWorkPeersRemovedNotTreatedAsEmpty ensures the empty filter does
|
||||||
// not swallow a PeersRemoved change — deletion cleanup must still run, and
|
// not swallow a PeersRemoved change — deletion cleanup must still run, and
|
||||||
// surviving recipients must still see the removal.
|
// 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.Nil(t, sent[0].DNSConfig, "policy response must not carry DNSConfig")
|
||||||
assert.NotNil(t, sent[1].DNSConfig, "self refresh must 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()
|
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)
|
sshPolicy, err := b.mapper.state.SSHPolicy(node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// SSH policy is optional for a node to function. Rather than fail the
|
// Don't fail the whole map over SSH, but fail closed: a nil
|
||||||
// whole map (leaving the node unable to connect), log and continue
|
// SSHPolicy would leave the client's previous rules in force.
|
||||||
// without it; the node still receives a usable netmap.
|
log.Error().Caller().
|
||||||
log.Warn().Caller().
|
|
||||||
Err(err).
|
Err(err).
|
||||||
Uint64(zf.NodeID, node.ID().Uint64()).
|
Uint64(zf.NodeID, node.ID().Uint64()).
|
||||||
Str(zf.NodeHostname, node.Hostname()).
|
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
|
b.resp.SSHPolicy = sshPolicy
|
||||||
@@ -346,6 +345,8 @@ func (b *MapResponseBuilder) Build() (*tailcfg.MapResponse, error) {
|
|||||||
return nil, multierr.New(b.errs...)
|
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 != "" {
|
if debugDumpMapResponsePath != "" {
|
||||||
writeDebugMapResponse(b.resp, b.debugType, b.nodeID)
|
writeDebugMapResponse(b.resp, b.debugType, b.nodeID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ func (m *mapper) selfMapResponse(
|
|||||||
// [handleNodeChange]. It sends:
|
// [handleNodeChange]. It sends:
|
||||||
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
|
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
|
||||||
// - Updated PacketFilters
|
// - 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)
|
// - Optionally, the node's own self info (when includeSelf is true)
|
||||||
//
|
//
|
||||||
// DNSConfig is left out: it forces clients into a full netmap rebuild, and
|
// DNSConfig is left out: it forces clients into a full netmap rebuild, and
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package mapper
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -55,6 +56,9 @@ type connectionEntry struct {
|
|||||||
// established connection must go through [multiChannelNodeConn.send]
|
// established connection must go through [multiChannelNodeConn.send]
|
||||||
// to keep it current.
|
// to keep it current.
|
||||||
lastSelf atomic.Pointer[tailcfg.Node]
|
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
|
// withSelfDelta returns data without its Node when this client already
|
||||||
@@ -70,6 +74,21 @@ func (entry *connectionEntry) withSelfDelta(data *tailcfg.MapResponse) *tailcfg.
|
|||||||
return &stripped
|
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.
|
// multiChannelNodeConn manages multiple concurrent connections for a single node.
|
||||||
type multiChannelNodeConn struct {
|
type multiChannelNodeConn struct {
|
||||||
id types.NodeID
|
id types.NodeID
|
||||||
@@ -342,7 +361,7 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for _, conn := range snapshot {
|
for _, conn := range snapshot {
|
||||||
err := conn.send(conn.withSelfDelta(data))
|
err := conn.send(conn.withSSHPolicyDelta(conn.withSelfDelta(data)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
@@ -357,6 +376,10 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
|
|||||||
if data.Node != nil {
|
if data.Node != nil {
|
||||||
conn.lastSelf.Store(data.Node)
|
conn.lastSelf.Store(data.Node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.SSHPolicy != nil {
|
||||||
|
conn.lastSSHPolicy.Store(data.SSHPolicy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-38
@@ -34,9 +34,6 @@ var ErrMissingURLParameter = errors.New("missing URL parameter")
|
|||||||
// ErrUnsupportedURLParameterType is returned when a URL parameter has an unsupported type.
|
// ErrUnsupportedURLParameterType is returned when a URL parameter has an unsupported type.
|
||||||
var ErrUnsupportedURLParameterType = errors.New("unsupported URL parameter type")
|
var ErrUnsupportedURLParameterType = errors.New("unsupported URL parameter type")
|
||||||
|
|
||||||
// ErrNoAuthSession is returned when an auth_id does not match any active auth session.
|
|
||||||
var ErrNoAuthSession = errors.New("no auth session found")
|
|
||||||
|
|
||||||
// ErrSSHDstNodeNotFound is returned when the dst node id on a Noise SSH
|
// ErrSSHDstNodeNotFound is returned when the dst node id on a Noise SSH
|
||||||
// action request does not match any registered node.
|
// action request does not match any registered node.
|
||||||
var ErrSSHDstNodeNotFound = errors.New("ssh action: unknown dst node id")
|
var ErrSSHDstNodeNotFound = errors.New("ssh action: unknown dst node id")
|
||||||
@@ -446,6 +443,7 @@ func (ns *noiseServer) SSHActionHandler(
|
|||||||
req.Context(),
|
req.Context(),
|
||||||
reqLog,
|
reqLog,
|
||||||
srcNodeID, dstNodeID,
|
srcNodeID, dstNodeID,
|
||||||
|
req.URL.Query().Get("local_user"),
|
||||||
req.URL.Query().Get("auth_id"),
|
req.URL.Query().Get("auth_id"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -473,19 +471,20 @@ func (ns *noiseServer) SSHActionHandler(
|
|||||||
// sshAction resolves the SSH action for the given request parameters.
|
// sshAction resolves the SSH action for the given request parameters.
|
||||||
// It returns the action to send to the client, or an [HTTPError] on failure.
|
// It returns the action to send to the client, or an [HTTPError] on failure.
|
||||||
//
|
//
|
||||||
// Three cases:
|
// Four cases:
|
||||||
// 1. Initial request, auto-approved — source recently authenticated
|
// 1. No check rule covers the pair — reject; the client's rule is stale.
|
||||||
|
// 2. Initial request, auto-approved — source recently authenticated
|
||||||
// within the check period, accept immediately.
|
// within the check period, accept immediately.
|
||||||
// 2. Initial request, needs auth — build a [tailcfg.SSHAction.HoldAndDelegate] URL and
|
// 3. Initial request, needs auth — build a [tailcfg.SSHAction.HoldAndDelegate] URL and
|
||||||
// wait for the user to authenticate.
|
// wait for the user to authenticate.
|
||||||
// 3. Follow-up request — an auth_id is present, wait for the auth
|
// 4. Follow-up request — an auth_id is present, wait for the auth
|
||||||
// verdict and accept or reject. A session that is gone or whose
|
// verdict and accept or reject. A session that is gone or whose
|
||||||
// verdict another follow-up consumed is re-decided.
|
// verdict another follow-up consumed is re-decided.
|
||||||
func (ns *noiseServer) sshAction(
|
func (ns *noiseServer) sshAction(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
reqLog zerolog.Logger,
|
reqLog zerolog.Logger,
|
||||||
srcNodeID, dstNodeID types.NodeID,
|
srcNodeID, dstNodeID types.NodeID,
|
||||||
authIDStr string,
|
localUser, authIDStr string,
|
||||||
) (*tailcfg.SSHAction, error) {
|
) (*tailcfg.SSHAction, error) {
|
||||||
action := tailcfg.SSHAction{
|
action := tailcfg.SSHAction{
|
||||||
AllowAgentForwarding: true,
|
AllowAgentForwarding: true,
|
||||||
@@ -495,21 +494,28 @@ func (ns *noiseServer) sshAction(
|
|||||||
|
|
||||||
// Look up check params from the server's own policy rather than
|
// Look up check params from the server's own policy rather than
|
||||||
// trusting URL parameters, which the client could tamper with.
|
// trusting URL parameters, which the client could tamper with.
|
||||||
|
// local_user only narrows which rule applies, and it comes from dst,
|
||||||
|
// the node enforcing the login.
|
||||||
checkPeriod, checkFound := ns.headscale.state.SSHCheckParams(
|
checkPeriod, checkFound := ns.headscale.state.SSHCheckParams(
|
||||||
srcNodeID, dstNodeID,
|
srcNodeID, dstNodeID, localUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Clients only call back for check rules they were sent. Without one in
|
||||||
|
// the current policy the client's copy is stale.
|
||||||
|
if !checkFound {
|
||||||
|
return sshActionDeny(reqLog, &action), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Follow-up request with auth_id — wait for the auth verdict.
|
// Follow-up request with auth_id — wait for the auth verdict.
|
||||||
if authIDStr != "" {
|
if authIDStr != "" {
|
||||||
return ns.sshActionFollowUp(
|
return ns.sshActionFollowUp(
|
||||||
ctx, reqLog, &action, authIDStr,
|
ctx, reqLog, &action, authIDStr,
|
||||||
srcNodeID, dstNodeID,
|
srcNodeID, dstNodeID, localUser,
|
||||||
checkFound,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial request — check if auto-approval applies.
|
// Initial request — check if auto-approval applies.
|
||||||
if checkFound && checkPeriod > 0 {
|
if checkPeriod > 0 {
|
||||||
if lastAuth, ok := ns.headscale.state.GetLastSSHAuth(
|
if lastAuth, ok := ns.headscale.state.GetLastSSHAuth(
|
||||||
srcNodeID, dstNodeID,
|
srcNodeID, dstNodeID,
|
||||||
); ok && time.Since(lastAuth) < checkPeriod {
|
); ok && time.Since(lastAuth) < checkPeriod {
|
||||||
@@ -525,7 +531,18 @@ func (ns *noiseServer) sshAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No auto-approval — create an auth session and hold.
|
// No auto-approval — create an auth session and hold.
|
||||||
return ns.sshActionHoldAndDelegate(reqLog, &action, srcNodeID, dstNodeID)
|
return ns.sshActionHoldAndDelegate(reqLog, &action, srcNodeID, dstNodeID, localUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshActionDeny rejects a check the current policy does not require. It is a
|
||||||
|
// 200 Reject, not an HTTP error: tailssh retries errors for up to 30 minutes.
|
||||||
|
func sshActionDeny(reqLog zerolog.Logger, action *tailcfg.SSHAction) *tailcfg.SSHAction {
|
||||||
|
action.Reject = true
|
||||||
|
action.Message = "# Headscale SSH: no check rule in the tailnet policy covers this connection.\n"
|
||||||
|
|
||||||
|
reqLog.Info().Caller().Msg("SSH check denied: no matching check rule")
|
||||||
|
|
||||||
|
return action
|
||||||
}
|
}
|
||||||
|
|
||||||
// sshActionHoldAndDelegate creates a new auth session bound to the
|
// sshActionHoldAndDelegate creates a new auth session bound to the
|
||||||
@@ -535,11 +552,11 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
|||||||
reqLog zerolog.Logger,
|
reqLog zerolog.Logger,
|
||||||
action *tailcfg.SSHAction,
|
action *tailcfg.SSHAction,
|
||||||
srcNodeID, dstNodeID types.NodeID,
|
srcNodeID, dstNodeID types.NodeID,
|
||||||
|
localUser string,
|
||||||
) (*tailcfg.SSHAction, error) {
|
) (*tailcfg.SSHAction, error) {
|
||||||
holdURL, err := url.Parse(
|
holdURL, err := url.Parse(
|
||||||
ns.headscale.cfg.ServerURL +
|
ns.headscale.cfg.ServerURL +
|
||||||
"/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
"/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID",
|
||||||
"?local_user=$LOCAL_USER",
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, NewHTTPError(
|
return nil, NewHTTPError(
|
||||||
@@ -565,7 +582,10 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
|||||||
|
|
||||||
authURL := ns.headscale.authProvider.AuthURL(authID)
|
authURL := ns.headscale.authProvider.AuthURL(authID)
|
||||||
|
|
||||||
|
// The concrete user, not $LOCAL_USER: Encode escapes the placeholder
|
||||||
|
// and tailssh only expands it literally.
|
||||||
q := holdURL.Query()
|
q := holdURL.Query()
|
||||||
|
q.Set("local_user", localUser)
|
||||||
q.Set("auth_id", authID.String())
|
q.Set("auth_id", authID.String())
|
||||||
holdURL.RawQuery = q.Encode()
|
holdURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
@@ -596,7 +616,7 @@ func (ns *noiseServer) sshActionFollowUp(
|
|||||||
action *tailcfg.SSHAction,
|
action *tailcfg.SSHAction,
|
||||||
authIDStr string,
|
authIDStr string,
|
||||||
srcNodeID, dstNodeID types.NodeID,
|
srcNodeID, dstNodeID types.NodeID,
|
||||||
checkFound bool,
|
localUser string,
|
||||||
) (*tailcfg.SSHAction, error) {
|
) (*tailcfg.SSHAction, error) {
|
||||||
authID, err := types.AuthIDFromString(authIDStr)
|
authID, err := types.AuthIDFromString(authIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -609,25 +629,14 @@ func (ns *noiseServer) sshActionFollowUp(
|
|||||||
|
|
||||||
reqLog = reqLog.With().Str("auth_id", authID.String()).Logger()
|
reqLog = reqLog.With().Str("auth_id", authID.String()).Logger()
|
||||||
|
|
||||||
// The session is gone (expired, evicted, or lost on a control-plane
|
// A missing session or consumed verdict must never approve access.
|
||||||
// restart) or its verdict was already consumed. A bare error dead-ends
|
// The caller checked that the policy still requires authentication;
|
||||||
// the client: it keeps polling this now-defunct auth_id until the SSH
|
// re-delegate so the client can complete a fresh check.
|
||||||
// connection times out. Re-delegate so a still-required check can
|
|
||||||
// complete instead.
|
|
||||||
sessionGone := func(logMsg string) (*tailcfg.SSHAction, error) {
|
sessionGone := func(logMsg string) (*tailcfg.SSHAction, error) {
|
||||||
if checkFound {
|
reqLog.Info().Caller().Msg(logMsg)
|
||||||
reqLog.Info().Caller().
|
|
||||||
Msg(logMsg)
|
|
||||||
|
|
||||||
return ns.sshActionHoldAndDelegate(
|
return ns.sshActionHoldAndDelegate(
|
||||||
reqLog, action, srcNodeID, dstNodeID,
|
reqLog, action, srcNodeID, dstNodeID, localUser,
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, NewHTTPError(
|
|
||||||
http.StatusBadRequest,
|
|
||||||
"Invalid auth_id",
|
|
||||||
fmt.Errorf("%w: %s", ErrNoAuthSession, authID),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,15 +709,19 @@ func (ns *noiseServer) sshActionFollowUp(
|
|||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The policy may have changed while the user authenticated, and the
|
||||||
|
// client won't drop a connection still waiting on its check.
|
||||||
|
if _, ok := ns.headscale.state.SSHCheckParams(srcNodeID, dstNodeID, localUser); !ok {
|
||||||
|
return sshActionDeny(reqLog, action), nil
|
||||||
|
}
|
||||||
|
|
||||||
action.Accept = true
|
action.Accept = true
|
||||||
|
|
||||||
// Record the successful auth for future auto-approval.
|
// Record the successful auth for future auto-approval.
|
||||||
if checkFound {
|
ns.headscale.state.SetLastSSHAuth(srcNodeID, dstNodeID)
|
||||||
ns.headscale.state.SetLastSSHAuth(srcNodeID, dstNodeID)
|
|
||||||
|
|
||||||
reqLog.Trace().Caller().
|
reqLog.Trace().Caller().
|
||||||
Msg("auth recorded for auto-approval")
|
Msg("auth recorded for auto-approval")
|
||||||
}
|
|
||||||
|
|
||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-29
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -242,13 +244,17 @@ func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshTestLocalUser is the non-root local user the SSH action tests log in as.
|
||||||
|
const sshTestLocalUser = "alice"
|
||||||
|
|
||||||
// newSSHActionRequest builds an httptest request with the chi URL params
|
// newSSHActionRequest builds an httptest request with the chi URL params
|
||||||
// [noiseServer.SSHActionHandler] reads (src_node_id and dst_node_id), so the handler
|
// [noiseServer.SSHActionHandler] reads (src_node_id and dst_node_id), so the handler
|
||||||
// can be exercised directly without going through the chi router.
|
// can be exercised directly without going through the chi router.
|
||||||
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
url := fmt.Sprintf("/machine/ssh/action/%d/to/%d", src.Uint64(), dst.Uint64())
|
url := fmt.Sprintf("/machine/ssh/action/%d/to/%d?local_user=%s",
|
||||||
|
src.Uint64(), dst.Uint64(), sshTestLocalUser)
|
||||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
|
||||||
rctx := chi.NewRouteContext()
|
rctx := chi.NewRouteContext()
|
||||||
@@ -271,6 +277,45 @@ func putTestNodeInStore(t *testing.T, app *Headscale, user *types.User, hostname
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshCheckPolicy subjects every same-user pair of userName's nodes to an
|
||||||
|
// SSH check.
|
||||||
|
func sshCheckPolicy(userName string) string {
|
||||||
|
return fmt.Sprintf(`{"ssh": [{
|
||||||
|
"action": "check",
|
||||||
|
"src": [%q],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}]}`, userName+"@")
|
||||||
|
}
|
||||||
|
|
||||||
|
// putSSHCheckNodes stages one node per hostname for a new user and sets
|
||||||
|
// [sshCheckPolicy], so the SSH action handler holds and delegates for any
|
||||||
|
// pair of them.
|
||||||
|
func putSSHCheckNodes(t *testing.T, app *Headscale, userName string, hostnames ...string) []*types.Node {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
user := app.state.CreateUserForTest(userName)
|
||||||
|
require.NoError(t, app.state.UpdatePolicyManagerUsersForTest())
|
||||||
|
|
||||||
|
nodes := make([]*types.Node, 0, len(hostnames))
|
||||||
|
for _, hostname := range hostnames {
|
||||||
|
node := app.state.CreateRegisteredNodeForTest(user, hostname)
|
||||||
|
node.User = user
|
||||||
|
app.state.PutNodeInStoreForTest(*node)
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, app.state.UpdatePolicyManagerNodesForTest())
|
||||||
|
|
||||||
|
_, err := app.state.SetPolicy([]byte(sshCheckPolicy(userName)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, checkFound := app.state.SSHCheckParams(nodes[0].ID, nodes[len(nodes)-1].ID, sshTestLocalUser)
|
||||||
|
require.True(t, checkFound, "test setup: nodes must be subject to an SSH check")
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
// TestSSHActionHandler_RejectsRogueMachineKey verifies that the SSH
|
// TestSSHActionHandler_RejectsRogueMachineKey verifies that the SSH
|
||||||
// check action endpoint rejects a Noise session whose machine key does
|
// check action endpoint rejects a Noise session whose machine key does
|
||||||
// not match the dst node.
|
// not match the dst node.
|
||||||
@@ -335,12 +380,9 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := createTestApp(t)
|
app := createTestApp(t)
|
||||||
user := app.state.CreateUserForTest("ssh-binding-user")
|
nodes := putSSHCheckNodes(t, app, "ssh-binding-user",
|
||||||
|
"src-cached", "dst-cached", "src-other", "dst-other")
|
||||||
srcCached := putTestNodeInStore(t, app, user, "src-cached")
|
srcCached, dstCached, srcOther, dstOther := nodes[0], nodes[1], nodes[2], nodes[3]
|
||||||
dstCached := putTestNodeInStore(t, app, user, "dst-cached")
|
|
||||||
srcOther := putTestNodeInStore(t, app, user, "src-other")
|
|
||||||
dstOther := putTestNodeInStore(t, app, user, "dst-other")
|
|
||||||
|
|
||||||
// Mint an SSH-check auth request bound to (srcCached, dstCached).
|
// Mint an SSH-check auth request bound to (srcCached, dstCached).
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
@@ -359,8 +401,8 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf(
|
url := fmt.Sprintf(
|
||||||
"/machine/ssh/action/%d/to/%d?auth_id=%s",
|
"/machine/ssh/action/%d/to/%d?local_user=%s&auth_id=%s",
|
||||||
srcOther.ID.Uint64(), dstOther.ID.Uint64(), authID.String(),
|
srcOther.ID.Uint64(), dstOther.ID.Uint64(), sshTestLocalUser, authID.String(),
|
||||||
)
|
)
|
||||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
|
||||||
@@ -411,9 +453,8 @@ func TestSSHActionHoldAndDelegate_PersistsAuthSession(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := createTestApp(t)
|
app := createTestApp(t)
|
||||||
user := app.state.CreateUserForTest("ssh-persist-user")
|
nodes := putSSHCheckNodes(t, app, "ssh-persist-user", "src-node", "dst-node")
|
||||||
src := putTestNodeInStore(t, app, user, "src-node")
|
src, dst := nodes[0], nodes[1]
|
||||||
dst := putTestNodeInStore(t, app, user, "dst-node")
|
|
||||||
|
|
||||||
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
||||||
|
|
||||||
@@ -438,12 +479,13 @@ func TestSSHActionHoldAndDelegate_PersistsAuthSession(t *testing.T) {
|
|||||||
require.True(t, ok, "auth session %s must persist after HoldAndDelegate", authID)
|
require.True(t, ok, "auth session %s must persist after HoldAndDelegate", authID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSSHActionHandler_RejectsMissingSessionWithoutCheck verifies that without
|
// TestSSHActionHandler_RejectsWithoutCheck verifies that a pair no check rule
|
||||||
// an SSH check covering the pair, a follow-up poll for an unknown auth_id is a
|
// covers is denied with a 200 Reject on both the initial and the follow-up
|
||||||
// genuinely bogus request and is rejected. The re-delegation behaviour for a
|
// poll. Such a call comes from a client holding a stale check rule; an HTTP
|
||||||
// missing session (issue #3305, exercised end to end with a real client in the
|
// error would make tailssh retry for up to 30 minutes. Re-delegation for a
|
||||||
// servertest package) applies only when the pair is still subject to a check.
|
// missing session (issue #3305) applies only while a check is required.
|
||||||
func TestSSHActionHandler_RejectsMissingSessionWithoutCheck(t *testing.T) {
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
func TestSSHActionHandler_RejectsWithoutCheck(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := createTestApp(t)
|
app := createTestApp(t)
|
||||||
@@ -451,18 +493,88 @@ func TestSSHActionHandler_RejectsMissingSessionWithoutCheck(t *testing.T) {
|
|||||||
src := putTestNodeInStore(t, app, user, "src-node")
|
src := putTestNodeInStore(t, app, user, "src-node")
|
||||||
dst := putTestNodeInStore(t, app, user, "dst-node")
|
dst := putTestNodeInStore(t, app, user, "dst-node")
|
||||||
|
|
||||||
// No SSH-check policy is set, so the pair is not subject to a check.
|
_, checkFound := app.state.SSHCheckParams(src.ID, dst.ID, sshTestLocalUser)
|
||||||
_, checkFound := app.state.SSHCheckParams(src.ID, dst.ID)
|
|
||||||
require.False(t, checkFound, "test setup: pair must not be subject to a check")
|
require.False(t, checkFound, "test setup: pair must not be subject to a check")
|
||||||
|
|
||||||
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
||||||
|
|
||||||
missing := types.MustAuthID()
|
for name, req := range map[string]*http.Request{
|
||||||
|
"initial": newSSHActionRequest(t, src.ID, dst.ID),
|
||||||
|
"follow-up": newSSHActionFollowUpRequest(t, src.ID, dst.ID, types.MustAuthID()),
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ns.SSHActionHandler(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "%s: body=%s", name, rec.Body.String())
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
var action tailcfg.SSHAction
|
||||||
ns.SSHActionHandler(rec, newSSHActionFollowUpRequest(t, src.ID, dst.ID, missing))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &action))
|
||||||
require.Equal(t, http.StatusBadRequest, rec.Code,
|
assert.True(t, action.Reject, "%s: must reject, got %+v", name, action)
|
||||||
"a bogus auth_id with no active check must be rejected, body=%s", rec.Body.String())
|
assert.Empty(t, action.HoldAndDelegate, "%s: must not delegate", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSSHActionFollowUp_RejectsVerdictAfterCheckRemoved verifies the check is
|
||||||
|
// re-evaluated once the user authenticates: a rule removed while the login
|
||||||
|
// prompt was open must not grant access.
|
||||||
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
func TestSSHActionFollowUp_RejectsVerdictAfterCheckRemoved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const userName = "ssh-verdict-user"
|
||||||
|
|
||||||
|
nonrootOnly := sshCheckPolicy(userName)
|
||||||
|
rootAndNonroot := strings.Replace(nonrootOnly,
|
||||||
|
`["autogroup:nonroot"]`, `["root", "autogroup:nonroot"]`, 1)
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
after string // policy once the user has authenticated; "" keeps it
|
||||||
|
localUser string
|
||||||
|
}{
|
||||||
|
"rule kept": {"", sshTestLocalUser},
|
||||||
|
"rule removed": {`{}`, sshTestLocalUser},
|
||||||
|
// Another check rule still covers the pair, but not for root.
|
||||||
|
"root rule removed": {nonrootOnly, "root"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := createTestApp(t)
|
||||||
|
nodes := putSSHCheckNodes(t, app, userName, "src-node", "dst-node")
|
||||||
|
src, dst := nodes[0], nodes[1]
|
||||||
|
|
||||||
|
_, err := app.state.SetPolicy([]byte(rootAndNonroot))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
app.state.SetAuthCacheEntry(authID, types.NewSSHCheckAuthRequest(src.ID, dst.ID))
|
||||||
|
|
||||||
|
auth, ok := app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok)
|
||||||
|
auth.FinishAuth(types.AuthVerdict{})
|
||||||
|
|
||||||
|
if tc.after != "" {
|
||||||
|
_, err := app.state.SetPolicy([]byte(tc.after))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
||||||
|
|
||||||
|
// Call the follow-up directly: the verdict is already in, as if
|
||||||
|
// the policy changed while the user was authenticating.
|
||||||
|
action, err := ns.sshActionFollowUp(
|
||||||
|
t.Context(), zerolog.Nop(), &tailcfg.SSHAction{},
|
||||||
|
authID.String(), src.ID, dst.ID, tc.localUser,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kept := tc.after == ""
|
||||||
|
_, recorded := app.state.GetLastSSHAuth(src.ID, dst.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, kept, action.Accept, "accept, got %+v", action)
|
||||||
|
assert.Equal(t, !kept, action.Reject, "reject, got %+v", action)
|
||||||
|
assert.Equal(t, kept, recorded, "auth recorded for auto-approval")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTS2021Route_AcceptsGETAndPOST reproduces a regression where the
|
// TestTS2021Route_AcceptsGETAndPOST reproduces a regression where the
|
||||||
@@ -597,7 +709,7 @@ func newSSHVerdictFixture(t *testing.T) *sshVerdictFixture {
|
|||||||
}`, user.Name+"@"))
|
}`, user.Name+"@"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
period, checkFound := app.state.SSHCheckParams(ids[0], ids[1])
|
period, checkFound := app.state.SSHCheckParams(ids[0], ids[1], sshTestLocalUser)
|
||||||
require.True(t, checkFound, "test setup: pair must be subject to a check")
|
require.True(t, checkFound, "test setup: pair must be subject to a check")
|
||||||
require.Zero(t, period, "test setup: checkPeriod must be always")
|
require.Zero(t, period, "test setup: checkPeriod must be always")
|
||||||
|
|
||||||
@@ -736,12 +848,13 @@ func TestSSHActionFollowUp_ConsumedVerdictNotReplayed(t *testing.T) {
|
|||||||
_, err := f.ns.headscale.state.SetPolicy([]byte(`{}`))
|
_, err := f.ns.headscale.state.SetPolicy([]byte(`{}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, checkFound := f.ns.headscale.state.SSHCheckParams(f.src, f.dst)
|
_, checkFound := f.ns.headscale.state.SSHCheckParams(f.src, f.dst, sshTestLocalUser)
|
||||||
require.False(t, checkFound, "test setup: pair must no longer be subject to a check")
|
require.False(t, checkFound, "test setup: pair must no longer be subject to a check")
|
||||||
|
|
||||||
rec := f.followUp(t, authID)
|
rec := f.followUp(t, authID)
|
||||||
assert.Equal(t, http.StatusBadRequest, rec.Code,
|
action := sshActionFromRecorder(t, rec)
|
||||||
"replay without a check must be refused, body=%s", rec.Body.String())
|
assert.True(t, action.Reject, "replay without a check must be refused")
|
||||||
|
assert.False(t, action.Accept, "replay without a check must never approve access")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ type PolicyManager interface {
|
|||||||
// BuildPeerMap constructs peer relationship maps for the given nodes
|
// BuildPeerMap constructs peer relationship maps for the given nodes
|
||||||
BuildPeerMap(nodes views.Slice[types.NodeView]) map[types.NodeID][]types.NodeID
|
BuildPeerMap(nodes views.Slice[types.NodeView]) map[types.NodeID][]types.NodeID
|
||||||
SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error)
|
SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error)
|
||||||
// SSHCheckParams resolves the SSH check period for a (src, dst) pair
|
// SSHCheckParams resolves the SSH check period for src logging in to
|
||||||
// from the current policy, avoiding trust of client-provided URL params.
|
// dst as localUser from the current policy, avoiding trust of
|
||||||
SSHCheckParams(srcNodeID, dstNodeID types.NodeID) (time.Duration, bool)
|
// client-provided URL params.
|
||||||
|
SSHCheckParams(srcNodeID, dstNodeID types.NodeID, localUser string) (time.Duration, bool)
|
||||||
SetPolicy(pol []byte) (bool, error)
|
SetPolicy(pol []byte) (bool, error)
|
||||||
// SetUsers replaces the user list. policyChanged reports whether clients
|
// SetUsers replaces the user list. policyChanged reports whether clients
|
||||||
// need a policy refresh; peerMapChanged reports whether user-derived peer
|
// need a policy refresh; peerMapChanged reports whether user-derived peer
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ func TestSSHPolicyRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`,
|
}`,
|
||||||
wantSSH: &tailcfg.SSHPolicy{Rules: nil},
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-action",
|
name: "invalid-action",
|
||||||
|
|||||||
@@ -269,13 +269,15 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
node types.NodeView,
|
node types.NodeView,
|
||||||
nodes views.Slice[types.NodeView],
|
nodes views.Slice[types.NodeView],
|
||||||
) (*tailcfg.SSHPolicy, error) {
|
) (*tailcfg.SSHPolicy, error) {
|
||||||
if pol == nil || pol.SSHs == nil || len(pol.SSHs) == 0 {
|
// Never nil: clients read a nil SSHPolicy as "keep the previous
|
||||||
return nil, nil //nolint:nilnil // intentional: no SSH policy when none configured
|
// rules", so revoked access would stay. SaaS sends "rules":[].
|
||||||
|
if pol == nil || len(pol.SSHs) == 0 {
|
||||||
|
return &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace().Caller().Msgf("compiling SSH policy for node %q", node.Hostname())
|
log.Trace().Caller().Msgf("compiling SSH policy for node %q", node.Hostname())
|
||||||
|
|
||||||
var rules []*tailcfg.SSHRule
|
rules := []*tailcfg.SSHRule{}
|
||||||
|
|
||||||
for index, rule := range pol.SSHs {
|
for index, rule := range pol.SSHs {
|
||||||
var autogroupSelfDests, otherDests []Alias
|
var autogroupSelfDests, otherDests []Alias
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: &tailcfg.SSHPolicy{},
|
want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2753,11 +2753,35 @@ func TestIPSetToPrincipals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overlappingSSHChecks has a localpart check rule whose self-access reaches
|
||||||
|
// user1's own nodes ahead of an always-check root rule for the same pair.
|
||||||
|
var overlappingSSHChecks = []byte(`{
|
||||||
|
"tagOwners": {"tag:server": ["user1@"]},
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "check",
|
||||||
|
"checkPeriod": "12h",
|
||||||
|
"src": ["user1@"],
|
||||||
|
"dst": ["tag:server"],
|
||||||
|
"users": ["localpart:*@example.com"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "check",
|
||||||
|
"checkPeriod": "always",
|
||||||
|
"src": ["user1@"],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["root"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
func TestSSHCheckParams(t *testing.T) {
|
func TestSSHCheckParams(t *testing.T) {
|
||||||
users := types.Users{
|
users := types.Users{
|
||||||
{Name: "user1", Model: gorm.Model{ID: 1}},
|
{Name: "user1", Model: gorm.Model{ID: 1}},
|
||||||
{Name: "user2", Model: gorm.Model{ID: 2}},
|
{Name: "user2", Model: gorm.Model{ID: 2}},
|
||||||
}
|
}
|
||||||
|
users[0].Email = "user1@example.com"
|
||||||
|
users[1].Email = "user2@example.com"
|
||||||
|
|
||||||
nodeUser1 := types.Node{
|
nodeUser1 := types.Node{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
@@ -2789,6 +2813,7 @@ func TestSSHCheckParams(t *testing.T) {
|
|||||||
policy []byte
|
policy []byte
|
||||||
srcID types.NodeID
|
srcID types.NodeID
|
||||||
dstID types.NodeID
|
dstID types.NodeID
|
||||||
|
localUser string // defaults to a non-root user
|
||||||
wantPeriod time.Duration
|
wantPeriod time.Duration
|
||||||
wantOK bool
|
wantOK bool
|
||||||
}{
|
}{
|
||||||
@@ -2936,6 +2961,66 @@ func TestSSHCheckParams(t *testing.T) {
|
|||||||
dstID: types.NodeID(2),
|
dstID: types.NodeID(2),
|
||||||
wantOK: false,
|
wantOK: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "root rejected by a nonroot rule",
|
||||||
|
policy: []byte(`{
|
||||||
|
"tagOwners": {"tag:server": ["user1@"]},
|
||||||
|
"ssh": [{
|
||||||
|
"action": "check",
|
||||||
|
"src": ["user2@"],
|
||||||
|
"dst": ["tag:server"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}]
|
||||||
|
}`),
|
||||||
|
srcID: types.NodeID(2),
|
||||||
|
dstID: types.NodeID(3),
|
||||||
|
localUser: "root",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal user must match",
|
||||||
|
policy: []byte(`{
|
||||||
|
"tagOwners": {"tag:server": ["user1@"]},
|
||||||
|
"ssh": [{
|
||||||
|
"action": "check",
|
||||||
|
"src": ["user2@"],
|
||||||
|
"dst": ["tag:server"],
|
||||||
|
"users": ["deploy"]
|
||||||
|
}]
|
||||||
|
}`),
|
||||||
|
srcID: types.NodeID(2),
|
||||||
|
dstID: types.NodeID(3),
|
||||||
|
localUser: "other",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The client skips the localpart rule for root and enforces
|
||||||
|
// the later always-check root rule; so must the server.
|
||||||
|
name: "overlapping rules: root skips localpart self-access",
|
||||||
|
policy: overlappingSSHChecks,
|
||||||
|
srcID: types.NodeID(1),
|
||||||
|
dstID: types.NodeID(1),
|
||||||
|
localUser: "root",
|
||||||
|
wantPeriod: 0,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping rules: localpart user gets its period",
|
||||||
|
policy: overlappingSSHChecks,
|
||||||
|
srcID: types.NodeID(1),
|
||||||
|
dstID: types.NodeID(1),
|
||||||
|
localUser: "user1",
|
||||||
|
wantPeriod: 12 * time.Hour,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping rules: another user's localpart is rejected",
|
||||||
|
policy: overlappingSSHChecks,
|
||||||
|
srcID: types.NodeID(1),
|
||||||
|
dstID: types.NodeID(1),
|
||||||
|
localUser: "user2",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -2943,7 +3028,12 @@ func TestSSHCheckParams(t *testing.T) {
|
|||||||
pm, err := NewPolicyManager(tt.policy, users, nodes.ViewSlice())
|
pm, err := NewPolicyManager(tt.policy, users, nodes.ViewSlice())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
period, ok := pm.SSHCheckParams(tt.srcID, tt.dstID)
|
localUser := tt.localUser
|
||||||
|
if localUser == "" {
|
||||||
|
localUser = "alice"
|
||||||
|
}
|
||||||
|
|
||||||
|
period, ok := pm.SSHCheckParams(tt.srcID, tt.dstID, localUser)
|
||||||
assert.Equal(t, tt.wantOK, ok, "ok mismatch")
|
assert.Equal(t, tt.wantOK, ok, "ok mismatch")
|
||||||
|
|
||||||
if tt.wantOK {
|
if tt.wantOK {
|
||||||
|
|||||||
@@ -447,16 +447,18 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
|
|||||||
return sshPol, nil
|
return sshPol, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHCheckParams resolves the SSH check period for a source-destination
|
// SSHCheckParams resolves the SSH check period for src logging in to dst
|
||||||
// node pair by looking up the current policy. This avoids trusting URL
|
// as localUser by looking up the current policy. This avoids trusting URL
|
||||||
// parameters that a client could tamper with. First-match wins across
|
// parameters that a client could tamper with. First-match wins across
|
||||||
// the policy's SSH rules.
|
// the policy's SSH rules; a rule only matches when it lets src log in as
|
||||||
|
// localUser, as the client does when it picks the rule.
|
||||||
//
|
//
|
||||||
// Returns (duration, true) when a matching rule is found and
|
// Returns (duration, true) when a matching rule is found and
|
||||||
// (0, false) when none is. A (0, true) return means the matched rule
|
// (0, false) when none is. A (0, true) return means the matched rule
|
||||||
// uses a zero check period (re-check every session).
|
// uses a zero check period (re-check every session).
|
||||||
func (pm *PolicyManager) SSHCheckParams(
|
func (pm *PolicyManager) SSHCheckParams(
|
||||||
srcNodeID, dstNodeID types.NodeID,
|
srcNodeID, dstNodeID types.NodeID,
|
||||||
|
localUser string,
|
||||||
) (time.Duration, bool) {
|
) (time.Duration, bool) {
|
||||||
pm.mu.RLock()
|
pm.mu.RLock()
|
||||||
defer pm.mu.RUnlock()
|
defer pm.mu.RUnlock()
|
||||||
@@ -503,24 +505,24 @@ func (pm *PolicyManager) SSHCheckParams(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !pm.sshRuleAllowsUser(rule, srcNode, localUser) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Check if dst node matches any destination.
|
// Check if dst node matches any destination.
|
||||||
|
hasOtherDests := false
|
||||||
|
|
||||||
for _, dst := range rule.Destinations {
|
for _, dst := range rule.Destinations {
|
||||||
if ag, isAG := dst.(*AutoGroup); isAG && ag.Is(AutoGroupSelf) {
|
if ag, isAG := dst.(*AutoGroup); isAG && ag.Is(AutoGroupSelf) {
|
||||||
// User().Valid() guards the User().ID() dereference: the
|
if sshNodesShareUser(srcNode, dstNode) {
|
||||||
// NodeStore can hold a non-tagged node with UserID set but
|
|
||||||
// the User association unhydrated (nil), and IsTagged()
|
|
||||||
// alone does not cover that. Mirrors filter.go's
|
|
||||||
// autogroup:self guard. Without it, a tailnet client on the
|
|
||||||
// Noise SSH-check path crashes the server (nil deref).
|
|
||||||
if !srcNode.IsTagged() && !dstNode.IsTagged() &&
|
|
||||||
srcNode.User().Valid() && dstNode.User().Valid() &&
|
|
||||||
srcNode.User().ID() == dstNode.User().ID() {
|
|
||||||
return checkPeriodFromRule(rule), true
|
return checkPeriodFromRule(rule), true
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasOtherDests = true
|
||||||
|
|
||||||
dstIPs, err := dst.Resolve(pm.pol, pm.users, pm.nodes)
|
dstIPs, err := dst.Resolve(pm.pol, pm.users, pm.nodes)
|
||||||
if err != nil || dstIPs == nil {
|
if err != nil || dstIPs == nil {
|
||||||
continue
|
continue
|
||||||
@@ -530,11 +532,56 @@ func (pm *PolicyManager) SSHCheckParams(
|
|||||||
return checkPeriodFromRule(rule), true
|
return checkPeriodFromRule(rule), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Localpart self-access: a source outside dst still gets the rule
|
||||||
|
// for its own user's nodes, or itself if tagged (compileSSHPolicy).
|
||||||
|
if hasOtherDests && rule.Users.ContainsLocalpart() &&
|
||||||
|
slices.ContainsFunc(dstNode.IPs(), srcIPs.Contains) {
|
||||||
|
if dstNode.IsTagged() {
|
||||||
|
if srcNodeID == dstNodeID {
|
||||||
|
return checkPeriodFromRule(rule), true
|
||||||
|
}
|
||||||
|
} else if sshNodesShareUser(srcNode, dstNode) {
|
||||||
|
return checkPeriodFromRule(rule), true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshNodesShareUser matches user-owned nodes with hydrated user associations.
|
||||||
|
// IsTagged alone does not guard User().ID(): the NodeStore can hold a
|
||||||
|
// non-tagged node whose UserID is set but whose User association is nil.
|
||||||
|
func sshNodesShareUser(srcNode, dstNode types.NodeView) bool {
|
||||||
|
return !srcNode.IsTagged() && !dstNode.IsTagged() &&
|
||||||
|
srcNode.User().Valid() && dstNode.User().Valid() &&
|
||||||
|
srcNode.User().ID() == dstNode.User().ID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshRuleAllowsUser reports whether rule lets src log in as localUser,
|
||||||
|
// mirroring the SSHUsers maps compileSSHPolicy emits: root when listed,
|
||||||
|
// other users via autogroup:nonroot or a literal name, and anyone, root
|
||||||
|
// included, whose name is the source user's localpart.
|
||||||
|
func (pm *PolicyManager) sshRuleAllowsUser(rule SSH, srcNode types.NodeView, localUser string) bool {
|
||||||
|
isRoot := localUser == "root"
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case localUser == "":
|
||||||
|
return false
|
||||||
|
case isRoot && rule.Users.ContainsRoot(),
|
||||||
|
!isRoot && rule.Users.ContainsNonRoot(),
|
||||||
|
!isRoot && slices.Contains(rule.Users.NormalUsers(), SSHUser(localUser)):
|
||||||
|
return true
|
||||||
|
case srcNode.IsTagged() || !srcNode.User().Valid() || !rule.Users.ContainsLocalpart():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lp, ok := resolveLocalparts(rule.Users.LocalpartEntries(), pm.users)[srcNode.User().ID()]
|
||||||
|
|
||||||
|
return ok && lp == localUser
|
||||||
|
}
|
||||||
|
|
||||||
func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
||||||
if len(polB) == 0 {
|
if len(polB) == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func TestSSHCheckParamsUnhydratedUserNoPanic(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
pm.SSHCheckParams(types.NodeID(1), types.NodeID(2))
|
pm.SSHCheckParams(types.NodeID(1), types.NodeID(2), "alice")
|
||||||
}, "SSHCheckParams must not panic when a non-tagged node has an unhydrated User")
|
}, "SSHCheckParams must not panic when a non-tagged node has an unhydrated User")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +954,61 @@ func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
|
|||||||
require.False(t, policyChanged2, "SetPolicy should return false when policy content hasn't changed")
|
require.False(t, policyChanged2, "SetPolicy should return false when policy content hasn't changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSSHPolicyRemovalClearsRules pins that removing every SSH rule
|
||||||
|
// yields a non-nil, empty [tailcfg.SSHPolicy]. A nil MapResponse.SSHPolicy
|
||||||
|
// tells the client to keep its previous rules, so returning nil leaves
|
||||||
|
// revoked SSH access in force. SaaS sends {"rules":[]} for policies
|
||||||
|
// without SSH rules; see the netmap in any testdata capture whose
|
||||||
|
// policy lacks an "ssh" key.
|
||||||
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
func TestSSHPolicyRemovalClearsRules(t *testing.T) {
|
||||||
|
users := types.Users{{Name: "user1", Email: "user1@headscale.net"}}
|
||||||
|
users[0].ID = 1
|
||||||
|
|
||||||
|
nodes := types.Nodes{
|
||||||
|
node("server", "100.64.0.1", "fd7a:115c:a1e0::1", users[0]),
|
||||||
|
node("client", "100.64.0.2", "fd7a:115c:a1e0::2", users[0]),
|
||||||
|
}
|
||||||
|
nodes[0].ID = 1
|
||||||
|
nodes[1].ID = 2
|
||||||
|
|
||||||
|
withSSH := `{
|
||||||
|
"acls": [],
|
||||||
|
"ssh": [{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["autogroup:member"],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["root"]
|
||||||
|
}]
|
||||||
|
}`
|
||||||
|
|
||||||
|
for name, without := range map[string]string{
|
||||||
|
"ssh key removed": `{"acls": []}`,
|
||||||
|
"ssh empty list": `{"acls": [], "ssh": []}`,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
pm, err := NewPolicyManager([]byte(withSSH), users, nodes.ViewSlice())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
before, err := pm.SSHPolicy("", nodes[0].View())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, before)
|
||||||
|
require.NotEmpty(t, before.Rules, "precondition: server has SSH rules")
|
||||||
|
|
||||||
|
changed, err := pm.SetPolicy([]byte(without))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
after, err := pm.SSHPolicy("", nodes[0].View())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, after,
|
||||||
|
"nil SSHPolicy means 'unchanged' on the wire; client keeps stale rules")
|
||||||
|
require.NotNil(t, after.Rules, `SaaS sends "rules":[], not null`)
|
||||||
|
require.Empty(t, after.Rules)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTagPropagationToPeerMap tests that when a node's tags change,
|
// TestTagPropagationToPeerMap tests that when a node's tags change,
|
||||||
// the peer map is correctly updated. This is a regression test for
|
// the peer map is correctly updated. This is a regression test for
|
||||||
// https://github.com/juanfont/headscale/issues/2389
|
// https://github.com/juanfont/headscale/issues/2389
|
||||||
@@ -2709,7 +2764,7 @@ func TestUnregisteredUsersAreNoOp(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
period, ok := pm.SSHCheckParams(n.ID, p.ID)
|
period, ok := pm.SSHCheckParams(n.ID, p.ID, "alice")
|
||||||
got[n.Hostname+"->"+p.Hostname+" via"] = pm.ViaRoutesForPeer(nv, p.View())
|
got[n.Hostname+"->"+p.Hostname+" via"] = pm.ViaRoutesForPeer(nv, p.View())
|
||||||
got[n.Hostname+"->"+p.Hostname+" check"] = checkParams{period, ok}
|
got[n.Hostname+"->"+p.Hostname+" check"] = checkParams{period, ok}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -190,15 +193,12 @@ func TestSSHDataCompat(t *testing.T) {
|
|||||||
nodeName,
|
nodeName,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build expected SSHPolicy from the typed rules.
|
// Nil and empty SSHPolicy differ on the wire: nil
|
||||||
var wantSSH *tailcfg.SSHPolicy
|
// keeps the client's previous rules, empty clears
|
||||||
if len(capture.SSHRules) > 0 {
|
// them. Take presence from the captured netmap.
|
||||||
wantSSH = &tailcfg.SSHPolicy{Rules: capture.SSHRules}
|
wantSSH := &tailcfg.SSHPolicy{Rules: capture.SSHRules}
|
||||||
}
|
if capture.Netmap != nil && capture.Netmap.SSHPolicy == nil {
|
||||||
|
wantSSH = nil
|
||||||
// Normalize: treat empty-rules SSHPolicy as nil
|
|
||||||
if gotSSH != nil && len(gotSSH.Rules) == 0 {
|
|
||||||
gotSSH = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare headscale output against Tailscale expected.
|
// Compare headscale output against Tailscale expected.
|
||||||
@@ -221,6 +221,17 @@ func TestSSHDataCompat(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EquateEmpty hides "rules":null vs "rules":[];
|
||||||
|
// pin the captured shape separately.
|
||||||
|
if gotSSH != nil && capture.Netmap != nil &&
|
||||||
|
capture.Netmap.SSHPolicy != nil {
|
||||||
|
assert.Equalf(t,
|
||||||
|
capture.Netmap.SSHPolicy.Rules == nil,
|
||||||
|
gotSSH.Rules == nil,
|
||||||
|
"%s/%s: rules null-vs-[] mismatch", tf.TestID, nodeName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Separate presence check: the fields ignored by
|
// Separate presence check: the fields ignored by
|
||||||
// the diff above must still be populated on matching
|
// the diff above must still be populated on matching
|
||||||
// rules. This catches regressions where headscale
|
// rules. This catches regressions where headscale
|
||||||
@@ -251,3 +262,182 @@ func TestSSHDataCompat(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshLocalUser maps sshUser to a local user the way tailssh does for one
|
||||||
|
// rule: an exact entry wins, then "*"; "" means the rule does not apply.
|
||||||
|
func sshLocalUser(users map[string]string, sshUser string) string {
|
||||||
|
local, ok := users[sshUser]
|
||||||
|
if !ok {
|
||||||
|
local = users["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if local == "=" {
|
||||||
|
return sshUser
|
||||||
|
}
|
||||||
|
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSSHCheckParamsMatchRules checks SSHCheckParams against check rules as
|
||||||
|
// tailssh reads them: (src, dst, local user) must be found exactly when a
|
||||||
|
// holdAndDelegate rule for dst lists src as a principal and maps some SSH
|
||||||
|
// user to that local user.
|
||||||
|
func assertSSHCheckParamsMatchRules(
|
||||||
|
t *testing.T,
|
||||||
|
pm *PolicyManager,
|
||||||
|
nodes types.Nodes,
|
||||||
|
rulesFor func(dst *types.Node) []*tailcfg.SSHRule,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
byIP := make(map[string]*types.Node)
|
||||||
|
|
||||||
|
for _, n := range nodes {
|
||||||
|
for _, ip := range n.IPs() {
|
||||||
|
byIP[ip.String()] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []string{"root", "nonroot-probe"}
|
||||||
|
|
||||||
|
for _, dst := range nodes {
|
||||||
|
for _, rule := range rulesFor(dst) {
|
||||||
|
for user := range rule.SSHUsers {
|
||||||
|
if user != "*" && !slices.Contains(candidates, user) {
|
||||||
|
candidates = append(candidates, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dst := range nodes {
|
||||||
|
want := make(map[types.NodeID]map[string]bool)
|
||||||
|
|
||||||
|
for _, rule := range rulesFor(dst) {
|
||||||
|
if rule.Action == nil || rule.Action.HoldAndDelegate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range rule.Principals {
|
||||||
|
src, ok := byIP[p.NodeIP]
|
||||||
|
require.Truef(t, ok, "principal %q is not a node", p.NodeIP)
|
||||||
|
|
||||||
|
if want[src.ID] == nil {
|
||||||
|
want[src.ID] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range candidates {
|
||||||
|
if local := sshLocalUser(rule.SSHUsers, user); local != "" {
|
||||||
|
want[src.ID][local] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, src := range nodes {
|
||||||
|
for _, user := range candidates {
|
||||||
|
_, got := pm.SSHCheckParams(src.ID, dst.ID, user)
|
||||||
|
assert.Equalf(t, want[src.ID][user], got,
|
||||||
|
"SSHCheckParams(%s -> %s as %s)", src.Hostname, dst.Hostname, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSSHCheckParamsMatchesCaptures pins SSHCheckParams, which decides the
|
||||||
|
// SSH check callback, to the check rules Tailscale sent.
|
||||||
|
func TestSSHCheckParamsMatchesCaptures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join("testdata", "ssh*_results", "*.hujson"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, files)
|
||||||
|
|
||||||
|
users := setupSSHDataCompatUsers()
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
tf := loadSSHTestFile(t, file)
|
||||||
|
if tf.Input.APIResponseCode != 200 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, skip := sshSkipReasons[tf.TestID]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run(tf.TestID, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||||
|
|
||||||
|
pm, err := NewPolicyManager(
|
||||||
|
[]byte(tf.Input.FullPolicy), users, nodes.ViewSlice(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assertSSHCheckParamsMatchRules(t, pm, nodes,
|
||||||
|
func(dst *types.Node) []*tailcfg.SSHRule {
|
||||||
|
return tf.Captures[dst.GivenName].SSHRules
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSSHCheckParamsMatchesCompiledRules pins SSHCheckParams to headscale's
|
||||||
|
// own compiled rules for shapes the captures lack, such as a user whose
|
||||||
|
// email localpart is root.
|
||||||
|
func TestSSHCheckParamsMatchesCompiledRules(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
users := types.Users{
|
||||||
|
{Name: "root", Email: "root@example.com"},
|
||||||
|
{Name: "alice", Email: "alice@example.com"},
|
||||||
|
}
|
||||||
|
users[0].ID = 1
|
||||||
|
users[1].ID = 2
|
||||||
|
|
||||||
|
nodes := types.Nodes{
|
||||||
|
node("root-1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0]),
|
||||||
|
node("root-2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0]),
|
||||||
|
node("alice-1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1]),
|
||||||
|
node("server", "100.64.0.4", "fd7a:115c:a1e0::4", users[1]),
|
||||||
|
}
|
||||||
|
for i, n := range nodes {
|
||||||
|
n.ID = types.NodeID(i + 1) //nolint:gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes[3].Tags = []string{"tag:server"}
|
||||||
|
|
||||||
|
check := func(dst string, users ...string) string {
|
||||||
|
usersJSON, err := json.Marshal(users)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return fmt.Sprintf(`{"action": "check", "src": ["autogroup:member"], "dst": [%q], "users": %s}`,
|
||||||
|
dst, usersJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, rules := range map[string][]string{
|
||||||
|
"localpart on tag": {check("tag:server", "localpart:*@example.com")},
|
||||||
|
"localpart on self": {check("autogroup:self", "localpart:*@example.com")},
|
||||||
|
"localpart then root": {check("tag:server", "localpart:*@example.com"), check("autogroup:self", "root")},
|
||||||
|
"nonroot and literal on self": {check("autogroup:self", "autogroup:nonroot", "deploy")},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
pol := fmt.Sprintf(`{"tagOwners": {"tag:server": ["alice@"]}, "ssh": [%s]}`,
|
||||||
|
strings.Join(rules, ","))
|
||||||
|
|
||||||
|
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assertSSHCheckParamsMatchRules(t, pm, nodes,
|
||||||
|
func(dst *types.Node) []*tailcfg.SSHRule {
|
||||||
|
sshPol, err := pm.SSHPolicy("", dst.View())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return sshPol.Rules
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package servertest_test
|
package servertest_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -129,6 +130,69 @@ func TestPolicyChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
t.Run("ssh_removal_clears_client_ssh_policy", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := servertest.NewHarness(t, 2)
|
||||||
|
|
||||||
|
h.ChangePolicy(t, []byte(`{
|
||||||
|
"acls": [],
|
||||||
|
"ssh": [{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["autogroup:member"],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["root"]
|
||||||
|
}]
|
||||||
|
}`))
|
||||||
|
|
||||||
|
h.Client(0).WaitForCondition(t, "SSH rules present", 10*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
return nm.SSHPolicy != nil && len(nm.SSHPolicy.Rules) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
h.ChangePolicy(t, []byte(`{"acls": []}`))
|
||||||
|
|
||||||
|
h.Client(0).WaitForCondition(t, "SSH rules cleared", 10*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
return nm.SSHPolicy != nil && len(nm.SSHPolicy.Rules) == 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// An unchanged SSHPolicy is dropped from policy frames; the client
|
||||||
|
// must keep the rules it holds.
|
||||||
|
t.Run("unrelated_policy_change_keeps_client_ssh_rules", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := servertest.NewHarness(t, 2)
|
||||||
|
|
||||||
|
withSSH := func(group string) []byte {
|
||||||
|
return fmt.Appendf(nil, `{
|
||||||
|
"groups": {%q: []},
|
||||||
|
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}],
|
||||||
|
"ssh": [{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["autogroup:member"],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["root"]
|
||||||
|
}]
|
||||||
|
}`, "group:"+group)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ChangePolicy(t, withSSH("a"))
|
||||||
|
h.Client(0).WaitForCondition(t, "SSH rules present", 10*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
return nm.SSHPolicy != nil && len(nm.SSHPolicy.Rules) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
countBefore := h.Client(0).UpdateCount()
|
||||||
|
|
||||||
|
h.ChangePolicy(t, withSSH("b"))
|
||||||
|
h.Client(0).WaitForCondition(t, "policy update with SSH rules kept", 10*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
return h.Client(0).UpdateCount() > countBefore &&
|
||||||
|
nm.SSHPolicy != nil && len(nm.SSHPolicy.Rules) > 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("policy_with_multiple_users", func(t *testing.T) {
|
t.Run("policy_with_multiple_users", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/netmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSSHCheckReDelegatesWhenSessionMissing exercises the fix for
|
// TestSSHCheckReDelegatesWhenSessionMissing exercises the fix for
|
||||||
@@ -44,7 +47,7 @@ func TestSSHCheckReDelegatesWhenSessionMissing(t *testing.T) {
|
|||||||
|
|
||||||
// Sanity: the policy must actually subject this pair to a check, otherwise
|
// Sanity: the policy must actually subject this pair to a check, otherwise
|
||||||
// the test would pass for the wrong reason.
|
// the test would pass for the wrong reason.
|
||||||
_, checkFound := h.Server.State().SSHCheckParams(srcID, dstID)
|
_, checkFound := h.Server.State().SSHCheckParams(srcID, dstID, sshCheckLocalUser)
|
||||||
require.True(t, checkFound, "test setup: (src, dst) must be subject to an SSH check")
|
require.True(t, checkFound, "test setup: (src, dst) must be subject to an SSH check")
|
||||||
|
|
||||||
// The dst node's first poll yields a real HoldAndDelegate carrying a real,
|
// The dst node's first poll yields a real HoldAndDelegate carrying a real,
|
||||||
@@ -71,6 +74,9 @@ func TestSSHCheckReDelegatesWhenSessionMissing(t *testing.T) {
|
|||||||
"re-delegation must mint a fresh auth_id")
|
"re-delegation must mint a fresh auth_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshCheckLocalUser is the non-root local user the SSH-check polls log in as.
|
||||||
|
const sshCheckLocalUser = "alice"
|
||||||
|
|
||||||
// pollSSHAction issues an /machine/ssh/action poll from the given node over its
|
// pollSSHAction issues an /machine/ssh/action poll from the given node over its
|
||||||
// real Noise connection, as tailscaled does. An empty authID is the initial
|
// real Noise connection, as tailscaled does. An empty authID is the initial
|
||||||
// poll; a non-empty one is a follow-up.
|
// poll; a non-empty one is a follow-up.
|
||||||
@@ -83,11 +89,20 @@ func pollSSHAction(
|
|||||||
) tailcfg.SSHAction {
|
) tailcfg.SSHAction {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
actionURL := fmt.Sprintf("%s/machine/ssh/action/%d/to/%d", serverURL, srcID, dstID)
|
actionURL := fmt.Sprintf("%s/machine/ssh/action/%d/to/%d?local_user=%s",
|
||||||
|
serverURL, srcID, dstID, sshCheckLocalUser)
|
||||||
if authID != "" {
|
if authID != "" {
|
||||||
actionURL += "?auth_id=" + authID
|
actionURL += "&auth_id=" + authID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return pollSSHActionURL(t, node, actionURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollSSHActionURL polls actionURL from the given node over its real Noise
|
||||||
|
// connection.
|
||||||
|
func pollSSHActionURL(t *testing.T, node *servertest.TestClient, actionURL string) tailcfg.SSHAction {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
// Noise requests are addressed with the https scheme; the control client
|
// Noise requests are addressed with the https scheme; the control client
|
||||||
// routes them over the established Noise connection (mirroring how
|
// routes them over the established Noise connection (mirroring how
|
||||||
// controlclient issues its own register/map calls).
|
// controlclient issues its own register/map calls).
|
||||||
@@ -125,3 +140,108 @@ func authIDFromHoldURL(t *testing.T, holdURL string) types.AuthID {
|
|||||||
|
|
||||||
return authID
|
return authID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSSHCheckRejectedAfterRuleRemoved verifies that once a check rule is
|
||||||
|
// removed or turned into accept, a client still holding the check gets a
|
||||||
|
// Reject on both the initial and the follow-up poll, not a hold it could
|
||||||
|
// pass by authenticating.
|
||||||
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
func TestSSHCheckRejectedAfterRuleRemoved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const check = `{"ssh": [{
|
||||||
|
"action": "check",
|
||||||
|
"src": ["harness-default@"],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}]}`
|
||||||
|
|
||||||
|
for name, after := range map[string]string{
|
||||||
|
"rule removed": `{}`,
|
||||||
|
"check to accept": strings.Replace(check, `"check"`, `"accept"`, 1),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := servertest.NewHarness(t, 2)
|
||||||
|
|
||||||
|
srcID := types.NodeID(h.Client(0).Netmap().SelfNode.ID()) //nolint:gosec
|
||||||
|
dstID := types.NodeID(h.Client(1).Netmap().SelfNode.ID()) //nolint:gosec
|
||||||
|
|
||||||
|
h.ChangePolicy(t, []byte(check))
|
||||||
|
|
||||||
|
initial := pollSSHAction(t, h.Server.URL, h.Client(1), srcID, dstID, "")
|
||||||
|
require.NotEmpty(t, initial.HoldAndDelegate, "check must hold, got %+v", initial)
|
||||||
|
|
||||||
|
authID := authIDFromHoldURL(t, initial.HoldAndDelegate)
|
||||||
|
|
||||||
|
h.ChangePolicy(t, []byte(after))
|
||||||
|
|
||||||
|
_, checkFound := h.Server.State().SSHCheckParams(srcID, dstID, sshCheckLocalUser)
|
||||||
|
require.False(t, checkFound, "test setup: check must be gone")
|
||||||
|
|
||||||
|
for poll, id := range map[string]string{"initial": "", "follow-up": authID.String()} {
|
||||||
|
action := pollSSHAction(t, h.Server.URL, h.Client(1), srcID, dstID, id)
|
||||||
|
assert.True(t, action.Reject, "%s poll must reject, got %+v", poll, action)
|
||||||
|
assert.Empty(t, action.HoldAndDelegate, "%s poll must not delegate", poll)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandHoldURL substitutes a HoldAndDelegate URL's variables literally, as
|
||||||
|
// tailssh does before polling it.
|
||||||
|
func expandHoldURL(holdURL string, srcID, dstID types.NodeID, localUser string) string {
|
||||||
|
return strings.NewReplacer(
|
||||||
|
"$SRC_NODE_ID", strconv.FormatUint(srcID.Uint64(), 10),
|
||||||
|
"$DST_NODE_ID", strconv.FormatUint(dstID.Uint64(), 10),
|
||||||
|
"$SSH_USER", url.QueryEscape(localUser),
|
||||||
|
"$LOCAL_USER", url.QueryEscape(localUser),
|
||||||
|
).Replace(holdURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSSHCheckFollowsReturnedHoldURL walks a check the way tailssh does:
|
||||||
|
// expand the HoldAndDelegate URL from the netmap, poll it, then poll the
|
||||||
|
// URL the server returns. The follow-up must still carry the login user,
|
||||||
|
// or a root-only rule denies a user who already authenticated.
|
||||||
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
func TestSSHCheckFollowsReturnedHoldURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := servertest.NewHarness(t, 2)
|
||||||
|
|
||||||
|
srcID := types.NodeID(h.Client(0).Netmap().SelfNode.ID()) //nolint:gosec
|
||||||
|
dstID := types.NodeID(h.Client(1).Netmap().SelfNode.ID()) //nolint:gosec
|
||||||
|
|
||||||
|
h.ChangePolicy(t, []byte(`{"ssh": [{
|
||||||
|
"action": "check",
|
||||||
|
"src": ["harness-default@"],
|
||||||
|
"dst": ["autogroup:self"],
|
||||||
|
"users": ["root"]
|
||||||
|
}]}`))
|
||||||
|
|
||||||
|
var ruleURL string
|
||||||
|
|
||||||
|
h.Client(1).WaitForCondition(t, "check rule in netmap", 10*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
if nm.SSHPolicy == nil || len(nm.SSHPolicy.Rules) == 0 ||
|
||||||
|
nm.SSHPolicy.Rules[0].Action == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleURL = nm.SSHPolicy.Rules[0].Action.HoldAndDelegate
|
||||||
|
|
||||||
|
return ruleURL != ""
|
||||||
|
})
|
||||||
|
|
||||||
|
initial := pollSSHActionURL(t, h.Client(1), expandHoldURL(ruleURL, srcID, dstID, "root"))
|
||||||
|
require.NotEmpty(t, initial.HoldAndDelegate, "check must hold, got %+v", initial)
|
||||||
|
|
||||||
|
auth, ok := h.Server.State().GetAuthCacheEntry(authIDFromHoldURL(t, initial.HoldAndDelegate))
|
||||||
|
require.True(t, ok)
|
||||||
|
auth.FinishAuth(types.AuthVerdict{})
|
||||||
|
|
||||||
|
followUp := pollSSHActionURL(t, h.Client(1),
|
||||||
|
expandHoldURL(initial.HoldAndDelegate, srcID, dstID, "root"))
|
||||||
|
assert.True(t, followUp.Accept, "authenticated root login must be accepted, got %+v", followUp)
|
||||||
|
}
|
||||||
@@ -1171,12 +1171,13 @@ func (s *State) SSHPolicy(node types.NodeView) (*tailcfg.SSHPolicy, error) {
|
|||||||
return s.polMan.SSHPolicy(s.cfg.ServerURL, node)
|
return s.polMan.SSHPolicy(s.cfg.ServerURL, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHCheckParams resolves the SSH check period for a source-destination
|
// SSHCheckParams resolves the SSH check period for src logging in to dst
|
||||||
// node pair from the current policy.
|
// as localUser from the current policy.
|
||||||
func (s *State) SSHCheckParams(
|
func (s *State) SSHCheckParams(
|
||||||
srcNodeID, dstNodeID types.NodeID,
|
srcNodeID, dstNodeID types.NodeID,
|
||||||
|
localUser string,
|
||||||
) (time.Duration, bool) {
|
) (time.Duration, bool) {
|
||||||
return s.polMan.SSHCheckParams(srcNodeID, dstNodeID)
|
return s.polMan.SSHCheckParams(srcNodeID, dstNodeID, localUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter returns the current network filter rules and matches.
|
// Filter returns the current network filter rules and matches.
|
||||||
@@ -1524,6 +1525,21 @@ func (s *State) DeleteNodeFromStoreForTest(id types.NodeID) {
|
|||||||
s.nodeStore.DeleteNode(id)
|
s.nodeStore.DeleteNode(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WrapPolicyManagerForTest replaces the policy manager with wrap(current) so
|
||||||
|
// tests can fail a single method. The [NodeStore] keeps the original manager,
|
||||||
|
// so wrap must delegate to its argument. Call before any map is served.
|
||||||
|
func (s *State) WrapPolicyManagerForTest(wrap func(policy.PolicyManager) policy.PolicyManager) {
|
||||||
|
s.polMan = wrap(s.polMan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicyManagerNodesForTest syncs the policy manager with the
|
||||||
|
// [NodeStore], for tests that stage nodes with [State.PutNodeInStoreForTest].
|
||||||
|
func (s *State) UpdatePolicyManagerNodesForTest() error {
|
||||||
|
_, err := s.polMan.SetNodes(s.ListNodes())
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// CreateRegisteredNodeForTest creates a test node with allocated IPs. This is a convenience wrapper around the database layer.
|
// CreateRegisteredNodeForTest creates a test node with allocated IPs. This is a convenience wrapper around the database layer.
|
||||||
func (s *State) CreateRegisteredNodeForTest(user *types.User, hostname ...string) *types.Node {
|
func (s *State) CreateRegisteredNodeForTest(user *types.User, hostname ...string) *types.Node {
|
||||||
return s.db.CreateRegisteredNodeForTest(user, hostname...)
|
return s.db.CreateRegisteredNodeForTest(user, hostname...)
|
||||||
|
|||||||
@@ -3648,8 +3648,11 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
|
|||||||
[]tsic.Option{
|
[]tsic.Option{
|
||||||
tsic.WithAcceptRoutes(),
|
tsic.WithAcceptRoutes(),
|
||||||
tsic.WithPackages("iptables"),
|
tsic.WithPackages("iptables"),
|
||||||
|
tsic.WithDERPOverHTTP(),
|
||||||
},
|
},
|
||||||
hsic.WithTestName("rt-hapingfail"),
|
hsic.WithTestName("rt-hapingfail"),
|
||||||
|
// The firewall matches the callback path while Noise remains encrypted.
|
||||||
|
hsic.WithoutTLS(),
|
||||||
hsic.WithHAProbing(10*time.Second, 5*time.Second),
|
hsic.WithHAProbing(10*time.Second, 5*time.Second),
|
||||||
)
|
)
|
||||||
requireNoErrHeadscaleEnv(t, err)
|
requireNoErrHeadscaleEnv(t, err)
|
||||||
@@ -3758,21 +3761,21 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
|
|||||||
|
|
||||||
t.Log("=== HA setup verified. Blocking ping callbacks on router 1 via iptables ===")
|
t.Log("=== HA setup verified. Blocking ping callbacks on router 1 via iptables ===")
|
||||||
|
|
||||||
// Block NEW outbound TCP from router 1 to headscale.
|
// Block plaintext ping callbacks, including requests on reused HTTP
|
||||||
// Preserves the existing Noise HTTP/2 long-poll (ESTABLISHED).
|
// connections. The encrypted Noise long-poll remains available.
|
||||||
hsIP := headscale.GetIPInNetwork(usernet1)
|
hsIP := headscale.GetIPInNetwork(usernet1)
|
||||||
iptablesAdd := []string{
|
iptablesAdd := []string{
|
||||||
"iptables", "-A", "OUTPUT",
|
"iptables", "-A", "OUTPUT",
|
||||||
"-d", hsIP,
|
"-d", hsIP,
|
||||||
"-p", "tcp", "--dport", "8080",
|
"-p", "tcp", "--dport", "8080",
|
||||||
"-m", "state", "--state", "NEW",
|
"-m", "string", "--algo", "kmp", "--string", "/machine/ping-response",
|
||||||
"-j", "DROP",
|
"-j", "DROP",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = subRouter1.Execute(iptablesAdd)
|
_, _, err = subRouter1.Execute(iptablesAdd)
|
||||||
require.NoError(t, err, "failed to add iptables rule")
|
require.NoError(t, err, "failed to add iptables rule")
|
||||||
|
|
||||||
t.Logf("Blocked new TCP connections from %s to headscale at %s:8080",
|
t.Logf("Blocked ping callbacks from %s to headscale at %s:8080",
|
||||||
subRouter1.Hostname(), hsIP)
|
subRouter1.Hostname(), hsIP)
|
||||||
|
|
||||||
// Wait for the prober to detect the failure and trigger failover.
|
// Wait for the prober to detect the failure and trigger failover.
|
||||||
@@ -3817,7 +3820,7 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
|
|||||||
"iptables", "-D", "OUTPUT",
|
"iptables", "-D", "OUTPUT",
|
||||||
"-d", hsIP,
|
"-d", hsIP,
|
||||||
"-p", "tcp", "--dport", "8080",
|
"-p", "tcp", "--dport", "8080",
|
||||||
"-m", "state", "--state", "NEW",
|
"-m", "string", "--algo", "kmp", "--string", "/machine/ping-response",
|
||||||
"-j", "DROP",
|
"-j", "DROP",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+93
-3
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||||
"github.com/juanfont/headscale/integration/hsic"
|
"github.com/juanfont/headscale/integration/hsic"
|
||||||
"github.com/juanfont/headscale/integration/integrationutil"
|
"github.com/juanfont/headscale/integration/integrationutil"
|
||||||
@@ -28,7 +29,13 @@ func isSSHNoAccessStdError(stderr string) bool {
|
|||||||
strings.Contains(stderr, "tailnet policy does not permit you to SSH")
|
strings.Contains(stderr, "tailnet policy does not permit you to SSH")
|
||||||
}
|
}
|
||||||
|
|
||||||
func sshScenario(t *testing.T, policy *policyv2.Policy, testName string, clientsPerUser int) *Scenario {
|
func sshScenario(
|
||||||
|
t *testing.T,
|
||||||
|
policy *policyv2.Policy,
|
||||||
|
testName string,
|
||||||
|
clientsPerUser int,
|
||||||
|
hsOpts ...hsic.Option,
|
||||||
|
) *Scenario {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
spec := ScenarioSpec{
|
spec := ScenarioSpec{
|
||||||
@@ -50,8 +57,10 @@ func sshScenario(t *testing.T, policy *policyv2.Policy, testName string, clients
|
|||||||
tsic.WithExtraCommands("adduser ssh-it-user"),
|
tsic.WithExtraCommands("adduser ssh-it-user"),
|
||||||
tsic.WithDockerWorkdir("/"),
|
tsic.WithDockerWorkdir("/"),
|
||||||
},
|
},
|
||||||
hsic.WithACLPolicy(policy),
|
append([]hsic.Option{
|
||||||
hsic.WithTestName(testName),
|
hsic.WithACLPolicy(policy),
|
||||||
|
hsic.WithTestName(testName),
|
||||||
|
}, hsOpts...)...,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -270,6 +279,87 @@ func TestSSHNoSSHConfigured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSSHRulesRemovedDeniesAccess verifies that removing every SSH rule
|
||||||
|
// from the policy revokes SSH that worked before, on every client version.
|
||||||
|
// The ACL stays open so the deny can only come from the SSH policy.
|
||||||
|
// https://github.com/juanfont/headscale/issues/3508
|
||||||
|
func TestSSHRulesRemovedDeniesAccess(t *testing.T) {
|
||||||
|
IntegrationSkip(t)
|
||||||
|
|
||||||
|
pol := &policyv2.Policy{
|
||||||
|
Groups: policyv2.Groups{
|
||||||
|
policyv2.Group("group:integration-test"): []policyv2.Username{policyv2.Username("user1@")},
|
||||||
|
},
|
||||||
|
ACLs: []policyv2.ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Sources: []policyv2.Alias{wildcard()},
|
||||||
|
Destinations: []policyv2.AliasWithPorts{
|
||||||
|
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SSHs: []policyv2.SSH{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
||||||
|
Destinations: policyv2.SSHDstAliases{
|
||||||
|
new(policyv2.AutoGroupMember),
|
||||||
|
new(policyv2.AutoGroupTagged),
|
||||||
|
},
|
||||||
|
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := sshScenario(t, pol, "ssh-rmrules", len(MustTestVersions),
|
||||||
|
hsic.WithPolicyMode(types.PolicyModeDB),
|
||||||
|
)
|
||||||
|
defer scenario.ShutdownAssertNoPanics(t)
|
||||||
|
|
||||||
|
allClients, err := scenario.ListTailscaleClients()
|
||||||
|
requireNoErrListClients(t, err)
|
||||||
|
|
||||||
|
user1Clients, err := scenario.ListTailscaleClients("user1")
|
||||||
|
requireNoErrListClients(t, err)
|
||||||
|
|
||||||
|
headscale, err := scenario.Headscale()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, client := range user1Clients {
|
||||||
|
for _, peer := range allClients {
|
||||||
|
if client.Hostname() == peer.Hostname() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSSHHostname(t, client, peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pol.SSHs = nil
|
||||||
|
err = headscale.SetPolicy(pol)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
||||||
|
for _, client := range user1Clients {
|
||||||
|
for _, peer := range allClients {
|
||||||
|
if client.Hostname() == peer.Hostname() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, stderr, err := doSSHWithoutRetry(t, client, peer)
|
||||||
|
assert.Error(ct, err, "%s -> %s", client.Hostname(), peer.Hostname())
|
||||||
|
assert.Empty(ct, result, "%s -> %s", client.Hostname(), peer.Hostname())
|
||||||
|
assert.True(ct, isSSHNoAccessStdError(stderr),
|
||||||
|
"%s -> %s: want SSH policy deny, stderr: %s",
|
||||||
|
client.Hostname(), peer.Hostname(), stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, integrationutil.PolicyPropagationTimeout, integrationutil.SlowPoll,
|
||||||
|
"SSH must be denied once every SSH rule is removed")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSSHIsBlockedInACL(t *testing.T) {
|
func TestSSHIsBlockedInACL(t *testing.T) {
|
||||||
IntegrationSkip(t)
|
IntegrationSkip(t)
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ type TailscaleInContainer struct {
|
|||||||
// optional config
|
// optional config
|
||||||
caCerts [][]byte
|
caCerts [][]byte
|
||||||
headscaleHostname string
|
headscaleHostname string
|
||||||
|
withDERPOverHTTP bool
|
||||||
withWebsocketDERP bool
|
withWebsocketDERP bool
|
||||||
withSSH bool
|
withSSH bool
|
||||||
withTags []string
|
withTags []string
|
||||||
@@ -165,6 +166,15 @@ func WithWebsocketDERP(enabled bool) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDERPOverHTTP reaches the embedded DERP over plain-HTTP websockets.
|
||||||
|
// Use with hsic.WithoutTLS so the client does not try HTTPS for DERP.
|
||||||
|
func WithDERPOverHTTP() Option {
|
||||||
|
return func(tsic *TailscaleInContainer) {
|
||||||
|
tsic.withWebsocketDERP = true
|
||||||
|
tsic.withDERPOverHTTP = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithSSH enables SSH for the Tailscale instance.
|
// WithSSH enables SSH for the Tailscale instance.
|
||||||
func WithSSH() Option {
|
func WithSSH() Option {
|
||||||
return func(tsic *TailscaleInContainer) {
|
return func(tsic *TailscaleInContainer) {
|
||||||
@@ -378,6 +388,10 @@ func New(
|
|||||||
tailscaleOptions.Env,
|
tailscaleOptions.Env,
|
||||||
fmt.Sprintf("TS_DEBUG_DERP_WS_CLIENT=%t", tsic.withWebsocketDERP),
|
fmt.Sprintf("TS_DEBUG_DERP_WS_CLIENT=%t", tsic.withWebsocketDERP),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if tsic.withDERPOverHTTP {
|
||||||
|
tailscaleOptions.Env = append(tailscaleOptions.Env, "TS_DEBUG_USE_DERP_HTTP=true")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscaleOptions.ExtraHosts = append(tailscaleOptions.ExtraHosts,
|
tailscaleOptions.ExtraHosts = append(tailscaleOptions.ExtraHosts,
|
||||||
|
|||||||
Reference in new issue
Block a user