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
+83
View File
@@ -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;
}