policy/v2: map login 0 to no local user in SSH rules

Every rule that maps root now carries "0": "", as Tailscale SaaS writes
it, also when the policy lists "0".
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 17:46:51 +00:00
1 parent cb706b1999
commit b56b6dcae1
8 files changed
+67 -206

No files matched your search

+10 -3
View File
@@ -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,
+7
View File
@@ -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,
+27 -26
View File
@@ -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,
+6 -2
View File
@@ -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
}
@@ -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",
}
@@ -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 <test_id> for the whole
// capture or <test_id>/<check>. 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()
@@ -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 (
+1 -1
View File
@@ -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)