mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
integration: add SSH check mode tests
Add ReadLog method to headscale integration container for log inspection. Split SSH check mode tests into CLI and OIDC variants and add comprehensive test coverage: - TestSSHOneUserToOneCheckModeCLI: basic check mode with CLI approval - TestSSHOneUserToOneCheckModeOIDC: check mode with OIDC approval - TestSSHCheckModeUnapprovedTimeout: rejection on cache expiry - TestSSHCheckModeCheckPeriodCLI: session expiry and re-auth - TestSSHCheckModeAutoApprove: auto-approval within check period - TestSSHCheckModeNegativeCLI: explicit rejection via CLI Update existing integration tests to use headscale auth register. Updates #1850
This commit is contained in:
parent
7bab8da366
commit
3db0a483ed
8 changed files with 674 additions and 57 deletions
|
|
@ -2948,7 +2948,7 @@ func TestPreAuthKeyLogoutAndReloginDifferentUser(t *testing.T) {
|
|||
// Scenario:
|
||||
// 1. Node registers with user1 via pre-auth key
|
||||
// 2. Node logs out (expires)
|
||||
// 3. Admin runs: headscale nodes register --user user2 --key <key>
|
||||
// 3. Admin runs: headscale auth register --auth-id <id> --user user2
|
||||
//
|
||||
// Expected behavior:
|
||||
// - User1's original node should STILL EXIST (expired)
|
||||
|
|
@ -3027,7 +3027,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) {
|
|||
require.NotEmpty(t, regID, "Should have valid registration ID")
|
||||
|
||||
// Step 4: Admin completes authentication via CLI
|
||||
// This simulates: headscale nodes register --user user2 --key <key>
|
||||
// This simulates: headscale auth register --auth-id <id> --user user2
|
||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||
regID,
|
||||
types.UserID(user2.ID), // Register to user2, not user1!
|
||||
|
|
@ -3942,7 +3942,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
|||
require.NotNil(t, alice, "Alice user should be created")
|
||||
|
||||
// Step 4: Re-register the node to alice via HandleNodeFromAuthPath
|
||||
// This is what happens when running: headscale nodes register --user alice --key ...
|
||||
// This is what happens when running: headscale auth register --auth-id <id> --user alice
|
||||
nodeKey2 := key.NewNode()
|
||||
registrationID := types.MustAuthID()
|
||||
regEntry := types.NewRegisterAuthRequest(types.Node{
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ func TestAuthWebFlowLogoutAndReloginNewUser(t *testing.T) {
|
|||
}
|
||||
|
||||
// Register all clients as user1 (this is where cross-user registration happens)
|
||||
// This simulates: headscale nodes register --user user1 --key <key>
|
||||
// This simulates: headscale auth register --auth-id <id> --user user1
|
||||
_ = scenario.runHeadscaleRegister("user1", body)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1100,11 +1100,11 @@ func TestNodeCommand(t *testing.T) {
|
|||
headscale,
|
||||
[]string{
|
||||
"headscale",
|
||||
"nodes",
|
||||
"auth",
|
||||
"register",
|
||||
"--user",
|
||||
"node-user",
|
||||
"register",
|
||||
"--key",
|
||||
"--auth-id",
|
||||
regID,
|
||||
"--output",
|
||||
"json",
|
||||
|
|
@ -1185,11 +1185,11 @@ func TestNodeCommand(t *testing.T) {
|
|||
headscale,
|
||||
[]string{
|
||||
"headscale",
|
||||
"nodes",
|
||||
"auth",
|
||||
"register",
|
||||
"--user",
|
||||
"other-user",
|
||||
"register",
|
||||
"--key",
|
||||
"--auth-id",
|
||||
regID,
|
||||
"--output",
|
||||
"json",
|
||||
|
|
@ -1359,11 +1359,11 @@ func TestNodeExpireCommand(t *testing.T) {
|
|||
headscale,
|
||||
[]string{
|
||||
"headscale",
|
||||
"nodes",
|
||||
"auth",
|
||||
"register",
|
||||
"--user",
|
||||
"node-expire-user",
|
||||
"register",
|
||||
"--key",
|
||||
"--auth-id",
|
||||
regID,
|
||||
"--output",
|
||||
"json",
|
||||
|
|
@ -1496,11 +1496,11 @@ func TestNodeRenameCommand(t *testing.T) {
|
|||
headscale,
|
||||
[]string{
|
||||
"headscale",
|
||||
"nodes",
|
||||
"auth",
|
||||
"register",
|
||||
"--user",
|
||||
"node-rename-command",
|
||||
"register",
|
||||
"--key",
|
||||
"--auth-id",
|
||||
regID,
|
||||
"--output",
|
||||
"json",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
type ControlServer interface {
|
||||
Shutdown() (string, string, error)
|
||||
SaveLog(path string) (string, string, error)
|
||||
ReadLog() (string, string, error)
|
||||
SaveProfile(path string) error
|
||||
Execute(command []string) (string, error)
|
||||
WriteFile(path string, content []byte) error
|
||||
|
|
|
|||
|
|
@ -699,6 +699,18 @@ func (t *HeadscaleInContainer) WriteLogs(stdout, stderr io.Writer) error {
|
|||
return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr)
|
||||
}
|
||||
|
||||
// ReadLog returns the current stdout and stderr logs from the headscale container.
|
||||
func (t *HeadscaleInContainer) ReadLog() (string, string, error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
err := dockertestutil.WriteLog(t.pool, t.container, &stdout, &stderr)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("reading container logs: %w", err)
|
||||
}
|
||||
|
||||
return stdout.String(), stderr.String(), nil
|
||||
}
|
||||
|
||||
// SaveLog saves the current stdout log of the container to a path
|
||||
// on the host system.
|
||||
func (t *HeadscaleInContainer) SaveLog(path string) (string, string, error) {
|
||||
|
|
|
|||
|
|
@ -1184,7 +1184,7 @@ func (s *Scenario) runHeadscaleRegister(userStr string, body string) error {
|
|||
return errParseAuthPage
|
||||
}
|
||||
|
||||
keySep := strings.Split(codeSep[0], "key ")
|
||||
keySep := strings.Split(codeSep[0], "--auth-id ")
|
||||
if len(keySep) != 2 {
|
||||
return errParseAuthPage
|
||||
}
|
||||
|
|
@ -1195,7 +1195,7 @@ func (s *Scenario) runHeadscaleRegister(userStr string, body string) error {
|
|||
|
||||
if headscale, err := s.Headscale(); err == nil { //nolint:noinlineerr
|
||||
_, err = headscale.Execute(
|
||||
[]string{"headscale", "nodes", "register", "--user", userStr, "--key", key},
|
||||
[]string{"headscale", "auth", "register", "--user", userStr, "--auth-id", key},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("registering node: %s", err)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ package integration
|
|||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||
"github.com/juanfont/headscale/integration/hsic"
|
||||
"github.com/juanfont/headscale/integration/tsic"
|
||||
"github.com/oauth2-proxy/mockoidc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
|
|
@ -580,40 +583,197 @@ func TestSSHAutogroupSelf(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSSHOneUserToOneCheckMode(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
type sshCheckResult struct {
|
||||
stdout string
|
||||
stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
scenario := sshScenario(t,
|
||||
&policyv2.Policy{
|
||||
Groups: policyv2.Groups{
|
||||
policyv2.Group("group:integration-test"): []policyv2.Username{policyv2.Username("user1@")},
|
||||
// doSSHCheck runs SSH in a goroutine with a longer timeout, returning a channel
|
||||
// for the result. The SSH command will block while waiting for auth approval in
|
||||
// check mode.
|
||||
func doSSHCheck(
|
||||
t *testing.T,
|
||||
client TailscaleClient,
|
||||
peer TailscaleClient,
|
||||
) chan sshCheckResult {
|
||||
t.Helper()
|
||||
|
||||
peerFQDN, _ := peer.FQDN()
|
||||
|
||||
command := []string{
|
||||
"/usr/bin/ssh", "-o StrictHostKeyChecking=no", "-o ConnectTimeout=30",
|
||||
fmt.Sprintf("%s@%s", "ssh-it-user", peerFQDN),
|
||||
"'hostname'",
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[SSH check] Running from %s to %s",
|
||||
client.Hostname(),
|
||||
peer.Hostname(),
|
||||
)
|
||||
|
||||
ch := make(chan sshCheckResult, 1)
|
||||
|
||||
go func() {
|
||||
stdout, stderr, err := client.Execute(
|
||||
command,
|
||||
dockertestutil.ExecuteCommandTimeout(60*time.Second),
|
||||
)
|
||||
ch <- sshCheckResult{stdout, stderr, err}
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// findSSHCheckAuthID polls headscale container logs for the SSH action auth-id.
|
||||
// The SSH action handler logs "SSH action follow-up" with the auth_id on the
|
||||
// follow-up request (where auth_id is non-empty).
|
||||
func findSSHCheckAuthID(t *testing.T, headscale ControlServer) string {
|
||||
t.Helper()
|
||||
|
||||
var authID string
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, stderr, err := headscale.ReadLog()
|
||||
assert.NoError(c, err)
|
||||
|
||||
for line := range strings.SplitSeq(stderr, "\n") {
|
||||
if !strings.Contains(line, "SSH action follow-up") {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx := strings.Index(line, "auth_id="); idx != -1 {
|
||||
start := idx + len("auth_id=")
|
||||
|
||||
end := strings.IndexByte(line[start:], ' ')
|
||||
if end == -1 {
|
||||
end = len(line[start:])
|
||||
}
|
||||
|
||||
authID = line[start : start+end]
|
||||
}
|
||||
}
|
||||
|
||||
assert.NotEmpty(c, authID, "auth-id not found in headscale logs")
|
||||
}, 10*time.Second, 500*time.Millisecond, "waiting for SSH check auth-id in headscale logs")
|
||||
|
||||
return authID
|
||||
}
|
||||
|
||||
// sshCheckPolicy returns a policy with SSH "check" mode for group:integration-test
|
||||
// targeting autogroup:member and autogroup:tagged destinations.
|
||||
func sshCheckPolicy() *policyv2.Policy {
|
||||
return &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: "check",
|
||||
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
||||
// Use autogroup:member and autogroup:tagged instead of wildcard
|
||||
// since wildcard (*) is no longer supported for SSH destinations
|
||||
Destinations: policyv2.SSHDstAliases{
|
||||
new(policyv2.AutoGroupMember),
|
||||
new(policyv2.AutoGroupTagged),
|
||||
},
|
||||
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
||||
},
|
||||
ACLs: []policyv2.ACL{
|
||||
{
|
||||
Action: "accept",
|
||||
Protocol: "tcp",
|
||||
Sources: []policyv2.Alias{wildcard()},
|
||||
Destinations: []policyv2.AliasWithPorts{
|
||||
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
||||
},
|
||||
},
|
||||
},
|
||||
1,
|
||||
)
|
||||
SSHs: []policyv2.SSH{
|
||||
{
|
||||
Action: "check",
|
||||
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
||||
Destinations: policyv2.SSHDstAliases{
|
||||
new(policyv2.AutoGroupMember),
|
||||
new(policyv2.AutoGroupTagged),
|
||||
},
|
||||
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sshCheckPolicyWithPeriod returns a policy with SSH "check" mode and a
|
||||
// specified checkPeriod for session duration.
|
||||
func sshCheckPolicyWithPeriod(period time.Duration) *policyv2.Policy {
|
||||
return &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: "check",
|
||||
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
||||
Destinations: policyv2.SSHDstAliases{
|
||||
new(policyv2.AutoGroupMember),
|
||||
new(policyv2.AutoGroupTagged),
|
||||
},
|
||||
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
||||
CheckPeriod: &policyv2.SSHCheckPeriod{Duration: period},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findNewSSHCheckAuthID polls headscale logs for an SSH check auth-id
|
||||
// that differs from excludeID. Used to verify re-authentication after
|
||||
// session expiry.
|
||||
func findNewSSHCheckAuthID(
|
||||
t *testing.T,
|
||||
headscale ControlServer,
|
||||
excludeID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
var authID string
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, stderr, err := headscale.ReadLog()
|
||||
assert.NoError(c, err)
|
||||
|
||||
for line := range strings.SplitSeq(stderr, "\n") {
|
||||
if !strings.Contains(line, "SSH action follow-up") {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx := strings.Index(line, "auth_id="); idx != -1 {
|
||||
start := idx + len("auth_id=")
|
||||
|
||||
end := strings.IndexByte(line[start:], ' ')
|
||||
if end == -1 {
|
||||
end = len(line[start:])
|
||||
}
|
||||
|
||||
id := line[start : start+end]
|
||||
if id != excludeID {
|
||||
authID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.NotEmpty(c, authID, "new auth-id not found in headscale logs")
|
||||
}, 10*time.Second, 500*time.Millisecond, "waiting for new SSH check auth-id")
|
||||
|
||||
return authID
|
||||
}
|
||||
|
||||
func TestSSHOneUserToOneCheckModeCLI(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
scenario := sshScenario(t, sshCheckPolicy(), 1)
|
||||
// defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
|
|
@ -625,6 +785,442 @@ func TestSSHOneUserToOneCheckMode(t *testing.T) {
|
|||
user2Clients, err := scenario.ListTailscaleClients("user2")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
_, err = scenario.ListTailscaleClientsFQDNs()
|
||||
requireNoErrListFQDN(t, err)
|
||||
|
||||
// user1 can SSH (via check) to all peers
|
||||
for _, client := range user1Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Start SSH — will block waiting for check auth
|
||||
sshResult := doSSHCheck(t, client, peer)
|
||||
|
||||
// Find the auth-id from headscale logs
|
||||
authID := findSSHCheckAuthID(t, headscale)
|
||||
|
||||
// Approve via CLI
|
||||
_, err := headscale.Execute(
|
||||
[]string{
|
||||
"headscale", "auth", "approve",
|
||||
"--auth-id", authID,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for SSH to complete
|
||||
select {
|
||||
case result := <-sshResult:
|
||||
require.NoError(t, result.err)
|
||||
require.Contains(
|
||||
t,
|
||||
peer.ContainerID(),
|
||||
strings.ReplaceAll(result.stdout, "\n", ""),
|
||||
)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("SSH did not complete after auth approval")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// user2 cannot SSH — not in the check policy group
|
||||
for _, client := range user2Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
assertSSHPermissionDenied(t, client, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHOneUserToOneCheckModeOIDC(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1", "user2"},
|
||||
OIDCSkipUserCreation: true,
|
||||
OIDCUsers: []mockoidc.MockUser{
|
||||
// First 2: consumed during node registration
|
||||
oidcMockUser("user1", true),
|
||||
oidcMockUser("user2", true),
|
||||
// Extra: consumed during SSH check auth flows.
|
||||
// Each SSH check pops one user from the queue.
|
||||
oidcMockUser("user1", true),
|
||||
},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
// defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
oidcMap := map[string]string{
|
||||
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
||||
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
||||
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
||||
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
||||
}
|
||||
|
||||
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
||||
[]tsic.Option{
|
||||
tsic.WithSSH(),
|
||||
tsic.WithNetfilter("off"),
|
||||
tsic.WithPackages("openssh"),
|
||||
tsic.WithExtraCommands("adduser ssh-it-user"),
|
||||
tsic.WithDockerWorkdir("/"),
|
||||
},
|
||||
hsic.WithACLPolicy(sshCheckPolicy()),
|
||||
hsic.WithTestName("sshcheckoidc"),
|
||||
hsic.WithConfigEnv(oidcMap),
|
||||
hsic.WithTLS(),
|
||||
hsic.WithFileInContainer(
|
||||
"/tmp/hs_client_oidc_secret",
|
||||
[]byte(scenario.mockOIDC.ClientSecret()),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
user1Clients, err := scenario.ListTailscaleClients("user1")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
user2Clients, err := scenario.ListTailscaleClients("user2")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
_, err = scenario.ListTailscaleClientsFQDNs()
|
||||
requireNoErrListFQDN(t, err)
|
||||
|
||||
// user1 can SSH (via check) to all peers
|
||||
for _, client := range user1Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Start SSH — will block waiting for check auth
|
||||
sshResult := doSSHCheck(t, client, peer)
|
||||
|
||||
// Find the auth-id from headscale logs
|
||||
authID := findSSHCheckAuthID(t, headscale)
|
||||
|
||||
// Build auth URL and visit it to trigger OIDC flow.
|
||||
// The mock OIDC server auto-authenticates from the user queue.
|
||||
authURL := headscale.GetEndpoint() + "/auth/" + authID
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = doLoginURL("ssh-check-oidc", parsedURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for SSH to complete
|
||||
select {
|
||||
case result := <-sshResult:
|
||||
require.NoError(t, result.err)
|
||||
require.Contains(
|
||||
t,
|
||||
peer.ContainerID(),
|
||||
strings.ReplaceAll(result.stdout, "\n", ""),
|
||||
)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("SSH did not complete after OIDC auth")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// user2 cannot SSH — not in the check policy group
|
||||
for _, client := range user2Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
assertSSHPermissionDenied(t, client, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHCheckModeUnapprovedTimeout verifies that SSH in check mode is rejected
|
||||
// when nobody approves the auth request and the registration cache entry expires.
|
||||
func TestSSHCheckModeUnapprovedTimeout(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1", "user2"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{
|
||||
tsic.WithSSH(),
|
||||
tsic.WithNetfilter("off"),
|
||||
tsic.WithPackages("openssh"),
|
||||
tsic.WithExtraCommands("adduser ssh-it-user"),
|
||||
tsic.WithDockerWorkdir("/"),
|
||||
},
|
||||
hsic.WithACLPolicy(sshCheckPolicy()),
|
||||
hsic.WithTestName("sshchecktimeout"),
|
||||
hsic.WithConfigEnv(map[string]string{
|
||||
"HEADSCALE_TUNING_REGISTER_CACHE_EXPIRATION": "15s",
|
||||
"HEADSCALE_TUNING_REGISTER_CACHE_CLEANUP": "5s",
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
user1Clients, err := scenario.ListTailscaleClients("user1")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
user2Clients, err := scenario.ListTailscaleClients("user2")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
_, err = scenario.ListTailscaleClientsFQDNs()
|
||||
requireNoErrListFQDN(t, err)
|
||||
|
||||
// user1 attempts SSH — enters check flow, but nobody approves
|
||||
for _, client := range user1Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
sshResult := doSSHCheck(t, client, peer)
|
||||
|
||||
// Confirm the check flow was entered
|
||||
_ = findSSHCheckAuthID(t, headscale)
|
||||
|
||||
// Do NOT approve — wait for cache expiry and SSH rejection
|
||||
select {
|
||||
case result := <-sshResult:
|
||||
require.Error(t, result.err, "SSH should be rejected when unapproved")
|
||||
assert.Empty(t, result.stdout, "no command output expected on rejection")
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("SSH did not complete after cache expiry timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// user2 still gets immediate Permission Denied
|
||||
for _, client := range user2Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
assertSSHPermissionDenied(t, client, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHCheckModeCheckPeriodCLI verifies that after approval with a short
|
||||
// checkPeriod, the session expires and the next SSH connection requires
|
||||
// re-authentication via a new check flow.
|
||||
func TestSSHCheckModeCheckPeriodCLI(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
// 1 minute is the documented minimum checkPeriod
|
||||
scenario := sshScenario(t, sshCheckPolicyWithPeriod(time.Minute), 1)
|
||||
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)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
_, err = scenario.ListTailscaleClientsFQDNs()
|
||||
requireNoErrListFQDN(t, err)
|
||||
|
||||
// === Phase 1: First SSH check — approve, verify success ===
|
||||
for _, client := range user1Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
sshResult := doSSHCheck(t, client, peer)
|
||||
firstAuthID := findSSHCheckAuthID(t, headscale)
|
||||
|
||||
_, err := headscale.Execute(
|
||||
[]string{
|
||||
"headscale", "auth", "approve",
|
||||
"--auth-id", firstAuthID,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case result := <-sshResult:
|
||||
require.NoError(t, result.err, "first SSH should succeed after approval")
|
||||
require.Contains(
|
||||
t,
|
||||
peer.ContainerID(),
|
||||
strings.ReplaceAll(result.stdout, "\n", ""),
|
||||
)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("first SSH did not complete after auth approval")
|
||||
}
|
||||
|
||||
// === Phase 2: Wait for checkPeriod to expire ===
|
||||
//nolint:forbidigo // Intentional sleep: waiting for the check period session
|
||||
// to expire. This is a time-based expiry, not a pollable condition — the
|
||||
// Tailscale client caches the approval for SessionDuration and only
|
||||
// re-triggers the check flow after it elapses.
|
||||
time.Sleep(70 * time.Second)
|
||||
|
||||
// === Phase 3: Second SSH — must re-authenticate ===
|
||||
sshResult2 := doSSHCheck(t, client, peer)
|
||||
secondAuthID := findNewSSHCheckAuthID(t, headscale, firstAuthID)
|
||||
|
||||
require.NotEqual(
|
||||
t,
|
||||
firstAuthID,
|
||||
secondAuthID,
|
||||
"second SSH should trigger a new auth flow after checkPeriod expiry",
|
||||
)
|
||||
|
||||
_, err = headscale.Execute(
|
||||
[]string{
|
||||
"headscale", "auth", "approve",
|
||||
"--auth-id", secondAuthID,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case result := <-sshResult2:
|
||||
require.NoError(t, result.err, "second SSH should succeed after re-approval")
|
||||
require.Contains(
|
||||
t,
|
||||
peer.ContainerID(),
|
||||
strings.ReplaceAll(result.stdout, "\n", ""),
|
||||
)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("second SSH did not complete after re-auth approval")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHCheckModeAutoApprove verifies that after SSH check approval, a second
|
||||
// SSH within the checkPeriod is auto-approved without requiring manual approval.
|
||||
func TestSSHCheckModeAutoApprove(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
// 5 minute checkPeriod — long enough not to expire during test
|
||||
scenario := sshScenario(t, sshCheckPolicyWithPeriod(5*time.Minute), 1)
|
||||
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)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
_, err = scenario.ListTailscaleClientsFQDNs()
|
||||
requireNoErrListFQDN(t, err)
|
||||
|
||||
// === Phase 1: First SSH check — approve, verify success ===
|
||||
for _, client := range user1Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
sshResult := doSSHCheck(t, client, peer)
|
||||
firstAuthID := findSSHCheckAuthID(t, headscale)
|
||||
|
||||
_, err := headscale.Execute(
|
||||
[]string{
|
||||
"headscale", "auth", "approve",
|
||||
"--auth-id", firstAuthID,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case result := <-sshResult:
|
||||
require.NoError(t, result.err, "first SSH should succeed after approval")
|
||||
require.Contains(
|
||||
t,
|
||||
peer.ContainerID(),
|
||||
strings.ReplaceAll(result.stdout, "\n", ""),
|
||||
)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("first SSH did not complete after auth approval")
|
||||
}
|
||||
|
||||
// === Phase 2: Immediate retry — should auto-approve ===
|
||||
result, _, err := doSSH(t, client, peer)
|
||||
require.NoError(t, err, "second SSH should auto-approve without manual auth")
|
||||
require.Contains(
|
||||
t,
|
||||
peer.ContainerID(),
|
||||
strings.ReplaceAll(result, "\n", ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHCheckModeNegativeCLI verifies that `headscale auth reject`
|
||||
// properly denies an SSH check.
|
||||
func TestSSHCheckModeNegativeCLI(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
scenario := sshScenario(t, sshCheckPolicy(), 1)
|
||||
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)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
|
|
@ -637,17 +1233,25 @@ func TestSSHOneUserToOneCheckMode(t *testing.T) {
|
|||
continue
|
||||
}
|
||||
|
||||
assertSSHHostname(t, client, peer)
|
||||
}
|
||||
}
|
||||
sshResult := doSSHCheck(t, client, peer)
|
||||
authID := findSSHCheckAuthID(t, headscale)
|
||||
|
||||
for _, client := range user2Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
// Reject via CLI
|
||||
_, err := headscale.Execute(
|
||||
[]string{
|
||||
"headscale", "auth", "reject",
|
||||
"--auth-id", authID,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case result := <-sshResult:
|
||||
require.Error(t, result.err, "SSH should be rejected")
|
||||
assert.Empty(t, result.stdout, "no command output expected on rejection")
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("SSH did not complete after auth rejection")
|
||||
}
|
||||
|
||||
assertSSHPermissionDenied(t, client, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3122,7 +3122,7 @@ func TestTagsAuthKeyWithoutUserRejectsAdvertisedTags(t *testing.T) {
|
|||
|
||||
// TestTagsAuthKeyConvertToUserViaCLIRegister reproduces the panic from
|
||||
// issue #3038: register a node with a tags-only preauthkey (no user), then
|
||||
// convert it to a user-owned node via "headscale nodes register --user <user> --key ...".
|
||||
// convert it to a user-owned node via "headscale auth register --auth-id <id> --user <user>".
|
||||
// The crash happens in the mapper's generateUserProfiles when node.User is nil
|
||||
// after the tag→user conversion in processReauthTags.
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue