From 8bf6bedec5738e6492eb16690b2d323c9449a1a0 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 21 Oct 2026 15:35:27 +0100 Subject: [PATCH] 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. --- .dockerignore | 1 - .env.example | 11 +- .gitignore | 3 - AGENTS.md | 6 +- Dockerfile | 7 +- README.md | 127 ++---- RELEASING.md | 8 +- install.sh | 392 ++---------------- package.json | 2 - src/data/app-data.ts | 10 - src/dict/env-dict.ts | 12 +- src/dict/help-dict.ts | 4 +- src/functions/server/serve-local-ca.ts | 60 --- src/functions/server/start-http-redirect.ts | 46 -- .../[bot_id]/(partials)/composer-editor.tsx | 5 +- src/pages/admin/help/(data)/help-topics.ts | 41 +- src/scripts/dev-phone.ts | 49 --- src/scripts/generate-local-certs.ts | 372 ----------------- src/server.ts | 85 +--- 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/pick-tls-files.test.ts | 69 --- src/utils/pick-tls-files.ts | 83 ---- src/utils/resolve-tls-options.test.ts | 55 --- src/utils/resolve-tls-options.ts | 56 --- 29 files changed, 114 insertions(+), 1751 deletions(-) delete mode 100644 src/functions/server/serve-local-ca.ts delete mode 100644 src/functions/server/start-http-redirect.ts delete mode 100644 src/scripts/dev-phone.ts delete mode 100644 src/scripts/generate-local-certs.ts delete mode 100644 src/utils/build-ca-profile.test.ts delete mode 100644 src/utils/build-ca-profile.ts delete mode 100644 src/utils/build-https-redirect-url.test.ts delete mode 100644 src/utils/build-https-redirect-url.ts delete mode 100644 src/utils/build-san-list.test.ts delete mode 100644 src/utils/build-san-list.ts delete mode 100644 src/utils/pick-tls-files.test.ts delete mode 100644 src/utils/pick-tls-files.ts delete mode 100644 src/utils/resolve-tls-options.test.ts delete mode 100644 src/utils/resolve-tls-options.ts diff --git a/.dockerignore b/.dockerignore index 59409fa..80d5bad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,7 +9,6 @@ node_modules .data .bunext .bun-sqlite -certs db/cobble db/.backups db/.exports diff --git a/.env.example b/.env.example index 4e31ee7..c592d70 100644 --- a/.env.example +++ b/.env.example @@ -17,15 +17,8 @@ DEV_EMAIL_RECIPIENT= ENCRYPTION_KEY= ENCRYPTION_SALT= -# HTTPS (see README). Generate files with `bun run gen:certs`; when these -# are empty the server uses certs/ if it exists, in every environment. Set -# TLS_DISABLED=1 to serve plain HTTP even when a certificate is there. -TLS_CERT_FILE= -TLS_KEY_FILE= -TLS_CA_FILE= -TLS_DISABLED= -# Plain-HTTP port that redirects to the HTTPS one (default PORT + 1). -HTTP_REDIRECT_PORT= +# The app serves plain HTTP. Put a reverse proxy or tunnel in front for +# HTTPS (see README). # Web Push: generated into Settings > Environment on first start. Set a # fixed pair here only if you want to keep one across installs. diff --git a/.gitignore b/.gitignore index cb785dc..860307a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,9 +39,6 @@ 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 18f585f..0449bad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ 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. A fresh install asks about HTTPS once, with the reasons, before doing any work (`ask_https`: updates keep the saved mode, `--yes` skips it, any `--https*` flag answers it; it reads `/dev/tty`, never stdin, so `curl | bash` still works, and without a terminal it prints the notice and keeps the default). 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); the server uses them whenever `TLS_*` are unset, in every environment (there is no `NODE_ENV` gate; `pick-tls-files` decides, and `TLS_DISABLED=1` is the explicit opt-out), and `bun run dev:phone` generates and starts in one command. A TLS port cannot answer plain HTTP, so every environment starts a redirect listener one port above (`HTTP_REDIRECT_PORT`) that 308s to the HTTPS page (`startHttpRedirect`, tested by `build-https-redirect-url`); the container publishes only the HTTPS port, so that listener is reachable there only if its port is published too. +The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`. 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. @@ -127,7 +127,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`, `TLS_CERT_FILE`, `TLS_KEY_FILE`, `TLS_CA_FILE`, `TLS_DISABLED`, `HTTP_REDIRECT_PORT`) can't be saved: they encrypt the table or are the container's own start-up settings. +- **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`, `DB_DIR`, `MAIN_SERVER`) 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). @@ -140,7 +140,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T - **Phone shell:** below `md` the rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (`RailDrawerContext`); it is a 64px icon rail at `md` and 260px at `lg`. Rail internals use the `@container` `@rail:` variant (`--container-rail` in `src/styles/main.css`), not viewport `lg:`, so the drawer shows the full rail. `RailDrawerButton` sits in both pane headers. The shell is `h-dvh` with safe-area insets, and the headers/thread/composer use mobile padding. - **Connection recovery:** twui's `useWebSocket` stops retrying after three attempts, so `LiveSocket` is remounted on `online`, `pageshow` and a hidden tab becoming visible (`use-connection-epoch`). `useOnlineStatus` drives the offline strip and disables sending: nothing is queued offline. - **Web Push:** `push_subscriptions` (one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) and `user_presence` (one row per user, refreshed by the socket keepalive in `src/websocket/index.ts`). VAPID keys are app settings in the `envs` table (`VAPID_PUBLIC_KEY` not secret, `VAPID_PRIVATE_KEY` secret), generated by `ensureVapidKeys` on the main server's first start; the subject is `SiteData.SiteURL`. `pushBotNotice` is the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped while `user_presence.last_seen_at` is within `AppData.PushPresenceWindowMs`, so an app open anywhere wins. `src/dict/push-dict.ts` owns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API in `api/admin/push/` (subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (`notifications/`) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app. -- **Risks and notes:** push needs HTTPS (or localhost). Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap. +- **Risks and notes:** push needs HTTPS (or localhost), so it depends on the proxy or tunnel in front. Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap. ### Harness (the model layer) diff --git a/Dockerfile b/Dockerfile index 3f3033a..519f30e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,9 @@ 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`). +# The app serves plain HTTP; HTTPS is the reverse proxy's job. RUN apt-get update \ - && apt-get install -y --no-install-recommends curl bash nano wget zip unzip openssl \ + && apt-get install -y --no-install-recommends curl bash nano wget zip unzip \ && rm -rf /var/lib/apt/lists/* ENV NODE_ENV=production @@ -33,6 +32,6 @@ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD if [ -z "$TLS_DISABLED" ] && ( [ -n "$TLS_CERT_FILE" ] || [ -f /app/certs/server.crt ] ); then curl -fsSk "https://localhost:${PORT:-7227}/api/health" || exit 1; else curl -fsS "http://localhost:${PORT:-7227}/api/health" || exit 1; fi + CMD curl -fsS "http://localhost:${PORT:-7227}/api/health" || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 08b5c0a..2442190 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,12 @@ missing): curl -fsSL https://url.moduletrace.com/cobble | sudo bash ``` -HTTPS is on by default, and a fresh install asks before continuing: it -encrypts the connection, and phones need it to install the app and receive -notifications. The installer creates a local CA for the machine, so each -device trusts it once. Answer no, or pass `--no-https`, if a proxy or tunnel -already handles HTTPS; `--yes` accepts the default without asking. Updates -keep the mode already chosen. - -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. +The app serves plain HTTP on the port it prints, usually +`http://:7227`. Put a reverse proxy or tunnel in front of it for +HTTPS (see [HTTPS](#https)), then open the proxy's address 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. @@ -53,49 +45,29 @@ 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 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. +- A reverse proxy or tunnel in front for HTTPS. The app itself serves plain + HTTP and never handles certificates. 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 (with `--no-https`). +thing that should reach it. ## HTTPS -The installer serves HTTPS by default, and a fresh install asks before -continuing (`--yes` skips the question, `--no-https` answers it, an update -keeps the saved mode). 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. +Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy +(Caddy, nginx, Traefik) or a tunnel (Cloudflare Tunnel, Tailscale Serve), and +point that at `http://:7227`: -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: +- Let the proxy set `X-Forwarded-Proto: https`. Cobble reads it to mark the + sign-in cookies `Secure`; Caddy does this by default, and nginx needs + `proxy_set_header X-Forwarded-Proto $scheme;`. +- Pass `/ws` through as well as ordinary requests, including the upgrade. +- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so + the installable app, offline support, Web Push notifications and voice + recording stay off until the address is HTTPS. -- **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`. +Nothing on the machine needs a certificate installed: the browser only ever +talks to the proxy. See the Help page in the app for the common proxy fixes. ## Update and uninstall @@ -126,17 +98,11 @@ 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 | | `--no-docker-install` | Fail instead of installing Docker | -| `--yes`, `-y` | Accept the defaults without asking (HTTPS on) | +| `--yes`, `-y` | Accept the defaults without asking | | `--uninstall` | Stop and remove the app container | | `--purge` | With `--uninstall`, also delete the data directory | @@ -149,7 +115,6 @@ 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/ @@ -157,15 +122,14 @@ Everything that must survive an update lives under the install directory: computers// # each computer's home directory ``` -`.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`. +`.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`. Edit `.env` and run `docker restart cobble` to apply changes. The installer only appends missing values and keeps what is already there, apart from the -port and the `TLS_*` entries it manages. +port 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 @@ -193,45 +157,21 @@ bun run db:schema bun run dev ``` -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: +Open `http://localhost:7227`. 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 any server, `bun run dev` or `bun run start`, reuses the -certificate whenever `certs/server.crt` and `certs/server.key` exist; set -`TLS_DISABLED=1` to serve plain HTTP instead. `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 a certificate the 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 -`HTTP_REDIRECT_PORT` to move the redirect. The container publishes only the -HTTPS port, so the redirect is reachable there only if that second port is -published. +The development server serves plain HTTP, the same as the container. To try +the app from a phone, run a proxy or tunnel in front of it (see +[HTTPS](#https)); the installed app, Web Push and voice need that secure +context. | 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 | @@ -239,7 +179,6 @@ published. | `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 d21d29b..4aaace3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -61,11 +61,9 @@ 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. -- [ ] 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. +- [ ] A plain `curl | bash` install answers on `http://:7227`, and + the same install behind a reverse proxy serves HTTPS, installs the + app, gets a live chat socket and can turn on notifications. ## Rollback diff --git a/install.sh b/install.sh index 50f9dd1..e4d5e92 100644 --- a/install.sh +++ b/install.sh @@ -9,9 +9,8 @@ # 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. +# The app serves plain HTTP: put a reverse proxy or tunnel in front of it +# for HTTPS. set -Eeuo pipefail CONTAINER="cobble" @@ -77,28 +76,17 @@ Options: --force Recreate the container even when it is current --skip-backup Do not back up the database before an update --no-docker-install Fail instead of installing Docker when it is missing - --yes, -y Accept the defaults without asking (HTTPS on) + --yes, -y Accept the defaults without asking --uninstall Stop and remove the app container --purge With --uninstall, also delete the data directory --help Show this help HTTPS: - It is on by default, and a fresh install asks before continuing. It - encrypts the connection, and phones need it to install the app and - receive notifications; a proxy or tunnel that already handles HTTPS is - the reason to answer no. A local CA and a certificate for this machine - are generated into HOME/certs, so each device trusts the CA once: - 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 app serves plain HTTP on the published port. Put a reverse proxy or + tunnel in front of it for HTTPS, and point that proxy at + http://:. Phones need HTTPS to install the app and + receive notifications, so expose Cobble through the proxy rather than + its bare port. The data directory holds the database, media and every computer's home directory. It is kept when the container is removed. Settings live in @@ -148,9 +136,7 @@ esac ENV_FILE="$HOME_DIR/.env" STATE_FILE="$HOME_DIR/install.conf" -HAD_STATE=0 if [ -f "$STATE_FILE" ]; then - HAD_STATE=1 # shellcheck disable=SC1090 . "$STATE_FILE" fi @@ -162,11 +148,6 @@ 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 @@ -174,16 +155,7 @@ NO_PULL=0 NO_DOCKER_INSTALL=0 SKIP_BACKUP=0 PURGE=0 -HTTPS_DISABLED=0 -HTTPS_FORCE=0 -HTTPS_EXPLICIT=0 -HTTPS_HOSTS_GIVEN=0 -HTTPS_CERT_GIVEN=0 -HTTPS_REGEN=0 ASSUME_YES=0 -HTTPS_MANAGED=1 -CERTS_CHANGED=0 -CERT_EXPIRY="" env_get() { local key="$1" @@ -231,44 +203,17 @@ while [ $# -gt 0 ]; do EXTRA_NETWORK="${2:-}" shift 2 ;; - --https) - HTTPS_ENABLED=1 - HTTPS_FORCE=1 - HTTPS_EXPLICIT=1 + # Accepted from older scripts and docs, then ignored: HTTPS is the + # proxy's job now, and a value that still pointed at a certificate + # should not be dropped silently. + --https | --no-https | --https-regen) + warn "$1 is no longer needed: Cobble serves plain HTTP. Put a proxy or tunnel in front for HTTPS." shift ;; - --no-https) - HTTPS_DISABLED=1 - HTTPS_EXPLICIT=1 - shift - ;; - --https-host) - if [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then - HTTPS_HOSTS="$HTTPS_HOSTS ${2:-}" - else - HTTPS_HOSTS="${2:-}" - HTTPS_HOSTS_GIVEN=1 - fi - HTTPS_EXPLICIT=1 + --https-host | --https-cert | --https-key) + warn "$1 is no longer needed: Cobble serves plain HTTP. Put a proxy or tunnel in front for HTTPS." shift 2 ;; - --https-cert) - HTTPS_CERT_SOURCE="${2:-}" - HTTPS_CERT_GIVEN=1 - HTTPS_EXPLICIT=1 - shift 2 - ;; - --https-key) - HTTPS_KEY_SOURCE="${2:-}" - HTTPS_CERT_GIVEN=1 - HTTPS_EXPLICIT=1 - shift 2 - ;; - --https-regen) - HTTPS_REGEN=1 - HTTPS_EXPLICIT=1 - shift - ;; --yes | -y) ASSUME_YES=1 shift @@ -346,45 +291,10 @@ 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 @@ -415,19 +325,9 @@ 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 @@ -466,60 +366,8 @@ unset_env() { 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" -} - -# A fresh install asks about HTTPS and states why; updates keep the saved -# mode, and any --https* flag (or --yes) skips the question. The prompt -# reads /dev/tty, never stdin: with `curl | bash` stdin is the script. -ask_https() { - local answer="" - - if [ "$HAD_STATE" = 1 ] || [ "$HTTPS_EXPLICIT" = 1 ] || [ "$ACTION" = "uninstall" ] || [ "$ASSUME_YES" = 1 ]; then - return 0 - fi - - if ! { : /dev/null; then - printf '\nHTTPS is on by default: it encrypts the connection, and phones need it to install the app and receive notifications. Each device trusts the local certificate once. Pass --no-https if a proxy or tunnel already handles HTTPS.\n\n' >&2 - return 0 - fi - - printf '\n' >&2 - printf 'Cobble can serve HTTPS, and that is the default.\n' >&2 - printf 'It encrypts the connection, and phones need it to install the app\n' >&2 - printf 'and receive notifications. The installer creates a local CA for\n' >&2 - printf 'this machine, so each device trusts it once (on an iPhone: install\n' >&2 - printf 'the profile, then turn it on under Certificate Trust Settings).\n' >&2 - printf '\n' >&2 - printf 'Answer no if a proxy or tunnel already handles HTTPS for Cobble.\n' >&2 - printf 'Serve HTTPS? [Y/n] ' >&2 - - exec 3&2 - ;; - *) - printf '\nHTTPS on. Trust the certificate on each device before using the app.\n' >&2 - ;; - esac + printf 'http://%s:%s/api/health' "$(health_host)" "$PORT" } set_env() { @@ -580,29 +428,16 @@ 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 + # An install from before TLS was removed: its container holds the old + # certificate mounts and envs, so recreate it rather than report it + # current. + 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_"; then + return 1 fi return 0 } @@ -627,7 +462,7 @@ wait_for_health() { url="$(health_url)" say "Waiting for Cobble to answer at $url" while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do - if curl -fsSk --max-time 3 -o /dev/null "$url" 2>/dev/null; then + if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then ok "Cobble is healthy" return 0 fi @@ -642,89 +477,6 @@ 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 @@ -817,21 +569,6 @@ run_container() { -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 \ @@ -924,8 +661,6 @@ uninstall() { command -v curl >/dev/null 2>&1 || fail "curl is required" -ask_https - ensure_docker if [ "$ACTION" = "uninstall" ]; then @@ -962,21 +697,13 @@ ensure_secret ENCRYPTION_KEY ensure_secret ENCRYPTION_SALT ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json" -if [ "$HTTPS_ENABLED" = 1 ]; then - unset_env TLS_DISABLED - 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 - set_env TLS_DISABLED 1 -fi +# Installs from before TLS was removed may still carry these; the app +# ignores them, so drop them rather than leave a stale decoration. +unset_env TLS_CERT_FILE +unset_env TLS_KEY_FILE +unset_env TLS_CA_FILE +unset_env TLS_DISABLED +unset_env HTTP_REDIRECT_PORT { printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR" @@ -986,10 +713,6 @@ fi 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" @@ -1011,15 +734,11 @@ 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 [ "$HTTPS_ENABLED" = 1 ]; then - prepare_certs -fi - -if [ "$FORCE" = 0 ] && [ "$CERTS_CHANGED" = 0 ] && container_matches; then +if [ "$FORCE" = 0 ] && container_matches; then connect_extra_network - if curl -fsSk --max-time 3 -o /dev/null "$(health_url)" 2>/dev/null; then + if curl -fsS --max-time 3 -o /dev/null "$(health_url)" 2>/dev/null; then ok "Cobble is already up to date" - say "Healthy at $(health_scheme)://$(health_host):$PORT" + say "Healthy at http://$(health_host):$PORT" exit 0 fi warn "The container is running but not answering; recreating it" @@ -1039,38 +758,19 @@ 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 ' %s://%s:%s\n' "$SCHEME" "$ip" "$PORT" + [ -n "$ip" ] && printf ' http://%s:%s\n' "$ip" "$PORT" done < <(host_ips) fi -printf ' %s://127.0.0.1:%s\n' "$SCHEME" "$PORT" +printf ' http://127.0.0.1:%s\n' "$PORT" printf '\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 ' Put a reverse proxy or tunnel in front for HTTPS, and open that\n' +printf ' address instead. The installed app and notifications need it.\n' +printf '\n' +printf ' First visit: create the super admin account.\n' printf ' Data: %s\n' "$DATA_DIR" printf ' Settings: %s\n' "$ENV_FILE" printf ' Backups: %s\n' "$BACKUP_DIR" @@ -1079,9 +779,5 @@ 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 - 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 + printf ' %sThis port is reachable on your network, without encryption. Put a proxy or tunnel in front of it for HTTPS.%s\n\n' "$C_DIM" "$C_RESET" fi diff --git a/package.json b/package.json index 16cf040..dd44127 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,7 @@ "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 3a2fa73..7455588 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -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`, /** diff --git a/src/dict/env-dict.ts b/src/dict/env-dict.ts index bc8cb99..3519d05 100644 --- a/src/dict/env-dict.ts +++ b/src/dict/env-dict.ts @@ -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; /** diff --git a/src/dict/help-dict.ts b/src/dict/help-dict.ts index 19a0e8b..4e346a9 100644 --- a/src/dict/help-dict.ts +++ b/src/dict/help-dict.ts @@ -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"; diff --git a/src/functions/server/serve-local-ca.ts b/src/functions/server/serve-local-ca.ts deleted file mode 100644 index db348e8..0000000 --- a/src/functions/server/serve-local-ca.ts +++ /dev/null @@ -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 { - 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 deleted file mode 100644 index c24d3e5..0000000 --- a/src/functions/server/start-http-redirect.ts +++ /dev/null @@ -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://:` still fails, but - * `http://:` 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, - ); - }, - }); -} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-editor.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-editor.tsx index 1bae070..75060b5 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-editor.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-editor.tsx @@ -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 ? ( ) : ( - + + + )} ); diff --git a/src/pages/admin/help/(data)/help-topics.ts b/src/pages/admin/help/(data)/help-topics.ts index e4a8a58..0aabca7 100644 --- a/src/pages/admin/help/(data)/help-topics.ts +++ b/src/pages/admin/help/(data)/help-topics.ts @@ -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://:7227/ca.mobileconfig\`. iOS offers a profile, which is one tap to install. -- **Everything else:** \`https://:7227/ca.crt\`. +The proxy should reach the app at \`http://: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, diff --git a/src/scripts/dev-phone.ts b/src/scripts/dev-phone.ts deleted file mode 100644 index ddb205e..0000000 --- a/src/scripts/dev-phone.ts +++ /dev/null @@ -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 - * - * 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 deleted file mode 100644 index 3c58b21..0000000 --- a/src/scripts/generate-local-certs.ts +++ /dev/null @@ -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)); -} diff --git a/src/server.ts b/src/server.ts index d5be5c5..0f533d4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 = 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. diff --git a/src/utils/build-ca-profile.test.ts b/src/utils/build-ca-profile.test.ts deleted file mode 100644 index f2e8d70..0000000 --- a/src/utils/build-ca-profile.test.ts +++ /dev/null @@ -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("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 deleted file mode 100644 index 3f1b60f..0000000 --- a/src/utils/build-ca-profile.ts +++ /dev/null @@ -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 = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", -}; - -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 deleted file mode 100644 index 2880d0e..0000000 --- a/src/utils/build-https-redirect-url.test.ts +++ /dev/null @@ -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/", - ); - }); -}); diff --git a/src/utils/build-https-redirect-url.ts b/src/utils/build-https-redirect-url.ts deleted file mode 100644 index b9da387..0000000 --- a/src/utils/build-https-redirect-url.ts +++ /dev/null @@ -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}`; -} diff --git a/src/utils/build-san-list.test.ts b/src/utils/build-san-list.test.ts deleted file mode 100644 index d54209c..0000000 --- a/src/utils/build-san-list.test.ts +++ /dev/null @@ -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", - ]); - }); -}); diff --git a/src/utils/build-san-list.ts b/src/utils/build-san-list.ts deleted file mode 100644 index ba30c7b..0000000 --- a/src/utils/build-san-list.ts +++ /dev/null @@ -1,83 +0,0 @@ -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/pick-tls-files.test.ts b/src/utils/pick-tls-files.test.ts deleted file mode 100644 index 32d799e..0000000 --- a/src/utils/pick-tls-files.test.ts +++ /dev/null @@ -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[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"); - }); -}); diff --git a/src/utils/pick-tls-files.ts b/src/utils/pick-tls-files.ts deleted file mode 100644 index be37a8f..0000000 --- a/src/utils/pick-tls-files.ts +++ /dev/null @@ -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" }; -} diff --git a/src/utils/resolve-tls-options.test.ts b/src/utils/resolve-tls-options.test.ts deleted file mode 100644 index 16b0f82..0000000 --- a/src/utils/resolve-tls-options.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/utils/resolve-tls-options.ts b/src/utils/resolve-tls-options.ts deleted file mode 100644 index 6955ea6..0000000 --- a/src/utils/resolve-tls-options.ts +++ /dev/null @@ -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; - 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) }; -}