mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: add support for https
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user