mirror of
https://github.com/juanfont/headscale.git
synced 2026-02-07 20:04:00 +01:00
Add two new integration tests for SSH check mode: - TestSSHCheckModeUnapprovedTimeout: verifies that SSH is rejected when the check auth request is never approved and the registration cache entry expires. Uses short cache TTL (15s) to avoid long waits. - TestSSHCheckModeCheckPeriodCLI: verifies that after approval with a 1-minute checkPeriod, the session expires and the next SSH connection requires re-authentication through a new check flow. Also adds helper functions sshCheckPolicyWithPeriod (policy with CheckPeriod) and findNewSSHCheckAuthID (finds auth-id excluding a known one for re-auth verification). Updates #1850
1141 lines
30 KiB
Go
1141 lines
30 KiB
Go
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/prometheus/common/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
func isSSHNoAccessStdError(stderr string) bool {
|
|
return strings.Contains(stderr, "Permission denied (tailscale)") ||
|
|
// Since https://github.com/tailscale/tailscale/pull/14853
|
|
strings.Contains(stderr, "failed to evaluate SSH policy") ||
|
|
// Since https://github.com/tailscale/tailscale/pull/16127
|
|
strings.Contains(stderr, "tailnet policy does not permit you to SSH to this node")
|
|
}
|
|
|
|
func sshScenario(t *testing.T, policy *policyv2.Policy, clientsPerUser int) *Scenario {
|
|
t.Helper()
|
|
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: clientsPerUser,
|
|
Users: []string{"user1", "user2"},
|
|
}
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
|
|
err = scenario.CreateHeadscaleEnv(
|
|
[]tsic.Option{
|
|
tsic.WithSSH(),
|
|
|
|
// Alpine containers dont have ip6tables set up, which causes
|
|
// tailscaled to stop configuring the wgengine, causing it
|
|
// to not configure DNS.
|
|
tsic.WithNetfilter("off"),
|
|
tsic.WithPackages("openssh"),
|
|
tsic.WithExtraCommands("adduser ssh-it-user"),
|
|
tsic.WithDockerWorkdir("/"),
|
|
},
|
|
hsic.WithACLPolicy(policy),
|
|
hsic.WithTestName("ssh"),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
require.NoError(t, err)
|
|
|
|
_, err = scenario.ListTailscaleClientsFQDNs()
|
|
require.NoError(t, err)
|
|
|
|
return scenario
|
|
}
|
|
|
|
func TestSSHOneUserToAll(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
scenario := sshScenario(t,
|
|
&policyv2.Policy{
|
|
Groups: policyv2.Groups{
|
|
policyv2.Group("group:integration-test"): []policyv2.Username{policyv2.Username("user1@")},
|
|
},
|
|
ACLs: []policyv2.ACL{
|
|
{
|
|
Action: "accept",
|
|
Protocol: "tcp",
|
|
Sources: []policyv2.Alias{wildcard()},
|
|
Destinations: []policyv2.AliasWithPorts{
|
|
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
|
},
|
|
},
|
|
},
|
|
SSHs: []policyv2.SSH{
|
|
{
|
|
Action: "accept",
|
|
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
|
// 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")},
|
|
},
|
|
},
|
|
},
|
|
len(MustTestVersions),
|
|
)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
user1Clients, err := scenario.ListTailscaleClients("user1")
|
|
requireNoErrListClients(t, err)
|
|
|
|
user2Clients, err := scenario.ListTailscaleClients("user2")
|
|
requireNoErrListClients(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
_, err = scenario.ListTailscaleClientsFQDNs()
|
|
requireNoErrListFQDN(t, err)
|
|
|
|
for _, client := range user1Clients {
|
|
for _, peer := range allClients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
|
|
for _, client := range user2Clients {
|
|
for _, peer := range allClients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSSHMultipleUsersAllToAll tests that users in a group can SSH to each other's devices
|
|
// using autogroup:self as the destination, which allows same-user SSH access.
|
|
func TestSSHMultipleUsersAllToAll(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
scenario := sshScenario(t,
|
|
&policyv2.Policy{
|
|
Groups: policyv2.Groups{
|
|
policyv2.Group("group:integration-test"): []policyv2.Username{policyv2.Username("user1@"), policyv2.Username("user2@")},
|
|
},
|
|
ACLs: []policyv2.ACL{
|
|
{
|
|
Action: "accept",
|
|
Protocol: "tcp",
|
|
Sources: []policyv2.Alias{wildcard()},
|
|
Destinations: []policyv2.AliasWithPorts{
|
|
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
|
},
|
|
},
|
|
},
|
|
SSHs: []policyv2.SSH{
|
|
{
|
|
Action: "accept",
|
|
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
|
// Use autogroup:self to allow users to SSH to their own devices.
|
|
// Username destinations (e.g., "user1@") now require the source
|
|
// to be that exact same user only. For group-to-group SSH access,
|
|
// use autogroup:self instead.
|
|
Destinations: policyv2.SSHDstAliases{new(policyv2.AutoGroupSelf)},
|
|
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
|
},
|
|
},
|
|
},
|
|
len(MustTestVersions),
|
|
)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
nsOneClients, err := scenario.ListTailscaleClients("user1")
|
|
requireNoErrListClients(t, err)
|
|
|
|
nsTwoClients, err := scenario.ListTailscaleClients("user2")
|
|
requireNoErrListClients(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
_, err = scenario.ListTailscaleClientsFQDNs()
|
|
requireNoErrListFQDN(t, err)
|
|
|
|
// With autogroup:self, users can SSH to their own devices, but not to other users' devices.
|
|
// Test that user1's devices can SSH to each other
|
|
for _, client := range nsOneClients {
|
|
for _, peer := range nsOneClients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
|
|
// Test that user2's devices can SSH to each other
|
|
for _, client := range nsTwoClients {
|
|
for _, peer := range nsTwoClients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
|
|
// Test that user1 cannot SSH to user2's devices (autogroup:self only allows same-user)
|
|
for _, client := range nsOneClients {
|
|
for _, peer := range nsTwoClients {
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
|
|
// Test that user2 cannot SSH to user1's devices (autogroup:self only allows same-user)
|
|
for _, client := range nsTwoClients {
|
|
for _, peer := range nsOneClients {
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHNoSSHConfigured(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
scenario := sshScenario(t,
|
|
&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{},
|
|
},
|
|
len(MustTestVersions),
|
|
)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
_, err = scenario.ListTailscaleClientsFQDNs()
|
|
requireNoErrListFQDN(t, err)
|
|
|
|
for _, client := range allClients {
|
|
for _, peer := range allClients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHIsBlockedInACL(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
scenario := sshScenario(t,
|
|
&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.PortRange{First: 80, Last: 80}),
|
|
},
|
|
},
|
|
},
|
|
SSHs: []policyv2.SSH{
|
|
{
|
|
Action: "accept",
|
|
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
|
Destinations: policyv2.SSHDstAliases{new(policyv2.AutoGroupSelf)},
|
|
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
|
},
|
|
},
|
|
},
|
|
len(MustTestVersions),
|
|
)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
_, err = scenario.ListTailscaleClientsFQDNs()
|
|
requireNoErrListFQDN(t, err)
|
|
|
|
for _, client := range allClients {
|
|
for _, peer := range allClients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHTimeout(t, client, peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHUserOnlyIsolation(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
scenario := sshScenario(t,
|
|
&policyv2.Policy{
|
|
Groups: policyv2.Groups{
|
|
policyv2.Group("group:ssh1"): []policyv2.Username{policyv2.Username("user1@")},
|
|
policyv2.Group("group:ssh2"): []policyv2.Username{policyv2.Username("user2@")},
|
|
},
|
|
ACLs: []policyv2.ACL{
|
|
{
|
|
Action: "accept",
|
|
Protocol: "tcp",
|
|
Sources: []policyv2.Alias{wildcard()},
|
|
Destinations: []policyv2.AliasWithPorts{
|
|
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
|
},
|
|
},
|
|
},
|
|
SSHs: []policyv2.SSH{
|
|
// Use autogroup:self to allow users in each group to SSH to their own devices.
|
|
// Username destinations (e.g., "user1@") require the source to be that
|
|
// exact same user only, not a group containing that user.
|
|
{
|
|
Action: "accept",
|
|
Sources: policyv2.SSHSrcAliases{groupp("group:ssh1")},
|
|
Destinations: policyv2.SSHDstAliases{new(policyv2.AutoGroupSelf)},
|
|
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
|
},
|
|
{
|
|
Action: "accept",
|
|
Sources: policyv2.SSHSrcAliases{groupp("group:ssh2")},
|
|
Destinations: policyv2.SSHDstAliases{new(policyv2.AutoGroupSelf)},
|
|
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
|
},
|
|
},
|
|
},
|
|
len(MustTestVersions),
|
|
)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
ssh1Clients, err := scenario.ListTailscaleClients("user1")
|
|
requireNoErrListClients(t, err)
|
|
|
|
ssh2Clients, err := scenario.ListTailscaleClients("user2")
|
|
requireNoErrListClients(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
_, err = scenario.ListTailscaleClientsFQDNs()
|
|
requireNoErrListFQDN(t, err)
|
|
|
|
for _, client := range ssh1Clients {
|
|
for _, peer := range ssh2Clients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
|
|
for _, client := range ssh2Clients {
|
|
for _, peer := range ssh1Clients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
|
|
for _, client := range ssh1Clients {
|
|
for _, peer := range ssh1Clients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
|
|
for _, client := range ssh2Clients {
|
|
for _, peer := range ssh2Clients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func doSSH(t *testing.T, client TailscaleClient, peer TailscaleClient) (string, string, error) {
|
|
t.Helper()
|
|
return doSSHWithRetry(t, client, peer, true)
|
|
}
|
|
|
|
func doSSHWithoutRetry(t *testing.T, client TailscaleClient, peer TailscaleClient) (string, string, error) {
|
|
t.Helper()
|
|
return doSSHWithRetry(t, client, peer, false)
|
|
}
|
|
|
|
func doSSHWithRetry(t *testing.T, client TailscaleClient, peer TailscaleClient, retry bool) (string, string, error) {
|
|
t.Helper()
|
|
|
|
peerFQDN, _ := peer.FQDN()
|
|
|
|
command := []string{
|
|
"/usr/bin/ssh", "-o StrictHostKeyChecking=no", "-o ConnectTimeout=1",
|
|
fmt.Sprintf("%s@%s", "ssh-it-user", peerFQDN),
|
|
"'hostname'",
|
|
}
|
|
|
|
log.Printf("Running from %s to %s", client.Hostname(), peer.Hostname())
|
|
log.Printf("Command: %s", strings.Join(command, " "))
|
|
|
|
var (
|
|
result, stderr string
|
|
err error
|
|
)
|
|
|
|
if retry {
|
|
// Use assert.EventuallyWithT to retry SSH connections for success cases
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
result, stderr, err = client.Execute(command)
|
|
|
|
// If we get a permission denied error, we can fail immediately
|
|
// since that is something we won't recover from by retrying.
|
|
if err != nil && isSSHNoAccessStdError(stderr) {
|
|
return // Don't retry permission denied errors
|
|
}
|
|
|
|
// For all other errors, assert no error to trigger retry
|
|
assert.NoError(ct, err)
|
|
}, 10*time.Second, 200*time.Millisecond)
|
|
} else {
|
|
// For failure cases, just execute once
|
|
result, stderr, err = client.Execute(command)
|
|
}
|
|
|
|
return result, stderr, err
|
|
}
|
|
|
|
func assertSSHHostname(t *testing.T, client TailscaleClient, peer TailscaleClient) {
|
|
t.Helper()
|
|
|
|
result, _, err := doSSH(t, client, peer)
|
|
require.NoError(t, err)
|
|
|
|
require.Contains(t, peer.ContainerID(), strings.ReplaceAll(result, "\n", ""))
|
|
}
|
|
|
|
func assertSSHPermissionDenied(t *testing.T, client TailscaleClient, peer TailscaleClient) {
|
|
t.Helper()
|
|
|
|
result, stderr, err := doSSHWithoutRetry(t, client, peer)
|
|
|
|
assert.Empty(t, result)
|
|
|
|
assertSSHNoAccessStdError(t, err, stderr)
|
|
}
|
|
|
|
func assertSSHTimeout(t *testing.T, client TailscaleClient, peer TailscaleClient) {
|
|
t.Helper()
|
|
|
|
result, stderr, _ := doSSHWithoutRetry(t, client, peer)
|
|
|
|
assert.Empty(t, result)
|
|
|
|
if !strings.Contains(stderr, "Connection timed out") &&
|
|
!strings.Contains(stderr, "Operation timed out") {
|
|
t.Fatalf("connection did not time out")
|
|
}
|
|
}
|
|
|
|
func assertSSHNoAccessStdError(t *testing.T, err error, stderr string) {
|
|
t.Helper()
|
|
require.Error(t, err)
|
|
|
|
if !isSSHNoAccessStdError(stderr) {
|
|
t.Errorf("expected stderr output suggesting access denied, got: %s", stderr)
|
|
}
|
|
}
|
|
|
|
// TestSSHAutogroupSelf tests that SSH with autogroup:self works correctly:
|
|
// - Users can SSH to their own devices
|
|
// - Users cannot SSH to other users' devices.
|
|
func TestSSHAutogroupSelf(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
scenario := sshScenario(t,
|
|
&policyv2.Policy{
|
|
ACLs: []policyv2.ACL{
|
|
{
|
|
Action: "accept",
|
|
Protocol: "tcp",
|
|
Sources: []policyv2.Alias{wildcard()},
|
|
Destinations: []policyv2.AliasWithPorts{
|
|
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
|
},
|
|
},
|
|
},
|
|
SSHs: []policyv2.SSH{
|
|
{
|
|
Action: "accept",
|
|
Sources: policyv2.SSHSrcAliases{
|
|
new(policyv2.AutoGroupMember),
|
|
},
|
|
Destinations: policyv2.SSHDstAliases{
|
|
new(policyv2.AutoGroupSelf),
|
|
},
|
|
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
|
},
|
|
},
|
|
},
|
|
2, // 2 clients per user
|
|
)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
user1Clients, err := scenario.ListTailscaleClients("user1")
|
|
requireNoErrListClients(t, err)
|
|
|
|
user2Clients, err := scenario.ListTailscaleClients("user2")
|
|
requireNoErrListClients(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
// Test that user1's devices can SSH to each other
|
|
for _, client := range user1Clients {
|
|
for _, peer := range user1Clients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
|
|
// Test that user2's devices can SSH to each other
|
|
for _, client := range user2Clients {
|
|
for _, peer := range user2Clients {
|
|
if client.Hostname() == peer.Hostname() {
|
|
continue
|
|
}
|
|
|
|
assertSSHHostname(t, client, peer)
|
|
}
|
|
}
|
|
|
|
// Test that user1 cannot SSH to user2's devices
|
|
for _, client := range user1Clients {
|
|
for _, peer := range user2Clients {
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
|
|
// Test that user2 cannot SSH to user1's devices
|
|
for _, client := range user2Clients {
|
|
for _, peer := range user1Clients {
|
|
assertSSHPermissionDenied(t, client, peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
type sshCheckResult struct {
|
|
stdout string
|
|
stderr string
|
|
err error
|
|
}
|
|
|
|
// 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")},
|
|
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: model.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()
|
|
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)
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
}
|
|
}
|