From e859bab294e449cc51ebc175a8f6b5721c063d17 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Tue, 20 Oct 2026 10:20:01 +0100 Subject: [PATCH] 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. --- .dockerignore | 1 + .env.example | 9 + .gitignore | 3 + AGENTS.md | 4 +- Dockerfile | 6 +- README.md | 103 +++++- RELEASING.md | 6 +- install.sh | 342 +++++++++++++++++- package.json | 2 + src/data/app-data.ts | 10 + src/dict/env-dict.ts | 9 +- src/dict/help-dict.ts | 5 + src/functions/server/serve-local-ca.ts | 60 ++++ src/functions/server/start-http-redirect.ts | 45 +++ src/pages/admin/help/(data)/help-topics.ts | 36 +- src/scripts/dev-phone.ts | 49 +++ src/scripts/generate-local-certs.ts | 372 ++++++++++++++++++++ src/server.ts | 88 ++++- src/utils/build-ca-profile.test.ts | 53 +++ src/utils/build-ca-profile.ts | 111 ++++++ src/utils/build-https-redirect-url.test.ts | 31 ++ src/utils/build-https-redirect-url.ts | 22 ++ src/utils/build-san-list.test.ts | 61 ++++ src/utils/build-san-list.ts | 83 +++++ src/utils/resolve-tls-options.test.ts | 55 +++ src/utils/resolve-tls-options.ts | 56 +++ 26 files changed, 1582 insertions(+), 40 deletions(-) create mode 100644 src/functions/server/serve-local-ca.ts create mode 100644 src/functions/server/start-http-redirect.ts create mode 100644 src/scripts/dev-phone.ts create mode 100644 src/scripts/generate-local-certs.ts create mode 100644 src/utils/build-ca-profile.test.ts create mode 100644 src/utils/build-ca-profile.ts create mode 100644 src/utils/build-https-redirect-url.test.ts create mode 100644 src/utils/build-https-redirect-url.ts create mode 100644 src/utils/build-san-list.test.ts create mode 100644 src/utils/build-san-list.ts create mode 100644 src/utils/resolve-tls-options.test.ts create mode 100644 src/utils/resolve-tls-options.ts diff --git a/.dockerignore b/.dockerignore index 80d5bad..59409fa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,7 @@ node_modules .data .bunext .bun-sqlite +certs db/cobble db/.backups db/.exports diff --git a/.env.example b/.env.example index c773b20..35aaad9 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,15 @@ DEV_EMAIL_RECIPIENT= ENCRYPTION_KEY= ENCRYPTION_SALT= +# HTTPS (optional in development; see README). Generate files with +# `bun run gen:certs` and point these at them. The installer sets them on a +# real install. Leave all three empty to serve plain HTTP. +TLS_CERT_FILE= +TLS_KEY_FILE= +TLS_CA_FILE= +# Plain-HTTP port that redirects to the HTTPS one (default PORT + 1). +DEV_HTTP_PORT= + # Web Push: generated into Settings > Environment on first start. Set a # fixed pair here only if you want to keep one across installs. VAPID_PUBLIC_KEY= diff --git a/.gitignore b/.gitignore index 860307a..cb785dc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json /test /.bunext +# locally generated HTTPS certificates (bun run gen:certs) +/certs + # bun-sqlite local database + artifacts /db/cobble /db/.backups diff --git a/AGENTS.md b/AGENTS.md index bf059cb..5a83ed3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl `install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`). +HTTPS is the default. `src/scripts/generate-local-certs.ts` (run inside the app image, since a container's interfaces are not the host's; the host's IPs and name are passed with `--host`) writes a local CA and a 397-day leaf into `$COBBLE_HOME/certs`; the certificate, key and CA are mounted read only into `/app/certs` (never the CA's key) and pointed at by `TLS_CERT_FILE`, `TLS_KEY_FILE` and `TLS_CA_FILE` (process-only envs). `src/utils/resolve-tls-options.ts` turns them into `Bun.serve`'s `tls`; a half-set or unreadable pair stops the process instead of falling back to HTTP, and `isSecureRequest` then sees an `https:` URL so cookies get `Secure`. `GET /ca.crt` (`serve-local-ca.ts`) serves the local CA for devices to trust, and `GET /ca.mobileconfig` (`build-ca-profile.ts`) serves the same CA as an iOS/macOS configuration profile for one-tap install (the trust switch after it is Apple's, not ours). The CA is kept across updates; the leaf is re-issued when the `sans.txt` names change or it is within 30 days of expiring, which a rerun does. `--no-https` removes the mount and envs (for a proxy or tunnel), `--https` turns it back on, `--https-cert`/`--https-key` copy in a real certificate, `--https-host` adds names or addresses, `--https-regen` replaces the CA. The installer and the image's `HEALTHCHECK` use `curl -k` when TLS is on. `bun run gen:certs` generates the same files into `certs/` (gitignored), `bun run dev` uses them when `TLS_*` are unset (development only), and `bun run dev:phone` generates and starts in one command. A TLS port cannot answer plain HTTP, so development also starts a redirect listener one port above (`DEV_HTTP_PORT`) that 308s to the HTTPS page (`startHttpRedirect`, tested by `build-https-redirect-url`); production publishes only the HTTPS port. + The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume. Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative. @@ -123,7 +125,7 @@ Images are built by GitHub Actions on a published release (`.github/workflows/re Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). -- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens. +- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`, `TLS_CERT_FILE`, `TLS_KEY_FILE`, `TLS_CA_FILE`) can't be saved: they encrypt the table or are the container's own start-up settings. - **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`). - **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back). diff --git a/Dockerfile b/Dockerfile index 50cdb89..7a8162a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,10 @@ LABEL org.opencontainers.image.title="Cobble" \ org.opencontainers.image.source="https://github.com/Moduletrace/cobble" \ org.opencontainers.image.licenses="MIT" +# openssl generates the local CA and certificate the installer serves HTTPS +# with (`src/scripts/generate-local-certs.ts`). RUN apt-get update \ - && apt-get install -y --no-install-recommends curl bash nano wget zip unzip \ + && apt-get install -y --no-install-recommends curl bash nano wget zip unzip openssl \ && rm -rf /var/lib/apt/lists/* ENV NODE_ENV=production @@ -31,6 +33,6 @@ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS "http://localhost:${PORT:-7227}/api/health" || exit 1 + CMD if [ -n "$TLS_CERT_FILE" ]; then curl -fsSk "https://localhost:${PORT:-7227}/api/health" || exit 1; else curl -fsS "http://localhost:${PORT:-7227}/api/health" || exit 1; fi ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 97a4cc7..1d7476d 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,13 @@ missing): curl -fsSL https://url.moduletrace.com/cobble | sudo bash ``` -Open the address it prints, usually `http://:7227`, and create the -super admin account. The first signup also creates the coordinator and the -main computer, which pulls the sandbox image in the background. Give it a -few minutes. +Open the address it prints, usually `https://:7227`. The +certificate is signed by a local CA the installer made, so the browser warns +until you trust it: open `https://:7227/ca.crt` on the device and +install it (see [HTTPS](#https)), then reload and create the super admin +account. The first signup also creates the coordinator and the main +computer, which pulls the sandbox image in the background. Give it a few +minutes. Then open **Settings > Models**, add an OpenRouter key (or another provider) and wait for the model list to sync. This is what the bots think with. @@ -43,13 +46,47 @@ and wait for the model list to sync. This is what the bots think with. - Docker Desktop on macOS works for development and small installs, but Linux is the supported host. - Port 7227, or another port with `--port`. -- HTTPS in front of the app before you expose it to the internet. Web Push - and installable PWA features need it. A Cloudflare Tunnel or Caddy in - front of `127.0.0.1:7227` is enough. +- HTTPS is on by default, with a certificate for this machine. Exposing the + app publicly? Use `--https-cert` and `--https-key` with a real + certificate, or `--no-https` behind a proxy or tunnel. The installer binds to `0.0.0.0` so the app is reachable from your phone on the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only -thing that should reach it. +thing that should reach it (with `--no-https`). + +## HTTPS + +The installer serves HTTPS by default. It generates a local CA and a +certificate for this machine into `$COBBLE_HOME/certs` and mounts them into +the container read only. The CA is kept across updates; the certificate is +re-issued when the machine's addresses change or it is within 30 days of +expiring, and a rerun of the updater does both. + +Trust the CA once on each device. Until it is trusted the browser warns, and +the installed app, its service worker and Web Push stay off: + +- **Download:** open `https://:7227/ca.crt` on the device, or + copy `$COBBLE_HOME/certs/ca.crt`. +- **iPhone or iPad:** open `https://:7227/ca.mobileconfig` + instead. iOS offers the profile in one tap; install it, then turn it on + under Settings > General > About > Certificate Trust Settings. +- **Android:** Settings > Security > Encryption & credentials > Install a + certificate > CA certificate. +- **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`. + +Options for the certificate: + +- A VPN name or address the installer did not detect: `--https-host NAME`, + repeatable. +- Your own certificate (a public CA, an internal CA, `tailscale cert`): + `--https-cert FILE --https-key FILE`. Rerun the installer when it is + renewed. +- Replace the local CA, for example after trusting the wrong one: + `--https-regen`. Every device must trust the new CA. +- Plain HTTP with a proxy or tunnel in front: `--no-https`. +- Turn HTTPS back on after `--no-https`: `--https`. ## Update and uninstall @@ -80,6 +117,12 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu | `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` | | `--version TAG` | Image tag, default `latest` | | `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy | +| `--no-https` | Serve plain HTTP, for a proxy or tunnel in front | +| `--https` | Serve HTTPS again after `--no-https` | +| `--https-host VALUE` | Names or IPs the certificate covers, repeatable | +| `--https-cert FILE` | Use your own certificate instead of the local CA | +| `--https-key FILE` | Its private key, given with `--https-cert` | +| `--https-regen` | Replace the local CA and the certificate | | `--force` | Recreate the container even when it is current | | `--no-pull` | Use an image that is already on the machine | | `--skip-backup` | Update without a database backup | @@ -96,6 +139,7 @@ Everything that must survive an update lives under the install directory: .env # generated once, mode 600 install.conf # installer state backups/ # pre-update database snapshots + certs/ # the local CA and the certificate (HTTPS) data/ db/cobble db/.backups/ @@ -103,13 +147,15 @@ Everything that must survive an update lives under the install directory: computers// # each computer's home directory ``` -`.env` holds the port and the encryption keys. The keys encrypt sessions and -settings, so keep a copy and do not rotate them without re-entering provider -keys and other secrets. Provider keys, MCP servers, tools and app settings -are managed in the app, not in `.env`. +`.env` holds the port, the encryption keys and the `TLS_*` certificate +paths. The keys encrypt sessions and settings, so keep a copy and do not +rotate them without re-entering provider keys and other secrets. Provider +keys, MCP servers, tools and app settings are managed in the app, not in +`.env`. Edit `.env` and run `docker restart cobble` to apply changes. The installer -only appends missing values and keeps what is already there. +only appends missing values and keeps what is already there, apart from the +port and the `TLS_*` entries it manages. The app container is started with the Docker socket mounted and `MAIN_SERVER=1`, so it can create computer containers and run cron, the @@ -137,16 +183,42 @@ bun run db:schema bun run dev ``` -Open `http://localhost:7227`. With no users, every page goes to -`/auth/signup` to create the super admin. For a headless setup: +Open `http://localhost:7227`, or `https://localhost:7227` once `certs/` +exists. With no users, every page goes to `/auth/signup` to create the super +admin. For a headless setup: ```bash SEED_SUPER_ADMIN_PASSWORD=... bun run seed ``` +The development server reads the same `TLS_*` variables as the container, so +it can serve HTTPS too. That is how to try the app from a phone over a VPN, +where the secure context (installed app, Web Push, voice) needs it: + +```bash +bun run dev:phone -- --host cobble-laptop.tailnet-name.ts.net +``` + +That generates `certs/` and starts the dev server with it, with no `.env` +edits. Afterwards a plain `bun run dev` reuses the certificate whenever +`certs/server.crt` and `certs/server.key` exist (production never does). +`bun run gen:certs` on its own regenerates them if you need to add a name. + +Open `https://:7227` on the phone and install the CA +from `https://:7227/ca.mobileconfig` first. On the +laptop, trust the same `certs/ca.crt` once, so `https://localhost:7227` +stops warning. + +With `certs/` present the dev server also listens one port above (`7228`) +for plain HTTP and redirects to the HTTPS page, so a stray +`http://localhost:7228/...` link still lands. The HTTPS port itself cannot +answer plain HTTP, so `http://localhost:7227` still fails; use +`DEV_HTTP_PORT` to move the redirect. + | Command | Purpose | | -------------------- | ------------------------------------ | | `bun run dev` | Development server | +| `bun run dev:phone` | Dev server over HTTPS for a phone | | `bun run start` | Production server on the host | | `bun run typecheck` | Type-check | | `bun run test` | Run the test suite | @@ -154,6 +226,7 @@ SEED_SUPER_ADMIN_PASSWORD=... bun run seed | `bun run seed:bots` | Add a sample crew | | `bun run db:schema` | Sync the schema and types | | `bun run gen:favicon` | Regenerate favicons and PWA assets | +| `bun run gen:certs` | Generate a local HTTPS certificate | The app is Bun, [`@moduletrace/bunext`](https://github.com/Moduletrace/bunext), SQLite via [`@moduletrace/bun-sqlite`](https://github.com/Moduletrace/bun-sqlite), diff --git a/RELEASING.md b/RELEASING.md index b2463b9..d21d29b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -61,7 +61,11 @@ Before the first public release: - [ ] The GHCR package is public. - [ ] The first release is installed on the real host and a signup, a chat reply, a computer and a routine have been exercised. -- [ ] DNS and the reverse proxy or tunnel point at the container. +- [ ] A plain `curl | bash` install ends on `https://`, `/ca.crt` + downloads, and a device that trusted the CA installs the app, gets a + live chat socket and can turn on notifications. +- [ ] `--no-https` behind a proxy, and `--https-cert`/`--https-key` with a + real certificate, were each tried once. ## Rollback diff --git a/install.sh b/install.sh index 7a8f51a..15bc280 100644 --- a/install.sh +++ b/install.sh @@ -8,6 +8,10 @@ # Docker when it is missing, creates the cobble network and starts the app # container with the docker socket mounted, so the app can create the # computers it runs. +# +# HTTPS is on by default: a local CA and a certificate for this machine are +# generated into HOME/certs and mounted into the container, which keeps the +# data and the certificate across updates. `--no-https` serves plain HTTP. set -Eeuo pipefail CONTAINER="cobble" @@ -77,6 +81,21 @@ Options: --purge With --uninstall, also delete the data directory --help Show this help +HTTPS: + It is on by default. A local CA and a certificate for this machine are + generated into HOME/certs. Trust the CA on each device first: + https://:/ca.crt, or HOME/certs/ca.crt. + iPhone and iPad install it in one tap from + https://:/ca.mobileconfig. + + --no-https Serve plain HTTP (for a proxy or tunnel in front) + --https Serve HTTPS (the default; re-enables after --no-https) + --https-host VALUE A name or IP the certificate covers (repeatable), + e.g. a VPN's DNS name + --https-cert FILE Use your own certificate instead of the local CA + --https-key FILE Its private key (given with --https-cert) + --https-regen Replace the local CA and the certificate + The data directory holds the database, media and every computer's home directory. It is kept when the container is removed. Settings live in HOME/.env; restart the container after changing them. @@ -137,6 +156,11 @@ IMAGE="${COBBLE_IMAGE:-}" VERSION="${COBBLE_VERSION:-}" EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}" DOCKER_SOCKET="${COBBLE_DOCKER_SOCKET:-${DOCKER_SOCKET:-/var/run/docker.sock}}" +# HTTPS is the default. The state file keeps a `--no-https` install off. +HTTPS_ENABLED="${COBBLE_HTTPS:-1}" +HTTPS_HOSTS="${COBBLE_HTTPS_HOSTS:-}" +HTTPS_CERT_SOURCE="${COBBLE_HTTPS_CERT:-}" +HTTPS_KEY_SOURCE="${COBBLE_HTTPS_KEY:-}" ACTION="install" FORCE=0 @@ -144,6 +168,14 @@ NO_PULL=0 NO_DOCKER_INSTALL=0 SKIP_BACKUP=0 PURGE=0 +HTTPS_DISABLED=0 +HTTPS_FORCE=0 +HTTPS_HOSTS_GIVEN=0 +HTTPS_CERT_GIVEN=0 +HTTPS_REGEN=0 +HTTPS_MANAGED=1 +CERTS_CHANGED=0 +CERT_EXPIRY="" env_get() { local key="$1" @@ -191,6 +223,38 @@ while [ $# -gt 0 ]; do EXTRA_NETWORK="${2:-}" shift 2 ;; + --https) + HTTPS_ENABLED=1 + HTTPS_FORCE=1 + shift + ;; + --no-https) + HTTPS_DISABLED=1 + shift + ;; + --https-host) + if [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then + HTTPS_HOSTS="$HTTPS_HOSTS ${2:-}" + else + HTTPS_HOSTS="${2:-}" + HTTPS_HOSTS_GIVEN=1 + fi + shift 2 + ;; + --https-cert) + HTTPS_CERT_SOURCE="${2:-}" + HTTPS_CERT_GIVEN=1 + shift 2 + ;; + --https-key) + HTTPS_KEY_SOURCE="${2:-}" + HTTPS_CERT_GIVEN=1 + shift 2 + ;; + --https-regen) + HTTPS_REGEN=1 + shift + ;; --force) FORCE=1 shift @@ -264,10 +328,45 @@ if [ "$PURGE" = 1 ] && [ "$ACTION" != "uninstall" ]; then fail "--purge only works with --uninstall" fi +if [ "$HTTPS_DISABLED" = 1 ]; then + [ "$HTTPS_FORCE" = 1 ] && fail "--https and --no-https cannot both be given" + if [ "$HTTPS_REGEN" = 1 ] || [ "$HTTPS_CERT_GIVEN" = 1 ] || [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then + fail "--no-https cannot be combined with the other --https options" + fi + HTTPS_ENABLED=0 + HTTPS_HOSTS="" + HTTPS_CERT_SOURCE="" + HTTPS_KEY_SOURCE="" +else + if [ "$HTTPS_FORCE" = 1 ] || [ "$HTTPS_REGEN" = 1 ] || [ "$HTTPS_CERT_GIVEN" = 1 ] || [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then + HTTPS_ENABLED=1 + fi + if [ -n "$HTTPS_CERT_SOURCE" ] || [ -n "$HTTPS_KEY_SOURCE" ]; then + [ -n "$HTTPS_CERT_SOURCE" ] && [ -n "$HTTPS_KEY_SOURCE" ] || fail "--https-cert and --https-key must be given together" + [ "$HTTPS_REGEN" = 1 ] && fail "--https-regen only works with the certificate the installer generates" + [ -f "$HTTPS_CERT_SOURCE" ] || fail "Certificate not found: $HTTPS_CERT_SOURCE" + [ -f "$HTTPS_KEY_SOURCE" ] || fail "Key not found: $HTTPS_KEY_SOURCE" + case "$HTTPS_CERT_SOURCE" in /*) ;; *) HTTPS_CERT_SOURCE="$PWD/$HTTPS_CERT_SOURCE" ;; esac + case "$HTTPS_KEY_SOURCE" in /*) ;; *) HTTPS_KEY_SOURCE="$PWD/$HTTPS_KEY_SOURCE" ;; esac + HTTPS_MANAGED=0 + else + HTTPS_MANAGED=1 + fi +fi + +if [ -n "$HTTPS_HOSTS" ]; then + for https_host in $HTTPS_HOSTS; do + case "$https_host" in + *[!A-Za-z0-9.:_-]*) fail "--https-host is not a hostname or IP address: $https_host" ;; + esac + done +fi + ENV_FILE="$HOME_DIR/.env" STATE_FILE="$HOME_DIR/install.conf" DATA_DIR="${DATA_DIR%/}" BACKUP_DIR="$HOME_DIR/backups" +CERTS_DIR="$HOME_DIR/certs" # ---------------------------------------------------------------- platform @@ -298,9 +397,19 @@ fi ENV_MOUNT="$ENV_FILE:/app/.env:ro" DATA_MOUNT="$DATA_DIR:/data" +CERTS_MOUNT="$CERTS_DIR:/certs" +# The app only needs the certificate, its key and the CA (for /ca.crt); +# the CA's own key stays out of the container. +CERT_MOUNT_RO="$CERTS_DIR/server.crt:/app/certs/server.crt:ro" +KEY_MOUNT_RO="$CERTS_DIR/server.key:/app/certs/server.key:ro" +CA_MOUNT_RO="$CERTS_DIR/ca.crt:/app/certs/ca.crt:ro" if [ -n "$SELINUX_SUFFIX" ]; then ENV_MOUNT="$ENV_FILE:/app/.env:ro,z" DATA_MOUNT="$DATA_DIR:/data,z" + CERTS_MOUNT="$CERTS_DIR:/certs,z" + CERT_MOUNT_RO="$CERTS_DIR/server.crt:/app/certs/server.crt:ro,z" + KEY_MOUNT_RO="$CERTS_DIR/server.key:/app/certs/server.key:ro,z" + CA_MOUNT_RO="$CERTS_DIR/ca.crt:/app/certs/ca.crt:ro,z" fi # ---------------------------------------------------------------- helpers @@ -332,6 +441,25 @@ ensure_secret() { fi } +unset_env() { + local key="$1" + [ -f "$ENV_FILE" ] || return 0 + sed -i.bak -E "/^[[:space:]]*${key}=.*$/d" "$ENV_FILE" + rm -f "$ENV_FILE.bak" +} + +health_scheme() { + if [ "$HTTPS_ENABLED" = 1 ]; then + printf 'https' + else + printf 'http' + fi +} + +health_url() { + printf '%s://%s:%s/api/health' "$(health_scheme)" "$(health_host)" "$PORT" +} + set_env() { local key="$1" value="$2" escaped escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')" @@ -371,6 +499,10 @@ docker_socket_mounted() { docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" } +mounted_source() { + docker inspect -f '{{range .Mounts}}{{if eq .Destination "'"$1"'"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" +} + container_matches() { container_running || return 1 [ "$(container_image_id)" = "$TARGET_IMAGE_ID" ] || return 1 @@ -386,6 +518,30 @@ container_matches() { if [ -n "$EXTRA_NETWORK" ]; then docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1 fi + if [ "$HTTPS_ENABLED" = 1 ]; then + [ "$(mounted_source /app/certs/server.crt)" = "$CERTS_DIR/server.crt" ] || return 1 + [ "$(mounted_source /app/certs/server.key)" = "$CERTS_DIR/server.key" ] || return 1 + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CERT_FILE=/app/certs/server.crt" || return 1 + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_KEY_FILE=/app/certs/server.key" || return 1 + if [ "$HTTPS_MANAGED" = 1 ]; then + [ "$(mounted_source /app/certs/ca.crt)" = "$CERTS_DIR/ca.crt" ] || return 1 + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CA_FILE=/app/certs/ca.crt" || return 1 + else + if [ -n "$(mounted_source /app/certs/ca.crt)" ]; then + return 1 + fi + if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CA_FILE=/app/certs/ca.crt"; then + return 1 + fi + fi + else + if [ -n "$(mounted_source /app/certs/server.crt)" ] || [ -n "$(mounted_source /app/certs/server.key)" ] || [ -n "$(mounted_source /app/certs/ca.crt)" ]; then + return 1 + fi + if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -q "^TLS_CERT_FILE="; then + return 1 + fi + fi return 0 } @@ -398,17 +554,18 @@ health_host() { host_ips() { if [ "$OS" = "Darwin" ]; then - ipconfig getifaddr en0 2>/dev/null || true + ifconfig 2>/dev/null | awk '/^[[:space:]]+inet / && $2 != "127.0.0.1" { print $2 }' | head -n 5 || true elif command -v hostname >/dev/null 2>&1; then - hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 3 || true + hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 5 || true fi } wait_for_health() { - local url="http://$(health_host):$PORT/api/health" start=0 + local url start=0 + url="$(health_url)" say "Waiting for Cobble to answer at $url" while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do - if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then + if curl -fsSk --max-time 3 -o /dev/null "$url" 2>/dev/null; then ok "Cobble is healthy" return 0 fi @@ -423,6 +580,89 @@ wait_for_health() { fail "Cobble did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s" } +validate_certs() { + local mount="$CERTS_DIR:/certs:ro" + local script out + if [ -n "$SELINUX_SUFFIX" ]; then + mount="$mount,z" + fi + + script='set -e +for file in /certs/server.crt /certs/server.key; do + [ -s "$file" ] || { echo "$file is missing"; exit 1; } +done +cert_public="$(openssl x509 -noout -pubkey -in /certs/server.crt)" +key_public="$(openssl pkey -pubout -in /certs/server.key 2>/dev/null || openssl rsa -pubout -in /certs/server.key 2>/dev/null)" +[ -n "$cert_public" ] && [ "$cert_public" = "$key_public" ] || { echo "the certificate and key do not match"; exit 1; } +openssl x509 -noout -checkend 0 -in /certs/server.crt >/dev/null || { echo "the certificate has expired"; exit 1; } +if [ -f /certs/ca.crt ]; then + openssl verify -CAfile /certs/ca.crt /certs/server.crt >/dev/null || { echo "the certificate is not signed by the local CA"; exit 1; } +fi +openssl x509 -noout -enddate -in /certs/server.crt | sed -e "s/^notAfter=//"' + + if ! out="$(docker run --rm -v "$mount" --entrypoint sh "$IMAGE:$VERSION" -c "$script" 2>&1)"; then + fail "The certificate can't be used: $(printf '%s\n' "$out" | tail -n 1)" + fi + + CERT_EXPIRY="$(printf '%s\n' "$out" | tail -n 1)" +} + +prepare_certs() { + CERTS_CHANGED=0 + mkdir -p "$CERTS_DIR" + chmod 700 "$CERTS_DIR" + + if [ "$HTTPS_MANAGED" = 0 ]; then + if [ ! -f "$CERTS_DIR/server.crt" ] || [ ! -f "$CERTS_DIR/server.key" ] || + ! cmp -s "$HTTPS_CERT_SOURCE" "$CERTS_DIR/server.crt" || + ! cmp -s "$HTTPS_KEY_SOURCE" "$CERTS_DIR/server.key"; then + CERTS_CHANGED=1 + fi + cp -f "$HTTPS_CERT_SOURCE" "$CERTS_DIR/server.crt" + cp -f "$HTTPS_KEY_SOURCE" "$CERTS_DIR/server.key" + chmod 644 "$CERTS_DIR/server.crt" + chmod 600 "$CERTS_DIR/server.key" + rm -f "$CERTS_DIR/ca.crt" "$CERTS_DIR/ca.key" "$CERTS_DIR/ca.srl" "$CERTS_DIR/sans.txt" + validate_certs + ok "Using the certificate at $HTTPS_CERT_SOURCE" + return 0 + fi + + local args=(--out /certs --no-detect) + local ip name fqdn host out + for ip in $(host_ips); do + args+=(--host "$ip") + done + name="$(hostname 2>/dev/null || true)" + if [ -n "$name" ]; then + args+=(--host "$name" --host "$name.local") + fi + fqdn="$(hostname -f 2>/dev/null || true)" + if [ -n "$fqdn" ] && [ "$fqdn" != "$name" ]; then + args+=(--host "$fqdn") + fi + for host in $HTTPS_HOSTS; do + args+=(--host "$host") + done + if [ "$HTTPS_REGEN" = 1 ]; then + args+=(--regen) + fi + + say "Preparing the HTTPS certificate" + if ! out="$(docker run --rm -v "$CERTS_MOUNT" --entrypoint bun "$IMAGE:$VERSION" src/scripts/generate-local-certs.ts "${args[@]}" 2>&1)"; then + printf '%s\n' "$out" >&2 + fail "Could not generate the certificate. An older image has no certificate generator: pull the current image (drop --no-pull) and rerun." + fi + printf '%s\n' "$out" | sed -e 's/^/ /' + + case "$out" in + *"certs: unchanged"*) CERTS_CHANGED=0 ;; + *) CERTS_CHANGED=1 ;; + esac + + validate_certs +} + backup_database() { local data_source="$DATA_DIR" if container_exists; then @@ -505,17 +745,39 @@ connect_extra_network() { } run_container() { + local mounts=( + -v "$ENV_MOUNT" + -v "$DATA_MOUNT" + -v "$DOCKER_SOCKET:/var/run/docker.sock" + ) + local envs=( + -e MAIN_SERVER=1 + -e "PORT=$PORT" + ) + + if [ "$HTTPS_ENABLED" = 1 ]; then + mounts+=( + -v "$CERT_MOUNT_RO" + -v "$KEY_MOUNT_RO" + ) + envs+=( + -e TLS_CERT_FILE=/app/certs/server.crt + -e TLS_KEY_FILE=/app/certs/server.key + ) + if [ "$HTTPS_MANAGED" = 1 ]; then + mounts+=(-v "$CA_MOUNT_RO") + envs+=(-e TLS_CA_FILE=/app/certs/ca.crt) + fi + fi + docker run -d \ --name "$CONTAINER" \ --restart unless-stopped \ --init \ --network "$NETWORK" \ -p "$BIND:$PORT:$PORT" \ - -v "$ENV_MOUNT" \ - -v "$DATA_MOUNT" \ - -v "$DOCKER_SOCKET:/var/run/docker.sock" \ - -e MAIN_SERVER=1 \ - -e PORT="$PORT" \ + "${mounts[@]}" \ + "${envs[@]}" \ --log-driver json-file \ --log-opt max-size=10m \ --log-opt max-file=3 \ @@ -636,6 +898,20 @@ ensure_secret ENCRYPTION_KEY ensure_secret ENCRYPTION_SALT ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json" +if [ "$HTTPS_ENABLED" = 1 ]; then + set_env TLS_CERT_FILE /app/certs/server.crt + set_env TLS_KEY_FILE /app/certs/server.key + if [ "$HTTPS_MANAGED" = 1 ]; then + set_env TLS_CA_FILE /app/certs/ca.crt + else + unset_env TLS_CA_FILE + fi +else + unset_env TLS_CERT_FILE + unset_env TLS_KEY_FILE + unset_env TLS_CA_FILE +fi + { printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR" printf 'COBBLE_PORT=%q\n' "$PORT" @@ -644,6 +920,10 @@ ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json" printf 'COBBLE_VERSION=%q\n' "$VERSION" printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK" printf 'COBBLE_DOCKER_SOCKET=%q\n' "$DOCKER_SOCKET" + printf 'COBBLE_HTTPS=%q\n' "$HTTPS_ENABLED" + printf 'COBBLE_HTTPS_HOSTS=%q\n' "$HTTPS_HOSTS" + printf 'COBBLE_HTTPS_CERT=%q\n' "$HTTPS_CERT_SOURCE" + printf 'COBBLE_HTTPS_KEY=%q\n' "$HTTPS_KEY_SOURCE" } >"$STATE_FILE" chmod 600 "$STATE_FILE" @@ -665,11 +945,15 @@ if [ "$IMAGE_ARCH" != "$ARCH" ]; then fail "Image $IMAGE:$VERSION is $IMAGE_ARCH but this machine is $ARCH. Pull the multi-arch image instead of --no-pull." fi -if [ "$FORCE" = 0 ] && container_matches; then +if [ "$HTTPS_ENABLED" = 1 ]; then + prepare_certs +fi + +if [ "$FORCE" = 0 ] && [ "$CERTS_CHANGED" = 0 ] && container_matches; then connect_extra_network - if curl -fsS --max-time 3 -o /dev/null "http://$(health_host):$PORT/api/health" 2>/dev/null; then + if curl -fsSk --max-time 3 -o /dev/null "$(health_url)" 2>/dev/null; then ok "Cobble is already up to date" - say "Healthy at http://$(health_host):$PORT" + say "Healthy at $(health_scheme)://$(health_host):$PORT" exit 0 fi warn "The container is running but not answering; recreating it" @@ -689,16 +973,38 @@ connect_extra_network wait_for_health ok "Cobble is running" +SCHEME="$(health_scheme)" printf '\n' printf ' %s%s%s\n' "$C_BOLD" "Open Cobble" "$C_RESET" if [ "$BIND" = "0.0.0.0" ]; then while IFS= read -r ip; do - [ -n "$ip" ] && printf ' http://%s:%s\n' "$ip" "$PORT" + [ -n "$ip" ] && printf ' %s://%s:%s\n' "$SCHEME" "$ip" "$PORT" done < <(host_ips) fi -printf ' http://127.0.0.1:%s\n' "$PORT" +printf ' %s://127.0.0.1:%s\n' "$SCHEME" "$PORT" printf '\n' -printf ' First visit: create the super admin account.\n' +if [ "$HTTPS_ENABLED" = 1 ]; then + printf ' Certificate\n' + if [ "$HTTPS_MANAGED" = 1 ]; then + printf ' Signed by a local CA on this machine. Trust it once on\n' + printf ' each device, then reload: the installed app and\n' + printf ' notifications need it.\n' + printf ' %s://:%s/ca.crt\n' "$SCHEME" "$PORT" + printf ' %s://:%s/ca.mobileconfig (iPhone/iPad)\n' "$SCHEME" "$PORT" + printf ' or copy %s\n' "$CERTS_DIR/ca.crt" + else + printf ' Using your certificate at %s\n' "$HTTPS_CERT_SOURCE" + fi + if [ -n "$CERT_EXPIRY" ]; then + printf ' Expires: %s\n' "$CERT_EXPIRY" + fi + printf '\n' +fi +if [ "$HTTPS_ENABLED" = 1 ] && [ "$HTTPS_MANAGED" = 1 ]; then + printf ' First visit: install the certificate, then create the super admin account.\n' +else + printf ' First visit: create the super admin account.\n' +fi printf ' Data: %s\n' "$DATA_DIR" printf ' Settings: %s\n' "$ENV_FILE" printf ' Backups: %s\n' "$BACKUP_DIR" @@ -707,5 +1013,9 @@ printf ' Update: curl -fsSL %s | sudo bash\n' "$INSTALL_URL" printf ' Uninstall: curl -fsSL %s | sudo bash -s -- --uninstall\n' "$INSTALL_URL" printf '\n' if [ "$BIND" = "0.0.0.0" ]; then - printf ' %sThis port is reachable on your network. Put HTTPS in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET" + if [ "$HTTPS_ENABLED" = 1 ]; then + printf ' %sThis port is reachable on your network. A device that does not trust the certificate shows a warning. Put a proxy or tunnel in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET" + else + printf ' %sThis port is reachable on your network. Put HTTPS in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET" + fi fi diff --git a/package.json b/package.json index af88b0d..b11c42f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "private": true, "scripts": { "dev": "bun src/server.ts", + "dev:phone": "bun src/scripts/dev-phone.ts", "gen:favicon": "bun src/scripts/generate-brand-assets.ts", + "gen:certs": "bun src/scripts/generate-local-certs.ts", "build": "bunx bunext build", "db:schema": "bunx bun-sqlite schema -t", "db:admin": "bunx bun-sqlite admin", diff --git a/src/data/app-data.ts b/src/data/app-data.ts index d31e934..734d76e 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -12,6 +12,16 @@ export const AppData = { */ ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`, 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`, AuthExpiryDays: 60, diff --git a/src/dict/env-dict.ts b/src/dict/env-dict.ts index 9441a9e..0dd2e90 100644 --- a/src/dict/env-dict.ts +++ b/src/dict/env-dict.ts @@ -37,9 +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` and - * `MAIN_SERVER` (read before the database opens). Those stay in the - * process environment. + * `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR`, + * `MAIN_SERVER` and the `TLS_*` certificate paths (the container's own + * start-up settings). Those stay in the process environment. */ export const EnvDict: readonly KnownEnv[] = [ { @@ -86,6 +86,9 @@ export const ProcessOnlyEnvKeys = [ "DATA_DIR", "DB_DIR", "MAIN_SERVER", + "TLS_CERT_FILE", + "TLS_KEY_FILE", + "TLS_CA_FILE", ] as const; /** diff --git a/src/dict/help-dict.ts b/src/dict/help-dict.ts index 65c0fdb..19a0e8b 100644 --- a/src/dict/help-dict.ts +++ b/src/dict/help-dict.ts @@ -7,3 +7,8 @@ export const HelpPath = "/admin/help"; * Signing in to Google and other strict sites with Cookie-Editor. */ export const HelpSignInTopicId = "sign-in-with-cookies"; + +/** + * Trusting the local HTTPS certificate on a device. + */ +export const HelpHttpsTopicId = "https-certificate"; diff --git a/src/functions/server/serve-local-ca.ts b/src/functions/server/serve-local-ca.ts new file mode 100644 index 0000000..db348e8 --- /dev/null +++ b/src/functions/server/serve-local-ca.ts @@ -0,0 +1,60 @@ +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 { + 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", + }), + }); +} diff --git a/src/functions/server/start-http-redirect.ts b/src/functions/server/start-http-redirect.ts new file mode 100644 index 0000000..e3824d6 --- /dev/null +++ b/src/functions/server/start-http-redirect.ts @@ -0,0 +1,45 @@ +import buildHttpsRedirectUrl from "@/src/utils/build-https-redirect-url"; + +type Params = { + /** + * The port the app serves HTTPS on. + */ + https_port: string | number; +}; + +/** + * Development convenience: 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://:` still fails, but + * `http://:` lands on the right page. `DEV_HTTP_PORT` + * picks another port. Production does not start it (only one port is + * published, and it is HTTPS). + */ +export default function startHttpRedirect({ https_port }: Params) { + const base = Number(https_port); + const configured = Number(process.env.DEV_HTTP_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, + ); + }, + }); +} diff --git a/src/pages/admin/help/(data)/help-topics.ts b/src/pages/admin/help/(data)/help-topics.ts index 85a2f12..e4a8a58 100644 --- a/src/pages/admin/help/(data)/help-topics.ts +++ b/src/pages/admin/help/(data)/help-topics.ts @@ -1,4 +1,4 @@ -import { HelpSignInTopicId } from "@/src/dict/help-dict"; +import { HelpHttpsTopicId, HelpSignInTopicId } from "@/src/dict/help-dict"; export type HelpTopic = { /** @@ -21,6 +21,40 @@ export type HelpTopic = { * common first. */ 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. + +**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: + +- **iPhone or iPad:** \`https://:7227/ca.mobileconfig\`. iOS offers a profile, which is one tap to install. +- **Everything else:** \`https://:7227/ca.crt\`. + +You can also copy it from the machine Cobble runs on: \`/opt/cobble/certs/ca.crt\` (or wherever it was installed). + +### 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.`, + }, { id: HelpSignInTopicId, title: "Google won't let me sign in on a computer", diff --git a/src/scripts/dev-phone.ts b/src/scripts/dev-phone.ts new file mode 100644 index 0000000..ddb205e --- /dev/null +++ b/src/scripts/dev-phone.ts @@ -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 + * + * 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://:${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); diff --git a/src/scripts/generate-local-certs.ts b/src/scripts/generate-local-certs.ts new file mode 100644 index 0000000..3c58b21 --- /dev/null +++ b/src/scripts/generate-local-certs.ts @@ -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)); +} diff --git a/src/server.ts b/src/server.ts index 5ae4f86..18fe86d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ 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"; @@ -10,6 +11,9 @@ import liveRelay from "./live-relay"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; import handleComputerProxy from "./functions/server/computers/handle-computer-proxy"; import handlePwaFile from "./functions/server/pwa/handle-pwa-file"; +import serveLocalCa from "./functions/server/serve-local-ca"; +import startHttpRedirect from "./functions/server/start-http-redirect"; +import resolveTlsOptions from "./utils/resolve-tls-options"; import { ComputerProxyPrefix } from "./dict/computer-dict"; import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network"; import { ensureVapidKeys } from "./push/vapid"; @@ -48,7 +52,59 @@ try { process.exit(1); } +/** + * HTTPS when the installer (or a development `.env`) points the server at + * a certificate. A broken or half-set configuration stops the process + * rather than quietly serving plain HTTP. + */ +/** + * In development, a certificate in `certs/` (made by `bun run gen:certs`, + * or by `bun run dev:phone`) is used without any `.env` entries, so + * trying the app on a phone over a VPN is one command. + */ +const DevCertFile = "certs/server.crt"; +const DevKeyFile = "certs/server.key"; +const DevCaFile = "certs/ca.crt"; + +let cert_file = process.env.TLS_CERT_FILE; +let key_file = process.env.TLS_KEY_FILE; + +if ( + process.env.NODE_ENV != "production" && + !cert_file && + !key_file && + existsSync(DevCertFile) && + existsSync(DevKeyFile) +) { + cert_file = DevCertFile; + key_file = DevKeyFile; + + if (!process.env.TLS_CA_FILE && existsSync(DevCaFile)) { + process.env.TLS_CA_FILE = DevCaFile; + } + + bunext.bunextLog.info(`Using the development certificate in certs/`); +} + +if (!cert_file && !key_file && process.env.NODE_ENV != "production") { + bunext.bunextLog.info( + "Serving plain HTTP (run `bun run dev:phone`, or `bun run gen:certs` then restart, for HTTPS)", + ); +} + +let tls: ReturnType = undefined; + +try { + tls = resolveTlsOptions({ cert_file, 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); @@ -57,6 +113,10 @@ const server = Bun.serve({ if (pwa_file) return pwa_file; + const local_ca = await serveLocalCa({ pathname: url.pathname }); + + if (local_ca) return local_ca; + if (url.pathname.startsWith("/media")) { try { return await handleMediaServer({ req }); @@ -108,13 +168,39 @@ const server = Bun.serve({ websocket, development, 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). + */ + reusePort: false, idleTimeout: development ? 80 : undefined, }); bunext.bunextLog.info( - `Server running on http://${server.hostname}:${server.port} ...`, + `Server running on ${tls ? "https" : "http"}://${server.hostname}:${server.port} ...`, ); +/** + * A TLS port cannot answer plain HTTP, so a development `http://` visit + * is sent to the HTTPS page from the port above it (`startHttpRedirect`). + */ +if (tls && process.env.NODE_ENV != "production") { + try { + const redirect = startHttpRedirect({ https_port: port }); + + if (redirect) { + bunext.bunextLog.info( + `HTTP redirect on port ${redirect.port} (DEV_HTTP_PORT to change it)`, + ); + } + } catch (error) { + bunext.bunextLog.info( + `Could not start the development HTTP redirect on port ${Number(port) + 1}: ${error instanceof Error ? error.message : String(error)}. Stop the other server or set DEV_HTTP_PORT.`, + ); + } +} + cron(); botsQueue(); liveRelay(); \ No newline at end of file diff --git a/src/utils/build-ca-profile.test.ts b/src/utils/build-ca-profile.test.ts new file mode 100644 index 0000000..f2e8d70 --- /dev/null +++ b/src/utils/build-ca-profile.test.ts @@ -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("com.apple.security.root"); + expect(profile()).toContain("Configuration"); + }); + + 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("Cobble local CA"); + expect(body).toContain("org.example.cobble.ca"); + expect(body).toContain( + "org.example.cobble.ca.certificate", + ); + }); + + test("escapes XML in the name", () => { + expect(profile("Ben & Co ")).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."); + }); +}); diff --git a/src/utils/build-ca-profile.ts b/src/utils/build-ca-profile.ts new file mode 100644 index 0000000..3f1b60f --- /dev/null +++ b/src/utils/build-ca-profile.ts @@ -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 = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeXml({ value }: { value: string }): string { + return value.replace(/[&<>"']/g, (character) => XmlEscapes[character]!); +} + +/** + * The certificate as base64 DER, which is what a plist `` 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 ` + + + + PayloadContent + + + PayloadCertificateFileName + ${name}.crt + PayloadContent + + ${certificateData({ certificate_pem })} + + PayloadDescription + Lets this device trust the HTTPS certificate ${name} serves. + PayloadDisplayName + ${name} + PayloadIdentifier + ${id}.certificate + PayloadType + com.apple.security.root + PayloadUUID + ${CertificateUUID} + PayloadVersion + 1 + + + PayloadDescription + Installs the local certificate authority that signs ${name}, then turn on trust under Certificate Trust Settings. + PayloadDisplayName + ${name} + PayloadIdentifier + ${id} + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + ${ProfileUUID} + PayloadVersion + 1 + + +`; +} diff --git a/src/utils/build-https-redirect-url.test.ts b/src/utils/build-https-redirect-url.test.ts new file mode 100644 index 0000000..2880d0e --- /dev/null +++ b/src/utils/build-https-redirect-url.test.ts @@ -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/", + ); + }); +}); diff --git a/src/utils/build-https-redirect-url.ts b/src/utils/build-https-redirect-url.ts new file mode 100644 index 0000000..b9da387 --- /dev/null +++ b/src/utils/build-https-redirect-url.ts @@ -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}`; +} diff --git a/src/utils/build-san-list.test.ts b/src/utils/build-san-list.test.ts new file mode 100644 index 0000000..d54209c --- /dev/null +++ b/src/utils/build-san-list.test.ts @@ -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", + ]); + }); +}); diff --git a/src/utils/build-san-list.ts b/src/utils/build-san-list.ts new file mode 100644 index 0000000..ba30c7b --- /dev/null +++ b/src/utils/build-san-list.ts @@ -0,0 +1,83 @@ +import net from "net"; + +type Params = { + /** + * The machine's own name, as `os.hostname()` gives it. Its short form + * and `.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(); + + 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; +} diff --git a/src/utils/resolve-tls-options.test.ts b/src/utils/resolve-tls-options.test.ts new file mode 100644 index 0000000..16b0f82 --- /dev/null +++ b/src/utils/resolve-tls-options.test.ts @@ -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"); + }); +}); diff --git a/src/utils/resolve-tls-options.ts b/src/utils/resolve-tls-options.ts new file mode 100644 index 0000000..6955ea6 --- /dev/null +++ b/src/utils/resolve-tls-options.ts @@ -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; + key: ReturnType; +}; + +/** + * `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) }; +}