policy/v2: revoke stale SSH access and recheck authentication

Updates #3517, #3508

(cherry picked from commit 4fd4da75f2)
This commit is contained in:
Kristoffer Dalby committed 2026-10-13 14:35:52 +02:00
1 parent 00f670213a
commit 5fb2d4e089
21 files changed
+1202 -121

No files matched your search

+8 -5
View File
@@ -3648,8 +3648,11 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
[]tsic.Option{
tsic.WithAcceptRoutes(),
tsic.WithPackages("iptables"),
tsic.WithDERPOverHTTP(),
},
hsic.WithTestName("rt-hapingfail"),
// The firewall matches the callback path while Noise remains encrypted.
hsic.WithoutTLS(),
hsic.WithHAProbing(10*time.Second, 5*time.Second),
)
requireNoErrHeadscaleEnv(t, err)
@@ -3758,21 +3761,21 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
t.Log("=== HA setup verified. Blocking ping callbacks on router 1 via iptables ===")
// Block NEW outbound TCP from router 1 to headscale.
// Preserves the existing Noise HTTP/2 long-poll (ESTABLISHED).
// Block plaintext ping callbacks, including requests on reused HTTP
// connections. The encrypted Noise long-poll remains available.
hsIP := headscale.GetIPInNetwork(usernet1)
iptablesAdd := []string{
"iptables", "-A", "OUTPUT",
"-d", hsIP,
"-p", "tcp", "--dport", "8080",
"-m", "state", "--state", "NEW",
"-m", "string", "--algo", "kmp", "--string", "/machine/ping-response",
"-j", "DROP",
}
_, _, err = subRouter1.Execute(iptablesAdd)
require.NoError(t, err, "failed to add iptables rule")
t.Logf("Blocked new TCP connections from %s to headscale at %s:8080",
t.Logf("Blocked ping callbacks from %s to headscale at %s:8080",
subRouter1.Hostname(), hsIP)
// Wait for the prober to detect the failure and trigger failover.
@@ -3817,7 +3820,7 @@ func TestHASubnetRouterPingFailover(t *testing.T) {
"iptables", "-D", "OUTPUT",
"-d", hsIP,
"-p", "tcp", "--dport", "8080",
"-m", "state", "--state", "NEW",
"-m", "string", "--algo", "kmp", "--string", "/machine/ping-response",
"-j", "DROP",
}
+93 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/integrationutil"
@@ -28,7 +29,13 @@ func isSSHNoAccessStdError(stderr string) bool {
strings.Contains(stderr, "tailnet policy does not permit you to SSH")
}
func sshScenario(t *testing.T, policy *policyv2.Policy, testName string, clientsPerUser int) *Scenario {
func sshScenario(
t *testing.T,
policy *policyv2.Policy,
testName string,
clientsPerUser int,
hsOpts ...hsic.Option,
) *Scenario {
t.Helper()
spec := ScenarioSpec{
@@ -50,8 +57,10 @@ func sshScenario(t *testing.T, policy *policyv2.Policy, testName string, clients
tsic.WithExtraCommands("adduser ssh-it-user"),
tsic.WithDockerWorkdir("/"),
},
hsic.WithACLPolicy(policy),
hsic.WithTestName(testName),
append([]hsic.Option{
hsic.WithACLPolicy(policy),
hsic.WithTestName(testName),
}, hsOpts...)...,
)
require.NoError(t, err)
@@ -270,6 +279,87 @@ func TestSSHNoSSHConfigured(t *testing.T) {
}
}
// TestSSHRulesRemovedDeniesAccess verifies that removing every SSH rule
// from the policy revokes SSH that worked before, on every client version.
// The ACL stays open so the deny can only come from the SSH policy.
// https://github.com/juanfont/headscale/issues/3508
func TestSSHRulesRemovedDeniesAccess(t *testing.T) {
IntegrationSkip(t)
pol := &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{
new(policyv2.AutoGroupMember),
new(policyv2.AutoGroupTagged),
},
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
},
},
}
scenario := sshScenario(t, pol, "ssh-rmrules", len(MustTestVersions),
hsic.WithPolicyMode(types.PolicyModeDB),
)
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)
for _, client := range user1Clients {
for _, peer := range allClients {
if client.Hostname() == peer.Hostname() {
continue
}
assertSSHHostname(t, client, peer)
}
}
pol.SSHs = nil
err = headscale.SetPolicy(pol)
require.NoError(t, err)
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
for _, client := range user1Clients {
for _, peer := range allClients {
if client.Hostname() == peer.Hostname() {
continue
}
result, stderr, err := doSSHWithoutRetry(t, client, peer)
assert.Error(ct, err, "%s -> %s", client.Hostname(), peer.Hostname())
assert.Empty(ct, result, "%s -> %s", client.Hostname(), peer.Hostname())
assert.True(ct, isSSHNoAccessStdError(stderr),
"%s -> %s: want SSH policy deny, stderr: %s",
client.Hostname(), peer.Hostname(), stderr)
}
}
}, integrationutil.PolicyPropagationTimeout, integrationutil.SlowPoll,
"SSH must be denied once every SSH rule is removed")
}
func TestSSHIsBlockedInACL(t *testing.T) {
IntegrationSkip(t)
+14
View File
@@ -102,6 +102,7 @@ type TailscaleInContainer struct {
// optional config
caCerts [][]byte
headscaleHostname string
withDERPOverHTTP bool
withWebsocketDERP bool
withSSH bool
withTags []string
@@ -165,6 +166,15 @@ func WithWebsocketDERP(enabled bool) Option {
}
}
// WithDERPOverHTTP reaches the embedded DERP over plain-HTTP websockets.
// Use with hsic.WithoutTLS so the client does not try HTTPS for DERP.
func WithDERPOverHTTP() Option {
return func(tsic *TailscaleInContainer) {
tsic.withWebsocketDERP = true
tsic.withDERPOverHTTP = true
}
}
// WithSSH enables SSH for the Tailscale instance.
func WithSSH() Option {
return func(tsic *TailscaleInContainer) {
@@ -378,6 +388,10 @@ func New(
tailscaleOptions.Env,
fmt.Sprintf("TS_DEBUG_DERP_WS_CLIENT=%t", tsic.withWebsocketDERP),
)
if tsic.withDERPOverHTTP {
tailscaleOptions.Env = append(tailscaleOptions.Env, "TS_DEBUG_USE_DERP_HTTP=true")
}
}
tailscaleOptions.ExtraHosts = append(tailscaleOptions.ExtraHosts,