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:
2026-10-20 10:20:01 +01:00
parent 535adb6e16
commit e859bab294
26 changed files with 1582 additions and 40 deletions
+49
View File
@@ -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);
+372
View File
@@ -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));
}