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") +}