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
- Include the non-empty id query parameter exactly as delivered in the tailcfg.PingRequest: curl -I '...?id=<pingID>'.
- In client code, build the URL with url.Values.Set("id", req.ID) rather than string concatenation.
- 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
- Pass the ping ID as the id query parameter, never in a header or body.
- Use net/url values builders so parameters cannot be silently dropped.
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
- --name or --identifier flag is required
- missing parameters
- invalid capability version
- Bad Request: invalid JSON
- creating metrics request: %w
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/8ab4bf8d281198ce.
Report an issue: GitHub.