From 98b3900470de6636de6b160c7fab75d10cae88b0 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:19:28 +0000 Subject: [PATCH] policy/v2: resolve user:*@domain to that domain's users SaaS accepts it and lists those users' nodes. It was accepted here too, then compiled to nothing with no log line. --- .../policy/v2/tailscale_grants_compat_test.go | 12 ------------ .../policy/v2/tailscale_ssh_data_compat_test.go | 5 +---- hscontrol/policy/v2/types.go | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/hscontrol/policy/v2/tailscale_grants_compat_test.go b/hscontrol/policy/v2/tailscale_grants_compat_test.go index 54da13bd1..d9a1e94b5 100644 --- a/hscontrol/policy/v2/tailscale_grants_compat_test.go +++ b/hscontrol/policy/v2/tailscale_grants_compat_test.go @@ -141,19 +141,7 @@ func loadGrantTestFile(t *testing.T, path string) *testcapture.Capture { // Skip categories document WHY tests are expected to differ from Tailscale SaaS. // Tests are grouped by root cause. -// -// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern not supported -// -// Total: 2 tests skipped, ~246 tests expected to pass. var grantSkipReasons = map[string]string{ - // USER_PASSKEY_WILDCARD (2 tests) - // - // Tailscale SaaS policies can use user:*@passkey as a wildcard matching - // all passkey-authenticated users. headscale does not support passkey - // authentication and has no equivalent for this wildcard pattern. - "grant-k20": "USER_PASSKEY_WILDCARD: src=user:*@passkey not supported in headscale", - "grant-k21": "USER_PASSKEY_WILDCARD: dst=user:*@passkey not supported in headscale", - // SaaS lists contiguous source addresses one by one and writes // autogroup:internet as address ranges; headscale merges sources into // ranges and writes the internet as prefixes. The address sets match; diff --git a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go index b5cf20511..4cda24e23 100644 --- a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go @@ -124,10 +124,7 @@ func TestSSHForwardingDataCompat(t *testing.T) { // sshSkipReasons documents captures the upstream control plane accepts // but headscale cannot yet represent, keyed for the whole capture // or //. Each entry names the feature gap. -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", -} +var sshSkipReasons = map[string]string{} // sshRejectSkipReasons documents captures the upstream control plane // rejects for reasons headscale cannot apply, keyed for the whole diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 4aa492d62..4255e5031 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -498,6 +498,22 @@ func (u *Username) Resolve(_ *Policy, users types.Users, nodes views.Slice[types func (u *Username) resolve(_ *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) { var ips netipx.IPSetBuilder + // user:*@domain names every user of that email domain, as on SaaS. + if domain, ok := strings.CutPrefix(string(*u), "user:*@"); ok { + for _, node := range nodes.All() { + if node.IsTagged() || !node.User().Valid() { + continue + } + + _, emailDomain, found := strings.CutLast(node.User().Email(), "@") + if found && strings.EqualFold(emailDomain, domain) { + node.AppendToIPSet(&ips) + } + } + + return ips.IPSet() + } + user, err := u.resolveUser(users) if err != nil { // Users may be missing before registration (#2863), or become