juanfont/headscale · error

setting node expiry

Error message

setting node expiry: %w

What it means

Wraps a failure of h.state.SetNodeExpiry(node.ID(), &expiry) during logout of a non-ephemeral node (hscontrol/auth.go:265). The code clamps the client-supplied expiry (tailscaled sends the 1970 sentinel time.Unix(123,0)) to now, then persists it; if the state/DB update fails, the error is returned and the node remains non-expired.

Solutions

  1. Read the wrapped error to identify the DB cause and address it (locks, connectivity).
  2. Retry logout from the node after DB recovery — the expiry update is idempotent.
  3. If the node cannot retry, expire it administratively: `headscale nodes expire -i <id>`.
  4. Reduce write contention (single writer for SQLite, adequate pool for PostgreSQL) if it recurs during node churn.
Defensive patterns

Strategy: retry

Try / catch

resp, err := h.handleRegister(req, mk)
if err != nil && strings.Contains(err.Error(), "setting node expiry") {
    // idempotent operation: retry logout, or run `headscale nodes expire -i <id>`
}

Prevention

When it happens

Trigger: A normal (non-ephemeral) node logs out while the database errors: connection lost, SQLite write lock timeout, or node row updated concurrently by the mapper (optimistic-update conflict).

Common situations: Logout racing a netmap update on the same node; DB restarts; SQLite deployments with concurrent writers.

Related errors


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

Appendix: source

Thrown at hscontrol/auth.go:265

		return nodeToRegisterResponse(node), nil
	}

	// Update the internal state with the nodes new expiry, meaning it is
	// logged out.
	//
	// Clamp the client-supplied value to now: Tailscale sends the
	// sentinel time.Unix(123, 0) on logout (controlclient/direct.go),
	// and storing it verbatim propagates a 1970 KeyExpiry to every
	// peer's netmap. Semantically identical (expired as of now), but
	// sane in logs, debug dumps, and peer netmaps.
	expiry := req.Expiry
	if now := time.Now(); expiry.Before(now) {
		expiry = now
	}

	updatedNode, c, err := h.state.SetNodeExpiry(node.ID(), &expiry)
	if err != nil {
		return nil, fmt.Errorf("setting node expiry: %w", err)
	}

	h.Change(c)

	return nodeToRegisterResponse(updatedNode), nil
}

// isAuthKey reports if the register request is a registration request
// using an pre auth key.
func isAuthKey(req tailcfg.RegisterRequest) bool {
	return req.Auth != nil && req.Auth.AuthKey != ""
}

func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
	resp := &tailcfg.RegisterResponse{
		NodeKeyExpired: node.IsExpired(),

		// Headscale does not implement the concept of machine authorization

View on GitHub (pinned to 565fd254d0)