From ea27c846e2a03068f744822e1a26a1891739c6ba Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 30 May 2026 19:39:02 -0400 Subject: [PATCH] feat: add support for https --- app/server/app.ts | 8 +++++ app/server/config/config-schema.ts | 9 ++++++ app/server/main.ts | 27 +++++++++++++++- cmd/hp_healthcheck/hp_healthcheck.go | 48 +++++++++++++++++++++++++++- config.example.yaml | 11 +++++++ docs/configuration/index.md | 34 ++++++++++++++++++++ 6 files changed, 135 insertions(+), 2 deletions(-) diff --git a/app/server/app.ts b/app/server/app.ts index aaee400..42fc480 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -35,6 +35,14 @@ try { exit(1); } +if ((config.server.tls_cert_path || config.server.tls_key_path) && !config.server.cookie_secure) { + log.warn( + "server", + "TLS is enabled but `server.cookie_secure` is false; forcing it to true (browsers reject Secure-less cookies over HTTPS)", + ); + config.server.cookie_secure = true; +} + const ctx = await createAppContext(config); ctx.startServices(); diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 63485e9..11637cb 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -42,6 +42,12 @@ const serverConfig = type({ cookie_secure: "boolean = true", cookie_domain: "string.lower?", cookie_max_age: "number.integer = 86400", + + // TLS termination. When both `tls_cert_path` and `tls_key_path` + // are provided, Headplane serves HTTPS on `server.port`. When + // either is set, `cookie_secure` is forced to `true`. + tls_cert_path: "string?", + tls_key_path: "string?", }); const partialServerConfig = type({ @@ -55,6 +61,9 @@ const partialServerConfig = type({ cookie_secure: "boolean?", cookie_domain: "string.lower?", cookie_max_age: "number.integer?", + + tls_cert_path: "string?", + tls_key_path: "string?", }); const headscaleConfig = type({ diff --git a/app/server/main.ts b/app/server/main.ts index 542cf9f..bc198ea 100644 --- a/app/server/main.ts +++ b/app/server/main.ts @@ -8,19 +8,44 @@ // Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests // straight to `./app`'s default export. +import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; +import { exit } from "node:process"; import { fileURLToPath } from "node:url"; -import { composeListener, startHttpServer } from "../../runtime/http"; +import log from "~/utils/log"; + +import { type StartOptions, composeListener, startHttpServer } from "../../runtime/http"; import requestListener, { config, dispose } from "./app"; // `import.meta.url` resolves to `build/server/index.js`; the built // client lives next to it at `build/client/`. const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client"); +let tls: StartOptions["tls"]; +const { tls_cert_path: certPath, tls_key_path: keyPath } = config.server; +if (certPath || keyPath) { + if (!certPath || !keyPath) { + log.error( + "server", + "TLS misconfigured: both `server.tls_cert_path` and `server.tls_key_path` must be provided", + ); + exit(1); + } + + try { + const [cert, key] = await Promise.all([readFile(certPath), readFile(keyPath)]); + tls = { cert, key }; + } catch (err) { + log.error("server", "Failed to read TLS material: %s", err); + exit(1); + } +} + startHttpServer({ host: config.server.host, port: config.server.port, + tls, listener: composeListener({ basename: __PREFIX__, staticRoot: clientDir, diff --git a/cmd/hp_healthcheck/hp_healthcheck.go b/cmd/hp_healthcheck/hp_healthcheck.go index 89df82b..4162335 100644 --- a/cmd/hp_healthcheck/hp_healthcheck.go +++ b/cmd/hp_healthcheck/hp_healthcheck.go @@ -1,18 +1,36 @@ package main import ( + "crypto/tls" "fmt" "net/http" "os" "time" ) +// hp_healthcheck pings the Headplane /healthz endpoint. It's invoked +// from the Docker HEALTHCHECK directive, so it must work whether the +// container is serving plain HTTP or has TLS termination enabled. +// +// Configuration (via env, all optional): +// - HEADPLANE_HEALTHCHECK_URL full URL, takes precedence over the +// pieces below (default: http://localhost:3000/admin/healthz) +// - HEADPLANE_HEALTHCHECK_TLS "true" to use https:// +// - HEADPLANE_HEALTHCHECK_HOST default: localhost +// - HEADPLANE_HEALTHCHECK_PORT default: 3000 +// - HEADPLANE_HEALTHCHECK_PATH default: /admin/healthz func main() { + url := healthcheckURL() + client := http.Client{ Timeout: 5 * time.Second, + Transport: &http.Transport{ + // We just care that the server is alive and with us + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, } - resp, err := client.Get("http://localhost:3000/admin/healthz") + resp, err := client.Get(url) if err != nil { fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err) os.Exit(1) @@ -27,3 +45,31 @@ func main() { fmt.Println("Health check passed.") os.Exit(0) } + +func healthcheckURL() string { + if v := os.Getenv("HEADPLANE_HEALTHCHECK_URL"); v != "" { + return v + } + + scheme := "http" + if os.Getenv("HEADPLANE_HEALTHCHECK_TLS") == "true" { + scheme = "https" + } + + host := os.Getenv("HEADPLANE_HEALTHCHECK_HOST") + if host == "" { + host = "localhost" + } + + port := os.Getenv("HEADPLANE_HEALTHCHECK_PORT") + if port == "" { + port = "3000" + } + + path := os.Getenv("HEADPLANE_HEALTHCHECK_PATH") + if path == "" { + path = "/admin/healthz" + } + + return fmt.Sprintf("%s://%s:%s%s", scheme, host, port, path) +} diff --git a/config.example.yaml b/config.example.yaml index 978dada..eeb95cd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,8 +17,19 @@ server: # Whether cookies should be marked as Secure # * Should be false if running without HTTPs # * Should be true if running behind a reverse proxy with HTTPs + # When `tls_cert_path`/`tls_key_path` are set below this is forced + # to true automatically. cookie_secure: true + # Optional TLS termination. When both `tls_cert_path` and + # `tls_key_path` are set, Headplane serves HTTPS on `server.port`. + # Both files must be PEM encoded. + # + # If you are terminating TLS at a reverse proxy (recommended for most + # deployments) leave these unset. + # tls_cert_path: "/var/lib/headplane/tls/fullchain.pem" + # tls_key_path: "/var/lib/headplane/tls/privkey.pem" + # The maximum age of the session cookie in seconds cookie_max_age: 86400 # 1 day in seconds diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 40c4a1e..c20e523 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -86,6 +86,40 @@ To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment vari This will enable all debug logs for Headplane, which could fill up log space very quickly. This is not recommended in production environments. +## TLS Termination + +Headplane can terminate TLS itself when both `server.tls_cert_path` and +`server.tls_key_path` are set in the configuration file. Both must point to +PEM-encoded files that Headplane can read. + +```yaml +server: + port: 443 + tls_cert_path: "/var/lib/headplane/tls/fullchain.pem" + tls_key_path: "/var/lib/headplane/tls/privkey.pem" +``` + +When TLS is configured Headplane serves HTTPS/1.1 on `server.port`. HTTP/2 +and HTTP/3 are intentionally not supported in-process — terminate those at a +reverse proxy (e.g. Caddy or Traefik) and forward to Headplane over HTTP/1.1 +if you need them today. + +`server.cookie_secure` is forced to `true` whenever TLS is enabled (browsers +refuse `Secure`-less cookies over HTTPS); a warning is logged if your config +had it set to `false`. + +For most deployments we still recommend terminating TLS at a reverse proxy +([see below](#reverse-proxying)) so you can share certificates with Headscale +and other services. Built-in TLS is meant for the simpler "Headplane on a +single box" scenarios. + +If you're running Headplane in Docker with TLS enabled, the bundled +healthcheck binary needs to know it should use HTTPS. Set the following +environment variables on the container: + +- `HEADPLANE_HEALTHCHECK_TLS=true` +- `HEADPLANE_HEALTHCHECK_PORT` (if you've changed it from the default `3000`) + ## Reverse Proxying Reverse proxying is very common when deploying web applications. Headscale and