Serve plain HTTP only and drop the local certificate tooling
The app never terminates TLS: a reverse proxy or tunnel does, and isSecureRequest still reads X-Forwarded-Proto so cookies stay Secure. Removes the cert generator, TLS resolution and HTTP redirect, the local CA download, and the TLS envs, and trims the installer, Dockerfile health check and docs to match. Also softens the composer's loading row.
This commit is contained in:
@@ -45,16 +45,6 @@ export const AppData = {
|
||||
LegacyHarnessOverviewStorageKey: `${SiteData["SiteSlug"]}_harness_overview`,
|
||||
HarnessOverviewCacheMs: 10 * 60 * 1000,
|
||||
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`,
|
||||
/**
|
||||
|
||||
@@ -37,10 +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`,
|
||||
* `MAIN_SERVER`, `TLS_DISABLED`, `HTTP_REDIRECT_PORT` and the `TLS_*`
|
||||
* certificate paths (the container's own start-up settings). Those stay in
|
||||
* the process environment.
|
||||
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR` and
|
||||
* `MAIN_SERVER` (the container's own start-up settings). Those stay in the
|
||||
* process environment.
|
||||
*/
|
||||
export const EnvDict: readonly KnownEnv[] = [
|
||||
{
|
||||
@@ -87,11 +86,6 @@ export const ProcessOnlyEnvKeys = [
|
||||
"DATA_DIR",
|
||||
"DB_DIR",
|
||||
"MAIN_SERVER",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"TLS_CA_FILE",
|
||||
"TLS_DISABLED",
|
||||
"HTTP_REDIRECT_PORT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,6 @@ export const HelpPath = "/admin/help";
|
||||
export const HelpSignInTopicId = "sign-in-with-cookies";
|
||||
|
||||
/**
|
||||
* Trusting the local HTTPS certificate on a device.
|
||||
* Reaching the app over HTTPS through a proxy or tunnel.
|
||||
*/
|
||||
export const HelpHttpsTopicId = "https-certificate";
|
||||
export const HelpSecureConnectionTopicId = "secure-connection";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import buildHttpsRedirectUrl from "@/src/utils/build-https-redirect-url";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The port the app serves HTTPS on.
|
||||
*/
|
||||
https_port: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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. `HTTP_REDIRECT_PORT`
|
||||
* picks another port. Every environment starts it; the container publishes
|
||||
* only the HTTPS port, so there it is reachable only if that second port is
|
||||
* published too.
|
||||
*/
|
||||
export default function startHttpRedirect({ https_port }: Params) {
|
||||
const base = Number(https_port);
|
||||
const configured = Number(process.env.HTTP_REDIRECT_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,6 +1,7 @@
|
||||
import { EditorContent, type Editor } from "@tiptap/react";
|
||||
import ComposerFormatBubble from "./composer-format-bubble";
|
||||
import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
@@ -25,7 +26,9 @@ export default function ComposerEditor({ editor, invalid, title }: Props) {
|
||||
{editor ? (
|
||||
<ComposerFormatBubble editor={editor} />
|
||||
) : (
|
||||
<LoadingBlock className="w-full h-[30px] -ml-6" />
|
||||
<Row className="w-full opacity-50">
|
||||
<LoadingBlock className="w-full h-[30px] -ml-4 lg:-ml-6" />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HelpHttpsTopicId, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
import { HelpSecureConnectionTopicId, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
|
||||
export type HelpTopic = {
|
||||
/**
|
||||
@@ -22,38 +22,21 @@ export type HelpTopic = {
|
||||
*/
|
||||
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.
|
||||
id: HelpSecureConnectionTopicId,
|
||||
title: "The app needs a secure connection",
|
||||
summary: `A browser warning, the app won't install, or notifications and voice stay off.`,
|
||||
body: `Cobble serves plain HTTP and expects HTTPS in front of it: a reverse proxy (Caddy, nginx, Traefik) or a tunnel (Cloudflare Tunnel, Tailscale Serve). Browsers treat plain HTTP as unsafe, so the installable app, its offline support, Web Push notifications and voice recording stay off until the address is HTTPS.
|
||||
|
||||
**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:
|
||||
### Point the proxy at Cobble
|
||||
|
||||
- **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\`.
|
||||
The proxy should reach the app at \`http://<this machine>:7227\` (or the port given with \`--port\`), and it should set \`X-Forwarded-Proto: https\`. Cobble reads that header to mark the sign-in cookies \`Secure\` and to build the right links. Caddy does it by default; with nginx add \`proxy_set_header X-Forwarded-Proto $scheme;\` to the location block.
|
||||
|
||||
You can also copy it from the machine Cobble runs on: \`/opt/cobble/certs/ca.crt\` (or wherever it was installed).
|
||||
### If it still doesn't work
|
||||
|
||||
### 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.`,
|
||||
- **You opened the plain HTTP address.** Use the HTTPS address your proxy serves. \`http://\` will always be treated as unsafe.
|
||||
- **The certificate warning comes from the proxy.** It is using a self-signed or expired certificate. Fix that on the proxy: Cobble never sees the certificate.
|
||||
- **Sign-in doesn't stick, or downloads fail.** The proxy is not passing \`X-Forwarded-Proto\` (see above), or it is dropping cookies.
|
||||
- **WebSocket updates don't arrive.** The proxy must pass the upgrade on \`/ws\` as well as ordinary requests.`,
|
||||
},
|
||||
{
|
||||
id: HelpSignInTopicId,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -1,372 +0,0 @@
|
||||
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));
|
||||
}
|
||||
+5
-80
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -15,10 +14,6 @@ import handleBuildInfo from "./functions/server/pwa/handle-build-info";
|
||||
import handlePrecacheGate from "./functions/server/pwa/handle-precache-gate";
|
||||
import grabPrecacheManifest from "./functions/server/pwa/grab-precache-manifest";
|
||||
import compressResponse from "./functions/server/compress-response";
|
||||
import serveLocalCa from "./functions/server/serve-local-ca";
|
||||
import startHttpRedirect from "./functions/server/start-http-redirect";
|
||||
import resolveTlsOptions from "./utils/resolve-tls-options";
|
||||
import pickTlsFiles from "./utils/pick-tls-files";
|
||||
import { ComputerProxyPrefix } from "./dict/computer-dict";
|
||||
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
|
||||
import { ensureVapidKeys } from "./push/vapid";
|
||||
@@ -70,54 +65,11 @@ try {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which certificate to serve with, the same way in every environment: the
|
||||
* `TLS_*` variables when set, else `certs/` (which is also where the
|
||||
* container's `/app/certs` mount lands). Plain HTTP needs `TLS_DISABLED`.
|
||||
* The app always serves plain HTTP and leaves TLS to whatever sits in
|
||||
* front of it (a reverse proxy or tunnel). `isSecureRequest` reads the
|
||||
* proxy's `X-Forwarded-Proto` so cookies still get `Secure`.
|
||||
*/
|
||||
const picked = pickTlsFiles({
|
||||
cert_file: process.env.TLS_CERT_FILE,
|
||||
key_file: process.env.TLS_KEY_FILE,
|
||||
ca_file: process.env.TLS_CA_FILE,
|
||||
disabled: process.env.TLS_DISABLED,
|
||||
fallback_cert_file: "certs/server.crt",
|
||||
fallback_key_file: "certs/server.key",
|
||||
fallback_ca_file: "certs/ca.crt",
|
||||
file_exists: existsSync,
|
||||
});
|
||||
|
||||
if (picked.ca_file) process.env.TLS_CA_FILE = picked.ca_file;
|
||||
else delete process.env.TLS_CA_FILE;
|
||||
|
||||
switch (picked.source) {
|
||||
case "fallback":
|
||||
bunext.bunextLog.info(`Using the certificate in certs/`);
|
||||
break;
|
||||
case "disabled":
|
||||
bunext.bunextLog.info(`Serving plain HTTP (TLS_DISABLED is set)`);
|
||||
break;
|
||||
case "none":
|
||||
bunext.bunextLog.info(
|
||||
"Serving plain HTTP (no certificate; run `bun run gen:certs`, or set TLS_CERT_FILE and TLS_KEY_FILE)",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let tls: ReturnType<typeof resolveTlsOptions> = undefined;
|
||||
|
||||
try {
|
||||
tls = resolveTlsOptions({
|
||||
cert_file: picked.cert_file,
|
||||
key_file: picked.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);
|
||||
@@ -134,10 +86,6 @@ const server = Bun.serve({
|
||||
|
||||
if (loading_page) return loading_page;
|
||||
|
||||
const local_ca = await serveLocalCa({ pathname: url.pathname });
|
||||
|
||||
if (local_ca) return local_ca;
|
||||
|
||||
if (url.pathname.startsWith("/media")) {
|
||||
try {
|
||||
return await handleMediaServer({ req });
|
||||
@@ -193,39 +141,16 @@ const server = Bun.serve({
|
||||
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).
|
||||
* port sharing split requests between an old and a new process.
|
||||
*/
|
||||
reusePort: false,
|
||||
idleTimeout: development ? 80 : undefined,
|
||||
});
|
||||
|
||||
bunext.bunextLog.info(
|
||||
`Server running on ${tls ? "https" : "http"}://${server.hostname}:${server.port} ...`,
|
||||
`Server running on http://${server.hostname}:${server.port} ...`,
|
||||
);
|
||||
|
||||
/**
|
||||
* A TLS port cannot answer plain HTTP, so a plain `http://` visit is sent
|
||||
* to the HTTPS page from the port above it (`startHttpRedirect`). The
|
||||
* container publishes only the HTTPS port, so there it is only reachable
|
||||
* if that second port is published.
|
||||
*/
|
||||
if (tls) {
|
||||
try {
|
||||
const redirect = startHttpRedirect({ https_port: port });
|
||||
|
||||
if (redirect) {
|
||||
bunext.bunextLog.info(
|
||||
`HTTP redirect on port ${redirect.port} (HTTP_REDIRECT_PORT to change it)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
bunext.bunextLog.info(
|
||||
`Could not start the HTTP redirect on port ${Number(port) + 1}: ${error instanceof Error ? error.message : String(error)}. Stop the other server or set HTTP_REDIRECT_PORT.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background services start after the server is up, and one failing to
|
||||
* start never takes the server (or the others) down with it.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import pickTlsFiles from "./pick-tls-files";
|
||||
|
||||
const fallback = {
|
||||
fallback_cert_file: "certs/server.crt",
|
||||
fallback_key_file: "certs/server.key",
|
||||
fallback_ca_file: "certs/ca.crt",
|
||||
};
|
||||
|
||||
const exists = (paths: string[]) => (path: string) => paths.includes(path);
|
||||
|
||||
const pick = (
|
||||
options: Partial<Parameters<typeof pickTlsFiles>[0]> = {},
|
||||
paths: string[] = ["certs/server.crt", "certs/server.key", "certs/ca.crt"],
|
||||
) =>
|
||||
pickTlsFiles({
|
||||
...fallback,
|
||||
file_exists: exists(paths),
|
||||
...options,
|
||||
});
|
||||
|
||||
describe("pickTlsFiles", () => {
|
||||
test("prefers the TLS variables", () => {
|
||||
const picked = pick({
|
||||
cert_file: "/etc/cert.pem",
|
||||
key_file: "/etc/key.pem",
|
||||
ca_file: "/etc/ca.pem",
|
||||
});
|
||||
|
||||
expect(picked).toEqual({
|
||||
cert_file: "/etc/cert.pem",
|
||||
key_file: "/etc/key.pem",
|
||||
ca_file: "/etc/ca.pem",
|
||||
source: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses certs/ when the variables are unset, in any environment", () => {
|
||||
const picked = pick({ cert_file: "", key_file: "" });
|
||||
|
||||
expect(picked).toEqual({
|
||||
cert_file: "certs/server.crt",
|
||||
key_file: "certs/server.key",
|
||||
ca_file: "certs/ca.crt",
|
||||
source: "fallback",
|
||||
});
|
||||
});
|
||||
|
||||
test("serves HTTP when the certificate is explicitly disabled", () => {
|
||||
expect(
|
||||
pick({
|
||||
cert_file: "/etc/cert.pem",
|
||||
key_file: "/etc/key.pem",
|
||||
disabled: "1",
|
||||
}),
|
||||
).toEqual({ source: "disabled" });
|
||||
expect(pick({ disabled: "true" })).toEqual({ source: "disabled" });
|
||||
expect(pick({ disabled: "no" }).source).toBe("fallback");
|
||||
});
|
||||
|
||||
test("serves HTTP when there is nothing to serve with", () => {
|
||||
expect(pick({}, []).source).toBe("none");
|
||||
expect(pick({}, ["certs/server.crt"]).source).toBe("none");
|
||||
});
|
||||
|
||||
test("keeps half a configuration for the resolver to reject", () => {
|
||||
expect(pick({ cert_file: "/etc/cert.pem" }, []).source).toBe("explicit");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
type Params = {
|
||||
/**
|
||||
* `TLS_CERT_FILE`, when the environment names one.
|
||||
*/
|
||||
cert_file?: string;
|
||||
/**
|
||||
* `TLS_KEY_FILE`, when the environment names one.
|
||||
*/
|
||||
key_file?: string;
|
||||
/**
|
||||
* `TLS_CA_FILE`, served for devices to trust.
|
||||
*/
|
||||
ca_file?: string;
|
||||
/**
|
||||
* `TLS_DISABLED`, the explicit opt-out.
|
||||
*/
|
||||
disabled?: string;
|
||||
/**
|
||||
* Where a certificate is looked for when the environment names none:
|
||||
* `certs/` at the project root, which is also where the container's
|
||||
* `/app/certs` mount lands.
|
||||
*/
|
||||
fallback_cert_file: string;
|
||||
fallback_key_file: string;
|
||||
fallback_ca_file: string;
|
||||
file_exists: (path: string) => boolean;
|
||||
};
|
||||
|
||||
export type TlsFiles = {
|
||||
cert_file?: string;
|
||||
key_file?: string;
|
||||
ca_file?: string;
|
||||
/**
|
||||
* Where the choice came from, for the startup log: the environment,
|
||||
* the `certs/` fallback, an explicit disable, or nothing at all.
|
||||
*/
|
||||
source: "explicit" | "fallback" | "disabled" | "none";
|
||||
};
|
||||
|
||||
const DisabledValues = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
/**
|
||||
* Which certificate files to serve with, in every environment: the
|
||||
* `TLS_*` variables when they are set, else `certs/` when it exists, and
|
||||
* plain HTTP only when `TLS_DISABLED` says so or there is nothing to serve
|
||||
* with.
|
||||
*
|
||||
* Nothing here is tied to `NODE_ENV`: a production process that has a
|
||||
* certificate serves HTTPS, and the only way to turn it off is to say so.
|
||||
*/
|
||||
export default function pickTlsFiles({
|
||||
cert_file,
|
||||
key_file,
|
||||
ca_file,
|
||||
disabled,
|
||||
fallback_cert_file,
|
||||
fallback_key_file,
|
||||
fallback_ca_file,
|
||||
file_exists,
|
||||
}: Params): TlsFiles {
|
||||
if (disabled && DisabledValues.has(disabled.trim().toLowerCase())) {
|
||||
return { source: "disabled" };
|
||||
}
|
||||
|
||||
const cert = cert_file?.trim();
|
||||
const key = key_file?.trim();
|
||||
|
||||
if (cert || key) {
|
||||
return { cert_file: cert, key_file: key, ca_file: ca_file?.trim(), source: "explicit" };
|
||||
}
|
||||
|
||||
if (file_exists(fallback_cert_file) && file_exists(fallback_key_file)) {
|
||||
return {
|
||||
cert_file: fallback_cert_file,
|
||||
key_file: fallback_key_file,
|
||||
ca_file: ca_file?.trim() ||
|
||||
(file_exists(fallback_ca_file) ? fallback_ca_file : undefined),
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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