{"record":{"id":"3ad3d4e49b138c42","repo":"juanfont/headscale","slug":"health-check-timed-out","errorCode":null,"errorMessage":"health check timed out","messagePattern":"health check timed out","errorType":"console","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"cmd/dev/main.go","lineNumber":27,"sourceCode":"\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"syscall\"\n\t\"time\"\n)\n\nvar (\n\tport = flag.Int(\"port\", 8080, \"headscale listen port\")\n\tkeep = flag.Bool(\"keep\", false, \"keep state directory on exit\")\n)\n\nvar errHealthTimeout = errors.New(\"health check timed out\")\n\nvar errEmptyAuthKey = errors.New(\"empty auth key in response\")\n\n// maxDevPort is the highest --port value that keeps the derived metrics\n// port (port+1010) inside the valid 1..65535 TCP range.\nconst maxDevPort = 64525\n\nconst devConfig = `---\nserver_url: http://127.0.0.1:%d\nlisten_addr: 127.0.0.1:%d\nmetrics_listen_addr: 127.0.0.1:%d\n\nnoise:\n  private_key_path: %s/noise_private.key\n\nprefixes:\n  v4: 100.64.0.0/10\n  v6: fd7a:115c:a1e0::/48","sourceCodeStart":9,"sourceCodeEnd":45,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/cmd/dev/main.go#L9-L45","documentation":"Returned as HTTP 403 when a request to a headscale debug endpoint arrives from a client that is neither a tailnet node (verified via the ts2021 noise-authenticated 'headscale' node) nor a private/LAN IP. The debug router deliberately gates diagnostics (ping, map dumps, etc.) so they are not exposed to the public internet. The check parses r.RemoteAddr and allows the request only when the source IP is RFC1918-private or the caller is inside the tailnet.","triggerScenarios":"Curling /debug/* endpoints from a machine whose source IP is public (e.g. operator's home IP over the internet, or behind a reverse proxy that forwards the proxy's public IP instead of the real client IP), or from a non-private address that is not part of the tailnet. Also triggered when a reverse proxy rewrites RemoteAddr to a non-private address.","commonSituations":"Running headscale behind a reverse proxy (nginx/caddy/traefik) on a public VPS and trying to open /debug from a browser at home; running the proxy in a Docker network where the forwarded address is the container bridge IP but the operator tests through a public hostname; forgetting that the allowlist is private-IP-or-tailnet only.","solutions":["Access the debug endpoint from a machine inside the tailnet (the authenticated 'headscale' node path), e.g. curl from a tailscale-up node or via the headscale host's tailscale IP.","If behind a reverse proxy, configure it to pass the real client IP (X-Forwarded-For handling / proxy_protocol) so RemoteAddr resolves to your private LAN address, or reach headscale directly on the LAN.","SSH-tunnel or wireguard into the private network first, then curl the endpoint from there (source IP becomes private and passes ip.IsPrivate()).","If you truly need remote debug access, expose headscale only over a VPN/tailscale and never widen this check to public IPs."],"exampleFix":"# before (from a public client IP -> 403 debug access denied)\ncurl http://headscale.example.com/debug\n\n# after (from inside the tailnet / private network)\ntailscale up\ncurl http://<headscale-lan-or-tailscale-ip>/debug","handlingStrategy":"validation","validationCode":"// Before hitting /debug/*, confirm the source IP is private or you are on tailnet.\nconn, err := net.Dial(\"udp\", \"8.8.8.8:80\") // learn egress iface without sending traffic\nif err == nil {\n    defer conn.Close()\n    localIP := conn.LocalAddr().(*net.UDPAddr).IP\n    if !localIP.IsPrivate() {\n        log.Printf(\"source %s is not private; debug endpoints will 403 — connect via tailnet first\", localIP)\n    }\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Always administer headscale debug endpoints from a tailscale-connected machine or a private-LAN host.","When placing headscale behind a reverse proxy, configure real-IP forwarding so RemoteAddr reflects the actual client.","Treat 403 from /debug as an access-posture signal, not a bug — never widen the private-IP check to public ranges."],"tags":["http","debug","access-control","network","reverse-proxy"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}