Serve HTTPS by default with a local CA
The installer generates a CA and a 397-day certificate into $COBBLE_HOME/certs (in the app image, with the host's addresses), mounts the certificate, key and CA into the container and serves TLS from Bun on the published port. --no-https opts out; --https-cert/--https-key use a real certificate, --https-host adds names and --https-regen replaces the CA. The installer and the image health check use curl -k when TLS is on. The CA is served at /ca.crt and as an iOS/macOS profile at /ca.mobileconfig for one-tap install. Development picks up certs/ automatically, `bun run dev:phone` generates and starts, and a plain-HTTP listener on PORT + 1 redirects to HTTPS. Port sharing is off, so a stale dev server fails instead of silently answering on the same port.
This commit is contained in:
@@ -12,6 +12,16 @@ export const AppData = {
|
||||
*/
|
||||
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`,
|
||||
ServerHealthMessage: "Server Running Well",
|
||||
/**
|
||||
* Where a device downloads the local CA that signed the app's
|
||||
* certificate, so it can trust it (`serveLocalCa`): the certificate
|
||||
* and an iOS/macOS profile that installs it, and the names they
|
||||
* download as. Only served with TLS and `TLS_CA_FILE`.
|
||||
*/
|
||||
TlsCaPath: "/ca.crt",
|
||||
TlsCaFilename: `${SiteData["SiteSlug"]}-ca.crt`,
|
||||
TlsCaProfilePath: "/ca.mobileconfig",
|
||||
TlsCaProfileFilename: `${SiteData["SiteSlug"]}-ca.mobileconfig`,
|
||||
AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`,
|
||||
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
|
||||
AuthExpiryDays: 60,
|
||||
|
||||
@@ -37,9 +37,9 @@ export type KnownEnv = {
|
||||
* their own keys beside these for bots and computers to use.
|
||||
*
|
||||
* Not here, and never in the `envs` table: `ENCRYPTION_KEY` and
|
||||
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR` and
|
||||
* `MAIN_SERVER` (read before the database opens). Those stay in the
|
||||
* process environment.
|
||||
* `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.
|
||||
*/
|
||||
export const EnvDict: readonly KnownEnv[] = [
|
||||
{
|
||||
@@ -86,6 +86,9 @@ export const ProcessOnlyEnvKeys = [
|
||||
"DATA_DIR",
|
||||
"DB_DIR",
|
||||
"MAIN_SERVER",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"TLS_CA_FILE",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,3 +7,8 @@ export const HelpPath = "/admin/help";
|
||||
* Signing in to Google and other strict sites with Cookie-Editor.
|
||||
*/
|
||||
export const HelpSignInTopicId = "sign-in-with-cookies";
|
||||
|
||||
/**
|
||||
* Trusting the local HTTPS certificate on a device.
|
||||
*/
|
||||
export const HelpHttpsTopicId = "https-certificate";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import buildCaProfile from "@/src/utils/build-ca-profile";
|
||||
|
||||
type Params = {
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves the local CA that signed the app's certificate
|
||||
* (`TLS_CA_FILE`), so a device can trust it without copying files across
|
||||
* the network: the certificate itself, and an iOS/macOS profile that
|
||||
* installs it in one tap.
|
||||
*
|
||||
* A certificate is public information; the key never leaves
|
||||
* `$COBBLE_HOME/certs`. Returns `null` for every other path, and for an
|
||||
* install using its own certificate, which has no local CA.
|
||||
*/
|
||||
export default async function serveLocalCa({
|
||||
pathname,
|
||||
}: Params): Promise<Response | null> {
|
||||
const certificate_path = pathname == AppData["TlsCaPath"];
|
||||
const profile_path = pathname == AppData["TlsCaProfilePath"];
|
||||
|
||||
if (!certificate_path && !profile_path) return null;
|
||||
|
||||
const ca_file = process.env.TLS_CA_FILE?.trim();
|
||||
|
||||
if (!ca_file) return null;
|
||||
|
||||
const file = Bun.file(ca_file);
|
||||
|
||||
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
|
||||
|
||||
if (certificate_path) {
|
||||
return new Response(file, {
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/x-x509-ca-cert",
|
||||
"Content-Disposition": `attachment; filename="${AppData["TlsCaFilename"]}"`,
|
||||
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const profile = buildCaProfile({
|
||||
certificate_pem: await file.text(),
|
||||
display_name: `${SiteData["SiteName"]} local CA`,
|
||||
identifier: `org.${SiteData["SiteSlug"]}.ca`,
|
||||
});
|
||||
|
||||
return new Response(profile, {
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/x-apple-aspen-config",
|
||||
"Content-Disposition": `attachment; filename="${AppData["TlsCaProfileFilename"]}"`,
|
||||
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import buildHttpsRedirectUrl from "@/src/utils/build-https-redirect-url";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The port the app serves HTTPS on.
|
||||
*/
|
||||
https_port: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Development convenience: 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).
|
||||
*/
|
||||
export default function startHttpRedirect({ https_port }: Params) {
|
||||
const base = Number(https_port);
|
||||
const configured = Number(process.env.DEV_HTTP_PORT);
|
||||
const redirect_port =
|
||||
Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: Number.isInteger(base) && base > 0
|
||||
? base + 1
|
||||
: 7228;
|
||||
|
||||
if (redirect_port == base) return undefined;
|
||||
|
||||
return Bun.serve({
|
||||
port: redirect_port,
|
||||
reusePort: false,
|
||||
fetch(req) {
|
||||
return Response.redirect(
|
||||
buildHttpsRedirectUrl({
|
||||
request_url: new URL(req.url),
|
||||
https_port,
|
||||
}),
|
||||
308,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
import { HelpHttpsTopicId, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
|
||||
export type HelpTopic = {
|
||||
/**
|
||||
@@ -21,6 +21,40 @@ export type HelpTopic = {
|
||||
* common first.
|
||||
*/
|
||||
export const HelpTopics: HelpTopic[] = [
|
||||
{
|
||||
id: HelpHttpsTopicId,
|
||||
title: "The browser says the connection isn't private",
|
||||
summary: `"Your connection is not private", a certificate warning, or an app that won't install or send notifications.`,
|
||||
body: `Cobble serves HTTPS with a certificate signed by a small local authority (a CA) that the installer made on your machine. Browsers only trust it once you have told them to, so the first visit warns.
|
||||
|
||||
**Download it on the device.** Open one of these in the device's browser, on the same address you use for Cobble, and accept the warning if it shows:
|
||||
|
||||
- **iPhone or iPad:** \`https://<your address>:7227/ca.mobileconfig\`. iOS offers a profile, which is one tap to install.
|
||||
- **Everything else:** \`https://<your address>:7227/ca.crt\`.
|
||||
|
||||
You can also copy it from the machine Cobble runs on: \`/opt/cobble/certs/ca.crt\` (or wherever it was installed).
|
||||
|
||||
### Install it
|
||||
|
||||
- **iPhone or iPad:** tap **Allow** on the downloaded profile, install it (it also appears under **Settings > General > VPN & Device Management**), then turn it on under **Settings > General > About > Certificate Trust Settings**. Both steps are needed, even with the one-tap profile.
|
||||
- **Android:** **Settings > Security > Encryption & credentials > Install a certificate > CA certificate**, then pick the file.
|
||||
- **macOS:** add it to the login keychain and set it to **Always Trust**.
|
||||
- **Linux:** copy it into \`/usr/local/share/ca-certificates/\` and run \`sudo update-ca-certificates\`.
|
||||
- **Windows:** install it under **Trusted Root Certification Authorities**.
|
||||
|
||||
Reload Cobble afterwards. The warning is gone.
|
||||
|
||||
### Why it matters
|
||||
|
||||
The connection is encrypted either way; the warning is about who signed the certificate. But the browser treats an untrusted address as unsafe, so the installable app, its offline support and Web Push notifications stay off until the CA is trusted. Voice recording and dictation need that safe context too.
|
||||
|
||||
### If it still warns
|
||||
|
||||
- **You installed the profile but iOS still warns.** The second step, Certificate Trust Settings, was missed. It is a separate switch.
|
||||
- **You replaced the CA.** Rerunning the installer with \`--https-regen\` makes a new CA. Remove the old profile and install the new one.
|
||||
- **The warning comes back every few months.** The certificate is renewed when the installer runs, but it is still signed by the same CA, so devices keep trusting it.
|
||||
- **You have your own certificate.** Installs using \`--https-cert\` and \`--https-key\`, or \`--no-https\` behind a proxy or tunnel, do not need any of this.`,
|
||||
},
|
||||
{
|
||||
id: HelpSignInTopicId,
|
||||
title: "Google won't let me sign in on a computer",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Starts the development server over HTTPS, generating the certificate
|
||||
* first, so a phone on the VPN can open the app in one command:
|
||||
*
|
||||
* bun run dev:phone -- --host <the VPN name>
|
||||
*
|
||||
* Without `--host` the machine's own name and addresses are used. The
|
||||
* files land in `certs/` (gitignored); `bun run dev` picks them up on its
|
||||
* own afterwards.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const generated = Bun.spawnSync({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"src/scripts/generate-local-certs.ts",
|
||||
"--out",
|
||||
"certs",
|
||||
...args,
|
||||
],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
if (!generated.success) process.exit(generated.exitCode ?? 1);
|
||||
|
||||
const port = process.env.PORT || "7227";
|
||||
|
||||
console.log(
|
||||
`\nPhone setup: open https://<a name above>:${port}/ca.mobileconfig first,\n` +
|
||||
`then the app at the same address. Trust the profile on the device.\n`,
|
||||
);
|
||||
|
||||
const server = Bun.spawnSync({
|
||||
cmd: [process.execPath, "src/server.ts"],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
TLS_CERT_FILE: "certs/server.crt",
|
||||
TLS_KEY_FILE: "certs/server.key",
|
||||
TLS_CA_FILE: "certs/ca.crt",
|
||||
},
|
||||
});
|
||||
|
||||
process.exit(server.exitCode ?? 0);
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import buildSanList from "../utils/build-san-list";
|
||||
|
||||
/**
|
||||
* Generates the certificate HTTPS runs on: a local CA and a leaf it signs,
|
||||
* so a device trusts the CA once and every re-issued certificate keeps
|
||||
* working.
|
||||
*
|
||||
* The installer runs this inside the app image (which has `openssl`), with
|
||||
* the host's own addresses passed in: a container's interfaces are not the
|
||||
* host's. Development runs it directly (`bun run gen:certs`), where the
|
||||
* machine's own name and addresses are detected.
|
||||
*
|
||||
* The CA is kept until `--regen`. The leaf is re-issued when the names
|
||||
* change or it is within 30 days of expiring, which is what rerunning the
|
||||
* installer on an update does.
|
||||
*
|
||||
* Usage:
|
||||
* bun run gen:certs [--out certs] [--host NAME_OR_IP]... [--no-detect] [--regen]
|
||||
*/
|
||||
|
||||
const CA_DAYS = 3650;
|
||||
const CERT_DAYS = 397;
|
||||
const RENEW_BEFORE_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
type Options = {
|
||||
out: string;
|
||||
hosts: string[];
|
||||
detect: boolean;
|
||||
regen: boolean;
|
||||
};
|
||||
|
||||
function usage() {
|
||||
console.log(`Generates the certificate HTTPS runs on.
|
||||
|
||||
Usage:
|
||||
bun run gen:certs [options]
|
||||
|
||||
Options:
|
||||
--out DIR Where to write the files (default certs)
|
||||
--host VALUE A name or IP the certificate covers (repeatable)
|
||||
--no-detect Do not add this machine's own name and addresses
|
||||
--regen Replace the local CA and the certificate
|
||||
--help Show this help
|
||||
|
||||
Writes ca.crt, ca.key, server.crt, server.key and sans.txt.`);
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseOptions(): Options {
|
||||
const argv = process.argv.slice(2);
|
||||
const hosts: string[] = [];
|
||||
let out = "certs";
|
||||
let detect = true;
|
||||
let regen = false;
|
||||
|
||||
const value = (index: number, name: string) => {
|
||||
const next = argv[index];
|
||||
|
||||
if (!next || next.startsWith("--")) fail(`${name} needs a value`);
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
switch (argv[i]) {
|
||||
case "--out":
|
||||
out = value(++i, "--out");
|
||||
break;
|
||||
case "--host":
|
||||
hosts.push(value(++i, "--host"));
|
||||
break;
|
||||
case "--no-detect":
|
||||
detect = false;
|
||||
break;
|
||||
case "--regen":
|
||||
regen = true;
|
||||
break;
|
||||
case "--":
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
usage();
|
||||
process.exit(0);
|
||||
default:
|
||||
fail(`Unknown option: ${argv[i]}. Use --help.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { out, hosts, detect, regen };
|
||||
}
|
||||
|
||||
function run(command: string[]): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: command,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = result.stdout?.toString() ?? "";
|
||||
const stderr = result.stderr?.toString() ?? "";
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`${command[0]} failed: ${stderr.trim() || stdout.trim() || `exit ${result.exitCode}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function certSecondsLeft(file: string): number | undefined {
|
||||
if (!existsSync(file)) return undefined;
|
||||
|
||||
try {
|
||||
const end = run(["openssl", "x509", "-noout", "-enddate", "-in", file])
|
||||
.trim()
|
||||
.replace(/^notAfter=/, "");
|
||||
const time = Date.parse(end);
|
||||
|
||||
if (Number.isNaN(time)) return undefined;
|
||||
|
||||
return (time - Date.now()) / 1000;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function localNames(): { hostname: string; addresses: string[] } {
|
||||
const addresses: string[] = [];
|
||||
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.address) addresses.push(entry.address);
|
||||
}
|
||||
}
|
||||
|
||||
return { hostname: os.hostname(), addresses };
|
||||
}
|
||||
|
||||
function caConfig(): string {
|
||||
return `[req]
|
||||
distinguished_name = dn
|
||||
prompt = no
|
||||
x509_extensions = v3_ca
|
||||
|
||||
[dn]
|
||||
CN = Cobble Local CA
|
||||
|
||||
[v3_ca]
|
||||
basicConstraints = critical,CA:TRUE,pathlen:0
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
`;
|
||||
}
|
||||
|
||||
function leafRequestConfig({ common_name }: { common_name: string }): string {
|
||||
return `[req]
|
||||
distinguished_name = dn
|
||||
prompt = no
|
||||
|
||||
[dn]
|
||||
CN = ${common_name}
|
||||
`;
|
||||
}
|
||||
|
||||
function leafExtensionsConfig({ sans }: { sans: string }): string {
|
||||
return `[v3_leaf]
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = ${sans}
|
||||
`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!Bun.which("openssl")) {
|
||||
fail(
|
||||
"openssl is required to generate the certificate. Install the openssl package and rerun.",
|
||||
);
|
||||
}
|
||||
|
||||
const options = parseOptions();
|
||||
const invalid_host = options.hosts.find(
|
||||
(host) => !/^[A-Za-z0-9.*:_-]+$/.test(host),
|
||||
);
|
||||
|
||||
if (invalid_host) {
|
||||
fail(`--host is not a hostname or IP address: ${invalid_host}`);
|
||||
}
|
||||
|
||||
const out = path.resolve(options.out);
|
||||
|
||||
mkdirSync(out, { recursive: true, mode: 0o700 });
|
||||
chmodSync(out, 0o700);
|
||||
|
||||
const build = path.join(out, ".build");
|
||||
rmSync(build, { recursive: true, force: true });
|
||||
mkdirSync(build, { recursive: true, mode: 0o700 });
|
||||
|
||||
const ca_crt = path.join(out, "ca.crt");
|
||||
const ca_key = path.join(out, "ca.key");
|
||||
const ca_serial = path.join(out, "ca.srl");
|
||||
const server_crt = path.join(out, "server.crt");
|
||||
const server_key = path.join(out, "server.key");
|
||||
const sans_file = path.join(out, "sans.txt");
|
||||
|
||||
if (options.regen) {
|
||||
for (const file of [
|
||||
ca_crt,
|
||||
ca_key,
|
||||
ca_serial,
|
||||
server_crt,
|
||||
server_key,
|
||||
sans_file,
|
||||
]) {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const detected: { hostname?: string; addresses?: string[] } = options.detect
|
||||
? localNames()
|
||||
: {};
|
||||
const sans = buildSanList({
|
||||
hostname: detected.hostname,
|
||||
addresses: detected.addresses,
|
||||
hosts: options.hosts,
|
||||
});
|
||||
const san_value = sans.join(",");
|
||||
const common_name = (
|
||||
sans.find(
|
||||
(entry) => entry.startsWith("DNS:") && entry != "DNS:localhost",
|
||||
) ?? "DNS:localhost"
|
||||
)
|
||||
.slice(4)
|
||||
.slice(0, 64);
|
||||
|
||||
let ca_created = false;
|
||||
|
||||
if (!existsSync(ca_crt) || !existsSync(ca_key)) {
|
||||
rmSync(ca_crt, { force: true });
|
||||
rmSync(ca_key, { force: true });
|
||||
rmSync(ca_serial, { force: true });
|
||||
|
||||
const config = path.join(build, "ca.cnf");
|
||||
writeFileSync(config, caConfig());
|
||||
|
||||
run([
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:3072",
|
||||
"-nodes",
|
||||
"-sha256",
|
||||
"-days",
|
||||
String(CA_DAYS),
|
||||
"-keyout",
|
||||
ca_key,
|
||||
"-out",
|
||||
ca_crt,
|
||||
"-config",
|
||||
config,
|
||||
]);
|
||||
|
||||
ca_created = true;
|
||||
}
|
||||
|
||||
const current_sans = existsSync(sans_file)
|
||||
? readFileSync(sans_file, "utf8").trim()
|
||||
: "";
|
||||
const seconds_left = certSecondsLeft(server_crt);
|
||||
const issue_certificate =
|
||||
ca_created ||
|
||||
!existsSync(server_crt) ||
|
||||
!existsSync(server_key) ||
|
||||
current_sans != san_value ||
|
||||
seconds_left === undefined ||
|
||||
seconds_left < RENEW_BEFORE_SECONDS;
|
||||
|
||||
if (issue_certificate) {
|
||||
const request_config = path.join(build, "leaf.cnf");
|
||||
const extensions = path.join(build, "leaf-ext.cnf");
|
||||
const csr = path.join(build, "server.csr");
|
||||
|
||||
writeFileSync(request_config, leafRequestConfig({ common_name }));
|
||||
writeFileSync(extensions, leafExtensionsConfig({ sans: san_value }));
|
||||
|
||||
run([
|
||||
"openssl",
|
||||
"req",
|
||||
"-new",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-sha256",
|
||||
"-keyout",
|
||||
server_key,
|
||||
"-out",
|
||||
csr,
|
||||
"-config",
|
||||
request_config,
|
||||
]);
|
||||
|
||||
run([
|
||||
"openssl",
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
csr,
|
||||
"-CA",
|
||||
ca_crt,
|
||||
"-CAkey",
|
||||
ca_key,
|
||||
"-CAcreateserial",
|
||||
"-CAserial",
|
||||
ca_serial,
|
||||
"-days",
|
||||
String(CERT_DAYS),
|
||||
"-sha256",
|
||||
"-extfile",
|
||||
extensions,
|
||||
"-extensions",
|
||||
"v3_leaf",
|
||||
"-out",
|
||||
server_crt,
|
||||
]);
|
||||
|
||||
writeFileSync(sans_file, `${san_value}\n`);
|
||||
}
|
||||
|
||||
run(["openssl", "verify", "-CAfile", ca_crt, server_crt]);
|
||||
|
||||
chmodSync(ca_key, 0o600);
|
||||
chmodSync(server_key, 0o600);
|
||||
if (existsSync(ca_serial)) chmodSync(ca_serial, 0o600);
|
||||
chmodSync(ca_crt, 0o644);
|
||||
chmodSync(server_crt, 0o644);
|
||||
chmodSync(sans_file, 0o644);
|
||||
|
||||
rmSync(build, { recursive: true, force: true });
|
||||
|
||||
console.log(`Certificate directory: ${out}`);
|
||||
console.log(`Covers: ${sans.join(", ")}`);
|
||||
|
||||
if (ca_created) console.log("Created a new local CA and certificate.");
|
||||
else if (issue_certificate)
|
||||
console.log("Issued a new certificate from the local CA.");
|
||||
else console.log("Kept the existing certificate.");
|
||||
|
||||
console.log(
|
||||
`certs: ${ca_created ? "created" : issue_certificate ? "renewed" : "unchanged"}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
+87
-1
@@ -1,4 +1,5 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import { existsSync } from "fs";
|
||||
import handleMediaServer from "./functions/server/media/handle-media-server";
|
||||
import cron from "./cron";
|
||||
import { websocket } from "./websocket";
|
||||
@@ -10,6 +11,9 @@ import liveRelay from "./live-relay";
|
||||
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
||||
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
|
||||
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 { ComputerProxyPrefix } from "./dict/computer-dict";
|
||||
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
|
||||
import { ensureVapidKeys } from "./push/vapid";
|
||||
@@ -48,7 +52,59 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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";
|
||||
|
||||
let cert_file = process.env.TLS_CERT_FILE;
|
||||
let key_file = process.env.TLS_KEY_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 && process.env.NODE_ENV != "production") {
|
||||
bunext.bunextLog.info(
|
||||
"Serving plain HTTP (run `bun run dev:phone`, or `bun run gen:certs` then restart, for HTTPS)",
|
||||
);
|
||||
}
|
||||
|
||||
let tls: ReturnType<typeof resolveTlsOptions> = undefined;
|
||||
|
||||
try {
|
||||
tls = resolveTlsOptions({ cert_file, key_file });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Can't start without a working TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
...(tls ? { tls } : {}),
|
||||
async fetch(req, server) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
@@ -57,6 +113,10 @@ const server = Bun.serve({
|
||||
|
||||
if (pwa_file) return pwa_file;
|
||||
|
||||
const local_ca = await serveLocalCa({ pathname: url.pathname });
|
||||
|
||||
if (local_ca) return local_ca;
|
||||
|
||||
if (url.pathname.startsWith("/media")) {
|
||||
try {
|
||||
return await handleMediaServer({ req });
|
||||
@@ -108,13 +168,39 @@ const server = Bun.serve({
|
||||
websocket,
|
||||
development,
|
||||
port,
|
||||
/**
|
||||
* Fail loudly on a second dev server instead of letting Bun's default
|
||||
* port sharing split requests between an old and a new process (one
|
||||
* might be plain HTTP while the other is HTTPS).
|
||||
*/
|
||||
reusePort: false,
|
||||
idleTimeout: development ? 80 : undefined,
|
||||
});
|
||||
|
||||
bunext.bunextLog.info(
|
||||
`Server running on http://${server.hostname}:${server.port} ...`,
|
||||
`Server running on ${tls ? "https" : "http"}://${server.hostname}:${server.port} ...`,
|
||||
);
|
||||
|
||||
/**
|
||||
* A TLS port cannot answer plain HTTP, so a development `http://` visit
|
||||
* is sent to the HTTPS page from the port above it (`startHttpRedirect`).
|
||||
*/
|
||||
if (tls && process.env.NODE_ENV != "production") {
|
||||
try {
|
||||
const redirect = startHttpRedirect({ https_port: port });
|
||||
|
||||
if (redirect) {
|
||||
bunext.bunextLog.info(
|
||||
`HTTP redirect on port ${redirect.port} (DEV_HTTP_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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cron();
|
||||
botsQueue();
|
||||
liveRelay();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import buildCaProfile from "./build-ca-profile";
|
||||
|
||||
const pem = `-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIUTEST
|
||||
-----END CERTIFICATE-----
|
||||
`;
|
||||
|
||||
const profile = (display_name = "Cobble local CA") =>
|
||||
buildCaProfile({
|
||||
certificate_pem: pem,
|
||||
display_name,
|
||||
identifier: "org.example.cobble.ca",
|
||||
});
|
||||
|
||||
describe("buildCaProfile", () => {
|
||||
test("is a root certificate profile", () => {
|
||||
expect(profile()).toContain("<string>com.apple.security.root</string>");
|
||||
expect(profile()).toContain("<string>Configuration</string>");
|
||||
});
|
||||
|
||||
test("embeds the PEM body as DER base64", () => {
|
||||
const body = profile();
|
||||
|
||||
expect(body).toContain("MIIBhTCCASugAwIBAgIUTEST");
|
||||
expect(body).not.toContain("BEGIN CERTIFICATE");
|
||||
expect(body).not.toContain("END CERTIFICATE");
|
||||
});
|
||||
|
||||
test("names the profile with its identifier", () => {
|
||||
const body = profile();
|
||||
|
||||
expect(body).toContain("<string>Cobble local CA</string>");
|
||||
expect(body).toContain("<string>org.example.cobble.ca</string>");
|
||||
expect(body).toContain(
|
||||
"<string>org.example.cobble.ca.certificate</string>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes XML in the name", () => {
|
||||
expect(profile("Ben & Co <CA>")).toContain("Ben & Co <CA>");
|
||||
});
|
||||
|
||||
test("refuses an empty certificate", () => {
|
||||
expect(() =>
|
||||
buildCaProfile({
|
||||
certificate_pem: " ",
|
||||
display_name: "Cobble",
|
||||
identifier: "org.example.cobble.ca",
|
||||
}),
|
||||
).toThrow("The CA certificate is empty.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The CA certificate in PEM, as `ca.crt` holds it.
|
||||
*/
|
||||
certificate_pem: string;
|
||||
/**
|
||||
* The name iOS and macOS show for the profile.
|
||||
*/
|
||||
display_name: string;
|
||||
/**
|
||||
* Reverse-DNS identifier of the profile, e.g. `org.example.cobble.ca`.
|
||||
*/
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixed UUIDs, so installing the profile again replaces it on the device
|
||||
* instead of leaving an older Cobble CA behind.
|
||||
*/
|
||||
const ProfileUUID = "B7F3C2A1-6D4E-4F8A-9C1B-2E5A7D9F0B31";
|
||||
const CertificateUUID = "D2A8E4C6-1B3F-4A7D-8E5C-9F0B6A2C4D18";
|
||||
|
||||
const XmlEscapes: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
function escapeXml({ value }: { value: string }): string {
|
||||
return value.replace(/[&<>"']/g, (character) => XmlEscapes[character]!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificate as base64 DER, which is what a plist `<data>` holds:
|
||||
* the PEM body itself, with its markers and line breaks removed.
|
||||
*/
|
||||
function certificateData({ certificate_pem }: { certificate_pem: string }) {
|
||||
const data = certificate_pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||
.replace(/-----END CERTIFICATE-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
|
||||
if (!data) {
|
||||
throw new Error("The CA certificate is empty.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* An iOS/macOS configuration profile carrying the local CA
|
||||
* (`com.apple.security.root`). Opening it on an iPhone offers a one-tap
|
||||
* install instead of a file to find and install by hand; iOS still asks
|
||||
* for the separate trust switch afterwards, which no profile can skip on
|
||||
* an unmanaged device.
|
||||
*/
|
||||
export default function buildCaProfile({
|
||||
certificate_pem,
|
||||
display_name,
|
||||
identifier,
|
||||
}: Params): string {
|
||||
const name = escapeXml({ value: display_name });
|
||||
const id = escapeXml({ value: identifier });
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>${name}.crt</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>
|
||||
${certificateData({ certificate_pem })}
|
||||
</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Lets this device trust the HTTPS certificate ${name} serves.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>${name}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>${id}.certificate</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${CertificateUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Installs the local certificate authority that signs ${name}, then turn on trust under Certificate Trust Settings.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>${name}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>${id}</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${ProfileUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import buildHttpsRedirectUrl from "./build-https-redirect-url";
|
||||
|
||||
const redirect = (url: string, https_port: string | number = 7227) =>
|
||||
buildHttpsRedirectUrl({ request_url: new URL(url), https_port });
|
||||
|
||||
describe("buildHttpsRedirectUrl", () => {
|
||||
test("moves the request to the HTTPS port", () => {
|
||||
expect(redirect("http://localhost:7228/")).toBe(
|
||||
"https://localhost:7227/",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the path and the query", () => {
|
||||
expect(redirect("http://localhost:7228/admin/bots?a=1&b=2")).toBe(
|
||||
"https://localhost:7227/admin/bots?a=1&b=2",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an IPv6 literal valid", () => {
|
||||
expect(redirect("http://[::1]:7228/login")).toBe(
|
||||
"https://[::1]:7227/login",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the host the browser asked for", () => {
|
||||
expect(redirect("http://cobble.tailnet.ts.net:7228/")).toBe(
|
||||
"https://cobble.tailnet.ts.net:7227/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The plain-HTTP request that should move to HTTPS.
|
||||
*/
|
||||
request_url: URL;
|
||||
/**
|
||||
* The port the app serves HTTPS on.
|
||||
*/
|
||||
https_port: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The HTTPS address for a plain-HTTP request: same host, path and query,
|
||||
* on the HTTPS port. `request_url.hostname` keeps the brackets of an IPv6
|
||||
* literal, so the result stays a valid URL.
|
||||
*/
|
||||
export default function buildHttpsRedirectUrl({
|
||||
request_url,
|
||||
https_port,
|
||||
}: Params): string {
|
||||
return `https://${request_url.hostname}:${https_port}${request_url.pathname}${request_url.search}`;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import buildSanList from "./build-san-list";
|
||||
|
||||
describe("buildSanList", () => {
|
||||
test("always covers localhost", () => {
|
||||
expect(buildSanList({})).toEqual([
|
||||
"DNS:localhost",
|
||||
"IP:127.0.0.1",
|
||||
"IP:::1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds the machine's name, its short form and its mDNS name", () => {
|
||||
const list = buildSanList({ hostname: "studio.tailnet.ts.net" });
|
||||
|
||||
expect(list).toContain("DNS:studio.tailnet.ts.net");
|
||||
expect(list).toContain("DNS:studio");
|
||||
expect(list).toContain("DNS:studio.local");
|
||||
});
|
||||
|
||||
test("does not invent localhost.local", () => {
|
||||
expect(buildSanList({ hostname: "localhost" })).toEqual([
|
||||
"DNS:localhost",
|
||||
"IP:127.0.0.1",
|
||||
"IP:::1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("classifies addresses and names", () => {
|
||||
const list = buildSanList({
|
||||
addresses: ["192.168.1.20", "fe80::1"],
|
||||
hosts: ["cobble.tailnet.ts.net", "10.8.0.1"],
|
||||
});
|
||||
|
||||
expect(list).toContain("IP:192.168.1.20");
|
||||
expect(list).toContain("IP:fe80::1");
|
||||
expect(list).toContain("DNS:cobble.tailnet.ts.net");
|
||||
expect(list).toContain("IP:10.8.0.1");
|
||||
expect(list).not.toContain("DNS:10.8.0.1");
|
||||
});
|
||||
|
||||
test("deduplicates names regardless of case", () => {
|
||||
const list = buildSanList({
|
||||
hostname: "Studio",
|
||||
hosts: ["studio", "STUDIO.local"],
|
||||
});
|
||||
|
||||
expect(list.filter((entry) => entry == "DNS:studio")).toHaveLength(1);
|
||||
expect(
|
||||
list.filter((entry) => entry == "DNS:studio.local"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("ignores empty entries", () => {
|
||||
expect(buildSanList({ hosts: ["", " "] })).toEqual([
|
||||
"DNS:localhost",
|
||||
"IP:127.0.0.1",
|
||||
"IP:::1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import net from "net";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The machine's own name, as `os.hostname()` gives it. Its short form
|
||||
* and `<short>.local` are added too, since that is how a LAN reaches
|
||||
* it.
|
||||
*/
|
||||
hostname?: string;
|
||||
/**
|
||||
* Addresses the app answers on.
|
||||
*/
|
||||
addresses?: string[];
|
||||
/**
|
||||
* Names or addresses given by the user, e.g. a VPN's DNS name.
|
||||
*/
|
||||
hosts?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The `subjectAltName` entries a certificate is issued for: `DNS:name` for
|
||||
* a name, `IP:address` for an address, deduplicated. `localhost` and both
|
||||
* loopback addresses are always there, so `https://localhost` (and a
|
||||
* forwarded port) keep working.
|
||||
*
|
||||
* A name is classified by `net.isIP`, so a user can pass an address
|
||||
* wherever a host is expected without it becoming a useless DNS entry.
|
||||
*/
|
||||
export default function buildSanList({
|
||||
hostname,
|
||||
addresses,
|
||||
hosts,
|
||||
}: Params): string[] {
|
||||
const entries: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = ({ type, value }: { type: "DNS" | "IP"; value: string }) => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
const entry = `${type}:${
|
||||
type == "DNS" ? trimmed.toLowerCase() : trimmed
|
||||
}`;
|
||||
const key = entry.toLowerCase();
|
||||
|
||||
if (seen.has(key)) return;
|
||||
|
||||
seen.add(key);
|
||||
entries.push(entry);
|
||||
};
|
||||
|
||||
const addHost = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
add({ type: net.isIP(trimmed) ? "IP" : "DNS", value: trimmed });
|
||||
};
|
||||
|
||||
add({ type: "DNS", value: "localhost" });
|
||||
add({ type: "IP", value: "127.0.0.1" });
|
||||
add({ type: "IP", value: "::1" });
|
||||
|
||||
const own = (hostname ?? "").trim();
|
||||
|
||||
if (own) {
|
||||
addHost(own);
|
||||
|
||||
const short = own.split(".")[0];
|
||||
|
||||
if (short && short != "localhost") {
|
||||
add({ type: "DNS", value: short });
|
||||
|
||||
if (short != own) add({ type: "DNS", value: `${short}.local` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const address of addresses ?? []) addHost(address);
|
||||
for (const host of hosts ?? []) addHost(host);
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import resolveTlsOptions from "./resolve-tls-options";
|
||||
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "cobble-tls-"));
|
||||
const cert = path.join(dir, "server.crt");
|
||||
const key = path.join(dir, "server.key");
|
||||
|
||||
writeFileSync(cert, "certificate");
|
||||
writeFileSync(key, "key");
|
||||
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
describe("resolveTlsOptions", () => {
|
||||
test("plain HTTP when neither is set", () => {
|
||||
expect(resolveTlsOptions({})).toBeUndefined();
|
||||
expect(
|
||||
resolveTlsOptions({ cert_file: "", key_file: " " }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reads both files when they are set", async () => {
|
||||
const tls = resolveTlsOptions({ cert_file: cert, key_file: key });
|
||||
|
||||
expect(tls).toBeDefined();
|
||||
expect(await tls!.cert.text()).toBe("certificate");
|
||||
expect(await tls!.key.text()).toBe("key");
|
||||
});
|
||||
|
||||
test("refuses half a configuration", () => {
|
||||
expect(() => resolveTlsOptions({ cert_file: cert })).toThrow(
|
||||
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||
);
|
||||
expect(() => resolveTlsOptions({ key_file: key })).toThrow(
|
||||
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a missing file", () => {
|
||||
expect(() =>
|
||||
resolveTlsOptions({
|
||||
cert_file: path.join(dir, "missing.crt"),
|
||||
key_file: key,
|
||||
}),
|
||||
).toThrow("Can't read the TLS file");
|
||||
});
|
||||
|
||||
test("refuses a directory", () => {
|
||||
expect(() =>
|
||||
resolveTlsOptions({ cert_file: dir, key_file: key }),
|
||||
).toThrow("Can't read the TLS file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { accessSync, constants, statSync } from "fs";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* `TLS_CERT_FILE`, the certificate (or chain) the app serves.
|
||||
*/
|
||||
cert_file?: string;
|
||||
/**
|
||||
* `TLS_KEY_FILE`, its private key.
|
||||
*/
|
||||
key_file?: string;
|
||||
};
|
||||
|
||||
export type TlsOptions = {
|
||||
cert: ReturnType<typeof Bun.file>;
|
||||
key: ReturnType<typeof Bun.file>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `Bun.serve`'s TLS options from the certificate paths the installer sets,
|
||||
* or `undefined` when the app runs over plain HTTP.
|
||||
*
|
||||
* A half-configured or unreadable certificate is an error, never a silent
|
||||
* fall back to HTTP: downgrading the connection quietly would drop the
|
||||
* `Secure` cookies and the secure context the installed app and Web Push
|
||||
* need.
|
||||
*/
|
||||
export default function resolveTlsOptions({
|
||||
cert_file,
|
||||
key_file,
|
||||
}: Params): TlsOptions | undefined {
|
||||
const cert = cert_file?.trim();
|
||||
const key = key_file?.trim();
|
||||
|
||||
if (!cert && !key) return undefined;
|
||||
|
||||
if (!cert || !key) {
|
||||
throw new Error(
|
||||
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of [cert, key]) {
|
||||
try {
|
||||
if (!statSync(file).isFile()) throw new Error("not a file");
|
||||
|
||||
accessSync(file, constants.R_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Can't read the TLS file ${file}. Check that it is mounted and readable.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { cert: Bun.file(cert), key: Bun.file(key) };
|
||||
}
|
||||
Reference in New Issue
Block a user