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:
@@ -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