headscale/integration/ssh_test.go

462 lines
11 KiB
Go
Raw Normal View History

2022-10-26 13:31:30 +02:00
package integration
import (
"fmt"
"log"
"strings"
2022-10-26 13:31:30 +02:00
"testing"
"time"
2022-10-26 13:31:30 +02:00
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
2022-11-11 13:20:12 +01:00
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
"tailscale.com/tailcfg"
2022-10-26 13:31:30 +02:00
)
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")
}
var retry = func(times int, sleepInterval time.Duration,
doWork func() (string, string, error),
) (string, string, error) {
var result string
var stderr string
2022-11-11 13:20:12 +01:00
var err error
for range times {
tempResult, tempStderr, err := doWork()
result += tempResult
stderr += tempStderr
2022-11-11 13:20:12 +01:00
if err == nil {
return result, stderr, nil
}
// If we get a permission denied error, we can fail immediately
2025-02-05 16:10:18 +01:00
// since that is something we won-t recover from by retrying.
if err != nil && isSSHNoAccessStdError(stderr) {
return result, stderr, err
}
2022-11-11 13:20:12 +01:00
time.Sleep(sleepInterval)
}
return result, stderr, err
2022-11-11 13:20:12 +01:00
}
func sshScenario(t *testing.T, policy *policyv2.Policy, clientsPerUser int) *Scenario {
t.Helper()
2022-10-26 13:31:30 +02:00
spec := ScenarioSpec{
NodesPerUser: clientsPerUser,
Users: []string{"user1", "user2"},
2022-11-11 13:20:12 +01:00
}
scenario, err := NewScenario(spec)
assertNoErr(t, err)
2022-11-11 13:20:12 +01:00
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.WithDockerEntrypoint([]string{
"/bin/sh",
"-c",
"/bin/sleep 3 ; apk add openssh ; adduser ssh-it-user ; update-ca-certificates ; tailscaled --tun=tsdev",
}),
tsic.WithDockerWorkdir("/"),
},
hsic.WithACLPolicy(policy),
hsic.WithTestName("ssh"),
2022-11-11 13:20:12 +01:00
)
assertNoErr(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErr(t, err)
_, err = scenario.ListTailscaleClientsFQDNs()
assertNoErr(t, err)
return scenario
}
func TestSSHOneUserToAll(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
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")},
Destinations: policyv2.SSHDstAliases{wildcard()},
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
},
},
},
len(MustTestVersions),
)
defer scenario.ShutdownAssertNoPanics(t)
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
user1Clients, err := scenario.ListTailscaleClients("user1")
assertNoErrListClients(t, err)
user2Clients, err := scenario.ListTailscaleClients("user2")
assertNoErrListClients(t, err)
2022-10-26 13:31:30 +02:00
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
2022-10-26 13:31:30 +02:00
_, err = scenario.ListTailscaleClientsFQDNs()
assertNoErrListFQDN(t, err)
2022-10-26 13:31:30 +02:00
for _, client := range user1Clients {
for _, peer := range allClients {
if client.Hostname() == peer.Hostname() {
continue
2022-10-26 13:31:30 +02:00
}
assertSSHHostname(t, client, peer)
2022-10-26 13:31:30 +02:00
}
}
for _, client := range user2Clients {
for _, peer := range allClients {
if client.Hostname() == peer.Hostname() {
continue
}
assertSSHPermissionDenied(t, client, peer)
}
}
2022-10-26 13:31:30 +02:00
}
2022-11-11 13:20:12 +01:00
func TestSSHMultipleUsersAllToAll(t *testing.T) {
2022-11-11 13:20:12 +01:00
IntegrationSkip(t)
t.Parallel()
2022-11-11 13:20:12 +01:00
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),
},
2022-11-11 13:20:12 +01:00
},
},
SSHs: []policyv2.SSH{
{
Action: "accept",
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
Destinations: policyv2.SSHDstAliases{usernamep("user1@"), usernamep("user2@")},
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
2022-11-11 13:20:12 +01:00
},
},
},
len(MustTestVersions),
2022-11-11 13:20:12 +01:00
)
defer scenario.ShutdownAssertNoPanics(t)
2022-11-11 13:20:12 +01:00
nsOneClients, err := scenario.ListTailscaleClients("user1")
assertNoErrListClients(t, err)
2022-11-11 13:20:12 +01:00
nsTwoClients, err := scenario.ListTailscaleClients("user2")
assertNoErrListClients(t, err)
2022-11-11 13:20:12 +01:00
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
2022-11-11 13:20:12 +01:00
_, err = scenario.ListTailscaleClientsFQDNs()
assertNoErrListFQDN(t, err)
2022-11-11 13:20:12 +01:00
testInterUserSSH := func(sourceClients []TailscaleClient, targetClients []TailscaleClient) {
2022-11-11 13:20:12 +01:00
for _, client := range sourceClients {
for _, peer := range targetClients {
assertSSHHostname(t, client, peer)
2022-11-11 13:20:12 +01:00
}
}
}
testInterUserSSH(nsOneClients, nsTwoClients)
testInterUserSSH(nsTwoClients, nsOneClients)
}
func TestSSHNoSSHConfigured(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
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),
2022-11-11 13:20:12 +01:00
)
defer scenario.ShutdownAssertNoPanics(t)
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
_, err = scenario.ListTailscaleClientsFQDNs()
assertNoErrListFQDN(t, err)
for _, client := range allClients {
for _, peer := range allClients {
if client.Hostname() == peer.Hostname() {
continue
}
assertSSHPermissionDenied(t, client, peer)
}
}
2022-11-11 13:20:12 +01:00
}
func TestSSHIsBlockedInACL(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
2022-11-11 13:20:12 +01:00
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{usernamep("user1@")},
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
},
},
},
len(MustTestVersions),
)
defer scenario.ShutdownAssertNoPanics(t)
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
2022-11-11 13:20:12 +01:00
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
2022-11-11 13:20:12 +01:00
_, err = scenario.ListTailscaleClientsFQDNs()
assertNoErrListFQDN(t, err)
for _, client := range allClients {
for _, peer := range allClients {
if client.Hostname() == peer.Hostname() {
continue
2022-11-11 13:20:12 +01:00
}
assertSSHTimeout(t, client, peer)
}
}
}
func TestSSHUserOnlyIsolation(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
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{
{
Action: "accept",
Sources: policyv2.SSHSrcAliases{groupp("group:ssh1")},
Destinations: policyv2.SSHDstAliases{usernamep("user1@")},
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
},
{
Action: "accept",
Sources: policyv2.SSHSrcAliases{groupp("group:ssh2")},
Destinations: policyv2.SSHDstAliases{usernamep("user2@")},
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
},
},
},
len(MustTestVersions),
)
defer scenario.ShutdownAssertNoPanics(t)
2022-11-11 13:20:12 +01:00
ssh1Clients, err := scenario.ListTailscaleClients("user1")
assertNoErrListClients(t, err)
ssh2Clients, err := scenario.ListTailscaleClients("user2")
assertNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
_, err = scenario.ListTailscaleClientsFQDNs()
assertNoErrListFQDN(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
2022-11-11 13:20:12 +01:00
}
assertSSHHostname(t, client, peer)
}
}
for _, client := range ssh2Clients {
for _, peer := range ssh2Clients {
if client.Hostname() == peer.Hostname() {
continue
2022-11-11 13:20:12 +01:00
}
assertSSHHostname(t, client, peer)
}
}
}
func doSSH(t *testing.T, client TailscaleClient, peer TailscaleClient) (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, " "))
return retry(10, 1*time.Second, func() (string, string, error) {
return client.Execute(command)
})
}
func assertSSHHostname(t *testing.T, client TailscaleClient, peer TailscaleClient) {
t.Helper()
result, _, err := doSSH(t, client, peer)
assertNoErr(t, err)
fix webauth + autoapprove routes (#2528) * types/node: add helper funcs for node tags Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * types/node: add DebugString method for node Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v2: add String func to AutoApprover interface Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v2: simplify, use slices.Contains Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v2: debug, use nodes.DebugString Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v1: fix potential nil pointer in NodeCanApproveRoute Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v1: slices.Contains Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration/tsic: fix diff in login commands Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: fix webauth running with wrong scenario Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: move common oidc opts to func Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: require node count, more verbose Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * auth: remove uneffective route approve Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * .github/workflows: fmt Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration/tsic: add id func Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: remove call that might be nil Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: test autoapprovers against web/authkey x group/tag/user Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: unique network id per scenario Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * Revert "integration: move common oidc opts to func" This reverts commit 7e9d165d4a900c304f1083b665f1a24a26e06e55. * remove cmd Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: clean docker images between runs in ci Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: run autoapprove test against differnt policy modes Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration/tsic: append, not overrwrite extra login args Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * .github/workflows: remove polv2 Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> --------- Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-04-30 08:54:04 +03:00
assertContains(t, peer.ContainerID(), strings.ReplaceAll(result, "\n", ""))
}
func assertSSHPermissionDenied(t *testing.T, client TailscaleClient, peer TailscaleClient) {
t.Helper()
result, stderr, err := doSSH(t, client, peer)
assert.Empty(t, result)
assertSSHNoAccessStdError(t, err, stderr)
}
func assertSSHTimeout(t *testing.T, client TailscaleClient, peer TailscaleClient) {
t.Helper()
result, stderr, _ := doSSH(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")
}
2022-11-11 13:20:12 +01:00
}
func assertSSHNoAccessStdError(t *testing.T, err error, stderr string) {
t.Helper()
assert.Error(t, err)
if !isSSHNoAccessStdError(stderr) {
t.Errorf("expected stderr output suggesting access denied, got: %s", stderr)
}
}