From 22ee2bfc9cad3492c35cf7531c83e190d6ac552b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 8 Dec 2025 18:51:07 +0100 Subject: [PATCH] tags: process tags on registration, simplify policy (#2931) This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model. When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users. A node can either be owned by a user, or a tag. Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time. Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code. Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different. A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one. --- .github/workflows/test-integration.yaml | 31 +- .golangci.yaml | 10 + AGENTS.md | 41 +- CHANGELOG.md | 8 + go.sum | 6 - hscontrol/auth_test.go | 135 +- hscontrol/grpcv1.go | 9 +- hscontrol/grpcv1_test.go | 4 +- hscontrol/mapper/builder.go | 4 +- hscontrol/mapper/tail.go | 23 +- hscontrol/mapper/tail_test.go | 10 - hscontrol/policy/pm.go | 3 + hscontrol/policy/v2/policy.go | 215 +- hscontrol/policy/v2/types.go | 189 +- hscontrol/policy/v2/types_test.go | 333 ++- hscontrol/state/state.go | 59 +- hscontrol/state/tags.go | 45 +- integration/acl_test.go | 33 +- integration/cli_test.go | 596 ------ integration/control.go | 2 + integration/hsic/hsic.go | 81 + integration/route_test.go | 71 +- integration/scenario.go | 42 +- integration/tags_test.go | 2465 +++++++++++++++++++++++ 24 files changed, 3414 insertions(+), 1001 deletions(-) create mode 100644 integration/tags_test.go diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index eebadb74..a9597cc3 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -54,10 +54,6 @@ jobs: - TestPreAuthKeyCommandReusableEphemeral - TestPreAuthKeyCorrectUserLoggedInCommand - TestApiKeyCommand - - TestNodeTagCommand - - TestTaggedNodeRegistration - - TestTagPersistenceAcrossRestart - - TestNodeAdvertiseTagCommand - TestNodeCommand - TestNodeExpireCommand - TestNodeRenameCommand @@ -97,6 +93,33 @@ jobs: - TestSSHIsBlockedInACL - TestSSHUserOnlyIsolation - TestSSHAutogroupSelf + - TestTagsAuthKeyWithTagRequestDifferentTag + - TestTagsAuthKeyWithTagNoAdvertiseFlag + - TestTagsAuthKeyWithTagCannotAddViaCLI + - TestTagsAuthKeyWithTagCannotChangeViaCLI + - TestTagsAuthKeyWithTagAdminOverrideReauthPreserves + - TestTagsAuthKeyWithTagCLICannotModifyAdminTags + - TestTagsAuthKeyWithoutTagCannotRequestTags + - TestTagsAuthKeyWithoutTagRegisterNoTags + - TestTagsAuthKeyWithoutTagCannotAddViaCLI + - TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset + - TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise + - TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag + - TestTagsUserLoginOwnedTagAtRegistration + - TestTagsUserLoginNonExistentTagAtRegistration + - TestTagsUserLoginUnownedTagAtRegistration + - TestTagsUserLoginAddTagViaCLIReauth + - TestTagsUserLoginRemoveTagViaCLIReauth + - TestTagsUserLoginCLINoOpAfterAdminAssignment + - TestTagsUserLoginCLICannotRemoveAdminTags + - TestTagsAuthKeyWithTagRequestNonExistentTag + - TestTagsAuthKeyWithTagRequestUnownedTag + - TestTagsAuthKeyWithoutTagRequestNonExistentTag + - TestTagsAuthKeyWithoutTagRequestUnownedTag + - TestTagsAdminAPICannotSetNonExistentTag + - TestTagsAdminAPICanSetUnownedTag + - TestTagsAdminAPICannotRemoveAllTags + - TestTagsAdminAPICannotSetInvalidFormat uses: ./.github/workflows/integration-test-template.yml with: test: ${{ matrix.test }} diff --git a/.golangci.yaml b/.golangci.yaml index 79b042a0..eda3bed4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -7,6 +7,7 @@ linters: - depguard - dupl - exhaustruct + - funcorder - funlen - gochecknoglobals - gochecknoinits @@ -28,6 +29,15 @@ linters: - wrapcheck - wsl settings: + forbidigo: + forbid: + # Forbid time.Sleep everywhere with context-appropriate alternatives + - pattern: 'time\.Sleep' + msg: >- + time.Sleep is forbidden. + In tests: use assert.EventuallyWithT for polling/waiting patterns. + In production code: use a backoff strategy (e.g., cenkalti/backoff) or proper synchronization primitives. + analyze-types: true gocritic: disabled-checks: - appendAssign diff --git a/AGENTS.md b/AGENTS.md index 42da654c..981183a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -411,7 +411,46 @@ go run ./cmd/hi run "TestPattern*" - Only ONE test can run at a time (Docker port conflicts) - Tests generate ~100MB of logs per run in `control_logs/` -- Clean environment before each test: `rm -rf control_logs/202507* && docker system prune -f` +- Clean environment before each test: `sudo rm -rf control_logs/202* && docker system prune -f` + +### Full Matrix Testing + +Some integration tests support **full matrix mode** that tests all combinations of test dimensions. This is critical for comprehensive validation but can take up to 2 hours to complete. + +**Example: TestAutoApproveMultiNetwork Full Matrix** + +```bash +# Set GOPATH to avoid environment issues +export GOPATH=$HOME/go + +# Enable full matrix mode and run with generous timeout +HEADSCALE_INTEGRATION_FULL_MATRIX=1 go run ./cmd/hi run "TestAutoApproveMultiNetwork" --timeout=7200s +``` + +**Full Matrix Dimensions:** +- **Base scenarios (6):** All combinations of: + - Auth methods: `authkey`, `webauth` + - Approver types: `tag`, `user`, `group` +- **Policy modes (2):** `database`, `file` +- **Advertisement timing (2):** `advertiseduringup-true`, `advertiseduringup-false` +- **Total combinations:** 6 × 2 × 2 = **24 tests** + +**Default (minimal) mode:** Runs only 3 representative tests covering all dimensions: +- `authkey-tag-advertiseduringup-false-pol-database` +- `webauth-user-advertiseduringup-true-pol-file` +- `authkey-group-advertiseduringup-false-pol-file` + +**Full Matrix Requirements:** +- **Time:** Up to 2 hours for complete execution +- **Disk space:** ~2-3GB for all test artifacts +- **Environment:** Clean Docker state before starting +- **Timeout:** Use `--timeout=7200s` (2 hours) minimum + +**When to use full matrix:** +- Before major releases or merges to main +- After changes to route management, ACL evaluation, or policy engine +- When debugging flaky tests or cross-scenario issues +- For comprehensive validation of tags-as-identity changes ### Test Artifacts Location diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd56c09..1aae7589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ backwards compatibility. Tags are now implemented following the Tailscale model where tags and user ownership are mutually exclusive. Devices can be either user-owned (authenticated via web/OIDC) or tagged (authenticated via tagged PreAuthKeys). Tagged devices receive their identity from tags rather than users, making them suitable for servers and infrastructure. Applying a tag to a device removes user-based authentication. See the [Tailscale tags documentation](https://tailscale.com/kb/1068/tags) for details on how tags work. +User-owned nodes can now request tags during registration using `--advertise-tags`. Tags are validated against the `tagOwners` policy and applied at registration time. Tags can be managed via the CLI or API after registration. + ### Database migration support removed for pre-0.25.0 databases Headscale no longer supports direct upgrades from databases created before @@ -36,6 +38,12 @@ release. - **Tags**: The gRPC `SetTags` endpoint now allows converting user-owned nodes to tagged nodes by setting tags. Once a node is tagged, it cannot be converted back to a user-owned node. +- **Tags**: Tags are now resolved from the node's stored Tags field only [#2931](https://github.com/juanfont/headscale/pull/2931) + - `--advertise-tags` is processed during registration, not on every policy evaluation + - PreAuthKey tagged devices ignore `--advertise-tags` from clients + - User-owned nodes can use `--advertise-tags` if authorized by `tagOwners` policy + - Tags can be managed via CLI (`headscale nodes tag`) or the SetTags API after registration + - Database migration support removed for pre-0.25.0 databases [#2883](https://github.com/juanfont/headscale/pull/2883) - If you are running a version older than 0.25.0, you must upgrade to 0.25.1 first, then upgrade to this release - See the [upgrade path documentation](https://headscale.net/stable/about/faq/#what-is-the-recommended-update-path-can-i-skip-multiple-versions-while-updating) for detailed guidance diff --git a/go.sum b/go.sum index e78e9aff..39858cdc 100644 --- a/go.sum +++ b/go.sum @@ -124,8 +124,6 @@ github.com/creachadair/command v0.2.0 h1:qTA9cMMhZePAxFoNdnk6F6nn94s1qPndIg9hJbq github.com/creachadair/command v0.2.0/go.mod h1:j+Ar+uYnFsHpkMeV9kGj6lJ45y9u2xqtg8FYy6cm+0o= github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE= github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= -github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q= -github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q= github.com/creachadair/mds v0.25.10 h1:9k9JB35D1xhOCFl0liBhagBBp8fWWkKZrA7UXsfoHtA= github.com/creachadair/mds v0.25.10/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= @@ -278,8 +276,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -463,8 +459,6 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d h1:mnqtPWYyvNiPU9l9tzO2YbHXU/xV664XthZYA26lOiE= github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d/go.mod h1:9BzmlFc3OLqLzLTF/5AY+BMs+clxMqyhSGzgXIm8mNI= -github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 h1:95eIP97c88cqAFU/8nURjgI9xxPbD+Ci6mY/a79BI/w= -github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694/go.mod h1:veguaG8tVg1H/JG5RfpoUW41I+O8ClPElo/fTYr8mMk= github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 h1:FyiiAvDAxpB0DrW2GW3KOVfi3YFOtsQUEeFWbf55JJU= github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4= github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 h1:JJkDnrAhHvOCttk8z9xeZzcDlzzkRA7+Duxj9cwOyxk= diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index e1e25821..6b9da7ab 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -927,6 +927,82 @@ func TestAuthenticationFlows(t *testing.T) { }, }, + // === ADVERTISE-TAGS (RequestTags) SCENARIOS === + // Tests for client-provided tags via --advertise-tags flag + + // TEST: PreAuthKey registration rejects client-provided RequestTags + // WHAT: Tests that PreAuthKey registrations cannot use client-provided tags + // INPUT: PreAuthKey registration with RequestTags in Hostinfo + // EXPECTED: Registration fails with "requested tags [...] are invalid or not permitted" error + // WHY: PreAuthKey nodes get their tags from the key itself, not from client requests + { + name: "preauth_key_rejects_request_tags", + setupFunc: func(t *testing.T, app *Headscale) (string, error) { + t.Helper() + + user := app.state.CreateUserForTest("pak-requesttags-user") + + pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + if err != nil { + return "", err + } + + return pak.Key, nil + }, + request: func(authKey string) tailcfg.RegisterRequest { + return tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: authKey, + }, + NodeKey: nodeKey1.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "pak-requesttags-node", + RequestTags: []string{"tag:unauthorized"}, + }, + Expiry: time.Now().Add(24 * time.Hour), + } + }, + machineKey: machineKey1.Public, + wantError: true, + }, + + // TEST: Tagged PreAuthKey ignores client-provided RequestTags + // WHAT: Tests that tagged PreAuthKey uses key tags, not client RequestTags + // INPUT: Tagged PreAuthKey registration with different RequestTags + // EXPECTED: Registration fails because RequestTags are rejected for PreAuthKey + // WHY: Tags-as-identity: PreAuthKey tags are authoritative, client cannot override + { + name: "tagged_preauth_key_rejects_client_request_tags", + setupFunc: func(t *testing.T, app *Headscale) (string, error) { + t.Helper() + + user := app.state.CreateUserForTest("tagged-pak-clienttags-user") + keyTags := []string{"tag:authorized"} + + pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, keyTags) + if err != nil { + return "", err + } + + return pak.Key, nil + }, + request: func(authKey string) tailcfg.RegisterRequest { + return tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: authKey, + }, + NodeKey: nodeKey1.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "tagged-pak-clienttags-node", + RequestTags: []string{"tag:client-wants-this"}, // Should be rejected + }, + Expiry: time.Now().Add(24 * time.Hour), + } + }, + machineKey: machineKey1.Public, + wantError: true, // RequestTags rejected for PreAuthKey registrations + }, + // === RE-AUTHENTICATION SCENARIOS === // TEST: Existing node re-authenticates with new pre-auth key // WHAT: Tests that existing node can re-authenticate using new pre-auth key @@ -1202,8 +1278,9 @@ func TestAuthenticationFlows(t *testing.T) { OS: "unknown-os", OSVersion: "999.999.999", DeviceModel: "test-device-model", - RequestTags: []string{"invalid:tag", "another!tag"}, - Services: []tailcfg.Service{{Proto: "tcp", Port: 65535}}, + // Note: RequestTags are not included for PreAuthKey registrations + // since tags come from the key itself, not client requests. + Services: []tailcfg.Service{{Proto: "tcp", Port: 65535}}, }, Expiry: time.Now().Add(24 * time.Hour), } @@ -1315,9 +1392,13 @@ func TestAuthenticationFlows(t *testing.T) { // === AUTH PROVIDER EDGE CASES === // TEST: Interactive workflow preserves custom hostinfo // WHAT: Tests that custom hostinfo fields are preserved through interactive flow - // INPUT: Interactive registration with detailed hostinfo (OS, version, model, etc.) + // INPUT: Interactive registration with detailed hostinfo (OS, version, model) // EXPECTED: Node registers with all hostinfo fields preserved // WHY: Ensures interactive flow doesn't lose custom hostinfo data + // NOTE: RequestTags are NOT tested here because tag authorization via + // advertise-tags requires the user to have existing nodes (for IP-based + // ownership verification). New users registering their first node cannot + // claim tags via RequestTags - they must use a tagged PreAuthKey instead. { name: "interactive_workflow_with_custom_hostinfo", setupFunc: func(t *testing.T, app *Headscale) (string, error) { @@ -1331,7 +1412,6 @@ func TestAuthenticationFlows(t *testing.T) { OS: "linux", OSVersion: "20.04", DeviceModel: "server", - RequestTags: []string{"tag:server"}, }, Expiry: time.Now().Add(24 * time.Hour), } @@ -1353,7 +1433,6 @@ func TestAuthenticationFlows(t *testing.T) { assert.Equal(t, "linux", node.Hostinfo().OS()) assert.Equal(t, "20.04", node.Hostinfo().OSVersion()) assert.Equal(t, "server", node.Hostinfo().DeviceModel()) - assert.Contains(t, node.Hostinfo().RequestTags().AsSlice(), "tag:server") } }, }, @@ -3423,3 +3502,49 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing. nodesAfterAttack := app.state.ListNodesByUser(types.UserID(user.ID)) require.Equal(t, 1, nodesAfterAttack.Len(), "Should still have exactly one node (attack prevented)") } + +// TestWebAuthRejectsUnauthorizedRequestTags tests that web auth registrations +// validate RequestTags against policy and reject unauthorized tags. +func TestWebAuthRejectsUnauthorizedRequestTags(t *testing.T) { + t.Parallel() + + app := createTestApp(t) + + // Create a user that will authenticate via web auth + user := app.state.CreateUserForTest("webauth-tags-user") + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + // Simulate a registration cache entry (as would be created during web auth) + registrationID := types.MustRegistrationID() + regEntry := types.NewRegisterNode(types.Node{ + MachineKey: machineKey.Public(), + NodeKey: nodeKey.Public(), + Hostname: "webauth-tags-node", + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "webauth-tags-node", + RequestTags: []string{"tag:unauthorized"}, // This tag is not in policy + }, + }) + app.state.SetRegistrationCacheEntry(registrationID, regEntry) + + // Complete the web auth - should fail because tag is unauthorized + _, _, err := app.state.HandleNodeFromAuthPath( + registrationID, + types.UserID(user.ID), + nil, // no expiry + "webauth", + ) + + // Expect error due to unauthorized tags + require.Error(t, err, "HandleNodeFromAuthPath should reject unauthorized RequestTags") + require.Contains(t, err.Error(), "requested tags", + "Error should indicate requested tags are invalid or not permitted") + require.Contains(t, err.Error(), "tag:unauthorized", + "Error should mention the rejected tag") + + // Verify no node was created + _, found := app.state.GetNodeByNodeKey(nodeKey.Public()) + require.False(t, found, "Node should not be created when tags are unauthorized") +} diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go index a0409e4e..0cccf5ca 100644 --- a/hscontrol/grpcv1.go +++ b/hscontrol/grpcv1.go @@ -16,7 +16,6 @@ import ( "time" "github.com/rs/zerolog/log" - "github.com/samber/lo" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" @@ -556,13 +555,7 @@ func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.N resp.User = types.TaggedDevices.Proto() } - var tags []string - for _, tag := range node.RequestTags() { - if state.NodeCanHaveTag(node, tag) { - tags = append(tags, tag) - } - } - resp.ValidTags = lo.Uniq(append(tags, node.Tags().AsSlice()...)) + resp.ValidTags = node.Tags().AsSlice() resp.SubnetRoutes = util.PrefixesToString(append(state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...)) response[index] = resp diff --git a/hscontrol/grpcv1_test.go b/hscontrol/grpcv1_test.go index 8a50dc59..65c21ca0 100644 --- a/hscontrol/grpcv1_test.go +++ b/hscontrol/grpcv1_test.go @@ -105,7 +105,7 @@ func TestSetTags_Conversion(t *testing.T) { tags: []string{"tag:server"}, wantErr: true, wantCode: codes.InvalidArgument, - wantErrMessage: "invalid or unauthorized tags", + wantErrMessage: "requested tags", }, { // Conversion is allowed, but tag authorization fails without tagOwners @@ -114,7 +114,7 @@ func TestSetTags_Conversion(t *testing.T) { tags: []string{"tag:server", "tag:database"}, wantErr: true, wantCode: codes.InvalidArgument, - wantErrMessage: "invalid or unauthorized tags", + wantErrMessage: "requested tags", }, { name: "reject non-existent node", diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index b85eb908..6f98e286 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -77,7 +77,7 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { _, matchers := b.mapper.state.Filter() tailnode, err := tailNode( - nv, b.capVer, b.mapper.state, + nv, b.capVer, func(id types.NodeID) []netip.Prefix { return policy.ReduceRoutes(nv, b.mapper.state.GetNodePrimaryRoutes(id), matchers) }, @@ -252,7 +252,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( } tailPeers, err := tailNodes( - changedViews, b.capVer, b.mapper.state, + changedViews, b.capVer, func(id types.NodeID) []netip.Prefix { return policy.ReduceRoutes(node, b.mapper.state.GetNodePrimaryRoutes(id), matchers) }, diff --git a/hscontrol/mapper/tail.go b/hscontrol/mapper/tail.go index 3153f62b..231dc4eb 100644 --- a/hscontrol/mapper/tail.go +++ b/hscontrol/mapper/tail.go @@ -5,21 +5,14 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/types" - "github.com/samber/lo" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/views" ) -// NodeCanHaveTagChecker is an interface for checking if a node can have a tag. -type NodeCanHaveTagChecker interface { - NodeCanHaveTag(node types.NodeView, tag string) bool -} - func tailNodes( nodes views.Slice[types.NodeView], capVer tailcfg.CapabilityVersion, - checker NodeCanHaveTagChecker, primaryRouteFunc routeFilterFunc, cfg *types.Config, ) ([]*tailcfg.Node, error) { @@ -29,7 +22,6 @@ func tailNodes( tNode, err := tailNode( node, capVer, - checker, primaryRouteFunc, cfg, ) @@ -47,7 +39,6 @@ func tailNodes( func tailNode( node types.NodeView, capVer tailcfg.CapabilityVersion, - checker NodeCanHaveTagChecker, primaryRouteFunc routeFilterFunc, cfg *types.Config, ) (*tailcfg.Node, error) { @@ -77,18 +68,6 @@ func tailNode( return nil, err } - var tags []string - for _, tag := range node.RequestTagsSlice().All() { - if checker.NodeCanHaveTag(node, tag) { - tags = append(tags, tag) - } - } - - for _, tag := range node.Tags().All() { - tags = append(tags, tag) - } - tags = lo.Uniq(tags) - routes := primaryRouteFunc(node.ID()) allowed := append(addrs, routes...) allowed = append(allowed, node.ExitRoutes()...) @@ -118,7 +97,7 @@ func tailNode( Online: node.IsOnline().Clone(), - Tags: tags, + Tags: node.Tags().AsSlice(), MachineAuthorized: !node.IsExpired(), Expired: node.IsExpired(), diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index 9b0765ba..966fe57b 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -8,10 +8,8 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/routes" "github.com/juanfont/headscale/hscontrol/types" - "github.com/stretchr/testify/require" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/key" @@ -71,7 +69,6 @@ func TestTailNode(t *testing.T) { HomeDERP: 0, LegacyDERPString: "127.3.3.40:0", Hostinfo: hiview(tailcfg.Hostinfo{}), - Tags: []string{}, MachineAuthorized: true, CapMap: tailcfg.NodeCapMap{ @@ -186,7 +183,6 @@ func TestTailNode(t *testing.T) { HomeDERP: 0, LegacyDERPString: "127.3.3.40:0", Hostinfo: hiview(tailcfg.Hostinfo{}), - Tags: []string{}, MachineAuthorized: true, CapMap: tailcfg.NodeCapMap{ @@ -204,8 +200,6 @@ func TestTailNode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - polMan, err := policy.NewPolicyManager(tt.pol, []types.User{}, types.Nodes{tt.node}.ViewSlice()) - require.NoError(t, err) primary := routes.New() cfg := &types.Config{ BaseDomain: tt.baseDomain, @@ -220,7 +214,6 @@ func TestTailNode(t *testing.T) { got, err := tailNode( tt.node.View(), 0, - polMan, func(id types.NodeID) []netip.Prefix { return primary.PrimaryRoutes(id) }, @@ -274,13 +267,10 @@ func TestNodeExpiry(t *testing.T) { GivenName: "test", Expiry: tt.exp, } - polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{}.ViewSlice()) - require.NoError(t, err) tn, err := tailNode( node.View(), 0, - polMan, func(id types.NodeID) []netip.Prefix { return []netip.Prefix{} }, diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 910eb4a2..f4db88a4 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -26,6 +26,9 @@ type PolicyManager interface { // NodeCanHaveTag reports whether the given node can have the given tag. NodeCanHaveTag(types.NodeView, string) bool + // TagExists reports whether the given tag is defined in the policy. + TagExists(tag string) bool + // NodeCanApproveRoute reports whether the given node can approve the given route. NodeCanApproveRoute(types.NodeView, netip.Prefix) bool diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 260a4ff7..6aeda271 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -1,7 +1,9 @@ package v2 import ( + "cmp" "encoding/json" + "errors" "fmt" "net/netip" "slices" @@ -19,6 +21,9 @@ import ( "tailscale.com/util/deephash" ) +// ErrInvalidTagOwner is returned when a tag owner is not an Alias type. +var ErrInvalidTagOwner = errors.New("tag owner is not an Alias") + type PolicyManager struct { mu sync.Mutex pol *Policy @@ -536,23 +541,108 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro return false, nil } +// NodeCanHaveTag checks if a node can have the specified tag during client-initiated +// registration or reauth flows (e.g., tailscale up --advertise-tags). +// +// This function is NOT used by the admin API's SetNodeTags - admins can set any +// existing tag on any node by calling State.SetNodeTags directly, which bypasses +// this authorization check. func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool { - if pm == nil { + if pm == nil || pm.pol == nil { return false } pm.mu.Lock() defer pm.mu.Unlock() + // Check if tag exists in policy + owners, exists := pm.pol.TagOwners[Tag(tag)] + if !exists { + return false + } + + // Check if node's owner can assign this tag via the pre-resolved tagOwnerMap. + // The tagOwnerMap contains IP sets built from resolving TagOwners entries + // (usernames/groups) to their nodes' IPs, so checking if the node's IP + // is in the set answers "does this node's owner own this tag?" if ips, ok := pm.tagOwnerMap[Tag(tag)]; ok { if slices.ContainsFunc(node.IPs(), ips.Contains) { return true } } + // For new nodes being registered, their IP may not yet be in the tagOwnerMap. + // Fall back to checking the node's user directly against the TagOwners. + // This handles the case where a user registers a new node with --advertise-tags. + if node.User().Valid() { + for _, owner := range owners { + if pm.userMatchesOwner(node.User(), owner) { + return true + } + } + } + return false } +// userMatchesOwner checks if a user matches a tag owner entry. +// This is used as a fallback when the node's IP is not in the tagOwnerMap. +func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool { + switch o := owner.(type) { + case *Username: + if o == nil { + return false + } + // Resolve the username to find the user it refers to + resolvedUser, err := o.resolveUser(pm.users) + if err != nil { + return false + } + + return user.ID() == resolvedUser.ID + + case *Group: + if o == nil || pm.pol == nil { + return false + } + // Resolve the group to get usernames + usernames, ok := pm.pol.Groups[*o] + if !ok { + return false + } + // Check if the user matches any username in the group + for _, uname := range usernames { + resolvedUser, err := uname.resolveUser(pm.users) + if err != nil { + continue + } + + if user.ID() == resolvedUser.ID { + return true + } + } + + return false + + default: + return false + } +} + +// TagExists reports whether the given tag is defined in the policy. +func (pm *PolicyManager) TagExists(tag string) bool { + if pm == nil || pm.pol == nil { + return false + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + _, exists := pm.pol.TagOwners[Tag(tag)] + + return exists +} + func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool { if pm == nil { return false @@ -834,3 +924,126 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types. } } } + +// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles. +// It will return a Owners list where all the Tag types have been resolved to their underlying Owners. +func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) { + if visiting[tag] { + cycleStart := 0 + + for i, t := range chain { + if t == tag { + cycleStart = i + break + } + } + + cycleTags := make([]string, len(chain[cycleStart:])) + for i, t := range chain[cycleStart:] { + cycleTags[i] = string(t) + } + + slices.Sort(cycleTags) + + return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> ")) + } + + visiting[tag] = true + + chain = append(chain, tag) + defer delete(visiting, tag) + + var result Owners + + for _, owner := range tagOwners[tag] { + switch o := owner.(type) { + case *Tag: + if _, ok := tagOwners[*o]; !ok { + return nil, fmt.Errorf("tag %q %w %q", tag, ErrUndefinedTagReference, *o) + } + + nested, err := flattenTags(tagOwners, *o, visiting, chain) + if err != nil { + return nil, err + } + + result = append(result, nested...) + default: + result = append(result, owner) + } + } + + return result, nil +} + +// flattenTagOwners flattens all TagOwners by resolving nested tags and detecting cycles. +// It will return a new TagOwners map where all the Tag types have been resolved to their underlying Owners. +func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) { + ret := make(TagOwners) + + for tag := range tagOwners { + flattened, err := flattenTags(tagOwners, tag, make(map[Tag]bool), nil) + if err != nil { + return nil, err + } + + slices.SortFunc(flattened, func(a, b Owner) int { + return cmp.Compare(a.String(), b.String()) + }) + ret[tag] = slices.CompactFunc(flattened, func(a, b Owner) bool { + return a.String() == b.String() + }) + } + + return ret, nil +} + +// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet. +// The resulting map can be used to quickly look up the IPSet for a given Tag. +// It is intended for internal use in a PolicyManager. +func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) { + if p == nil { + return make(map[Tag]*netipx.IPSet), nil + } + + if len(p.TagOwners) == 0 { + return make(map[Tag]*netipx.IPSet), nil + } + + ret := make(map[Tag]*netipx.IPSet) + + tagOwners, err := flattenTagOwners(p.TagOwners) + if err != nil { + return nil, err + } + + for tag, owners := range tagOwners { + var ips netipx.IPSetBuilder + + for _, owner := range owners { + switch o := owner.(type) { + case *Tag: + // After flattening, Tag types should not appear in the owners list. + // If they do, skip them as they represent already-resolved references. + + case Alias: + // If it does not resolve, that means the tag is not associated with any IP addresses. + resolved, _ := o.Resolve(p, users, nodes) + ips.AddSet(resolved) + + default: + // Should never happen - after flattening, all owners should be Alias types + return nil, fmt.Errorf("%w: %v", ErrInvalidTagOwner, owner) + } + } + + ipSet, err := ips.IPSet() + if err != nil { + return nil, err + } + + ret[tag] = ipSet + } + + return ret, nil +} diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 0c4aec38..fe0333b8 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -1,7 +1,6 @@ package v2 import ( - "cmp" "errors" "fmt" "net/netip" @@ -307,35 +306,11 @@ func (t *Tag) UnmarshalJSON(b []byte) error { func (t Tag) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) { var ips netipx.IPSetBuilder - // TODO(kradalby): This is currently resolved twice, and should be resolved once. - // It is added temporary until we sort out the story on how and when we resolve tags - // from the three places they can be "approved": - // - As part of a PreAuthKey (handled in HasTag) - // - As part of ForcedTags (set via CLI) (handled in HasTag) - // - As part of HostInfo.RequestTags and approved by policy (this is happening here) - // Part of #2417 - tagMap, err := resolveTagOwners(p, users, nodes) - if err != nil { - return nil, err - } - for _, node := range nodes.All() { // Check if node has this tag if node.HasTag(string(t)) { node.AppendToIPSet(&ips) } - - // TODO(kradalby): remove as part of #2417, see comment above - if tagMap != nil { - if tagips, ok := tagMap[t]; ok && node.InIPSet(tagips) && node.Hostinfo().Valid() { - for _, tag := range node.RequestTagsSlice().All() { - if tag == string(t) { - node.AppendToIPSet(&ips) - break - } - } - } - } } return ips.IPSet() @@ -545,61 +520,26 @@ func (ag AutoGroup) Resolve(p *Policy, users types.Users, nodes views.Slice[type return util.TheInternet(), nil case AutoGroupMember: - // autogroup:member represents all untagged devices in the tailnet. - tagMap, err := resolveTagOwners(p, users, nodes) - if err != nil { - return nil, err - } - for _, node := range nodes.All() { // Skip if node is tagged if node.IsTagged() { continue } - // Skip if node has any allowed requested tags - hasAllowedTag := false - if node.RequestTagsSlice().Len() != 0 { - for _, tag := range node.RequestTagsSlice().All() { - if _, ok := tagMap[Tag(tag)]; ok { - hasAllowedTag = true - break - } - } - } - if hasAllowedTag { - continue - } - - // Node is a member if it has no forced tags and no allowed requested tags + // Node is a member if it is not tagged node.AppendToIPSet(&build) } return build.IPSet() case AutoGroupTagged: - // autogroup:tagged represents all devices with a tag in the tailnet. - tagMap, err := resolveTagOwners(p, users, nodes) - if err != nil { - return nil, err - } - for _, node := range nodes.All() { // Include if node is tagged - if node.IsTagged() { - node.AppendToIPSet(&build) + if !node.IsTagged() { continue } - // Include if node has any allowed requested tags - if node.RequestTagsSlice().Len() != 0 { - for _, tag := range node.RequestTagsSlice().All() { - if _, ok := tagMap[Tag(tag)]; ok { - node.AppendToIPSet(&build) - break - } - } - } + node.AppendToIPSet(&build) } return build.IPSet() @@ -1177,129 +1117,6 @@ func (to TagOwners) Contains(tagOwner *Tag) error { return fmt.Errorf(`Tag %q is not defined in the Policy, please define or remove the reference to it`, tagOwner) } -// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet. -// The resulting map can be used to quickly look up the IPSet for a given Tag. -// It is intended for internal use in a PolicyManager. -func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) { - if p == nil { - return make(map[Tag]*netipx.IPSet), nil - } - - if len(p.TagOwners) == 0 { - return make(map[Tag]*netipx.IPSet), nil - } - - ret := make(map[Tag]*netipx.IPSet) - - tagOwners, err := flattenTagOwners(p.TagOwners) - if err != nil { - return nil, err - } - - for tag, owners := range tagOwners { - var ips netipx.IPSetBuilder - - for _, owner := range owners { - switch o := owner.(type) { - case *Tag: - // After flattening, Tag types should not appear in the owners list. - // If they do, skip them as they represent already-resolved references. - - case Alias: - // If it does not resolve, that means the tag is not associated with any IP addresses. - resolved, _ := o.Resolve(p, users, nodes) - ips.AddSet(resolved) - - default: - // Should never happen - return nil, fmt.Errorf("owner %v is not an Alias", owner) - } - } - - ipSet, err := ips.IPSet() - if err != nil { - return nil, err - } - - ret[tag] = ipSet - } - - return ret, nil -} - -// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles. -// It will return a Owners list where all the Tag types have been resolved to their underlying Owners. -func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) { - if visiting[tag] { - cycleStart := 0 - - for i, t := range chain { - if t == tag { - cycleStart = i - break - } - } - - cycleTags := make([]string, len(chain[cycleStart:])) - for i, t := range chain[cycleStart:] { - cycleTags[i] = string(t) - } - - slices.Sort(cycleTags) - - return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> ")) - } - - visiting[tag] = true - - chain = append(chain, tag) - defer delete(visiting, tag) - - var result Owners - - for _, owner := range tagOwners[tag] { - switch o := owner.(type) { - case *Tag: - if _, ok := tagOwners[*o]; !ok { - return nil, fmt.Errorf("tag %q %w %q", tag, ErrUndefinedTagReference, *o) - } - - nested, err := flattenTags(tagOwners, *o, visiting, chain) - if err != nil { - return nil, err - } - - result = append(result, nested...) - default: - result = append(result, owner) - } - } - - return result, nil -} - -// flattenTagOwners flattens all TagOwners by resolving nested tags and detecting cycles. -// It will return a new TagOwners map where all the Tag types have been resolved to their underlying Owners. -func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) { - ret := make(TagOwners) - - for tag := range tagOwners { - flattened, err := flattenTags(tagOwners, tag, make(map[Tag]bool), nil) - if err != nil { - return nil, err - } - - slices.SortFunc(flattened, func(a, b Owner) int { - return cmp.Compare(a.String(), b.String()) - }) - ret[tag] = slices.CompactFunc(flattened, func(a, b Owner) bool { - return a.String() == b.String() - }) - } - - return ret, nil -} - type AutoApproverPolicy struct { Routes map[netip.Prefix]AutoApprovers `json:"routes,omitempty"` ExitNode AutoApprovers `json:"exitNode,omitempty"` diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index a5e5e8d2..664f76b7 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -1862,124 +1862,108 @@ func TestResolvePolicy(t *testing.T) { name: "autogroup-member-comprehensive", toResolve: ptr.To(AutoGroup(AutoGroupMember)), nodes: types.Nodes{ - // Node with no tags (should be included) + // Node with no tags (should be included - is a member) { User: ptr.To(testuser), IPv4: ap("100.100.101.1"), }, - // Node with forced tags (should be excluded) + // Node with single tag (should be excluded - tagged nodes are not members) { User: ptr.To(testuser), Tags: []string{"tag:test"}, IPv4: ap("100.100.101.2"), }, - // Node with allowed requested tag (should be excluded) + // Node with multiple tags, all defined in policy (should be excluded) { User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:test"}, - }, + Tags: []string{"tag:test", "tag:other"}, IPv4: ap("100.100.101.3"), }, - // Node with non-allowed requested tag (should be included) + // Node with tag not defined in policy (should be excluded - still tagged) { User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:notallowed"}, - }, + Tags: []string{"tag:undefined"}, IPv4: ap("100.100.101.4"), }, - // Node with multiple requested tags, one allowed (should be excluded) + // Node with mixed tags - some defined, some not (should be excluded) { User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:test", "tag:notallowed"}, - }, + Tags: []string{"tag:test", "tag:undefined"}, IPv4: ap("100.100.101.5"), }, - // Node with multiple requested tags, none allowed (should be included) + // Another untagged node from different user (should be included) { - User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:notallowed1", "tag:notallowed2"}, - }, + User: ptr.To(testuser2), IPv4: ap("100.100.101.6"), }, }, pol: &Policy{ TagOwners: TagOwners{ - Tag("tag:test"): Owners{ptr.To(Username("testuser@"))}, + Tag("tag:test"): Owners{ptr.To(Username("testuser@"))}, + Tag("tag:other"): Owners{ptr.To(Username("testuser@"))}, }, }, want: []netip.Prefix{ - mp("100.100.101.1/32"), // No tags - mp("100.100.101.4/32"), // Non-allowed requested tag - mp("100.100.101.6/32"), // Multiple non-allowed requested tags + mp("100.100.101.1/32"), // No tags - is a member + mp("100.100.101.6/32"), // No tags, different user - is a member }, }, { name: "autogroup-tagged", toResolve: ptr.To(AutoGroup(AutoGroupTagged)), nodes: types.Nodes{ - // Node with no tags (should be excluded) + // Node with no tags (should be excluded - not tagged) { User: ptr.To(testuser), IPv4: ap("100.100.101.1"), }, - // Node with forced tag (should be included) + // Node with single tag defined in policy (should be included) { User: ptr.To(testuser), Tags: []string{"tag:test"}, IPv4: ap("100.100.101.2"), }, - // Node with allowed requested tag (should be included) - { - User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:test"}, - }, - IPv4: ap("100.100.101.3"), - }, - // Node with non-allowed requested tag (should be excluded) - { - User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:notallowed"}, - }, - IPv4: ap("100.100.101.4"), - }, - // Node with multiple requested tags, one allowed (should be included) - { - User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:test", "tag:notallowed"}, - }, - IPv4: ap("100.100.101.5"), - }, - // Node with multiple requested tags, none allowed (should be excluded) - { - User: ptr.To(testuser), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:notallowed1", "tag:notallowed2"}, - }, - IPv4: ap("100.100.101.6"), - }, - // Node with multiple forced tags (should be included) + // Node with multiple tags, all defined in policy (should be included) { User: ptr.To(testuser), Tags: []string{"tag:test", "tag:other"}, + IPv4: ap("100.100.101.3"), + }, + // Node with tag not defined in policy (should be included - still tagged) + { + User: ptr.To(testuser), + Tags: []string{"tag:undefined"}, + IPv4: ap("100.100.101.4"), + }, + // Node with mixed tags - some defined, some not (should be included) + { + User: ptr.To(testuser), + Tags: []string{"tag:test", "tag:undefined"}, + IPv4: ap("100.100.101.5"), + }, + // Another untagged node from different user (should be excluded) + { + User: ptr.To(testuser2), + IPv4: ap("100.100.101.6"), + }, + // Tagged node from different user (should be included) + { + User: ptr.To(testuser2), + Tags: []string{"tag:server"}, IPv4: ap("100.100.101.7"), }, }, pol: &Policy{ TagOwners: TagOwners{ - Tag("tag:test"): Owners{ptr.To(Username("testuser@"))}, + Tag("tag:test"): Owners{ptr.To(Username("testuser@"))}, + Tag("tag:other"): Owners{ptr.To(Username("testuser@"))}, + Tag("tag:server"): Owners{ptr.To(Username("testuser2@"))}, }, }, want: []netip.Prefix{ - mp("100.100.101.2/31"), // Forced tag and allowed requested tag consecutive IPs are put in 31 prefix - mp("100.100.101.5/32"), // Multiple requested tags, one allowed - mp("100.100.101.7/32"), // Multiple forced tags + mp("100.100.101.2/31"), // .2, .3 consecutive tagged nodes + mp("100.100.101.4/31"), // .4, .5 consecutive tagged nodes + mp("100.100.101.7/32"), // Tagged node from different user }, }, { @@ -2001,9 +1985,7 @@ func TestResolvePolicy(t *testing.T) { }, { User: ptr.To(testuser2), - Hostinfo: &tailcfg.Hostinfo{ - RequestTags: []string{"tag:test"}, - }, + Tags: []string{"tag:test"}, IPv4: ap("100.100.101.4"), }, }, @@ -2738,6 +2720,127 @@ func TestNodeCanHaveTag(t *testing.T) { tag: "tag:dev", // This tag is not defined in tagOwners want: false, }, + // Test cases for nodes without IPs (new registration scenario) + // These test the user-based fallback in NodeCanHaveTag + { + name: "node-without-ip-user-owns-tag", + policy: &Policy{ + TagOwners: TagOwners{ + Tag("tag:test"): Owners{ptr.To(Username("user1@"))}, + }, + }, + node: &types.Node{ + // No IPv4 or IPv6 - simulates new node registration + User: &users[0], + UserID: ptr.To(users[0].ID), + }, + tag: "tag:test", + want: true, // Should succeed via user-based fallback + }, + { + name: "node-without-ip-user-does-not-own-tag", + policy: &Policy{ + TagOwners: TagOwners{ + Tag("tag:test"): Owners{ptr.To(Username("user2@"))}, + }, + }, + node: &types.Node{ + // No IPv4 or IPv6 - simulates new node registration + User: &users[0], // user1, but tag owned by user2 + UserID: ptr.To(users[0].ID), + }, + tag: "tag:test", + want: false, // user1 does not own tag:test + }, + { + name: "node-without-ip-group-owns-tag", + policy: &Policy{ + Groups: Groups{ + "group:admins": Usernames{"user1@", "user2@"}, + }, + TagOwners: TagOwners{ + Tag("tag:admin"): Owners{ptr.To(Group("group:admins"))}, + }, + }, + node: &types.Node{ + // No IPv4 or IPv6 - simulates new node registration + User: &users[1], // user2 is in group:admins + UserID: ptr.To(users[1].ID), + }, + tag: "tag:admin", + want: true, // Should succeed via group membership + }, + { + name: "node-without-ip-not-in-group", + policy: &Policy{ + Groups: Groups{ + "group:admins": Usernames{"user1@"}, + }, + TagOwners: TagOwners{ + Tag("tag:admin"): Owners{ptr.To(Group("group:admins"))}, + }, + }, + node: &types.Node{ + // No IPv4 or IPv6 - simulates new node registration + User: &users[1], // user2 is NOT in group:admins + UserID: ptr.To(users[1].ID), + }, + tag: "tag:admin", + want: false, // user2 is not in group:admins + }, + { + name: "node-without-ip-no-user", + policy: &Policy{ + TagOwners: TagOwners{ + Tag("tag:test"): Owners{ptr.To(Username("user1@"))}, + }, + }, + node: &types.Node{ + // No IPv4, IPv6, or User - edge case + }, + tag: "tag:test", + want: false, // No user means can't authorize via user-based fallback + }, + { + name: "node-without-ip-mixed-owners-user-match", + policy: &Policy{ + Groups: Groups{ + "group:ops": Usernames{"user3@"}, + }, + TagOwners: TagOwners{ + Tag("tag:server"): Owners{ + ptr.To(Username("user1@")), + ptr.To(Group("group:ops")), + }, + }, + }, + node: &types.Node{ + User: &users[0], // user1 directly owns the tag + UserID: ptr.To(users[0].ID), + }, + tag: "tag:server", + want: true, + }, + { + name: "node-without-ip-mixed-owners-group-match", + policy: &Policy{ + Groups: Groups{ + "group:ops": Usernames{"user3@"}, + }, + TagOwners: TagOwners{ + Tag("tag:server"): Owners{ + ptr.To(Username("user1@")), + ptr.To(Group("group:ops")), + }, + }, + }, + node: &types.Node{ + User: &users[2], // user3 is in group:ops + UserID: ptr.To(users[2].ID), + }, + tag: "tag:server", + want: true, + }, } for _, tt := range tests { @@ -2760,6 +2863,106 @@ func TestNodeCanHaveTag(t *testing.T) { } } +func TestUserMatchesOwner(t *testing.T) { + users := types.Users{ + {Model: gorm.Model{ID: 1}, Name: "user1"}, + {Model: gorm.Model{ID: 2}, Name: "user2"}, + {Model: gorm.Model{ID: 3}, Name: "user3"}, + } + + tests := []struct { + name string + policy *Policy + user types.User + owner Owner + want bool + }{ + { + name: "username-match", + policy: &Policy{}, + user: users[0], + owner: ptr.To(Username("user1@")), + want: true, + }, + { + name: "username-no-match", + policy: &Policy{}, + user: users[0], + owner: ptr.To(Username("user2@")), + want: false, + }, + { + name: "group-match", + policy: &Policy{ + Groups: Groups{ + "group:admins": Usernames{"user1@", "user2@"}, + }, + }, + user: users[1], // user2 is in group:admins + owner: ptr.To(Group("group:admins")), + want: true, + }, + { + name: "group-no-match", + policy: &Policy{ + Groups: Groups{ + "group:admins": Usernames{"user1@"}, + }, + }, + user: users[1], // user2 is NOT in group:admins + owner: ptr.To(Group("group:admins")), + want: false, + }, + { + name: "group-not-defined", + policy: &Policy{ + Groups: Groups{}, + }, + user: users[0], + owner: ptr.To(Group("group:undefined")), + want: false, + }, + { + name: "nil-username-owner", + policy: &Policy{}, + user: users[0], + owner: (*Username)(nil), + want: false, + }, + { + name: "nil-group-owner", + policy: &Policy{}, + user: users[0], + owner: (*Group)(nil), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a minimal PolicyManager for testing + // We need nodes with IPs to initialize the tagOwnerMap + nodes := types.Nodes{ + { + IPv4: ap("100.64.0.1"), + User: &users[0], + }, + } + + b, err := json.Marshal(tt.policy) + require.NoError(t, err) + + pm, err := NewPolicyManager(b, users, nodes.ViewSlice()) + require.NoError(t, err) + + got := pm.userMatchesOwner(tt.user.View(), tt.owner) + if got != tt.want { + t.Errorf("userMatchesOwner() = %v, want %v", got, tt.want) + } + }) + } +} + func TestACL_UnmarshalJSON_WithCommentFields(t *testing.T) { tests := []struct { name string diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 149bae4d..9dbe8374 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -12,6 +12,7 @@ import ( "net/netip" "os" "slices" + "strings" "sync" "sync/atomic" "time" @@ -669,12 +670,27 @@ func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView, return types.NodeView{}, change.EmptySet, fmt.Errorf("%w: %d", ErrNodeNotFound, nodeID) } - // Validate tags against policy - validatedTags, err := s.validateAndNormalizeTags(existingNode.AsStruct(), tags) - if err != nil { - return types.NodeView{}, change.EmptySet, err + // Validate tags: must have correct format and exist in policy + validatedTags := make([]string, 0, len(tags)) + invalidTags := make([]string, 0) + + for _, tag := range tags { + if !strings.HasPrefix(tag, "tag:") || !s.polMan.TagExists(tag) { + invalidTags = append(invalidTags, tag) + + continue + } + + validatedTags = append(validatedTags, tag) } + if len(invalidTags) > 0 { + return types.NodeView{}, change.EmptySet, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, invalidTags) + } + + slices.Sort(validatedTags) + validatedTags = slices.Compact(validatedTags) + // Log the operation logTagOperation(existingNode, validatedTags) @@ -1128,6 +1144,41 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro nodeToRegister.Tags = nil } + // Reject advertise-tags for PreAuthKey registrations early, before any resource allocation. + // PreAuthKey nodes get their tags from the key itself, not from client requests. + if params.PreAuthKey != nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 { + return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, params.Hostinfo.RequestTags) + } + + // Process RequestTags (from tailscale up --advertise-tags) ONLY for non-PreAuthKey registrations. + // Validate early before IP allocation to avoid resource leaks on failure. + if params.PreAuthKey == nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 { + var approvedTags, rejectedTags []string + + for _, tag := range params.Hostinfo.RequestTags { + if s.polMan.NodeCanHaveTag(nodeToRegister.View(), tag) { + approvedTags = append(approvedTags, tag) + } else { + rejectedTags = append(rejectedTags, tag) + } + } + + // Reject registration if any requested tags are unauthorized + if len(rejectedTags) > 0 { + return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, rejectedTags) + } + + if len(approvedTags) > 0 { + nodeToRegister.Tags = approvedTags + slices.Sort(nodeToRegister.Tags) + nodeToRegister.Tags = slices.Compact(nodeToRegister.Tags) + log.Info(). + Str("node.name", nodeToRegister.Hostname). + Strs("tags", nodeToRegister.Tags). + Msg("approved advertise-tags during registration") + } + } + // Validate before saving err := validateNodeOwnership(&nodeToRegister) if err != nil { diff --git a/hscontrol/state/tags.go b/hscontrol/state/tags.go index c1dd3127..ef745241 100644 --- a/hscontrol/state/tags.go +++ b/hscontrol/state/tags.go @@ -3,8 +3,6 @@ package state import ( "errors" "fmt" - "slices" - "strings" "github.com/juanfont/headscale/hscontrol/types" "github.com/rs/zerolog/log" @@ -17,8 +15,9 @@ var ( // ErrNodeHasNeitherUserNorTags is returned when a node has neither a user nor tags. ErrNodeHasNeitherUserNorTags = errors.New("node has neither user nor tags - must be owned by user or tagged") - // ErrInvalidOrUnauthorizedTags is returned when tags are invalid or unauthorized. - ErrInvalidOrUnauthorizedTags = errors.New("invalid or unauthorized tags") + // ErrRequestedTagsInvalidOrNotPermitted is returned when requested tags are invalid or not permitted. + // This message format matches Tailscale SaaS: "requested tags [tag:xxx] are invalid or not permitted". + ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags") ) // validateNodeOwnership ensures proper node ownership model. @@ -44,44 +43,6 @@ func validateNodeOwnership(node *types.Node) error { return nil } -// validateAndNormalizeTags validates tags against policy and normalizes them. -// Returns validated and normalized tags, or an error if validation fails. -func (s *State) validateAndNormalizeTags(node *types.Node, requestedTags []string) ([]string, error) { - if len(requestedTags) == 0 { - return nil, nil - } - - var ( - validTags []string - invalidTags []string - ) - - for _, tag := range requestedTags { - // Validate format - if !strings.HasPrefix(tag, "tag:") { - invalidTags = append(invalidTags, tag) - continue - } - - // Validate against policy - nodeView := node.View() - if s.polMan.NodeCanHaveTag(nodeView, tag) { - validTags = append(validTags, tag) - } else { - invalidTags = append(invalidTags, tag) - } - } - - if len(invalidTags) > 0 { - return nil, fmt.Errorf("%w: %v", ErrInvalidOrUnauthorizedTags, invalidTags) - } - - // Normalize: sort and deduplicate - slices.Sort(validTags) - - return slices.Compact(validTags), nil -} - // logTagOperation logs tag assignment operations for audit purposes. func logTagOperation(existingNode types.NodeView, newTags []string) { if existingNode.IsTagged() { diff --git a/integration/acl_test.go b/integration/acl_test.go index 50924891..86e16f05 100644 --- a/integration/acl_test.go +++ b/integration/acl_test.go @@ -1383,27 +1383,31 @@ func TestACLAutogroupTagged(t *testing.T) { require.NoError(t, err) // Create users and nodes manually with specific tags + // Tags are now set via PreAuthKey (tags-as-identity model), not via --advertise-tags for _, userStr := range spec.Users { user, err := scenario.CreateUser(userStr) require.NoError(t, err) - // Create a single pre-auth key per user - authKey, err := scenario.CreatePreAuthKey(user.GetId(), true, false) + // Create two pre-auth keys per user: one tagged, one untagged + taggedAuthKey, err := scenario.CreatePreAuthKeyWithTags(user.GetId(), true, false, []string{"tag:test"}) + require.NoError(t, err) + + untaggedAuthKey, err := scenario.CreatePreAuthKey(user.GetId(), true, false) require.NoError(t, err) // Create nodes with proper naming for i := range spec.NodesPerUser { - var tags []string + var authKey string var version string if i == 0 { - // First node is tagged - tags = []string{"tag:test"} + // First node is tagged - use tagged PreAuthKey + authKey = taggedAuthKey.GetKey() version = "head" t.Logf("Creating tagged node for %s", userStr) } else { - // Second node is untagged - tags = nil + // Second node is untagged - use untagged PreAuthKey + authKey = untaggedAuthKey.GetKey() version = "unstable" t.Logf("Creating untagged node for %s", userStr) } @@ -1429,11 +1433,6 @@ func TestACLAutogroupTagged(t *testing.T) { tsic.WithDockerWorkdir("/"), } - // Add tags if this is a tagged node - if len(tags) > 0 { - opts = append(opts, tsic.WithTags(tags)) - } - tsClient, err := tsic.New( scenario.Pool(), version, @@ -1444,8 +1443,8 @@ func TestACLAutogroupTagged(t *testing.T) { err = tsClient.WaitForNeedsLogin(integrationutil.PeerSyncTimeout()) require.NoError(t, err) - // Login with the auth key - err = tsClient.Login(headscale.GetEndpoint(), authKey.GetKey()) + // Login with the appropriate auth key (tags come from the PreAuthKey) + err = tsClient.Login(headscale.GetEndpoint(), authKey) require.NoError(t, err) err = tsClient.WaitForRunning(integrationutil.PeerSyncTimeout()) @@ -1699,17 +1698,17 @@ func TestACLAutogroupSelf(t *testing.T) { routerUser, err := scenario.CreateUser("user-router") require.NoError(t, err) - authKey, err := scenario.CreatePreAuthKey(routerUser.GetId(), true, false) + // Create a tagged PreAuthKey for the router node (tags-as-identity model) + authKey, err := scenario.CreatePreAuthKeyWithTags(routerUser.GetId(), true, false, []string{"tag:router-node"}) require.NoError(t, err) - // Create router node (tagged with tag:router-node) + // Create router node (tags come from the PreAuthKey) routerClient, err := tsic.New( scenario.Pool(), "unstable", tsic.WithCACert(headscale.GetCert()), tsic.WithHeadscaleName(headscale.GetHostname()), tsic.WithNetwork(network), - tsic.WithTags([]string{"tag:router-node"}), tsic.WithNetfilter("off"), tsic.WithDockerEntrypoint([]string{ "/bin/sh", diff --git a/integration/cli_test.go b/integration/cli_test.go index cf1badb2..3bbfe8d5 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -833,602 +833,6 @@ func TestApiKeyCommand(t *testing.T) { assert.Len(t, listedAPIKeysAfterDelete, 4) } -func TestNodeTagCommand(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - Users: []string{"user1"}, - } - - scenario, err := NewScenario(spec) - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("clins")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - // Test 1: Verify that tags require authorization via ACL policy - // The tags-as-identity model allows conversion from user-owned to tagged, but only - // if the tag is authorized via tagOwners in the ACL policy. - regID := types.MustRegistrationID().String() - - _, err = headscale.Execute( - []string{ - "headscale", - "debug", - "create-node", - "--name", - "user-owned-node", - "--user", - "user1", - "--key", - regID, - "--output", - "json", - }, - ) - assert.NoError(t, err) - - var userOwnedNode v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "--user", - "user1", - "register", - "--key", - regID, - "--output", - "json", - }, - &userOwnedNode, - ) - assert.NoError(c, err) - }, 10*time.Second, 200*time.Millisecond, "Waiting for user-owned node registration") - - // Verify node is user-owned (no tags) - assert.Empty(t, userOwnedNode.GetValidTags(), "User-owned node should not have tags") - assert.Empty(t, userOwnedNode.GetForcedTags(), "User-owned node should not have forced tags") - - // Attempt to set tags on user-owned node should FAIL because there's no ACL policy - // authorizing the tag. The tags-as-identity model allows conversion from user-owned - // to tagged, but only if the tag is authorized via tagOwners in the ACL policy. - _, err = headscale.Execute( - []string{ - "headscale", - "nodes", - "tag", - "-i", strconv.FormatUint(userOwnedNode.GetId(), 10), - "-t", "tag:test", - "--output", "json", - }, - ) - require.ErrorContains(t, err, "invalid or unauthorized tags", "Setting unauthorized tags should fail") - - // Test 2: Verify tag format validation - // Create a PreAuthKey with tags to create a tagged node - // Get the user ID from the node - userID := userOwnedNode.GetUser().GetId() - - var preAuthKey v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", strconv.FormatUint(userID, 10), - "create", - "--reusable", - "--tags", "tag:integration-test", - "--output", "json", - }, - &preAuthKey, - ) - assert.NoError(c, err) - }, 10*time.Second, 200*time.Millisecond, "Creating PreAuthKey with tags") - - // Verify PreAuthKey has tags - assert.Contains(t, preAuthKey.GetAclTags(), "tag:integration-test", "PreAuthKey should have tags") - - // Test 3: Verify invalid tag format is rejected - _, err = headscale.Execute( - []string{ - "headscale", - "preauthkeys", - "--user", strconv.FormatUint(userID, 10), - "create", - "--tags", "wrong-tag", // Missing "tag:" prefix - "--output", "json", - }, - ) - assert.ErrorContains(t, err, "tag must start with the string 'tag:'", "Invalid tag format should be rejected") -} - -func TestTaggedNodeRegistration(t *testing.T) { - IntegrationSkip(t) - - // ACL policy that authorizes the tags used in tagged PreAuthKeys - // user1 and user2 can assign these tags when creating PreAuthKeys - policy := &policyv2.Policy{ - TagOwners: policyv2.TagOwners{ - "tag:server": policyv2.Owners{usernameOwner("user1@"), usernameOwner("user2@")}, - "tag:prod": policyv2.Owners{usernameOwner("user1@"), usernameOwner("user2@")}, - "tag:forbidden": policyv2.Owners{usernameOwner("user1@"), usernameOwner("user2@")}, - }, - ACLs: []policyv2.ACL{ - { - Action: "accept", - Sources: []policyv2.Alias{policyv2.Wildcard}, - Destinations: []policyv2.AliasWithPorts{{Alias: policyv2.Wildcard, Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}}, - }, - }, - } - - spec := ScenarioSpec{ - Users: []string{"user1", "user2"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv( - []tsic.Option{}, - hsic.WithACLPolicy(policy), - hsic.WithTestName("tagged-reg"), - ) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - // Get users (they were already created by ScenarioSpec) - users, err := headscale.ListUsers() - require.NoError(t, err) - require.Len(t, users, 2, "Should have 2 users") - - var user1, user2 *v1.User - - for _, u := range users { - if u.GetName() == "user1" { - user1 = u - } else if u.GetName() == "user2" { - user2 = u - } - } - - require.NotNil(t, user1, "Should find user1") - require.NotNil(t, user2, "Should find user2") - - // Test 1: Create a PreAuthKey with tags - var taggedKey v1.PreAuthKey - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", strconv.FormatUint(user1.GetId(), 10), - "create", - "--reusable", - "--tags", "tag:server,tag:prod", - "--output", "json", - }, - &taggedKey, - ) - assert.NoError(c, err) - }, 10*time.Second, 200*time.Millisecond, "Creating tagged PreAuthKey") - - // Verify PreAuthKey has both tags - assert.Contains(t, taggedKey.GetAclTags(), "tag:server", "PreAuthKey should have tag:server") - assert.Contains(t, taggedKey.GetAclTags(), "tag:prod", "PreAuthKey should have tag:prod") - assert.Len(t, taggedKey.GetAclTags(), 2, "PreAuthKey should have exactly 2 tags") - - // Test 2: Register a node using the tagged PreAuthKey - err = scenario.CreateTailscaleNodesInUser("user1", "unstable", 1, tsic.WithNetwork(scenario.Networks()[0])) - require.NoError(t, err) - - err = scenario.RunTailscaleUp("user1", headscale.GetEndpoint(), taggedKey.GetKey()) - require.NoError(t, err) - - // Wait for the node to be registered - var registeredNode *v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err := headscale.ListNodes() - assert.NoError(c, err) - assert.GreaterOrEqual(c, len(nodes), 1, "Should have at least 1 node") - - // Find the tagged node - it will have user "tagged-devices" per tags-as-identity model - for _, node := range nodes { - if node.GetUser().GetName() == "tagged-devices" && len(node.GetValidTags()) > 0 { - registeredNode = node - break - } - } - - assert.NotNil(c, registeredNode, "Should find a tagged node") - }, 30*time.Second, 500*time.Millisecond, "Waiting for tagged node registration") - - // Test 3: Verify the registered node has the tags from the PreAuthKey - assert.Contains(t, registeredNode.GetValidTags(), "tag:server", "Node should have tag:server") - assert.Contains(t, registeredNode.GetValidTags(), "tag:prod", "Node should have tag:prod") - assert.Len(t, registeredNode.GetValidTags(), 2, "Node should have exactly 2 tags") - - // Test 4: Verify the node shows as TaggedDevices user (tags-as-identity model) - // Tagged nodes always show as "tagged-devices" in API responses, even though - // internally UserID may be set for "created by" tracking - assert.Equal(t, "tagged-devices", registeredNode.GetUser().GetName(), "Tagged node should show as tagged-devices user") - - // Test 5: Verify the node is identified as tagged - assert.NotEmpty(t, registeredNode.GetValidTags(), "Tagged node should have tags") - - // Test 6: Verify tag modification on tagged nodes - // NOTE: Changing tags requires complex ACL authorization where the node's IP - // must be authorized for the new tags via tagOwners. For simplicity, we skip - // this test and instead verify that tags cannot be arbitrarily changed without - // proper ACL authorization. - // - // This is expected behavior - tag changes must be authorized by ACL policy. - _, err = headscale.Execute( - []string{ - "headscale", - "nodes", - "tag", - "-i", strconv.FormatUint(registeredNode.GetId(), 10), - "-t", "tag:unauthorized", - "--output", "json", - }, - ) - // This SHOULD fail because tag:unauthorized is not in our ACL policy - require.ErrorContains(t, err, "invalid or unauthorized tags", "Unauthorized tag should be rejected") - - // Test 7: Create a user-owned node for comparison - var userOwnedKey v1.PreAuthKey - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", strconv.FormatUint(user2.GetId(), 10), - "create", - "--reusable", - "--output", "json", - }, - &userOwnedKey, - ) - assert.NoError(c, err) - }, 10*time.Second, 200*time.Millisecond, "Creating user-owned PreAuthKey") - - // Verify this PreAuthKey has NO tags - assert.Empty(t, userOwnedKey.GetAclTags(), "User-owned PreAuthKey should have no tags") - - err = scenario.CreateTailscaleNodesInUser("user2", "unstable", 1, tsic.WithNetwork(scenario.Networks()[0])) - require.NoError(t, err) - - err = scenario.RunTailscaleUp("user2", headscale.GetEndpoint(), userOwnedKey.GetKey()) - require.NoError(t, err) - - // Wait for the user-owned node to be registered - var userOwnedNode *v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err := headscale.ListNodes() - assert.NoError(c, err) - assert.GreaterOrEqual(c, len(nodes), 2, "Should have at least 2 nodes") - - // Find the node registered with user2 - for _, node := range nodes { - if node.GetUser().GetName() == "user2" { - userOwnedNode = node - break - } - } - - assert.NotNil(c, userOwnedNode, "Should find a node for user2") - }, 30*time.Second, 500*time.Millisecond, "Waiting for user-owned node registration") - - // Test 8: Verify user-owned node has NO tags - assert.Empty(t, userOwnedNode.GetValidTags(), "User-owned node should have no tags") - assert.NotZero(t, userOwnedNode.GetUser().GetId(), "User-owned node should have UserID") - - // Test 9: Verify attempting to set UNAUTHORIZED tags on user-owned node fails - // Note: Under tags-as-identity model, user-owned nodes CAN be converted to tagged nodes - // if the tags are authorized. We use an unauthorized tag to test rejection. - _, err = headscale.Execute( - []string{ - "headscale", - "nodes", - "tag", - "-i", strconv.FormatUint(userOwnedNode.GetId(), 10), - "-t", "tag:not-in-policy", - "--output", "json", - }, - ) - require.ErrorContains(t, err, "invalid or unauthorized tags", "Setting unauthorized tags should fail") - - // Test 10: Verify basic connectivity - wait for sync - err = scenario.WaitForTailscaleSync() - require.NoError(t, err, "Clients should be able to sync") -} - -// TestTagPersistenceAcrossRestart validates that tags persist across container -// restarts and that re-authentication doesn't re-apply tags from PreAuthKey. -// This is a regression test for issue #2830. -func TestTagPersistenceAcrossRestart(t *testing.T) { - IntegrationSkip(t) - - spec := ScenarioSpec{ - Users: []string{"user1"}, - } - - scenario, err := NewScenario(spec) - - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("tag-persist")) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - // Get user - users, err := headscale.ListUsers() - require.NoError(t, err) - require.Len(t, users, 1) - user1 := users[0] - - // Create a reusable PreAuthKey with tags - var taggedKey v1.PreAuthKey - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "preauthkeys", - "--user", strconv.FormatUint(user1.GetId(), 10), - "create", - "--reusable", // Critical: key must be reusable for container restart - "--tags", "tag:server,tag:prod", - "--output", "json", - }, - &taggedKey, - ) - assert.NoError(c, err) - }, 10*time.Second, 200*time.Millisecond, "Creating reusable tagged PreAuthKey") - - require.True(t, taggedKey.GetReusable(), "PreAuthKey must be reusable for restart scenario") - require.Contains(t, taggedKey.GetAclTags(), "tag:server") - require.Contains(t, taggedKey.GetAclTags(), "tag:prod") - - // Register initial node with tagged PreAuthKey - err = scenario.CreateTailscaleNodesInUser("user1", "unstable", 1, tsic.WithNetwork(scenario.Networks()[0])) - require.NoError(t, err) - - err = scenario.RunTailscaleUp("user1", headscale.GetEndpoint(), taggedKey.GetKey()) - require.NoError(t, err) - - // Wait for node registration and get initial node state - var initialNode *v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err := headscale.ListNodes() - assert.NoError(c, err) - assert.GreaterOrEqual(c, len(nodes), 1, "Should have at least 1 node") - - for _, node := range nodes { - if node.GetUser().GetId() == user1.GetId() || node.GetUser().GetName() == "tagged-devices" { - initialNode = node - break - } - } - - assert.NotNil(c, initialNode, "Should find the registered node") - }, 30*time.Second, 500*time.Millisecond, "Waiting for initial node registration") - - // Verify initial tags - require.Contains(t, initialNode.GetValidTags(), "tag:server", "Initial node should have tag:server") - require.Contains(t, initialNode.GetValidTags(), "tag:prod", "Initial node should have tag:prod") - require.Len(t, initialNode.GetValidTags(), 2, "Initial node should have exactly 2 tags") - - initialNodeID := initialNode.GetId() - t.Logf("Initial node registered with ID %d and tags %v", initialNodeID, initialNode.GetValidTags()) - - // Simulate container restart by shutting down and restarting Tailscale client - allClients, err := scenario.ListTailscaleClients() - require.NoError(t, err) - require.Len(t, allClients, 1, "Should have exactly 1 client") - - client := allClients[0] - - // Stop the client (simulates container stop) - err = client.Down() - require.NoError(t, err) - - // Wait a bit to ensure the client is fully stopped - time.Sleep(2 * time.Second) - - // Restart the client with the SAME PreAuthKey (container restart scenario) - // This simulates what happens when a Docker container restarts with a reusable PreAuthKey - err = scenario.RunTailscaleUp("user1", headscale.GetEndpoint(), taggedKey.GetKey()) - require.NoError(t, err) - - // Wait for re-authentication - var nodeAfterRestart *v1.Node - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err := headscale.ListNodes() - assert.NoError(c, err) - - for _, node := range nodes { - if node.GetId() == initialNodeID { - nodeAfterRestart = node - break - } - } - - assert.NotNil(c, nodeAfterRestart, "Should find the same node after restart") - }, 30*time.Second, 500*time.Millisecond, "Waiting for node re-authentication") - - // CRITICAL ASSERTION: Tags should NOT be re-applied from PreAuthKey - // Tags are only applied during INITIAL authentication, not re-authentication - // The node should keep its existing tags (which happen to be the same in this case) - assert.Contains(t, nodeAfterRestart.GetValidTags(), "tag:server", "Node should still have tag:server after restart") - assert.Contains(t, nodeAfterRestart.GetValidTags(), "tag:prod", "Node should still have tag:prod after restart") - assert.Len(t, nodeAfterRestart.GetValidTags(), 2, "Node should still have exactly 2 tags after restart") - - // Verify it's the SAME node (same ID), not a new registration - assert.Equal(t, initialNodeID, nodeAfterRestart.GetId(), "Should be the same node, not a new registration") - - // Verify node count hasn't increased (no duplicate nodes) - finalNodes, err := headscale.ListNodes() - require.NoError(t, err) - assert.Len(t, finalNodes, 1, "Should still have exactly 1 node (no duplicates from restart)") - - t.Logf("Container restart validation complete - node %d maintained tags across restart", initialNodeID) -} - -func TestNodeAdvertiseTagCommand(t *testing.T) { - IntegrationSkip(t) - - tests := []struct { - name string - policy *policyv2.Policy - wantTag bool - }{ - { - name: "no-policy", - wantTag: false, - }, - { - name: "with-policy-email", - policy: &policyv2.Policy{ - ACLs: []policyv2.ACL{ - { - Action: "accept", - Protocol: "tcp", - Sources: []policyv2.Alias{wildcard()}, - Destinations: []policyv2.AliasWithPorts{ - aliasWithPorts(wildcard(), tailcfg.PortRangeAny), - }, - }, - }, - TagOwners: policyv2.TagOwners{ - policyv2.Tag("tag:test"): policyv2.Owners{usernameOwner("user1@test.no")}, - }, - }, - wantTag: true, - }, - { - name: "with-policy-username", - policy: &policyv2.Policy{ - ACLs: []policyv2.ACL{ - { - Action: "accept", - Protocol: "tcp", - Sources: []policyv2.Alias{wildcard()}, - Destinations: []policyv2.AliasWithPorts{ - aliasWithPorts(wildcard(), tailcfg.PortRangeAny), - }, - }, - }, - TagOwners: policyv2.TagOwners{ - policyv2.Tag("tag:test"): policyv2.Owners{usernameOwner("user1@")}, - }, - }, - wantTag: true, - }, - { - name: "with-policy-groups", - policy: &policyv2.Policy{ - Groups: policyv2.Groups{ - policyv2.Group("group:admins"): []policyv2.Username{policyv2.Username("user1@")}, - }, - ACLs: []policyv2.ACL{ - { - Action: "accept", - Protocol: "tcp", - Sources: []policyv2.Alias{wildcard()}, - Destinations: []policyv2.AliasWithPorts{ - aliasWithPorts(wildcard(), tailcfg.PortRangeAny), - }, - }, - }, - TagOwners: policyv2.TagOwners{ - policyv2.Tag("tag:test"): policyv2.Owners{groupOwner("group:admins")}, - }, - }, - wantTag: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - spec := ScenarioSpec{ - NodesPerUser: 1, - Users: []string{"user1"}, - } - - scenario, err := NewScenario(spec) - require.NoError(t, err) - defer scenario.ShutdownAssertNoPanics(t) - - err = scenario.CreateHeadscaleEnv( - []tsic.Option{tsic.WithTags([]string{"tag:test"})}, - hsic.WithTestName("cliadvtags"), - hsic.WithACLPolicy(tt.policy), - ) - require.NoError(t, err) - - headscale, err := scenario.Headscale() - require.NoError(t, err) - - // Test list all nodes after added seconds - var resultMachines []*v1.Node - assert.EventuallyWithT(t, func(c *assert.CollectT) { - resultMachines = make([]*v1.Node, spec.NodesPerUser) - err = executeAndUnmarshal( - headscale, - []string{ - "headscale", - "nodes", - "list", - "--tags", - "--output", "json", - }, - &resultMachines, - ) - assert.NoError(c, err) - found := false - for _, node := range resultMachines { - if tags := node.GetValidTags(); tags != nil { - found = slices.Contains(tags, "tag:test") - } - } - assert.Equalf( - c, - tt.wantTag, - found, - "'tag:test' found(%t) is the list of nodes, expected %t", found, tt.wantTag, - ) - }, 10*time.Second, 200*time.Millisecond, "Waiting for tag propagation to nodes") - }) - } -} - func TestNodeCommand(t *testing.T) { IntegrationSkip(t) diff --git a/integration/control.go b/integration/control.go index fea2e1f2..2b3b9cb3 100644 --- a/integration/control.go +++ b/integration/control.go @@ -24,6 +24,7 @@ type ControlServer interface { WaitForRunning() error CreateUser(user string) (*v1.User, error) CreateAuthKey(user uint64, reusable bool, ephemeral bool) (*v1.PreAuthKey, error) + CreateAuthKeyWithTags(user uint64, reusable bool, ephemeral bool, tags []string) (*v1.PreAuthKey, error) DeleteAuthKey(user uint64, key string) error ListNodes(users ...string) ([]*v1.Node, error) DeleteNode(nodeID uint64) error @@ -32,6 +33,7 @@ type ControlServer interface { ListUsers() ([]*v1.User, error) MapUsers() (map[string]*v1.User, error) ApproveRoutes(uint64, []netip.Prefix) (*v1.Node, error) + SetNodeTags(nodeID uint64, tags []string) error GetCert() []byte GetHostname() string GetIPInNetwork(network *dockertest.Network) string diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index 92246b3f..76910f2f 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -1052,6 +1052,57 @@ func (t *HeadscaleInContainer) CreateAuthKey( return &preAuthKey, nil } +// CreateAuthKeyWithTags creates a new "authorisation key" for a User with the specified tags. +// This is used to create tagged PreAuthKeys for testing the tags-as-identity model. +func (t *HeadscaleInContainer) CreateAuthKeyWithTags( + user uint64, + reusable bool, + ephemeral bool, + tags []string, +) (*v1.PreAuthKey, error) { + command := []string{ + "headscale", + "--user", + strconv.FormatUint(user, 10), + "preauthkeys", + "create", + "--expiration", + "24h", + "--output", + "json", + } + + if reusable { + command = append(command, "--reusable") + } + + if ephemeral { + command = append(command, "--ephemeral") + } + + if len(tags) > 0 { + command = append(command, "--tags", strings.Join(tags, ",")) + } + + result, _, err := dockertestutil.ExecuteCommand( + t.container, + command, + []string{}, + ) + if err != nil { + return nil, fmt.Errorf("failed to execute create auth key with tags command: %w", err) + } + + var preAuthKey v1.PreAuthKey + + err = json.Unmarshal([]byte(result), &preAuthKey) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal auth key: %w", err) + } + + return &preAuthKey, nil +} + // DeleteAuthKey deletes an "authorisation key" for a User. func (t *HeadscaleInContainer) DeleteAuthKey( user uint64, @@ -1369,6 +1420,36 @@ func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) ( return node, nil } +// SetNodeTags sets tags on a node via the headscale CLI. +// This simulates what the Tailscale admin console UI does - it calls the headscale +// SetTags API which is exposed via the CLI command: headscale nodes tag -i -t . +func (t *HeadscaleInContainer) SetNodeTags(nodeID uint64, tags []string) error { + command := []string{ + "headscale", "nodes", "tag", + "--identifier", strconv.FormatUint(nodeID, 10), + "--output", "json", + } + + // Add tags - the CLI expects -t flag for each tag or comma-separated + if len(tags) > 0 { + command = append(command, "--tags", strings.Join(tags, ",")) + } else { + // Empty tags to clear all tags + command = append(command, "--tags", "") + } + + _, _, err := dockertestutil.ExecuteCommand( + t.container, + command, + []string{}, + ) + if err != nil { + return fmt.Errorf("failed to execute set tags command (node %d, tags %v): %w", nodeID, tags, err) + } + + return nil +} + // WriteFile save file inside the Headscale container. func (t *HeadscaleInContainer) WriteFile(path string, data []byte) error { return integrationutil.WriteFileToContainer(t.pool, t.container, path, data) diff --git a/integration/route_test.go b/integration/route_test.go index 39292806..3a2be29a 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -2259,16 +2259,16 @@ func TestAutoApproveMultiNetwork(t *testing.T) { tsic.WithAcceptRoutes(), } - if tt.approver == "tag:approve" { - tsOpts = append(tsOpts, - tsic.WithTags([]string{"tag:approve"}), - ) - } - route, err := scenario.SubnetOfNetwork("usernet1") require.NoError(t, err) - err = scenario.createHeadscaleEnv(tt.withURL, tsOpts, + // For authkey with tag approver, use tagged PreAuthKeys (tags-as-identity model) + var preAuthKeyTags []string + if !tt.withURL && strings.HasPrefix(tt.approver, "tag:") { + preAuthKeyTags = []string{tt.approver} + } + + err = scenario.createHeadscaleEnvWithTags(tt.withURL, tsOpts, preAuthKeyTags, opts..., ) requireNoErrHeadscaleEnv(t, err) @@ -2315,6 +2315,12 @@ func TestAutoApproveMultiNetwork(t *testing.T) { ) } + // For webauth with tag approver, the node needs to advertise the tag during registration + // (tags-as-identity model: webauth nodes can use --advertise-tags if authorized by tagOwners) + if tt.withURL && strings.HasPrefix(tt.approver, "tag:") { + tsOpts = append(tsOpts, tsic.WithTags([]string{tt.approver})) + } + tsOpts = append(tsOpts, tsic.WithNetwork(usernet1)) // This whole dance is to add a node _after_ all the other nodes @@ -2349,7 +2355,14 @@ func TestAutoApproveMultiNetwork(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - pak, err := scenario.CreatePreAuthKey(userMap["user1"].GetId(), false, false) + // If the approver is a tag, create a tagged PreAuthKey + // (tags-as-identity model: tags come from PreAuthKey, not --advertise-tags) + var pak *v1.PreAuthKey + if strings.HasPrefix(tt.approver, "tag:") { + pak, err = scenario.CreatePreAuthKeyWithTags(userMap["user1"].GetId(), false, false, []string{tt.approver}) + } else { + pak, err = scenario.CreatePreAuthKey(userMap["user1"].GetId(), false, false) + } require.NoError(t, err) err = routerUsernet1.Login(headscale.GetEndpoint(), pak.GetKey()) @@ -2444,7 +2457,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { } assert.True(c, routerPeerFound, "Client should see the router peer") - }, 5*time.Second, 200*time.Millisecond, "Verifying routes sent to client after auto-approval") + }, 30*time.Second, 200*time.Millisecond, "Verifying routes sent to client after auto-approval") url := fmt.Sprintf("http://%s/etc/hostname", webip) t.Logf("url from %s to %s", client.Hostname(), url) @@ -2453,7 +2466,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { result, err := client.Curl(url) assert.NoError(c, err) assert.Len(c, result, 13) - }, 20*time.Second, 200*time.Millisecond, "Verifying client can reach webservice through auto-approved route") + }, 60*time.Second, 200*time.Millisecond, "Verifying client can reach webservice through auto-approved route") assert.EventuallyWithT(t, func(c *assert.CollectT) { tr, err := client.Traceroute(webip) @@ -2463,7 +2476,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { return } assertTracerouteViaIPWithCollect(c, tr, ip) - }, 20*time.Second, 200*time.Millisecond, "Verifying traceroute goes through auto-approved router") + }, 60*time.Second, 200*time.Millisecond, "Verifying traceroute goes through auto-approved router") // Remove the auto approval from the policy, any routes already enabled should be allowed. prefix = *route @@ -2506,7 +2519,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) } } - }, 5*time.Second, 200*time.Millisecond, "Verifying routes remain after policy change") + }, 30*time.Second, 200*time.Millisecond, "Verifying routes remain after policy change") url = fmt.Sprintf("http://%s/etc/hostname", webip) t.Logf("url from %s to %s", client.Hostname(), url) @@ -2515,7 +2528,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { result, err := client.Curl(url) assert.NoError(c, err) assert.Len(c, result, 13) - }, 20*time.Second, 200*time.Millisecond, "Verifying client can still reach webservice after policy change") + }, 60*time.Second, 200*time.Millisecond, "Verifying client can still reach webservice after policy change") assert.EventuallyWithT(t, func(c *assert.CollectT) { tr, err := client.Traceroute(webip) @@ -2525,7 +2538,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { return } assertTracerouteViaIPWithCollect(c, tr, ip) - }, 20*time.Second, 200*time.Millisecond, "Verifying traceroute still goes through router after policy change") + }, 60*time.Second, 200*time.Millisecond, "Verifying traceroute still goes through router after policy change") // Disable the route, making it unavailable since it is no longer auto-approved _, err = headscale.ApproveRoutes( @@ -2541,7 +2554,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { nodes, err = headscale.ListNodes() assert.NoError(c, err) requireNodeRouteCountWithCollect(c, MustFindNode(routerUsernet1.Hostname(), nodes), 1, 0, 0) - }, 10*time.Second, 500*time.Millisecond, "route state changes should propagate") + }, 15*time.Second, 500*time.Millisecond, "route state changes should propagate") // Verify that the routes have been sent to the client. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -2552,7 +2565,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { peerStatus := status.Peer[peerKey] requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) } - }, 5*time.Second, 200*time.Millisecond, "Verifying routes disabled after route removal") + }, 30*time.Second, 200*time.Millisecond, "Verifying routes disabled after route removal") // Add the route back to the auto approver in the policy, the route should // now become available again. @@ -2580,7 +2593,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { nodes, err = headscale.ListNodes() assert.NoError(c, err) requireNodeRouteCountWithCollect(c, MustFindNode(routerUsernet1.Hostname(), nodes), 1, 1, 1) - }, 10*time.Second, 500*time.Millisecond, "route state changes should propagate") + }, 15*time.Second, 500*time.Millisecond, "route state changes should propagate") // Verify that the routes have been sent to the client. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -2600,7 +2613,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) } } - }, 5*time.Second, 200*time.Millisecond, "Verifying routes re-enabled after policy re-approval") + }, 30*time.Second, 200*time.Millisecond, "Verifying routes re-enabled after policy re-approval") url = fmt.Sprintf("http://%s/etc/hostname", webip) t.Logf("url from %s to %s", client.Hostname(), url) @@ -2609,7 +2622,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { result, err := client.Curl(url) assert.NoError(c, err) assert.Len(c, result, 13) - }, 20*time.Second, 200*time.Millisecond, "Verifying client can reach webservice after route re-approval") + }, 60*time.Second, 200*time.Millisecond, "Verifying client can reach webservice after route re-approval") assert.EventuallyWithT(t, func(c *assert.CollectT) { tr, err := client.Traceroute(webip) @@ -2619,7 +2632,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { return } assertTracerouteViaIPWithCollect(c, tr, ip) - }, 20*time.Second, 200*time.Millisecond, "Verifying traceroute goes through router after re-approval") + }, 60*time.Second, 200*time.Millisecond, "Verifying traceroute goes through router after re-approval") // Advertise and validate a subnet of an auto approved route, /24 inside the // auto approved /16. @@ -2639,7 +2652,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { assert.NoError(c, err) requireNodeRouteCountWithCollect(c, MustFindNode(routerUsernet1.Hostname(), nodes), 1, 1, 1) requireNodeRouteCountWithCollect(c, nodes[1], 1, 1, 1) - }, 10*time.Second, 500*time.Millisecond, "route state changes should propagate") + }, 15*time.Second, 500*time.Millisecond, "route state changes should propagate") // Verify that the routes have been sent to the client. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -2663,7 +2676,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) } } - }, 5*time.Second, 200*time.Millisecond, "Verifying sub-route propagated to client") + }, 30*time.Second, 200*time.Millisecond, "Verifying sub-route propagated to client") // Advertise a not approved route will not end up anywhere command = []string{ @@ -2683,7 +2696,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requireNodeRouteCountWithCollect(c, MustFindNode(routerUsernet1.Hostname(), nodes), 1, 1, 1) requireNodeRouteCountWithCollect(c, nodes[1], 1, 1, 0) requireNodeRouteCountWithCollect(c, nodes[2], 0, 0, 0) - }, 10*time.Second, 500*time.Millisecond, "route state changes should propagate") + }, 15*time.Second, 500*time.Millisecond, "route state changes should propagate") // Verify that the routes have been sent to the client. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -2703,7 +2716,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) } } - }, 5*time.Second, 200*time.Millisecond, "Verifying unapproved route not propagated") + }, 30*time.Second, 200*time.Millisecond, "Verifying unapproved route not propagated") // Exit routes are also automatically approved command = []string{ @@ -2721,7 +2734,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requireNodeRouteCountWithCollect(c, MustFindNode(routerUsernet1.Hostname(), nodes), 1, 1, 1) requireNodeRouteCountWithCollect(c, nodes[1], 1, 1, 0) requireNodeRouteCountWithCollect(c, nodes[2], 2, 2, 2) - }, 10*time.Second, 500*time.Millisecond, "route state changes should propagate") + }, 15*time.Second, 500*time.Millisecond, "route state changes should propagate") // Verify that the routes have been sent to the client. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -2742,7 +2755,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { requirePeerSubnetRoutesWithCollect(c, peerStatus, nil) } } - }, 5*time.Second, 200*time.Millisecond, "Verifying exit node routes propagated to client") + }, 30*time.Second, 200*time.Millisecond, "Verifying exit node routes propagated to client") }) } } @@ -2985,7 +2998,7 @@ func TestSubnetRouteACLFiltering(t *testing.T) { // Check that the router has 3 routes now approved and available requireNodeRouteCountWithCollect(c, routerNode, 3, 3, 3) - }, 10*time.Second, 500*time.Millisecond, "route state changes should propagate") + }, 15*time.Second, 500*time.Millisecond, "route state changes should propagate") // Now check the client node status assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -3006,7 +3019,7 @@ func TestSubnetRouteACLFiltering(t *testing.T) { result, err := nodeClient.Curl(weburl) assert.NoError(c, err) assert.Len(c, result, 13) - }, 20*time.Second, 200*time.Millisecond, "Verifying node can reach webservice through allowed route") + }, 60*time.Second, 200*time.Millisecond, "Verifying node can reach webservice through allowed route") assert.EventuallyWithT(t, func(c *assert.CollectT) { tr, err := nodeClient.Traceroute(webip) @@ -3016,5 +3029,5 @@ func TestSubnetRouteACLFiltering(t *testing.T) { return } assertTracerouteViaIPWithCollect(c, tr, ip) - }, 20*time.Second, 200*time.Millisecond, "Verifying traceroute goes through router") + }, 60*time.Second, 200*time.Millisecond, "Verifying traceroute goes through router") } diff --git a/integration/scenario.go b/integration/scenario.go index d584a3ef..2f93210e 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -473,6 +473,27 @@ func (s *Scenario) CreatePreAuthKey( return nil, fmt.Errorf("failed to create user: %w", errNoHeadscaleAvailable) } +// CreatePreAuthKeyWithTags creates a "pre authorised key" with the specified tags +// to be created in the Headscale instance on behalf of the Scenario. +func (s *Scenario) CreatePreAuthKeyWithTags( + user uint64, + reusable bool, + ephemeral bool, + tags []string, +) (*v1.PreAuthKey, error) { + headscale, err := s.Headscale() + if err != nil { + return nil, fmt.Errorf("failed to create preauth key with tags: %w", errNoHeadscaleAvailable) + } + + key, err := headscale.CreateAuthKeyWithTags(user, reusable, ephemeral, tags) + if err != nil { + return nil, fmt.Errorf("failed to create preauth key with tags: %w", err) + } + + return key, nil +} + // CreateUser creates a User to be created in the // Headscale instance on behalf of the Scenario. func (s *Scenario) CreateUser(user string) (*v1.User, error) { @@ -767,6 +788,19 @@ func (s *Scenario) createHeadscaleEnv( withURL bool, tsOpts []tsic.Option, opts ...hsic.Option, +) error { + return s.createHeadscaleEnvWithTags(withURL, tsOpts, nil, opts...) +} + +// createHeadscaleEnvWithTags starts the headscale environment and the clients +// according to the ScenarioSpec passed to the Scenario. If preAuthKeyTags is +// non-empty and withURL is false, the tags will be applied to the PreAuthKey +// (tags-as-identity model). +func (s *Scenario) createHeadscaleEnvWithTags( + withURL bool, + tsOpts []tsic.Option, + preAuthKeyTags []string, + opts ...hsic.Option, ) error { headscale, err := s.Headscale(opts...) if err != nil { @@ -797,7 +831,13 @@ func (s *Scenario) createHeadscaleEnv( return err } } else { - key, err := s.CreatePreAuthKey(u.GetId(), true, false) + // Use tagged PreAuthKey if tags are provided (tags-as-identity model) + var key *v1.PreAuthKey + if len(preAuthKeyTags) > 0 { + key, err = s.CreatePreAuthKeyWithTags(u.GetId(), true, false, preAuthKeyTags) + } else { + key, err = s.CreatePreAuthKey(u.GetId(), true, false) + } if err != nil { return err } diff --git a/integration/tags_test.go b/integration/tags_test.go new file mode 100644 index 00000000..d0008835 --- /dev/null +++ b/integration/tags_test.go @@ -0,0 +1,2465 @@ +package integration + +import ( + "sort" + "testing" + "time" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/ptr" +) + +const tagTestUser = "taguser" + +// ============================================================================= +// Helper Functions +// ============================================================================= + +// tagsTestPolicy creates a policy for tag tests with: +// - tag:valid-owned: owned by the specified user +// - tag:second: owned by the specified user +// - tag:valid-unowned: owned by "other-user" (not the test user) +// - tag:nonexistent is deliberately NOT defined. +func tagsTestPolicy() *policyv2.Policy { + return &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + "tag:valid-owned": policyv2.Owners{ptr.To(policyv2.Username(tagTestUser + "@"))}, + "tag:second": policyv2.Owners{ptr.To(policyv2.Username(tagTestUser + "@"))}, + "tag:valid-unowned": policyv2.Owners{ptr.To(policyv2.Username("other-user@"))}, + // Note: tag:nonexistent deliberately NOT defined + }, + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{policyv2.Wildcard}, + Destinations: []policyv2.AliasWithPorts{{Alias: policyv2.Wildcard, Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}}, + }, + }, + } +} + +// tagsEqual compares two tag slices as unordered sets. +func tagsEqual(actual, expected []string) bool { + if len(actual) != len(expected) { + return false + } + + sortedActual := append([]string{}, actual...) + sortedExpected := append([]string{}, expected...) + + sort.Strings(sortedActual) + sort.Strings(sortedExpected) + + for i := range sortedActual { + if sortedActual[i] != sortedExpected[i] { + return false + } + } + + return true +} + +// assertNodeHasTagsWithCollect asserts that a node has exactly the expected tags (order-independent). +func assertNodeHasTagsWithCollect(c *assert.CollectT, node *v1.Node, expectedTags []string) { + actualTags := node.GetValidTags() + sortedActual := append([]string{}, actualTags...) + sortedExpected := append([]string{}, expectedTags...) + + sort.Strings(sortedActual) + sort.Strings(sortedExpected) + assert.Equal(c, sortedExpected, sortedActual, "Node %s tags mismatch", node.GetName()) +} + +// assertNodeHasNoTagsWithCollect asserts that a node has no tags. +func assertNodeHasNoTagsWithCollect(c *assert.CollectT, node *v1.Node) { + assert.Empty(c, node.GetValidTags(), "Node %s should have no tags, but has: %v", node.GetName(), node.GetValidTags()) +} + +// ============================================================================= +// Test Suite 2: Auth Key WITH Pre-assigned Tags +// ============================================================================= + +// TestTagsAuthKeyWithTagRequestDifferentTag tests that requesting a different tag +// than what the auth key provides results in registration failure. +// +// Test 2.1: Request different tag than key provides +// Setup: Run `tailscale up --advertise-tags="tag:second" --auth-key AUTH_KEY_WITH_TAG` +// Expected: Registration fails with error containing "requested tags [tag:second] are invalid or not permitted". +func TestTagsAuthKeyWithTagRequestDifferentTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, // We'll create the node manually + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-diff"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + + // Create a tailscale client that will try to use --advertise-tags with a DIFFERENT tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:second"}), + ) + require.NoError(t, err) + + // Login should fail because the advertised tags don't match the auth key's tags + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + + // Document actual behavior - we expect this to fail + if err != nil { + t.Logf("Test 2.1 PASS: Registration correctly rejected with error: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + // If it succeeded, document this unexpected behavior + t.Logf("Test 2.1 UNEXPECTED: Registration succeeded when it should have failed") + + // Check what tags the node actually has + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node state") + + t.Fail() + } +} + +// TestTagsAuthKeyWithTagNoAdvertiseFlag tests that registering with a tagged auth key +// but no --advertise-tags flag results in the node inheriting the key's tags. +// +// Test 2.2: Register with no advertise-tags flag +// Setup: Run `tailscale up --auth-key AUTH_KEY_WITH_TAG` (no --advertise-tags) +// Expected: Registration succeeds, node has ["tag:valid-owned"] (inherited from key). +func TestTagsAuthKeyWithTagNoAdvertiseFlag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-inherit"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + + // Create a tailscale client WITHOUT --advertise-tags + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + // Note: NO WithExtraLoginArgs for --advertise-tags + ) + require.NoError(t, err) + + // Login with the tagged PreAuthKey + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for node to be registered and verify it has the key's tags + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + node := nodes[0] + t.Logf("Node registered with tags: %v", node.GetValidTags()) + assertNodeHasTagsWithCollect(c, node, []string{"tag:valid-owned"}) + } + }, 30*time.Second, 500*time.Millisecond, "verifying node inherited tags from auth key") + + t.Logf("Test 2.2 completed - node inherited tags from auth key") +} + +// TestTagsAuthKeyWithTagCannotAddViaCLI tests that nodes registered with a tagged auth key +// cannot add additional tags via the client CLI. +// +// Test 2.3: Cannot add tags via CLI after registration +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITH_TAG +// 2. Run `tailscale up --advertise-tags="tag:valid-owned,tag:second" --auth-key AUTH_KEY_WITH_TAG` +// +// Expected: Command fails with error containing "requested tags [tag:second] are invalid or not permitted". +func TestTagsAuthKeyWithTagCannotAddViaCLI(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-noadd"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + t.Logf("Node registered with tag:valid-owned, now attempting to add tag:second via CLI") + + // Attempt to add additional tags via tailscale up + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--advertise-tags=tag:valid-owned,tag:second", + } + _, stderr, err := client.Execute(command) + + // Document actual behavior + if err != nil { + t.Logf("Test 2.3 PASS: CLI correctly rejected adding tags: %v, stderr: %s", err, stderr) + } else { + t.Logf("Test 2.3: CLI command succeeded, checking if tags actually changed") + + // Check if tags actually changed + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + // If still only has original tag, that's the expected behavior + if tagsEqual(nodes[0].GetValidTags(), []string{"tag:valid-owned"}) { + t.Logf("Test 2.3 PASS: Tags unchanged after CLI attempt: %v", nodes[0].GetValidTags()) + } else { + t.Logf("Test 2.3 FAIL: Tags changed unexpectedly to: %v", nodes[0].GetValidTags()) + assert.Fail(c, "Tags should not have changed") + } + } + }, 10*time.Second, 500*time.Millisecond, "verifying tags unchanged") + } +} + +// TestTagsAuthKeyWithTagCannotChangeViaCLI tests that nodes registered with a tagged auth key +// cannot change to a completely different tag set via the client CLI. +// +// Test 2.4: Cannot change to different tag set via CLI +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITH_TAG +// 2. Run `tailscale up --advertise-tags="tag:second" --auth-key AUTH_KEY_WITH_TAG` +// +// Expected: Command fails, tags remain ["tag:valid-owned"]. +func TestTagsAuthKeyWithTagCannotChangeViaCLI(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-nochange"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + t.Logf("Node registered, now attempting to change to different tag via CLI") + + // Attempt to change to a different tag via tailscale up + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--advertise-tags=tag:second", + } + _, stderr, err := client.Execute(command) + + // Document actual behavior + if err != nil { + t.Logf("Test 2.4 PASS: CLI correctly rejected changing tags: %v, stderr: %s", err, stderr) + } else { + t.Logf("Test 2.4: CLI command succeeded, checking if tags actually changed") + + // Check if tags remain unchanged + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + if tagsEqual(nodes[0].GetValidTags(), []string{"tag:valid-owned"}) { + t.Logf("Test 2.4 PASS: Tags unchanged: %v", nodes[0].GetValidTags()) + } else { + t.Logf("Test 2.4 FAIL: Tags changed unexpectedly to: %v", nodes[0].GetValidTags()) + assert.Fail(c, "Tags should not have changed") + } + } + }, 10*time.Second, 500*time.Millisecond, "verifying tags unchanged") + } +} + +// TestTagsAuthKeyWithTagAdminOverrideReauthPreserves tests that admin-assigned tags +// are preserved even after reauthentication - admin decisions are authoritative. +// +// Test 2.5: Admin assignment is preserved through reauth +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITH_TAG +// 2. Assign ["tag:second"] via headscale CLI +// 3. Run `tailscale up --auth-key AUTH_KEY_WITH_TAG --force-reauth` +// +// Expected: After step 2 tags are ["tag:second"], after step 3 tags remain ["tag:second"]. +func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-admin"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, true, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + t.Logf("Step 1 complete: Node %d registered with tag:valid-owned", nodeID) + + // Step 2: Admin assigns different tags via headscale CLI + err = headscale.SetNodeTags(nodeID, []string{"tag:second"}) + require.NoError(t, err) + + // Verify admin assignment took effect + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("After admin assignment, tags are: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin tag assignment") + + t.Logf("Step 2 complete: Admin assigned tag:second") + + // Step 3: Force reauthentication + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--force-reauth", + } + //nolint:errcheck // Intentionally ignoring error - we check results below + client.Execute(command) + + // Verify admin tags are preserved even after reauth - admin decisions are authoritative + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.GreaterOrEqual(c, len(nodes), 1, "Should have at least 1 node") + + if len(nodes) >= 1 { + // Find the most recently updated node (in case a new one was created) + node := nodes[len(nodes)-1] + t.Logf("After reauth, tags are: %v", node.GetValidTags()) + + // Expected: admin-assigned tags are preserved through reauth + assertNodeHasTagsWithCollect(c, node, []string{"tag:second"}) + } + }, 30*time.Second, 500*time.Millisecond, "admin tags should be preserved after reauth") + + t.Logf("Test 2.5 PASS: Admin tags preserved through reauth (admin decisions are authoritative)") +} + +// TestTagsAuthKeyWithTagCLICannotModifyAdminTags tests that the client CLI +// cannot modify admin-assigned tags. +// +// Test 2.6: Client CLI cannot modify admin-assigned tags +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITH_TAG +// 2. Assign ["tag:valid-owned", "tag:second"] via headscale CLI +// 3. Run `tailscale up --advertise-tags="tag:valid-owned" --auth-key AUTH_KEY_WITH_TAG` +// +// Expected: Command either fails or is no-op, tags remain ["tag:valid-owned", "tag:second"]. +func TestTagsAuthKeyWithTagCLICannotModifyAdminTags(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-noadmin"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, true, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + // Step 2: Admin assigns multiple tags via headscale CLI + err = headscale.SetNodeTags(nodeID, []string{"tag:valid-owned", "tag:second"}) + require.NoError(t, err) + + // Verify admin assignment + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin tag assignment") + + t.Logf("Admin assigned both tags, now attempting to reduce via CLI") + + // Step 3: Attempt to reduce tags via CLI + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--advertise-tags=tag:valid-owned", + } + _, stderr, err := client.Execute(command) + + t.Logf("CLI command result: err=%v, stderr=%s", err, stderr) + + // Verify admin tags are preserved - CLI should not be able to reduce them + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("After CLI attempt, tags are: %v", nodes[0].GetValidTags()) + + // Expected: tags should remain unchanged (admin wins) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "admin tags should be preserved after CLI attempt") + + t.Logf("Test 2.6 PASS: Admin tags preserved - CLI cannot modify admin-assigned tags") +} + +// ============================================================================= +// Test Suite 3: Auth Key WITHOUT Tags +// ============================================================================= + +// TestTagsAuthKeyWithoutTagCannotRequestTags tests that nodes cannot request tags +// when using an auth key that has no tags. +// +// Test 3.1: Cannot request tags with tagless key +// Setup: Run `tailscale up --advertise-tags="tag:valid-owned" --auth-key AUTH_KEY_WITHOUT_TAG` +// Expected: Registration fails with error containing "requested tags [tag:valid-owned] are invalid or not permitted". +func TestTagsAuthKeyWithoutTagCannotRequestTags(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-req"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, false, false) + require.NoError(t, err) + t.Logf("Created PreAuthKey without tags") + + // Create a tailscale client that will try to request tags + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}), + ) + require.NoError(t, err) + + // Login should fail because the auth key has no tags + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + if err != nil { + t.Logf("Test 3.1 PASS: Registration correctly rejected: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + // If it succeeded, document this unexpected behavior + t.Logf("Test 3.1 UNEXPECTED: Registration succeeded when it should have failed") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node state") + + t.Fail() + } +} + +// TestTagsAuthKeyWithoutTagRegisterNoTags tests that registering with a tagless auth key +// and no --advertise-tags results in a node with no tags. +// +// Test 3.2: Register with no tags +// Setup: Run `tailscale up --auth-key AUTH_KEY_WITHOUT_TAG` (no --advertise-tags) +// Expected: Registration succeeds, node has no tags (empty tag set). +func TestTagsAuthKeyWithoutTagRegisterNoTags(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-noreg"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, false, false) + require.NoError(t, err) + + // Create a tailscale client without --advertise-tags + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Login should succeed + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Verify node has no tags + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v", nodes[0].GetValidTags()) + assertNodeHasNoTagsWithCollect(c, nodes[0]) + } + }, 30*time.Second, 500*time.Millisecond, "verifying node has no tags") + + t.Logf("Test 3.2 completed - node registered without tags") +} + +// TestTagsAuthKeyWithoutTagCannotAddViaCLI tests that nodes registered with a tagless +// auth key cannot add tags via the client CLI. +// +// Test 3.3: Cannot add tags via CLI after registration +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITHOUT_TAG +// 2. Run `tailscale up --advertise-tags="tag:valid-owned" --auth-key AUTH_KEY_WITHOUT_TAG` +// +// Expected: Command fails, node remains with no tags. +func TestTagsAuthKeyWithoutTagCannotAddViaCLI(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-noadd"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, true, false) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + assertNodeHasNoTagsWithCollect(c, nodes[0]) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + t.Logf("Node registered without tags, attempting to add via CLI") + + // Attempt to add tags via tailscale up + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--advertise-tags=tag:valid-owned", + } + _, stderr, err := client.Execute(command) + + // Document actual behavior + if err != nil { + t.Logf("Test 3.3 PASS: CLI correctly rejected adding tags: %v, stderr: %s", err, stderr) + } else { + t.Logf("Test 3.3: CLI command succeeded, checking if tags actually changed") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + if len(nodes[0].GetValidTags()) == 0 { + t.Logf("Test 3.3 PASS: Tags still empty after CLI attempt") + } else { + t.Logf("Test 3.3 FAIL: Tags changed to: %v", nodes[0].GetValidTags()) + assert.Fail(c, "Tags should not have changed") + } + } + }, 10*time.Second, 500*time.Millisecond, "verifying tags unchanged") + } +} + +// TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset tests that the client CLI +// is a no-op after admin tag assignment, even with --reset flag. +// +// Test 3.4: CLI no-op after admin tag assignment (with --reset) +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITHOUT_TAG +// 2. Assign ["tag:valid-owned"] via headscale CLI +// 3. Run `tailscale up --auth-key AUTH_KEY_WITHOUT_TAG --reset` +// +// Expected: Command is no-op, tags remain ["tag:valid-owned"]. +func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-reset"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, true, false) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + assertNodeHasNoTagsWithCollect(c, nodes[0]) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + // Step 2: Admin assigns tags + err = headscale.SetNodeTags(nodeID, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Verify admin assignment + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin tag assignment") + + t.Logf("Admin assigned tag, now running CLI with --reset") + + // Step 3: Run tailscale up with --reset + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--reset", + } + _, stderr, err := client.Execute(command) + t.Logf("CLI --reset result: err=%v, stderr=%s", err, stderr) + + // Verify admin tags are preserved - --reset should not remove them + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("After --reset, tags are: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 10*time.Second, 500*time.Millisecond, "admin tags should be preserved after --reset") + + t.Logf("Test 3.4 PASS: Admin tags preserved after --reset") +} + +// TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise tests that the client CLI +// is a no-op after admin tag assignment, even with empty --advertise-tags. +// +// Test 3.5: CLI no-op after admin tag assignment (with empty advertise-tags) +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITHOUT_TAG +// 2. Assign ["tag:valid-owned"] via headscale CLI +// 3. Run `tailscale up --auth-key AUTH_KEY_WITHOUT_TAG --advertise-tags=""` +// +// Expected: Command is no-op, tags remain ["tag:valid-owned"]. +func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-empty"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, true, false) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + // Step 2: Admin assigns tags + err = headscale.SetNodeTags(nodeID, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Verify admin assignment + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin tag assignment") + + t.Logf("Admin assigned tag, now running CLI with empty --advertise-tags") + + // Step 3: Run tailscale up with empty --advertise-tags + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--advertise-tags=", + } + _, stderr, err := client.Execute(command) + t.Logf("CLI empty advertise-tags result: err=%v, stderr=%s", err, stderr) + + // Verify admin tags are preserved - empty --advertise-tags should not remove them + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("After empty --advertise-tags, tags are: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 10*time.Second, 500*time.Millisecond, "admin tags should be preserved after empty --advertise-tags") + + t.Logf("Test 3.5 PASS: Admin tags preserved after empty --advertise-tags") +} + +// TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag tests that the client CLI +// cannot reduce an admin-assigned multi-tag set. +// +// Test 3.6: Client CLI cannot reduce admin-assigned multi-tag set +// Setup: +// 1. Register with --auth-key AUTH_KEY_WITHOUT_TAG +// 2. Assign ["tag:valid-owned", "tag:second"] via headscale CLI +// 3. Run `tailscale up --advertise-tags="tag:valid-owned" --auth-key AUTH_KEY_WITHOUT_TAG` +// +// Expected: Command is no-op (or fails), tags remain ["tag:valid-owned", "tag:second"]. +func TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-reduce"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, true, false) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + // Initial login + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for initial registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + // Step 2: Admin assigns multiple tags + err = headscale.SetNodeTags(nodeID, []string{"tag:valid-owned", "tag:second"}) + require.NoError(t, err) + + // Verify admin assignment + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin tag assignment") + + t.Logf("Admin assigned both tags, now attempting to reduce via CLI") + + // Step 3: Attempt to reduce tags via CLI + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--authkey=" + authKey.GetKey(), + "--advertise-tags=tag:valid-owned", + } + _, stderr, err := client.Execute(command) + t.Logf("CLI reduce result: err=%v, stderr=%s", err, stderr) + + // Verify admin tags are preserved - CLI should not be able to reduce them + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("After CLI reduce attempt, tags are: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "admin tags should be preserved after CLI reduce attempt") + + t.Logf("Test 3.6 PASS: Admin tags preserved - CLI cannot reduce admin-assigned multi-tag set") +} + +// ============================================================================= +// Test Suite 1: User Login Authentication (Web Auth Flow) +// ============================================================================= + +// TestTagsUserLoginOwnedTagAtRegistration tests that a user can advertise an owned tag +// during web auth registration. +// +// Test 1.1: Advertise owned tag at registration +// Setup: Web auth login with --advertise-tags="tag:valid-owned" +// Expected: Node has ["tag:valid-owned"]. +func TestTagsUserLoginOwnedTagAtRegistration(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, // We'll create the node manually + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{ + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}), + }, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-owned"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Create a tailscale client with --advertise-tags + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}), + ) + require.NoError(t, err) + + // Login via web auth flow + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + // Complete the web auth by visiting the login URL + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + // Register the node via headscale CLI + err = scenario.runHeadscaleRegister(tagTestUser, body) + require.NoError(t, err) + + // Wait for client to be running + err = client.WaitForRunning(120 * time.Second) + require.NoError(t, err) + + // Verify node has the advertised tag + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 30*time.Second, 500*time.Millisecond, "verifying node has advertised tag") + + t.Logf("Test 1.1 completed - web auth with owned tag succeeded") +} + +// TestTagsUserLoginNonExistentTagAtRegistration tests that advertising a non-existent tag +// during web auth registration fails. +// +// Test 1.2: Advertise non-existent tag at registration +// Setup: Web auth login with --advertise-tags="tag:nonexistent" +// Expected: Registration fails - node should not be registered OR should have no tags. +func TestTagsUserLoginNonExistentTagAtRegistration(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-nonexist"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Create a tailscale client with non-existent tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:nonexistent"}), + ) + require.NoError(t, err) + + // Login via web auth flow + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + // Complete the web auth by visiting the login URL + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + // Register the node via headscale CLI - this should fail due to non-existent tag + err = scenario.runHeadscaleRegister(tagTestUser, body) + + // We expect registration to fail with an error about invalid/unauthorized tags + if err != nil { + t.Logf("Test 1.2 PASS: Registration correctly rejected with error: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + // Check the result - if registration succeeded, the node should not have the invalid tag + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err, "Should be able to list nodes") + + if len(nodes) == 0 { + t.Logf("Test 1.2 PASS: Registration rejected - no nodes registered") + } else { + // If a node was registered, it should NOT have the non-existent tag + assert.NotContains(c, nodes[0].GetValidTags(), "tag:nonexistent", + "Non-existent tag should not be applied to node") + t.Logf("Test 1.2: Node registered with tags: %v (non-existent tag correctly rejected)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node registration result") + } +} + +// TestTagsUserLoginUnownedTagAtRegistration tests that advertising an unowned tag +// during web auth registration is rejected. +// +// Test 1.3: Advertise unowned tag at registration +// Setup: Web auth login with --advertise-tags="tag:valid-unowned" +// Expected: Registration fails - node should not be registered OR should have no tags. +func TestTagsUserLoginUnownedTagAtRegistration(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-unowned"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Create a tailscale client with unowned tag (tag:valid-unowned is owned by "other-user", not "taguser") + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-unowned"}), + ) + require.NoError(t, err) + + // Login via web auth flow + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + // Complete the web auth + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + // Register the node - should fail or reject the unowned tag + _ = scenario.runHeadscaleRegister(tagTestUser, body) + + // Check the result - user should NOT be able to claim an unowned tag + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err, "Should be able to list nodes") + + // Either: no nodes registered (ideal), or node registered without the unowned tag + if len(nodes) == 0 { + t.Logf("Test 1.3 PASS: Registration rejected - no nodes registered") + } else { + // If a node was registered, it should NOT have the unowned tag + assert.NotContains(c, nodes[0].GetValidTags(), "tag:valid-unowned", + "Unowned tag should not be applied to node (tag:valid-unowned is owned by other-user)") + t.Logf("Test 1.3: Node registered with tags: %v (unowned tag correctly rejected)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node registration result") +} + +// TestTagsUserLoginAddTagViaCLIReauth tests that a user can add tags via CLI reauthentication. +// +// Test 1.4: Add tag via CLI reauthentication +// Setup: +// 1. Register with --advertise-tags="tag:valid-owned" +// 2. Run tailscale up --advertise-tags="tag:valid-owned,tag:second" +// +// Expected: Triggers full reauthentication, node has both tags. +func TestTagsUserLoginAddTagViaCLIReauth(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-addtag"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Step 1: Create and register with one tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}), + ) + require.NoError(t, err) + + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + err = scenario.runHeadscaleRegister(tagTestUser, body) + require.NoError(t, err) + + err = client.WaitForRunning(120 * time.Second) + require.NoError(t, err) + + // Verify initial tag + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Initial tags: %v", nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "checking initial tags") + + // Step 2: Try to add second tag via CLI + t.Logf("Attempting to add second tag via CLI reauth") + + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--advertise-tags=tag:valid-owned,tag:second", + } + _, stderr, err := client.Execute(command) + t.Logf("CLI result: err=%v, stderr=%s", err, stderr) + + // Check final state - EventuallyWithT handles waiting for propagation + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) >= 1 { + t.Logf("Test 1.4: After CLI, tags are: %v", nodes[0].GetValidTags()) + + if tagsEqual(nodes[0].GetValidTags(), []string{"tag:valid-owned", "tag:second"}) { + t.Logf("Test 1.4 PASS: Both tags present after reauth") + } else { + t.Logf("Test 1.4: Tags are %v (may require manual reauth completion)", nodes[0].GetValidTags()) + } + } + }, 30*time.Second, 500*time.Millisecond, "checking tags after CLI") +} + +// TestTagsUserLoginRemoveTagViaCLIReauth tests that a user can remove tags via CLI reauthentication. +// +// Test 1.5: Remove tag via CLI reauthentication +// Setup: +// 1. Register with --advertise-tags="tag:valid-owned,tag:second" +// 2. Run tailscale up --advertise-tags="tag:valid-owned" +// +// Expected: Triggers full reauthentication, node has only ["tag:valid-owned"]. +func TestTagsUserLoginRemoveTagViaCLIReauth(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-rmtag"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Step 1: Create and register with two tags + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned,tag:second"}), + ) + require.NoError(t, err) + + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + err = scenario.runHeadscaleRegister(tagTestUser, body) + require.NoError(t, err) + + err = client.WaitForRunning(120 * time.Second) + require.NoError(t, err) + + // Verify initial tags + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Initial tags: %v", nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "checking initial tags") + + // Step 2: Try to remove second tag via CLI + t.Logf("Attempting to remove tag via CLI reauth") + + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--advertise-tags=tag:valid-owned", + } + _, stderr, err := client.Execute(command) + t.Logf("CLI result: err=%v, stderr=%s", err, stderr) + + // Check final state - EventuallyWithT handles waiting for propagation + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) >= 1 { + t.Logf("Test 1.5: After CLI, tags are: %v", nodes[0].GetValidTags()) + + if tagsEqual(nodes[0].GetValidTags(), []string{"tag:valid-owned"}) { + t.Logf("Test 1.5 PASS: Only one tag after removal") + } + } + }, 30*time.Second, 500*time.Millisecond, "checking tags after CLI") +} + +// TestTagsUserLoginCLINoOpAfterAdminAssignment tests that CLI advertise-tags becomes +// a no-op after admin tag assignment. +// +// Test 1.6: CLI advertise-tags becomes no-op after admin tag assignment +// Setup: +// 1. Register with --advertise-tags="tag:valid-owned" +// 2. Assign ["tag:second"] via headscale CLI +// 3. Run tailscale up --advertise-tags="tag:valid-owned" +// +// Expected: Step 3 does NOT trigger reauthentication, tags remain ["tag:second"]. +func TestTagsUserLoginCLINoOpAfterAdminAssignment(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-adminwin"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Step 1: Register with one tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}), + ) + require.NoError(t, err) + + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + err = scenario.runHeadscaleRegister(tagTestUser, body) + require.NoError(t, err) + + err = client.WaitForRunning(120 * time.Second) + require.NoError(t, err) + + // Get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + t.Logf("Step 1: Node %d registered with tags: %v", nodeID, nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + // Step 2: Admin assigns different tag + err = headscale.SetNodeTags(nodeID, []string{"tag:second"}) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Step 2: After admin assignment, tags: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin assignment") + + // Step 3: Try to change tags via CLI + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--advertise-tags=tag:valid-owned", + } + _, stderr, err := client.Execute(command) + t.Logf("Step 3 CLI result: err=%v, stderr=%s", err, stderr) + + // Verify admin tags are preserved - CLI advertise-tags should be a no-op after admin assignment + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("Step 3: After CLI, tags are: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "admin tags should be preserved - CLI advertise-tags should be no-op") + + t.Logf("Test 1.6 PASS: Admin tags preserved (CLI was no-op)") +} + +// TestTagsUserLoginCLICannotRemoveAdminTags tests that CLI cannot remove admin-assigned tags. +// +// Test 1.7: CLI cannot remove admin-assigned tags +// Setup: +// 1. Register with --advertise-tags="tag:valid-owned" +// 2. Assign ["tag:valid-owned", "tag:second"] via headscale CLI +// 3. Run tailscale up --advertise-tags="tag:valid-owned" +// +// Expected: Command is no-op, tags remain ["tag:valid-owned", "tag:second"]. +func TestTagsUserLoginCLICannotRemoveAdminTags(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnvWithLoginURL( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-webauth-norem"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Step 1: Register with one tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}), + ) + require.NoError(t, err) + + loginURL, err := client.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + body, err := doLoginURL(client.Hostname(), loginURL) + require.NoError(t, err) + + err = scenario.runHeadscaleRegister(tagTestUser, body) + require.NoError(t, err) + + err = client.WaitForRunning(120 * time.Second) + require.NoError(t, err) + + // Get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + } + }, 30*time.Second, 500*time.Millisecond, "waiting for initial registration") + + // Step 2: Admin assigns both tags + err = headscale.SetNodeTags(nodeID, []string{"tag:valid-owned", "tag:second"}) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("After admin assignment, tags: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying admin assignment") + + // Step 3: Try to reduce tags via CLI + command := []string{ + "tailscale", "up", + "--login-server=" + headscale.GetEndpoint(), + "--advertise-tags=tag:valid-owned", + } + _, stderr, err := client.Execute(command) + t.Logf("CLI result: err=%v, stderr=%s", err, stderr) + + // Verify admin tags are preserved - CLI should not be able to remove admin-assigned tags + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1, "Should have exactly 1 node") + + if len(nodes) == 1 { + t.Logf("Test 1.7: After CLI, tags are: %v", nodes[0].GetValidTags()) + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) + } + }, 10*time.Second, 500*time.Millisecond, "admin tags should be preserved - CLI cannot remove them") + + t.Logf("Test 1.7 PASS: Admin tags preserved (CLI cannot remove)") +} + +// ============================================================================= +// Test Suite 2 (continued): Additional Auth Key WITH Tags Tests +// ============================================================================= + +// TestTagsAuthKeyWithTagRequestNonExistentTag tests that requesting a non-existent tag +// with a tagged auth key results in registration failure. +// +// Test 2.7: Request non-existent tag with tagged key +// Setup: Run `tailscale up --advertise-tags="tag:nonexistent" --auth-key AUTH_KEY_WITH_TAG` +// Expected: Registration fails with error containing "requested tags". +func TestTagsAuthKeyWithTagRequestNonExistentTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-nonexist"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + + // Create a tailscale client that will try to use --advertise-tags with a NON-EXISTENT tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:nonexistent"}), + ) + require.NoError(t, err) + + // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + if err != nil { + t.Logf("Test 2.7 PASS: Registration correctly rejected with error: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + t.Logf("Test 2.7 UNEXPECTED: Registration succeeded when it should have failed") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node state") + + t.Fail() + } +} + +// TestTagsAuthKeyWithTagRequestUnownedTag tests that requesting an unowned tag +// with a tagged auth key results in registration failure. +// +// Test 2.8: Request unowned tag with tagged key +// Setup: Run `tailscale up --advertise-tags="tag:valid-unowned" --auth-key AUTH_KEY_WITH_TAG` +// Expected: Registration fails with error containing "requested tags". +func TestTagsAuthKeyWithTagRequestUnownedTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-authkey-unowned"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey with tag:valid-owned + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + + // Create a tailscale client that will try to use --advertise-tags with an UNOWNED tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-unowned"}), + ) + require.NoError(t, err) + + // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + if err != nil { + t.Logf("Test 2.8 PASS: Registration correctly rejected with error: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + t.Logf("Test 2.8 UNEXPECTED: Registration succeeded when it should have failed") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node state") + + t.Fail() + } +} + +// ============================================================================= +// Test Suite 3 (continued): Additional Auth Key WITHOUT Tags Tests +// ============================================================================= + +// TestTagsAuthKeyWithoutTagRequestNonExistentTag tests that requesting a non-existent tag +// with a tagless auth key results in registration failure. +// +// Test 3.7: Request non-existent tag with tagless key +// Setup: Run `tailscale up --advertise-tags="tag:nonexistent" --auth-key AUTH_KEY_WITHOUT_TAG` +// Expected: Registration fails with error containing "requested tags". +func TestTagsAuthKeyWithoutTagRequestNonExistentTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-nonexist"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, false, false) + require.NoError(t, err) + t.Logf("Created PreAuthKey without tags") + + // Create a tailscale client that will try to request a NON-EXISTENT tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:nonexistent"}), + ) + require.NoError(t, err) + + // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + if err != nil { + t.Logf("Test 3.7 PASS: Registration correctly rejected: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + t.Logf("Test 3.7 UNEXPECTED: Registration succeeded when it should have failed") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node state") + + t.Fail() + } +} + +// TestTagsAuthKeyWithoutTagRequestUnownedTag tests that requesting an unowned tag +// with a tagless auth key results in registration failure. +// +// Test 3.8: Request unowned tag with tagless key +// Setup: Run `tailscale up --advertise-tags="tag:valid-unowned" --auth-key AUTH_KEY_WITHOUT_TAG` +// Expected: Registration fails with error containing "requested tags". +func TestTagsAuthKeyWithoutTagRequestUnownedTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-nokey-unowned"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create an auth key WITHOUT tags + authKey, err := scenario.CreatePreAuthKey(userID, false, false) + require.NoError(t, err) + t.Logf("Created PreAuthKey without tags") + + // Create a tailscale client that will try to request an UNOWNED tag + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-unowned"}), + ) + require.NoError(t, err) + + // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + if err != nil { + t.Logf("Test 3.8 PASS: Registration correctly rejected: %v", err) + assert.ErrorContains(t, err, "requested tags") + } else { + t.Logf("Test 3.8 UNEXPECTED: Registration succeeded when it should have failed") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + + if len(nodes) == 1 { + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetValidTags()) + } + }, 10*time.Second, 500*time.Millisecond, "checking node state") + + t.Fail() + } +} + +// ============================================================================= +// Test Suite 4: Admin API (SetNodeTags) Validation Tests +// ============================================================================= + +// TestTagsAdminAPICannotSetNonExistentTag tests that the admin API rejects +// setting a tag that doesn't exist in the policy. +// +// Test 4.1: Admin cannot set non-existent tag +// Setup: Create node, then call SetNodeTags with ["tag:nonexistent"] +// Expected: SetNodeTags returns error. +func TestTagsAdminAPICannotSetNonExistentTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-admin-nonexist"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey to register a node + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for registration") + + // Try to set a non-existent tag via admin API - should fail + err = headscale.SetNodeTags(nodeID, []string{"tag:nonexistent"}) + + require.Error(t, err, "SetNodeTags should fail for non-existent tag") + t.Logf("Test 4.1 PASS: Admin API correctly rejected non-existent tag: %v", err) +} + +// TestTagsAdminAPICanSetUnownedTag tests that the admin API CAN set a tag +// that exists in policy but is owned by a different user. +// Admin has full authority over tags - ownership only matters for client requests. +// +// Test 4.2: Admin CAN set unowned tag (admin has full authority) +// Setup: Create node, then call SetNodeTags with ["tag:valid-unowned"] +// Expected: SetNodeTags succeeds (admin can assign any existing tag). +func TestTagsAdminAPICanSetUnownedTag(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-admin-unowned"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey to register a node + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for registration") + + // Admin sets an "unowned" tag - should SUCCEED because admin has full authority + // (tag:valid-unowned is owned by other-user, but admin can assign it) + err = headscale.SetNodeTags(nodeID, []string{"tag:valid-unowned"}) + require.NoError(t, err, "SetNodeTags should succeed for admin setting any existing tag") + + // Verify the tag was applied + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-unowned"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying unowned tag was applied") + + t.Logf("Test 4.2 PASS: Admin API correctly allowed setting unowned tag") +} + +// TestTagsAdminAPICannotRemoveAllTags tests that the admin API rejects +// removing all tags from a node (would orphan the node). +// +// Test 4.3: Admin cannot remove all tags +// Setup: Create tagged node, then call SetNodeTags with [] +// Expected: SetNodeTags returns error. +func TestTagsAdminAPICannotRemoveAllTags(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-admin-empty"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey to register a node + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for registration") + + // Try to remove all tags - should fail + err = headscale.SetNodeTags(nodeID, []string{}) + + require.Error(t, err, "SetNodeTags should fail when trying to remove all tags") + t.Logf("Test 4.3 PASS: Admin API correctly rejected removing all tags: %v", err) + + // Verify original tags are preserved + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying original tags preserved") +} + +// TestTagsAdminAPICannotSetInvalidFormat tests that the admin API rejects +// tags that don't have the correct format (must start with "tag:"). +// +// Test 4.4: Admin cannot set invalid format tag +// Setup: Create node, then call SetNodeTags with ["invalid-no-prefix"] +// Expected: SetNodeTags returns error. +func TestTagsAdminAPICannotSetInvalidFormat(t *testing.T) { + IntegrationSkip(t) + + policy := tagsTestPolicy() + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{tagTestUser}, + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("tags-admin-invalid"), + hsic.WithTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + userID := userMap[tagTestUser].GetId() + + // Create a tagged PreAuthKey to register a node + authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) + require.NoError(t, err) + + // Create and register a tailscale client + client, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + // Wait for registration and get node ID + var nodeID uint64 + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + nodeID = nodes[0].GetId() + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetValidTags()) + } + }, 30*time.Second, 500*time.Millisecond, "waiting for registration") + + // Try to set a tag without the "tag:" prefix - should fail + err = headscale.SetNodeTags(nodeID, []string{"invalid-no-prefix"}) + + require.Error(t, err, "SetNodeTags should fail for invalid tag format") + t.Logf("Test 4.4 PASS: Admin API correctly rejected invalid tag format: %v", err) + + // Verify original tags are preserved + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes(tagTestUser) + assert.NoError(c, err) + assert.Len(c, nodes, 1) + + if len(nodes) == 1 { + assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) + } + }, 10*time.Second, 500*time.Millisecond, "verifying original tags preserved") +}