feat: add support for https

This commit is contained in:
Aarnav Tale
2026-05-30 19:39:02 -04:00
parent 8c508e0602
commit ea27c846e2
6 changed files with 135 additions and 2 deletions
+8
View File
@@ -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();
+9
View File
@@ -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({
+26 -1
View File
@@ -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,
+47 -1
View File
@@ -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)
}
+11
View File
@@ -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
+34
View File
@@ -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