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

  1. Keep at least one tag on the node: `headscale nodes tag <id> --tags tag:newtag`
  2. If the node must become user-owned, delete it and re-register it under the user (ownership switch is not supported in place)
  3. 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

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


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/202ef632470ce80b. Report an issue: GitHub.