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
+53
View File
@@ -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 &amp; Co &lt;CA&gt;");
});
test("refuses an empty certificate", () => {
expect(() =>
buildCaProfile({
certificate_pem: " ",
display_name: "Cobble",
identifier: "org.example.cobble.ca",
}),
).toThrow("The CA certificate is empty.");
});
});
+111
View File
@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&apos;",
};
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/",
);
});
});
+22
View File
@@ -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}`;
}
+61
View File
@@ -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",
]);
});
});
+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;
}
+55
View File
@@ -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");
});
});
+56
View 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) };
}