juanfont/headscale · warning · HTTPError

invalid capability version

Error message

invalid capability version

What it means

Thrown by parseCapabilityVersion in hscontrol/handlers.go when the 'v' query parameter on a control-plane HTTP endpoint (e.g. /key or /ts2021) is present but cannot be parsed as an integer by strconv.Atoi. The capability version tells the server which tailcfg feature set the client speaks; a non-numeric value means the request did not come from a well-formed tailscaled client.

Source

Thrown at hscontrol/handlers.go:114

	return HTTPError{Code: code, Msg: msg, Err: err}
}

var errMethodNotAllowed = NewHTTPError(http.StatusMethodNotAllowed, "method not allowed", nil)

var ErrRegisterMethodCLIDoesNotSupportExpire = errors.New(
	"machines registered with CLI do not support expiry",
)

func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error) {
	clientCapabilityStr := req.URL.Query().Get("v")

	if clientCapabilityStr == "" {
		return 0, NewHTTPError(http.StatusBadRequest, "capability version must be set", nil)
	}

	clientCapabilityVersion, err := strconv.Atoi(clientCapabilityStr)
	if err != nil {
		return 0, NewHTTPError(http.StatusBadRequest, "invalid capability version", fmt.Errorf("parsing capability version: %w", err))
	}

	return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
}

// verifyBodyLimit caps the request body for /verify. The DERP verify
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
// bytes; 4 KiB is generous and prevents an unauthenticated client from
// OOMing the public router with arbitrarily large POSTs.
const verifyBodyLimit int64 = 4 * 1024

func (h *Headscale) handleVerifyRequest(
	req *http.Request,
	writer io.Writer,
) error {
	body, err := io.ReadAll(req.Body)
	if err != nil {
		return NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large", fmt.Errorf("reading request body: %w", err))

View on GitHub (pinned to 565fd254d0)

Solutions

  1. If testing manually, use an integer version, e.g. curl 'http://server/key?v=94'
  2. If seen from a real client, capture the full request URL and check for proxies/load balancers that mangle query strings
  3. Verify the client is an actual tailscaled/ipn client and not a custom script sending garbage

Example fix

// before
curl http://headscale:8080/key?v=latest

// after
curl http://headscale:8080/key?v=94
Defensive patterns

Strategy: validation

Validate before calling

u, _ := url.Parse(rawQuery)
v := u.Query().Get("v")
if n, err := strconv.Atoi(v); err != nil || v == "" {
    return fmt.Errorf("capability version must be an integer, got %q", v)
} else if !isSupportedVersion(tailcfg.CapabilityVersion(n)) {
    return fmt.Errorf("unsupported capability version %d", n)
}

Prevention

When it happens

Trigger: A GET to /key?v=abc or /ts2021?v=1.2 (any non-integer 'v'), or a hand-crafted curl against the control port that includes a malformed version parameter.

Common situations: Manually testing the control endpoint with curl and forgetting that 'v' must be an integer; a proxy or middleware rewriting the query string; a non-Tailscale client scraping the server.

Related errors


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