mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-28 01:06:50 +09:00
policy/v2: revoke stale SSH access and recheck authentication
Updates #3517, #3508
(cherry picked from commit 4fd4da75f2)
This commit is contained in:
1 parent
00f670213a
commit
5fb2d4e089
21 files changed
+1202
-121
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user