mirror of
https://github.com/juanfont/headscale.git
synced 2026-03-25 19:01:35 +01: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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user