From 3db0a483edaa87d08e02b3213972c2283d1368a6 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 24 Feb 2026 18:56:50 +0000 Subject: [PATCH] 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 --- hscontrol/auth_test.go | 6 +- integration/auth_web_flow_test.go | 2 +- integration/cli_test.go | 24 +- integration/control.go | 1 + integration/hsic/hsic.go | 12 + integration/scenario.go | 4 +- integration/ssh_test.go | 680 ++++++++++++++++++++++++++++-- integration/tags_test.go | 2 +- 8 files changed, 674 insertions(+), 57 deletions(-) diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index 2a878851..07032917 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -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 +// 3. Admin runs: headscale auth register --auth-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 + // This simulates: headscale auth register --auth-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 --user alice nodeKey2 := key.NewNode() registrationID := types.MustAuthID() regEntry := types.NewRegisterAuthRequest(types.Node{ diff --git a/integration/auth_web_flow_test.go b/integration/auth_web_flow_test.go index eba2ebbf..d00c5fdd 100644 --- a/integration/auth_web_flow_test.go +++ b/integration/auth_web_flow_test.go @@ -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 + // This simulates: headscale auth register --auth-id --user user1 _ = scenario.runHeadscaleRegister("user1", body) } diff --git a/integration/cli_test.go b/integration/cli_test.go index c46361d4..a7696bb4 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -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", diff --git a/integration/control.go b/integration/control.go index f390d080..d9273ae6 100644 --- a/integration/control.go +++ b/integration/control.go @@ -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 diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index 3ef4d5d4..cd60c20d 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -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) { diff --git a/integration/scenario.go b/integration/scenario.go index e769bd73..ba99a392 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -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) diff --git a/integration/ssh_test.go b/integration/ssh_test.go index 15867579..30f94433 100644 --- a/integration/ssh_test.go +++ b/integration/ssh_test.go @@ -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) } } } diff --git a/integration/tags_test.go b/integration/tags_test.go index 2b5cb1b9..399ba7cf 100644 --- a/integration/tags_test.go +++ b/integration/tags_test.go @@ -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 --key ...". +// convert it to a user-owned node via "headscale auth register --auth-id --user ". // The crash happens in the mapper's generateUserProfiles when node.User is nil // after the tag→user conversion in processReauthTags. //