{"record":{"id":"202ef632470ce80b","repo":"juanfont/headscale","slug":"cannot-remove-all-tags-from-node","errorCode":null,"errorMessage":"cannot remove all tags from node","messagePattern":"cannot remove all tags from node","errorType":"validation","errorClass":"types.ErrCannotRemoveAllTags","httpStatus":null,"severity":"error","filePath":"hscontrol/types/node.go","lineNumber":30,"sourceCode":"\n\t\"github.com/juanfont/headscale/hscontrol/policy/matcher\"\n\t\"github.com/juanfont/headscale/hscontrol/util\"\n\t\"github.com/juanfont/headscale/hscontrol/util/zlog/zf\"\n\t\"github.com/rs/zerolog\"\n\t\"go4.org/netipx\"\n\t\"tailscale.com/net/tsaddr\"\n\t\"tailscale.com/tailcfg\"\n\t\"tailscale.com/types/key\"\n\t\"tailscale.com/types/views\"\n\t\"tailscale.com/util/dnsname\"\n)\n\nvar (\n\tErrNodeAddressesInvalid = errors.New(\"parsing node addresses\")\n\tErrHostnameTooLong      = errors.New(\"hostname too long, cannot accept more than 255 ASCII chars\")\n\tErrNodeHasNoGivenName   = errors.New(\"node has no given name\")\n\tErrNodeUserHasNoName    = errors.New(\"node user has no name\")\n\tErrCannotRemoveAllTags  = errors.New(\"cannot remove all tags from node\")\n\tErrInvalidNodeView      = errors.New(\"cannot convert invalid NodeView to tailcfg.Node\")\n)\n\n// RouteFunc is a function that takes a node ID and returns a list of\n// [netip.Prefix] values representing the routes for that node.\ntype RouteFunc func(id NodeID) []netip.Prefix\n\n// nodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the\n// node's own IPv4 CGNAT prefix in [tailcfg.Node.Addresses] and\n// [tailcfg.Node.AllowedIPs]. Subnet routes the node advertises remain.\n// See https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts.\nconst nodeAttrDisableIPv4 tailcfg.NodeCapability = \"disable-ipv4\"\n\n// filterIPv4 returns ps with every IPv4 prefix dropped. Used by\n// [NodeView.TailNode] when the node carries the disable-ipv4 nodeAttr.\nfunc filterIPv4(ps []netip.Prefix) []netip.Prefix {\n\tout := ps[:0:0]\n\tfor _, p := range ps {","sourceCodeStart":12,"sourceCodeEnd":48,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/types/node.go#L12-L48","documentation":"ErrCannotRemoveAllTags (hscontrol/types/node.go:30) is returned by State.SetTags at hscontrol/state/state.go:939 when a caller tries to set an empty tag list on a tagged node. Headscale enforces tags-XOR-user ownership as a load-bearing invariant: a node is either owned by tags or by a user namespace, and a tagged node cannot become ownerless. Removing all tags would leave the node in no namespace, so the operation is rejected.","triggerScenarios":"Calling the gRPC/API SetTags (e.g. `headscale nodes tag <node-id> --tags ''` or the v1/v2 API SetTags with an empty list) on a node where node.IsTagged() is true; also reachable from any state-layer code path that passes a zero-length tag slice.","commonSituations":"Automation that 'clears' tags by setting an empty string; trying to convert a tagged node back to a user-owned node via untagging (not supported); UI scripts that compute the tag list dynamically and end up with none.","solutions":["Keep at least one tag on the node: `headscale nodes tag <id> --tags tag:newtag`","If the node must become user-owned, delete it and re-register it under the user (ownership switch is not supported in place)","In code, guard with node.IsTagged() before calling SetTags and skip/log when the new tag list is empty (see hscontrol/auth_tags_test.go:186 for the documented behaviour)"],"exampleFix":"// before\nerr := h.state.SetTags(ctx, nodeID, []string{})\n\n// after\nnode := h.state.GetNode(nodeID)\nif node.IsTagged() && len(newTags) == 0 {\n\treturn types.ErrCannotRemoveAllTags // handle: require >=1 tag or delete+re-register\n}\nerr := h.state.SetTags(ctx, nodeID, newTags)","handlingStrategy":"validation","validationCode":"// only attempt tag updates that keep ownership valid\nnode := state.GetNode(ctx, nodeID)\nif node.IsTagged() && len(newTags) == 0 {\n\treturn fmt.Errorf(\"node %d is tagged; provide at least one replacement tag or delete and re-register it\", nodeID)\n}\nreturn state.SetTags(ctx, nodeID, newTags)","typeGuard":null,"tryCatchPattern":"if err := h.state.SetTags(ctx, nodeID, tags); err != nil {\n\tif errors.Is(err, types.ErrCannotRemoveAllTags) {\n\t\t// keep >=1 tag or delete + re-register; do not retry with []\n\t\treturn reconcileOwnership(nodeID)\n\t}\n\treturn err\n}","preventionTips":["Use node.IsTagged() (not UserID validity) to decide ownership before tag operations","Make automation never compute an empty tag list for a tagged node","Remember tagged -> user-owned conversion requires delete + re-registration"],"tags":["tags","node-ownership","state","api"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}