{"record":{"id":"8ab4bf8d281198ce","repo":"juanfont/headscale","slug":"headscale-cli-api-key-environment-variable-needs-t","errorCode":null,"errorMessage":"HEADSCALE_CLI_API_KEY environment variable needs to be set","messagePattern":"HEADSCALE_CLI_API_KEY environment variable needs to be set","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"cmd/headscale/cli/utils.go","lineNumber":39,"sourceCode":"\t\"github.com/juanfont/headscale/hscontrol/util/zlog/zf\"\n\t\"github.com/prometheus/common/model\"\n\t\"github.com/pterm/pterm\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/spf13/cobra\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tHeadscaleDateTimeFormat = \"2006-01-02 15:04:05\"\n\tSocketWritePermissions  = 0o666\n\n\toutputFormatJSON     = \"json\"\n\toutputFormatJSONLine = \"json-line\"\n\toutputFormatYAML     = \"yaml\"\n)\n\nvar (\n\terrAPIKeyNotSet     = errors.New(\"HEADSCALE_CLI_API_KEY environment variable needs to be set\")\n\terrMissingParameter = errors.New(\"missing parameters\")\n\terrResponseStatus   = errors.New(\"unexpected response status\")\n)\n\n// apiError turns a non-2xx response into an error, surfacing the server's\n// RFC7807 problem detail. detail holds the operation context and errors[] the\n// wrapped cause (e.g. \"name is too long\"); both are joined so the server's\n// message text is not lost.\nfunc apiError(statusCode int, problem *clientv1.ErrorModel) error {\n\tif problem == nil {\n\t\treturn fmt.Errorf(\"%w: %d %s\", errResponseStatus, statusCode, http.StatusText(statusCode))\n\t}\n\n\tparts := make([]string, 0, 2)\n\n\tif problem.Detail != nil && *problem.Detail != \"\" {\n\t\tparts = append(parts, *problem.Detail)\n\t}","sourceCodeStart":21,"sourceCodeEnd":57,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/cmd/headscale/cli/utils.go#L21-L57","documentation":"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.","triggerScenarios":"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.","commonSituations":"Hand-testing connectivity during DERP/ping debugging; a custom ping-responder implementation that forgets to append the ID; proxies that strip unknown query parameters.","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."],"exampleFix":"// before\nreq, _ := http.NewRequest(http.MethodHead, baseURL+\"/ping-response\", nil)\n\n// after\nu := baseURL.JoinPath(\"ping-response\")\nq := u.Query()\nq.Set(\"id\", pingID)\nu.RawQuery = q.Encode()\nreq, _ := http.NewRequest(http.MethodHead, u.String(), nil)","handlingStrategy":"validation","validationCode":"// Build the URL with the required id before sending.\nif pingID == \"\" {\n    return errors.New(\"cannot answer ping: no ping ID supplied\")\n}\nu, _ := url.Parse(pingBase)\nq := u.Query()\nq.Set(\"id\", pingID)\nu.RawQuery = q.Encode()\n// then HEAD u.String()","typeGuard":null,"tryCatchPattern":null,"preventionTips":["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."],"tags":["http","validation","ping","query-params"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}