Use the certs/ fallback in every environment

TLS no longer depends on NODE_ENV: the TLS_* variables win, else certs/
is used whenever it exists (the container's /app/certs mount lands there
too), and TLS_DISABLED=1 is the only way to serve plain HTTP with a
certificate present. The plain-HTTP redirect listener now starts whenever
TLS is on, not only in development, and HTTP_REDIRECT_PORT replaces
DEV_HTTP_PORT. The installer writes TLS_DISABLED=1 for --no-https, and the
health check follows the same rule.
This commit is contained in:
2026-10-20 10:29:37 +01:00
parent 8c20c9a7ed
commit 459eebf73d
10 changed files with 222 additions and 63 deletions
+5 -2
View File
@@ -38,8 +38,9 @@ export type KnownEnv = {
*
* Not here, and never in the `envs` table: `ENCRYPTION_KEY` and
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR`,
* `MAIN_SERVER` and the `TLS_*` certificate paths (the container's own
* start-up settings). Those stay in the process environment.
* `MAIN_SERVER`, `TLS_DISABLED`, `HTTP_REDIRECT_PORT` and the `TLS_*`
* certificate paths (the container's own start-up settings). Those stay in
* the process environment.
*/
export const EnvDict: readonly KnownEnv[] = [
{
@@ -89,6 +90,8 @@ export const ProcessOnlyEnvKeys = [
"TLS_CERT_FILE",
"TLS_KEY_FILE",
"TLS_CA_FILE",
"TLS_DISABLED",
"HTTP_REDIRECT_PORT",
] as const;
/**
+7 -6
View File
@@ -8,18 +8,19 @@ type Params = {
};
/**
* Development convenience: a plain-HTTP listener on the port above the
* HTTPS one, which sends the browser to the same page over HTTPS.
* A plain-HTTP listener on the port above the HTTPS one, which sends the
* browser to the same page over HTTPS.
*
* A port that speaks TLS cannot answer plain HTTP, so this cannot sit on
* the HTTPS port itself: `http://<host>:<port>` still fails, but
* `http://<host>:<port + 1>` lands on the right page. `DEV_HTTP_PORT`
* picks another port. Production does not start it (only one port is
* published, and it is HTTPS).
* `http://<host>:<port + 1>` lands on the right page. `HTTP_REDIRECT_PORT`
* picks another port. Every environment starts it; the container publishes
* only the HTTPS port, so there it is reachable only if that second port is
* published too.
*/
export default function startHttpRedirect({ https_port }: Params) {
const base = Number(https_port);
const configured = Number(process.env.DEV_HTTP_PORT);
const configured = Number(process.env.HTTP_REDIRECT_PORT);
const redirect_port =
Number.isInteger(configured) && configured > 0
? configured
+39 -42
View File
@@ -14,6 +14,7 @@ import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
import serveLocalCa from "./functions/server/serve-local-ca";
import startHttpRedirect from "./functions/server/start-http-redirect";
import resolveTlsOptions from "./utils/resolve-tls-options";
import pickTlsFiles from "./utils/pick-tls-files";
import { ComputerProxyPrefix } from "./dict/computer-dict";
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
import { ensureVapidKeys } from "./push/vapid";
@@ -53,51 +54,45 @@ try {
}
/**
* HTTPS when the installer (or a development `.env`) points the server at
* a certificate. A broken or half-set configuration stops the process
* rather than quietly serving plain HTTP.
* Which certificate to serve with, the same way in every environment: the
* `TLS_*` variables when set, else `certs/` (which is also where the
* container's `/app/certs` mount lands). Plain HTTP needs `TLS_DISABLED`.
*/
/**
* In development, a certificate in `certs/` (made by `bun run gen:certs`,
* or by `bun run dev:phone`) is used without any `.env` entries, so
* trying the app on a phone over a VPN is one command.
*/
const DevCertFile = "certs/server.crt";
const DevKeyFile = "certs/server.key";
const DevCaFile = "certs/ca.crt";
const picked = pickTlsFiles({
cert_file: process.env.TLS_CERT_FILE,
key_file: process.env.TLS_KEY_FILE,
ca_file: process.env.TLS_CA_FILE,
disabled: process.env.TLS_DISABLED,
fallback_cert_file: "certs/server.crt",
fallback_key_file: "certs/server.key",
fallback_ca_file: "certs/ca.crt",
file_exists: existsSync,
});
let cert_file = process.env.TLS_CERT_FILE;
let key_file = process.env.TLS_KEY_FILE;
if (picked.ca_file) process.env.TLS_CA_FILE = picked.ca_file;
else delete process.env.TLS_CA_FILE;
if (
process.env.NODE_ENV != "production" &&
!cert_file &&
!key_file &&
existsSync(DevCertFile) &&
existsSync(DevKeyFile)
) {
cert_file = DevCertFile;
key_file = DevKeyFile;
if (!process.env.TLS_CA_FILE && existsSync(DevCaFile)) {
process.env.TLS_CA_FILE = DevCaFile;
}
bunext.bunextLog.info(`Using the development certificate in certs/`);
}
if (!cert_file && !key_file) {
bunext.bunextLog.info(
process.env.NODE_ENV == "production"
? "Serving plain HTTP (set TLS_CERT_FILE and TLS_KEY_FILE for HTTPS)"
: "Serving plain HTTP (run `bun run dev:phone`, or `bun run gen:certs` then restart, for HTTPS)",
);
switch (picked.source) {
case "fallback":
bunext.bunextLog.info(`Using the certificate in certs/`);
break;
case "disabled":
bunext.bunextLog.info(`Serving plain HTTP (TLS_DISABLED is set)`);
break;
case "none":
bunext.bunextLog.info(
"Serving plain HTTP (no certificate; run `bun run gen:certs`, or set TLS_CERT_FILE and TLS_KEY_FILE)",
);
break;
}
let tls: ReturnType<typeof resolveTlsOptions> = undefined;
try {
tls = resolveTlsOptions({ cert_file, key_file });
tls = resolveTlsOptions({
cert_file: picked.cert_file,
key_file: picked.key_file,
});
} catch (error) {
console.error(
`Can't start without a working TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
@@ -184,21 +179,23 @@ bunext.bunextLog.info(
);
/**
* A TLS port cannot answer plain HTTP, so a development `http://` visit
* is sent to the HTTPS page from the port above it (`startHttpRedirect`).
* A TLS port cannot answer plain HTTP, so a plain `http://` visit is sent
* to the HTTPS page from the port above it (`startHttpRedirect`). The
* container publishes only the HTTPS port, so there it is only reachable
* if that second port is published.
*/
if (tls && process.env.NODE_ENV != "production") {
if (tls) {
try {
const redirect = startHttpRedirect({ https_port: port });
if (redirect) {
bunext.bunextLog.info(
`HTTP redirect on port ${redirect.port} (DEV_HTTP_PORT to change it)`,
`HTTP redirect on port ${redirect.port} (HTTP_REDIRECT_PORT to change it)`,
);
}
} catch (error) {
bunext.bunextLog.info(
`Could not start the development HTTP redirect on port ${Number(port) + 1}: ${error instanceof Error ? error.message : String(error)}. Stop the other server or set DEV_HTTP_PORT.`,
`Could not start the HTTP redirect on port ${Number(port) + 1}: ${error instanceof Error ? error.message : String(error)}. Stop the other server or set HTTP_REDIRECT_PORT.`,
);
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import pickTlsFiles from "./pick-tls-files";
const fallback = {
fallback_cert_file: "certs/server.crt",
fallback_key_file: "certs/server.key",
fallback_ca_file: "certs/ca.crt",
};
const exists = (paths: string[]) => (path: string) => paths.includes(path);
const pick = (
options: Partial<Parameters<typeof pickTlsFiles>[0]> = {},
paths: string[] = ["certs/server.crt", "certs/server.key", "certs/ca.crt"],
) =>
pickTlsFiles({
...fallback,
file_exists: exists(paths),
...options,
});
describe("pickTlsFiles", () => {
test("prefers the TLS variables", () => {
const picked = pick({
cert_file: "/etc/cert.pem",
key_file: "/etc/key.pem",
ca_file: "/etc/ca.pem",
});
expect(picked).toEqual({
cert_file: "/etc/cert.pem",
key_file: "/etc/key.pem",
ca_file: "/etc/ca.pem",
source: "explicit",
});
});
test("uses certs/ when the variables are unset, in any environment", () => {
const picked = pick({ cert_file: "", key_file: "" });
expect(picked).toEqual({
cert_file: "certs/server.crt",
key_file: "certs/server.key",
ca_file: "certs/ca.crt",
source: "fallback",
});
});
test("serves HTTP when the certificate is explicitly disabled", () => {
expect(
pick({
cert_file: "/etc/cert.pem",
key_file: "/etc/key.pem",
disabled: "1",
}),
).toEqual({ source: "disabled" });
expect(pick({ disabled: "true" })).toEqual({ source: "disabled" });
expect(pick({ disabled: "no" }).source).toBe("fallback");
});
test("serves HTTP when there is nothing to serve with", () => {
expect(pick({}, []).source).toBe("none");
expect(pick({}, ["certs/server.crt"]).source).toBe("none");
});
test("keeps half a configuration for the resolver to reject", () => {
expect(pick({ cert_file: "/etc/cert.pem" }, []).source).toBe("explicit");
});
});
+83
View File
@@ -0,0 +1,83 @@
type Params = {
/**
* `TLS_CERT_FILE`, when the environment names one.
*/
cert_file?: string;
/**
* `TLS_KEY_FILE`, when the environment names one.
*/
key_file?: string;
/**
* `TLS_CA_FILE`, served for devices to trust.
*/
ca_file?: string;
/**
* `TLS_DISABLED`, the explicit opt-out.
*/
disabled?: string;
/**
* Where a certificate is looked for when the environment names none:
* `certs/` at the project root, which is also where the container's
* `/app/certs` mount lands.
*/
fallback_cert_file: string;
fallback_key_file: string;
fallback_ca_file: string;
file_exists: (path: string) => boolean;
};
export type TlsFiles = {
cert_file?: string;
key_file?: string;
ca_file?: string;
/**
* Where the choice came from, for the startup log: the environment,
* the `certs/` fallback, an explicit disable, or nothing at all.
*/
source: "explicit" | "fallback" | "disabled" | "none";
};
const DisabledValues = new Set(["1", "true", "yes", "on"]);
/**
* Which certificate files to serve with, in every environment: the
* `TLS_*` variables when they are set, else `certs/` when it exists, and
* plain HTTP only when `TLS_DISABLED` says so or there is nothing to serve
* with.
*
* Nothing here is tied to `NODE_ENV`: a production process that has a
* certificate serves HTTPS, and the only way to turn it off is to say so.
*/
export default function pickTlsFiles({
cert_file,
key_file,
ca_file,
disabled,
fallback_cert_file,
fallback_key_file,
fallback_ca_file,
file_exists,
}: Params): TlsFiles {
if (disabled && DisabledValues.has(disabled.trim().toLowerCase())) {
return { source: "disabled" };
}
const cert = cert_file?.trim();
const key = key_file?.trim();
if (cert || key) {
return { cert_file: cert, key_file: key, ca_file: ca_file?.trim(), source: "explicit" };
}
if (file_exists(fallback_cert_file) && file_exists(fallback_key_file)) {
return {
cert_file: fallback_cert_file,
key_file: fallback_key_file,
ca_file: ca_file?.trim() ||
(file_exists(fallback_ca_file) ? fallback_ca_file : undefined),
source: "fallback",
};
}
return { source: "none" };
}