diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go index 53f5813d5..22d2cf7fd 100644 --- a/hscontrol/policy/policy_test.go +++ b/hscontrol/policy/policy_test.go @@ -385,6 +385,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "*": "=", "root": "", }, @@ -424,6 +425,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "*": "=", "root": "", }, @@ -538,6 +540,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "autogroup:invalid": "autogroup:invalid", "root": "", }, @@ -577,6 +580,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "*": "=", "root": "", }, @@ -616,6 +620,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "*": "=", "root": "root", }, @@ -655,6 +660,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "root": "root", "ubuntu": "ubuntu", }, @@ -705,6 +711,7 @@ func TestSSHPolicyRules(t *testing.T) { {NodeIP: "100.64.0.2"}, }, SSHUsers: map[string]string{ + "0": "", "debian": "debian", "root": "", }, @@ -738,7 +745,7 @@ func TestSSHPolicyRules(t *testing.T) { wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: &tailcfg.SSHAction{ Accept: true, AllowAgentForwarding: true, @@ -758,7 +765,7 @@ func TestSSHPolicyRules(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.7"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: &tailcfg.SSHAction{ Accept: true, AllowAgentForwarding: true, @@ -799,7 +806,7 @@ func TestSSHPolicyRules(t *testing.T) { wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}}, - SSHUsers: map[string]string{"root": "root"}, + SSHUsers: map[string]string{"0": "", "root": "root"}, Action: &tailcfg.SSHAction{ Accept: true, AllowAgentForwarding: true, diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index 53dc7e915..a5ce1d86a 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -283,6 +283,10 @@ type compiledSSHRule struct { destRules []*tailcfg.SSHRule } +// uidZeroLogin is a login name that can resolve to uid 0 on the node, so +// every rule that maps root maps it to no local user, as Tailscale SaaS does. +const uidZeroLogin = "0" + // sshSources is what the sources of an [SSH] rule resolve to. It is // read-only once built: rules with the same source addresses share it. type sshSources struct { @@ -407,6 +411,9 @@ func (pol *Policy) compileSSHRules( cr.sshUsers[u.String()] = u.String() } + // After the literal users, so listing "0" cannot map it. + cr.sshUsers[uidZeroLogin] = "" + if cr.hasLocalpart { cr.localpartByUser = resolveLocalparts( rule.Users.LocalpartEntries(), users, diff --git a/hscontrol/policy/v2/filter_test.go b/hscontrol/policy/v2/filter_test.go index 4ae4f1639..d4f46988c 100644 --- a/hscontrol/policy/v2/filter_test.go +++ b/hscontrol/policy/v2/filter_test.go @@ -460,7 +460,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: user2Principal, - SSHUsers: map[string]string{"root": "", "ssh-it-user": "ssh-it-user"}, + SSHUsers: map[string]string{"0": "", "root": "", "ssh-it-user": "ssh-it-user"}, Action: acceptAction, }, }}, @@ -487,7 +487,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: user2Principal, - SSHUsers: map[string]string{"root": "", "ubuntu": "ubuntu", "admin": "admin", "deploy": "deploy"}, + SSHUsers: map[string]string{"0": "", "root": "", "ubuntu": "ubuntu", "admin": "admin", "deploy": "deploy"}, Action: acceptAction, }, }}, @@ -514,7 +514,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: user2Principal, - SSHUsers: map[string]string{"*": "=", "root": ""}, + SSHUsers: map[string]string{"0": "", "*": "=", "root": ""}, Action: acceptAction, }, }}, @@ -541,7 +541,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: user2Principal, - SSHUsers: map[string]string{"root": "root"}, + SSHUsers: map[string]string{"0": "", "root": "root"}, Action: acceptAction, }, }}, @@ -568,7 +568,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: user2Principal, - SSHUsers: map[string]string{"*": "=", "root": "root"}, + SSHUsers: map[string]string{"0": "", "*": "=", "root": "root"}, Action: acceptAction, }, }}, @@ -595,7 +595,7 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: user2Principal, - SSHUsers: map[string]string{"*": "=", "root": "root", "ubuntu": "ubuntu", "admin": "admin"}, + SSHUsers: map[string]string{"0": "", "*": "=", "root": "root", "ubuntu": "ubuntu", "admin": "admin"}, Action: acceptAction, }, }}, @@ -715,7 +715,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.2"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { @@ -725,7 +725,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.3"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { @@ -735,12 +735,12 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.4"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.5"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, }}, @@ -765,7 +765,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.2"}}, - SSHUsers: map[string]string{"root": "root"}, + SSHUsers: map[string]string{"0": "", "root": "root"}, Action: acceptAction, }, { @@ -775,7 +775,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.3"}}, - SSHUsers: map[string]string{"root": "root"}, + SSHUsers: map[string]string{"0": "", "root": "root"}, Action: acceptAction, }, { @@ -785,12 +785,12 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.4"}}, - SSHUsers: map[string]string{"root": "root"}, + SSHUsers: map[string]string{"0": "", "root": "root"}, Action: acceptAction, }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.5"}}, - SSHUsers: map[string]string{"root": "root"}, + SSHUsers: map[string]string{"0": "", "root": "root"}, Action: acceptAction, }, }}, @@ -815,22 +815,22 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.2"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.3"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.4"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.5"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, }}, @@ -869,7 +869,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.10"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { @@ -913,7 +913,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.5"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, }}, @@ -942,7 +942,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.2"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { @@ -952,7 +952,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.3"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { @@ -962,7 +962,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.4"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, { @@ -972,7 +972,7 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) { }, { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.5"}}, - SSHUsers: map[string]string{"root": ""}, + SSHUsers: map[string]string{"0": "", "root": ""}, Action: acceptAction, }, }}, @@ -1057,6 +1057,7 @@ func TestCompileSSHPolicy_CheckAction(t *testing.T) { // Verify SSH users are correctly mapped expectedUsers := map[string]string{ + "0": "", "ssh-it-user": "ssh-it-user", "root": "", } @@ -1201,7 +1202,7 @@ func TestSSHIntegrationReproduction(t *testing.T) { want := &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.2"}}, - SSHUsers: map[string]string{"root": "", "ssh-it-user": "ssh-it-user"}, + SSHUsers: map[string]string{"0": "", "root": "", "ssh-it-user": "ssh-it-user"}, Action: &tailcfg.SSHAction{ Accept: true, AllowAgentForwarding: true, @@ -1252,7 +1253,7 @@ func TestSSHJSONSerialization(t *testing.T) { want := &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.1"}}, - SSHUsers: map[string]string{"root": "", "ssh-it-user": "ssh-it-user", "ubuntu": "ubuntu", "admin": "admin"}, + SSHUsers: map[string]string{"0": "", "root": "", "ssh-it-user": "ssh-it-user", "ubuntu": "ubuntu", "admin": "admin"}, Action: &tailcfg.SSHAction{ Accept: true, AllowAgentForwarding: true, diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index f2d5d5afd..596a41bab 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -639,18 +639,22 @@ func sshNodesShareUser(srcNode, dstNode types.NodeView) bool { // sshRuleAllowsUser reports whether rule lets src log in as localUser, // mirroring the SSHUsers maps compileSSHPolicy emits: root when listed, // other users via autogroup:nonroot or a literal name, and anyone, root -// included, whose name is the source user's localpart. +// included, whose name is the source user's localpart. Login "0" only the +// last way: the rule that maps root maps it to no local user. func (pm *PolicyManager) sshRuleAllowsUser(rule SSH, srcNode types.NodeView, localUser string) bool { isRoot := localUser == "root" switch { case localUser == "": return false + case localUser == uidZeroLogin: case isRoot && rule.Users.ContainsRoot(), !isRoot && rule.Users.ContainsNonRoot(), !isRoot && slices.Contains(rule.Users.NormalUsers(), SSHUser(localUser)): return true - case srcNode.IsTagged() || !srcNode.User().Valid() || !rule.Users.ContainsLocalpart(): + } + + if srcNode.IsTagged() || !srcNode.User().Valid() || !rule.Users.ContainsLocalpart() { return false } diff --git a/hscontrol/policy/v2/tailscale_sequence_compat_test.go b/hscontrol/policy/v2/tailscale_sequence_compat_test.go index 81d19e149..6d6ac6227 100644 --- a/hscontrol/policy/v2/tailscale_sequence_compat_test.go +++ b/hscontrol/policy/v2/tailscale_sequence_compat_test.go @@ -41,38 +41,6 @@ var policySequenceSkipReasons = map[string]string{ "policy-generation/G3/rt-b/filter": viaRuleMerged, "policy-generation/G4/rt-b/filter": viaRuleMerged, "policy-generation/G5/rt-b/filter": viaRuleMerged, - - "policy-generation/G0/odin-1/uid-zero": sshUIDZero, - "policy-generation/G0/srv/uid-zero": sshUIDZero, - "policy-generation/G0/thor-1/uid-zero": sshUIDZero, - "policy-generation/G1/freya-1/uid-zero": sshUIDZero, - "policy-generation/G1/odin-1/uid-zero": sshUIDZero, - "policy-generation/G1/srv/uid-zero": sshUIDZero, - "policy-generation/G1/thor-1/uid-zero": sshUIDZero, - "policy-generation/G2/freya-1/uid-zero": sshUIDZero, - "policy-generation/G2/odin-1/uid-zero": sshUIDZero, - "policy-generation/G2/srv/uid-zero": sshUIDZero, - "policy-generation/G2/thor-1/uid-zero": sshUIDZero, - "policy-generation/G2/thor-2/uid-zero": sshUIDZero, - "policy-generation/G3/freya-1/uid-zero": sshUIDZero, - "policy-generation/G3/odin-1/uid-zero": sshUIDZero, - "policy-generation/G3/srv/uid-zero": sshUIDZero, - "policy-generation/G3/thor-1/uid-zero": sshUIDZero, - "policy-generation/G3/thor-2/uid-zero": sshUIDZero, - "policy-generation/G4/freya-1/uid-zero": sshUIDZero, - "policy-generation/G4/odin-1/uid-zero": sshUIDZero, - "policy-generation/G4/srv/uid-zero": sshUIDZero, - "policy-generation/G4/thor-1/uid-zero": sshUIDZero, - "policy-generation/G4/thor-2/uid-zero": sshUIDZero, - "policy-generation/G5/freya-1/uid-zero": sshUIDZero, - "policy-generation/G5/odin-1/uid-zero": sshUIDZero, - "policy-generation/G5/srv/uid-zero": sshUIDZero, - "policy-generation/G5/thor-2/uid-zero": sshUIDZero, - "policy-generation/G7/freya-1/uid-zero": sshUIDZero, - "policy-generation/G7/odin-1/uid-zero": sshUIDZero, - "policy-generation/G7/srv/uid-zero": sshUIDZero, - "policy-generation/G8/freya-1/uid-zero": sshUIDZero, - "policy-generation/G8/odin-1/uid-zero": sshUIDZero, } const viaRuleMerged = "VIA_RULE_MERGED: on a via router headscale merges the via grant's filter rule with an ACL rule of the same sources; SaaS sends the two apart" @@ -82,6 +50,7 @@ const viaRuleMerged = "VIA_RULE_MERGED: on a via router headscale merges the via var policySequenceNotCompared = []string{ "policy-generation/G7/rt-plain/filter", "policy-generation/G7/srv/ssh-rules", + "policy-generation/G7/srv/uid-zero", "policy-generation/G7/srv/filter", "policy-generation/G7/rt-b/filter", } diff --git a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go index 231c1ee7d..e9f4c11a3 100644 --- a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go @@ -127,143 +127,8 @@ func TestSSHForwardingDataCompat(t *testing.T) { var sshSkipReasons = map[string]string{ "ssh-b5": "headscale has no passkey authentication; user:*@passkey wildcard unsupported", "ssh-d10": "headscale has no passkey authentication; user:*@passkey wildcard unsupported", - - "ssh-a1/bulbasaur/uid-zero": sshUIDZero, - "ssh-a1/ivysaur/uid-zero": sshUIDZero, - "ssh-a1/venusaur/uid-zero": sshUIDZero, - "ssh-a2/bulbasaur/uid-zero": sshUIDZero, - "ssh-a2/ivysaur/uid-zero": sshUIDZero, - "ssh-a2/venusaur/uid-zero": sshUIDZero, - "ssh-a3/bulbasaur/uid-zero": sshUIDZero, - "ssh-a3/ivysaur/uid-zero": sshUIDZero, - "ssh-a3/venusaur/uid-zero": sshUIDZero, - "ssh-a4/beedrill/uid-zero": sshUIDZero, - "ssh-a5/beedrill/uid-zero": sshUIDZero, - "ssh-a6/bulbasaur/uid-zero": sshUIDZero, - "ssh-a6/ivysaur/uid-zero": sshUIDZero, - "ssh-a6/venusaur/uid-zero": sshUIDZero, - "ssh-a7/bulbasaur/uid-zero": sshUIDZero, - "ssh-a7/ivysaur/uid-zero": sshUIDZero, - "ssh-a7/venusaur/uid-zero": sshUIDZero, - "ssh-a8/bulbasaur/uid-zero": sshUIDZero, - "ssh-a8/ivysaur/uid-zero": sshUIDZero, - "ssh-a8/venusaur/uid-zero": sshUIDZero, - "ssh-acceptenv-null/beedrill/uid-zero": sshUIDZero, - "ssh-acceptenv-omitted/beedrill/uid-zero": sshUIDZero, - "ssh-b1/beedrill/uid-zero": sshUIDZero, - "ssh-b2/beedrill/uid-zero": sshUIDZero, - "ssh-b3/beedrill/uid-zero": sshUIDZero, - "ssh-b6/beedrill/uid-zero": sshUIDZero, - "ssh-c1/bulbasaur/uid-zero": sshUIDZero, - "ssh-c1/ivysaur/uid-zero": sshUIDZero, - "ssh-c1/venusaur/uid-zero": sshUIDZero, - "ssh-c2/beedrill/uid-zero": sshUIDZero, - "ssh-c3/ivysaur/uid-zero": sshUIDZero, - "ssh-c4/beedrill/uid-zero": sshUIDZero, - "ssh-c4/kakuna/uid-zero": sshUIDZero, - "ssh-checkperiod-null/beedrill/uid-zero": sshUIDZero, - "ssh-d11/beedrill/uid-zero": sshUIDZero, - "ssh-d11/bulbasaur/uid-zero": sshUIDZero, - "ssh-d11/ivysaur/uid-zero": sshUIDZero, - "ssh-d11/venusaur/uid-zero": sshUIDZero, - "ssh-d12/beedrill/uid-zero": sshUIDZero, - "ssh-d12/bulbasaur/uid-zero": sshUIDZero, - "ssh-d12/ivysaur/uid-zero": sshUIDZero, - "ssh-d12/venusaur/uid-zero": sshUIDZero, - "ssh-d2/beedrill/uid-zero": sshUIDZero, - "ssh-d2/bulbasaur/uid-zero": sshUIDZero, - "ssh-d2/ivysaur/uid-zero": sshUIDZero, - "ssh-d2/venusaur/uid-zero": sshUIDZero, - "ssh-d3/beedrill/uid-zero": sshUIDZero, - "ssh-d3/bulbasaur/uid-zero": sshUIDZero, - "ssh-d3/ivysaur/uid-zero": sshUIDZero, - "ssh-d3/venusaur/uid-zero": sshUIDZero, - "ssh-d4/beedrill/uid-zero": sshUIDZero, - "ssh-d4/bulbasaur/uid-zero": sshUIDZero, - "ssh-d4/ivysaur/uid-zero": sshUIDZero, - "ssh-d4/venusaur/uid-zero": sshUIDZero, - "ssh-d5/beedrill/uid-zero": sshUIDZero, - "ssh-d5/bulbasaur/uid-zero": sshUIDZero, - "ssh-d5/ivysaur/uid-zero": sshUIDZero, - "ssh-d5/venusaur/uid-zero": sshUIDZero, - "ssh-d6/beedrill/uid-zero": sshUIDZero, - "ssh-d6/bulbasaur/uid-zero": sshUIDZero, - "ssh-d6/ivysaur/uid-zero": sshUIDZero, - "ssh-d6/venusaur/uid-zero": sshUIDZero, - "ssh-d7/beedrill/uid-zero": sshUIDZero, - "ssh-d7/bulbasaur/uid-zero": sshUIDZero, - "ssh-d7/ivysaur/uid-zero": sshUIDZero, - "ssh-d7/venusaur/uid-zero": sshUIDZero, - "ssh-d8/bulbasaur/uid-zero": sshUIDZero, - "ssh-d8/ivysaur/uid-zero": sshUIDZero, - "ssh-d8/venusaur/uid-zero": sshUIDZero, - "ssh-d9/bulbasaur/uid-zero": sshUIDZero, - "ssh-d9/ivysaur/uid-zero": sshUIDZero, - "ssh-d9/venusaur/uid-zero": sshUIDZero, - "ssh-dst-trailing-whitespace/beedrill/uid-zero": sshUIDZero, - "ssh-duplicate-rules/beedrill/uid-zero": sshUIDZero, - "ssh-duplicate-rules/kakuna/uid-zero": sshUIDZero, - "ssh-e5/beedrill/uid-zero": sshUIDZero, - "ssh-e5/kakuna/uid-zero": sshUIDZero, - "ssh-e6/beedrill/uid-zero": sshUIDZero, - "ssh-e6/bulbasaur/uid-zero": sshUIDZero, - "ssh-e6/ivysaur/uid-zero": sshUIDZero, - "ssh-e6/venusaur/uid-zero": sshUIDZero, - "ssh-equal-src-aliases/beedrill/uid-zero": sshUIDZero, - "ssh-f1/beedrill/uid-zero": sshUIDZero, - "ssh-f1/bulbasaur/uid-zero": sshUIDZero, - "ssh-f1/ivysaur/uid-zero": sshUIDZero, - "ssh-f1/venusaur/uid-zero": sshUIDZero, - "ssh-f2/beedrill/uid-zero": sshUIDZero, - "ssh-f3/beedrill/uid-zero": sshUIDZero, - "ssh-f3/bulbasaur/uid-zero": sshUIDZero, - "ssh-f3/ivysaur/uid-zero": sshUIDZero, - "ssh-f3/venusaur/uid-zero": sshUIDZero, - "ssh-f4/beedrill/uid-zero": sshUIDZero, - "ssh-f4/bulbasaur/uid-zero": sshUIDZero, - "ssh-f4/ivysaur/uid-zero": sshUIDZero, - "ssh-f4/venusaur/uid-zero": sshUIDZero, - "ssh-f5/beedrill/uid-zero": sshUIDZero, - "ssh-f5/bulbasaur/uid-zero": sshUIDZero, - "ssh-f5/ivysaur/uid-zero": sshUIDZero, - "ssh-f5/venusaur/uid-zero": sshUIDZero, - "ssh-g1/beedrill/uid-zero": sshUIDZero, - "ssh-g2/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-acceptenv-bad-glob/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-acceptenv-glob-shapes/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-action-whitespace/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-checkperiod-exact-max/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-checkperiod-too-short/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-checkperiod-zero/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-autogroup-internet/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-autogroup-member/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-autogroup-self/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-autogroup-tagged/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-group-prefix/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-localpart-empty/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-localpart-no-at/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-localpart-no-domain/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-localpart-no-glob/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-tag-prefix/beedrill/uid-zero": sshUIDZero, - "ssh-malformed-user-whitespace-leading/beedrill/uid-zero": sshUIDZero, - "ssh-many-tags-as-dst/beedrill/uid-zero": sshUIDZero, - "ssh-many-tags-as-dst/kakuna/uid-zero": sshUIDZero, - "ssh-multi-rule-accept-then-check/beedrill/uid-zero": sshUIDZero, - "ssh-multi-rule-check-then-accept/beedrill/uid-zero": sshUIDZero, - "ssh-multi-rule-disjoint-srcs/beedrill/uid-zero": sshUIDZero, - "ssh-multi-rule-overlap-conflicting-acceptenv/beedrill/uid-zero": sshUIDZero, - "ssh-multi-rule-overlap-different-users/beedrill/uid-zero": sshUIDZero, - "ssh-multi-src-multi-dst-multi-user/beedrill/uid-zero": sshUIDZero, - "ssh-multi-src-multi-dst-multi-user/kakuna/uid-zero": sshUIDZero, - "ssh-src-autogroup-tagged-no-tagged-nodes/beedrill/uid-zero": sshUIDZero, - "ssh-src-leading-whitespace/beedrill/uid-zero": sshUIDZero, - "ssh-tab-in-user/beedrill/uid-zero": sshUIDZero, - "ssh-unicode-emoji-user/beedrill/uid-zero": sshUIDZero, - "ssh-unicode-rtl-user/beedrill/uid-zero": sshUIDZero, } -const sshUIDZero = `SSH_UID_ZERO: SaaS maps login "0" to no local user in every rule; headscale leaves it out` - // sshRejectSkipReasons documents captures the upstream control plane // rejects for reasons headscale cannot apply, keyed for the whole // capture or /. Each entry names the feature gap. @@ -514,7 +379,7 @@ func withoutUIDZero(capture testcapture.Node) testcapture.Node { rules := make([]*tailcfg.SSHRule, 0, len(capture.SSHRules)) for _, r := range capture.SSHRules { - if local, ok := r.SSHUsers["0"]; ok && local == "" { + if mapsUIDZero(r) { c := *r c.SSHUsers = maps.Clone(r.SSHUsers) delete(c.SSHUsers, "0") @@ -529,6 +394,13 @@ func withoutUIDZero(capture testcapture.Node) testcapture.Node { return capture } +// mapsUIDZero reports whether r maps login "0" to no local user. +func mapsUIDZero(r *tailcfg.SSHRule) bool { + local, ok := r.SSHUsers["0"] + + return ok && local == "" +} + // uidZeroMismatch compares, rule by rule, which rules map login "0" to no // local user; "" is a match. func uidZeroMismatch(pm *PolicyManager, node *types.Node, capture testcapture.Node) string { @@ -540,8 +412,7 @@ func uidZeroMismatch(pm *PolicyManager, node *types.Node, capture testcapture.No uidZero := func(rules []*tailcfg.SSHRule) []bool { out := make([]bool, 0, len(rules)) for _, r := range rules { - local, ok := r.SSHUsers["0"] - out = append(out, ok && local == "") + out = append(out, mapsUIDZero(r)) } return out @@ -679,22 +550,25 @@ func TestSSHCheckParamsMatchesCaptures(t *testing.T) { // TestSSHCheckParamsMatchesCompiledRules pins SSHCheckParams to headscale's // own compiled rules for shapes the captures lack, such as a user whose -// email localpart is root. +// email localpart is root or "0". func TestSSHCheckParamsMatchesCompiledRules(t *testing.T) { t.Parallel() users := types.Users{ {Name: "root", Email: "root@example.com"}, {Name: "alice", Email: "alice@example.com"}, + {Name: "zero", Email: "0@example.com"}, } users[0].ID = 1 users[1].ID = 2 + users[2].ID = 3 nodes := types.Nodes{ node("root-1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0]), node("root-2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0]), node("alice-1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1]), node("server", "100.64.0.4", "fd7a:115c:a1e0::4", users[1]), + node("zero-1", "100.64.0.5", "fd7a:115c:a1e0::5", users[2]), } for i, n := range nodes { n.ID = types.NodeID(i + 1) //nolint:gosec @@ -715,6 +589,7 @@ func TestSSHCheckParamsMatchesCompiledRules(t *testing.T) { "localpart on self": {check("autogroup:self", "localpart:*@example.com")}, "localpart then root": {check("tag:server", "localpart:*@example.com"), check("autogroup:self", "root")}, "nonroot and literal on self": {check("autogroup:self", "autogroup:nonroot", "deploy")}, + "literal 0 on self": {check("autogroup:self", "0", "autogroup:nonroot")}, } { t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/hscontrol/policy/v2/tailscale_users_compat_test.go b/hscontrol/policy/v2/tailscale_users_compat_test.go index 19b6759f3..8db392f3d 100644 --- a/hscontrol/policy/v2/tailscale_users_compat_test.go +++ b/hscontrol/policy/v2/tailscale_users_compat_test.go @@ -206,9 +206,7 @@ var userTokenSkipReasons = map[string]string{ "users-bare-at-test-src/no-email-two/user-list": bareAtNoEmail, "users-bare-at-test-src/okta-name-only/verdict": bareAtNoEmail, - "users-short-name/server/filter": shortUsername, - "users-unknown-member/prod/ssh": sshUIDZero, - "users-unknown-member/server/ssh": sshUIDZero, + "users-short-name/server/filter": shortUsername, } const ( diff --git a/hscontrol/servertest/scale_test.go b/hscontrol/servertest/scale_test.go index 703692383..55f1614be 100644 --- a/hscontrol/servertest/scale_test.go +++ b/hscontrol/servertest/scale_test.go @@ -470,7 +470,7 @@ func checkScaleSSH( require.True(t, rule.Action.Accept) require.False(t, rule.Action.Reject) require.Empty(t, rule.Action.HoldAndDelegate) - require.Equal(t, map[string]string{"root": "root"}, rule.SSHUsers) + require.Equal(t, map[string]string{"0": "", "root": "root"}, rule.SSHUsers) wantIPs := make(map[string]bool)