juanfont/headscale · error · types.ErrCannotRemoveAllTags
cannot remove all tags from node
Error message
cannot remove all tags from node
What it means
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.
Source
Thrown at hscontrol/types/node.go:30
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"go4.org/netipx"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
)
var (
ErrNodeAddressesInvalid = errors.New("parsing node addresses")
ErrHostnameTooLong = errors.New("hostname too long, cannot accept more than 255 ASCII chars")
ErrNodeHasNoGivenName = errors.New("node has no given name")
ErrNodeUserHasNoName = errors.New("node user has no name")
ErrCannotRemoveAllTags = errors.New("cannot remove all tags from node")
ErrInvalidNodeView = errors.New("cannot convert invalid NodeView to tailcfg.Node")
)
// RouteFunc is a function that takes a node ID and returns a list of
// [netip.Prefix] values representing the routes for that node.
type RouteFunc func(id NodeID) []netip.Prefix
// nodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the
// node's own IPv4 CGNAT prefix in [tailcfg.Node.Addresses] and
// [tailcfg.Node.AllowedIPs]. Subnet routes the node advertises remain.
// See https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts.
const nodeAttrDisableIPv4 tailcfg.NodeCapability = "disable-ipv4"
// filterIPv4 returns ps with every IPv4 prefix dropped. Used by
// [NodeView.TailNode] when the node carries the disable-ipv4 nodeAttr.
func filterIPv4(ps []netip.Prefix) []netip.Prefix {
out := ps[:0:0]
for _, p := range ps {View on GitHub (pinned to 565fd254d0)
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)
Example fix
// before
err := h.state.SetTags(ctx, nodeID, []string{})
// after
node := h.state.GetNode(nodeID)
if node.IsTagged() && len(newTags) == 0 {
return types.ErrCannotRemoveAllTags // handle: require >=1 tag or delete+re-register
}
err := h.state.SetTags(ctx, nodeID, newTags) Defensive patterns
Strategy: validation
Validate before calling
// only attempt tag updates that keep ownership valid
node := state.GetNode(ctx, nodeID)
if node.IsTagged() && len(newTags) == 0 {
return fmt.Errorf("node %d is tagged; provide at least one replacement tag or delete and re-register it", nodeID)
}
return state.SetTags(ctx, nodeID, newTags) Try / catch
if err := h.state.SetTags(ctx, nodeID, tags); err != nil {
if errors.Is(err, types.ErrCannotRemoveAllTags) {
// keep >=1 tag or delete + re-register; do not retry with []
return reconcileOwnership(nodeID)
}
return err
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- missing parameters
- references undefined tag
- tag must start with 'tag:'
- tag not found
- SSH tests dst contains unknown tag
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/202ef632470ce80b.
Report an issue: GitHub.