juanfont/headscale · warning

HEADSCALE_CLI_API_KEY environment variable needs to be set

Error message

HEADSCALE_CLI_API_KEY environment variable needs to be set

What it means

HTTP 400 from PingResponseHandler when the request is a valid HEAD but the 'id' query parameter is absent or empty. The ping ID is the sole credential for this endpoint (it proves the caller received the server-issued tailcfg.PingRequest), so a missing ID cannot be authenticated or completed.

Source

Thrown at cmd/headscale/cli/utils.go:39

	"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
	"github.com/prometheus/common/model"
	"github.com/pterm/pterm"
	"github.com/rs/zerolog/log"
	"github.com/spf13/cobra"
	"gopkg.in/yaml.v3"
)

const (
	HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
	SocketWritePermissions  = 0o666

	outputFormatJSON     = "json"
	outputFormatJSONLine = "json-line"
	outputFormatYAML     = "yaml"
)

var (
	errAPIKeyNotSet     = errors.New("HEADSCALE_CLI_API_KEY environment variable needs to be set")
	errMissingParameter = errors.New("missing parameters")
	errResponseStatus   = errors.New("unexpected response status")
)

// apiError turns a non-2xx response into an error, surfacing the server's
// RFC7807 problem detail. detail holds the operation context and errors[] the
// wrapped cause (e.g. "name is too long"); both are joined so the server's
// message text is not lost.
func apiError(statusCode int, problem *clientv1.ErrorModel) error {
	if problem == nil {
		return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode))
	}

	parts := make([]string, 0, 2)

	if problem.Detail != nil && *problem.Detail != "" {
		parts = append(parts, *problem.Detail)
	}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Include the non-empty id query parameter exactly as delivered in the tailcfg.PingRequest: curl -I '...?id=<pingID>'.
  2. In client code, build the URL with url.Values.Set("id", req.ID) rather than string concatenation.
  3. Verify no intermediate proxy or URL rewriter drops the query string.

Example fix

// before
req, _ := http.NewRequest(http.MethodHead, baseURL+"/ping-response", nil)

// after
u := baseURL.JoinPath("ping-response")
q := u.Query()
q.Set("id", pingID)
u.RawQuery = q.Encode()
req, _ := http.NewRequest(http.MethodHead, u.String(), nil)
Defensive patterns

Strategy: validation

Validate before calling

// Build the URL with the required id before sending.
if pingID == "" {
    return errors.New("cannot answer ping: no ping ID supplied")
}
u, _ := url.Parse(pingBase)
q := u.Query()
q.Set("id", pingID)
u.RawQuery = q.Encode()
// then HEAD u.String()

Prevention

When it happens

Trigger: curl -I to the ping-response path without ?id=; client code sending the ping ID in a header or body instead of the query string; URL-templates dropping empty-looking query values.

Common situations: Hand-testing connectivity during DERP/ping debugging; a custom ping-responder implementation that forgets to append the ID; proxies that strip unknown query parameters.

Related errors


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