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:
Kristoffer Dalby
2026-02-24 18:56:50 +00:00
parent 7bab8da366
commit 3db0a483ed
8 changed files with 674 additions and 57 deletions

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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.
//