From 2be94ce19a040a3523c934387f0f5b6f22cd4523 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 24 Feb 2026 19:40:11 +0000 Subject: [PATCH] integration: add TestSSHLocalpart integration test Add end-to-end integration test that validates localpart:*@domain SSH user mapping with real Tailscale clients. The test sets up an SSH policy with localpart entries and verifies that users can SSH into tagged servers using their email local-part as the username. Updates #3049 --- CHANGELOG.md | 1 + integration/ssh_test.go | 275 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 685f736c..c04ac477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ A new `headscale auth` CLI command group supports the approval flow: ### Changes +- **SSH Policy**: Add support for `localpart:*@` in SSH rule `users` field, mapping each matching user's email local-part as their OS username [#3091](https://github.com/juanfont/headscale/pull/3091) - **ACL Policy**: Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036) - **ACL Policy**: Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036) - **ACL Policy**: Use CIDR format for autogroup:self destination IPs matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036) diff --git a/integration/ssh_test.go b/integration/ssh_test.go index 30f94433..73918b9b 100644 --- a/integration/ssh_test.go +++ b/integration/ssh_test.go @@ -23,7 +23,8 @@ func isSSHNoAccessStdError(stderr string) bool { // 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") + // Covers both "to this node" and "as user " variants. + strings.Contains(stderr, "tailnet policy does not permit you to SSH") } func sshScenario(t *testing.T, policy *policyv2.Policy, clientsPerUser int) *Scenario { @@ -423,15 +424,27 @@ func doSSHWithoutRetry(t *testing.T, client TailscaleClient, peer TailscaleClien func doSSHWithRetry(t *testing.T, client TailscaleClient, peer TailscaleClient, retry bool) (string, string, error) { t.Helper() + return doSSHWithRetryAsUser(t, client, peer, "ssh-it-user", retry) +} + +func doSSHWithRetryAsUser( + t *testing.T, + client TailscaleClient, + peer TailscaleClient, + sshUser string, + 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), + fmt.Sprintf("%s@%s", sshUser, peerFQDN), "'hostname'", } - log.Printf("Running from %s to %s", client.Hostname(), peer.Hostname()) + log.Printf("Running from %s to %s as %s", client.Hostname(), peer.Hostname(), sshUser) log.Printf("Command: %s", strings.Join(command, " ")) var ( @@ -502,6 +515,31 @@ func assertSSHNoAccessStdError(t *testing.T, err error, stderr string) { } } +func doSSHAsUser(t *testing.T, client TailscaleClient, peer TailscaleClient, sshUser string) (string, string, error) { + t.Helper() + + return doSSHWithRetryAsUser(t, client, peer, sshUser, true) +} + +func assertSSHHostnameAsUser(t *testing.T, client TailscaleClient, peer TailscaleClient, sshUser string) { + t.Helper() + + result, _, err := doSSHAsUser(t, client, peer, sshUser) + require.NoError(t, err) + + require.Contains(t, peer.ContainerID(), strings.ReplaceAll(result, "\n", "")) +} + +func assertSSHPermissionDeniedAsUser(t *testing.T, client TailscaleClient, peer TailscaleClient, sshUser string) { + t.Helper() + + result, stderr, err := doSSHWithRetryAsUser(t, client, peer, sshUser, false) + + assert.Empty(t, result) + + assertSSHNoAccessStdError(t, err, stderr) +} + // TestSSHAutogroupSelf tests that SSH with autogroup:self works correctly: // - Users can SSH to their own devices // - Users cannot SSH to other users' devices. @@ -1255,3 +1293,234 @@ func TestSSHCheckModeNegativeCLI(t *testing.T) { } } } + +// TestSSHLocalpart tests that SSH with localpart:*@ works correctly. +// localpart maps the local-part of each user's OIDC email to an OS user, +// so user1@headscale.net can SSH as local user "user1". +// This requires OIDC login so that users have real email addresses. +func TestSSHLocalpart(t *testing.T) { + IntegrationSkip(t) + + baseACLs := []policyv2.ACL{ + { + Action: "accept", + Protocol: "tcp", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + } + + tests := []struct { + name string + policy *policyv2.Policy + testFn func(t *testing.T, scenario *Scenario) + }{ + { + name: "MemberAndTagged", + policy: &policyv2.Policy{ + ACLs: baseACLs, + SSHs: []policyv2.SSH{ + { + Action: "accept", + Sources: policyv2.SSHSrcAliases{new(policyv2.AutoGroupMember)}, + Destinations: policyv2.SSHDstAliases{ + new(policyv2.AutoGroupMember), + new(policyv2.AutoGroupTagged), + }, + Users: []policyv2.SSHUser{"localpart:*@headscale.net"}, + }, + }, + }, + testFn: func(t *testing.T, scenario *Scenario) { + t.Helper() + + user1Clients, err := scenario.ListTailscaleClients("user1") + requireNoErrListClients(t, err) + + user2Clients, err := scenario.ListTailscaleClients("user2") + requireNoErrListClients(t, err) + + // user1 can SSH to user2's nodes as "user1" (localpart of user1@headscale.net) + for _, client := range user1Clients { + for _, peer := range user2Clients { + assertSSHHostnameAsUser(t, client, peer, "user1") + } + } + + // user2 can SSH to user1's nodes as "user2" (localpart of user2@headscale.net) + for _, client := range user2Clients { + for _, peer := range user1Clients { + assertSSHHostnameAsUser(t, client, peer, "user2") + } + } + + // user1 CANNOT SSH as "user2" — no rule maps user1's IPs to user2 + for _, client := range user1Clients { + for _, peer := range user2Clients { + assertSSHPermissionDeniedAsUser(t, client, peer, "user2") + } + } + + // user2 CANNOT SSH as "user1" — no rule maps user2's IPs to user1 + for _, client := range user2Clients { + for _, peer := range user1Clients { + assertSSHPermissionDeniedAsUser(t, client, peer, "user1") + } + } + }, + }, + { + name: "AutogroupSelf", + policy: &policyv2.Policy{ + ACLs: baseACLs, + SSHs: []policyv2.SSH{ + { + Action: "accept", + Sources: policyv2.SSHSrcAliases{new(policyv2.AutoGroupMember)}, + Destinations: policyv2.SSHDstAliases{new(policyv2.AutoGroupSelf)}, + Users: []policyv2.SSHUser{"localpart:*@headscale.net"}, + }, + }, + }, + testFn: func(t *testing.T, scenario *Scenario) { + t.Helper() + + user1Clients, err := scenario.ListTailscaleClients("user1") + requireNoErrListClients(t, err) + + user2Clients, err := scenario.ListTailscaleClients("user2") + requireNoErrListClients(t, err) + + // With autogroup:self, cross-user SSH should be denied regardless of localpart. + // user1 cannot SSH to user2's nodes as "user1" + for _, client := range user1Clients { + for _, peer := range user2Clients { + assertSSHPermissionDeniedAsUser(t, client, peer, "user1") + } + } + + // user2 cannot SSH to user1's nodes as "user2" + for _, client := range user2Clients { + for _, peer := range user1Clients { + assertSSHPermissionDeniedAsUser(t, client, peer, "user2") + } + } + + // user1 also cannot SSH to user2's nodes as "user2" + for _, client := range user1Clients { + for _, peer := range user2Clients { + assertSSHPermissionDeniedAsUser(t, client, peer, "user2") + } + } + }, + }, + { + name: "LocalpartPlusRoot", + policy: &policyv2.Policy{ + ACLs: baseACLs, + SSHs: []policyv2.SSH{ + { + Action: "accept", + Sources: policyv2.SSHSrcAliases{new(policyv2.AutoGroupMember)}, + Destinations: policyv2.SSHDstAliases{ + new(policyv2.AutoGroupMember), + new(policyv2.AutoGroupTagged), + }, + Users: []policyv2.SSHUser{ + "localpart:*@headscale.net", + "root", + }, + }, + }, + }, + testFn: func(t *testing.T, scenario *Scenario) { + t.Helper() + + user1Clients, err := scenario.ListTailscaleClients("user1") + requireNoErrListClients(t, err) + + user2Clients, err := scenario.ListTailscaleClients("user2") + requireNoErrListClients(t, err) + + // localpart works: user1 can SSH to user2's nodes as "user1" + for _, client := range user1Clients { + for _, peer := range user2Clients { + assertSSHHostnameAsUser(t, client, peer, "user1") + } + } + + // root also works: user1 can SSH to user2's nodes as "root" + for _, client := range user1Clients { + for _, peer := range user2Clients { + assertSSHHostnameAsUser(t, client, peer, "root") + } + } + + // user2 can SSH as "user2" (localpart) + for _, client := range user2Clients { + for _, peer := range user1Clients { + assertSSHHostnameAsUser(t, client, peer, "user2") + } + } + + // user2 can SSH as "root" + for _, client := range user2Clients { + for _, peer := range user1Clients { + assertSSHHostnameAsUser(t, client, peer, "root") + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1", "user2"}, + OIDCUsers: []mockoidc.MockUser{ + oidcMockUser("user1", true), + oidcMockUser("user2", 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 user1", "adduser user2"), + tsic.WithDockerWorkdir("/"), + }, + hsic.WithTestName("sshlocalpart"), + hsic.WithACLPolicy(tt.policy), + hsic.WithConfigEnv(oidcMap), + hsic.WithTLS(), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())), + ) + requireNoErrHeadscaleEnv(t, err) + + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + _, err = scenario.ListTailscaleClientsFQDNs() + requireNoErrListFQDN(t, err) + + tt.testFn(t, scenario) + }) + } +}