Serve plain HTTP only and drop the local certificate tooling
The app never terminates TLS: a reverse proxy or tunnel does, and isSecureRequest still reads X-Forwarded-Proto so cookies stay Secure. Removes the cert generator, TLS resolution and HTTP redirect, the local CA download, and the TLS envs, and trims the installer, Dockerfile health check and docs to match. Also softens the composer's loading row.
This commit is contained in:
@@ -9,7 +9,6 @@ node_modules
|
||||
.data
|
||||
.bunext
|
||||
.bun-sqlite
|
||||
certs
|
||||
db/cobble
|
||||
db/.backups
|
||||
db/.exports
|
||||
|
||||
+2
-9
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,7 +117,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - 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)
|
||||
|
||||
|
||||
+3
-4
@@ -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"]
|
||||
|
||||
@@ -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://<your-ip>:7227`. The
|
||||
certificate is signed by a local CA the installer made, so the browser warns
|
||||
until you trust it: open `https://<your-ip>: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://<your-ip>: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://<this machine>: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://<this machine>:7227/ca.crt` on the device, or
|
||||
copy `$COBBLE_HOME/certs/ca.crt`.
|
||||
- **iPhone or iPad:** open `https://<this machine>: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/<id>/ # 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://<the name you passed>:7227` on the phone and install the CA
|
||||
from `https://<the name you passed>: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),
|
||||
|
||||
+3
-5
@@ -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://<host>: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
|
||||
|
||||
|
||||
+41
-345
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -45,16 +45,6 @@ export const AppData = {
|
||||
LegacyHarnessOverviewStorageKey: `${SiteData["SiteSlug"]}_harness_overview`,
|
||||
HarnessOverviewCacheMs: 10 * 60 * 1000,
|
||||
ServerHealthMessage: "Server Running Well",
|
||||
/**
|
||||
* Where a device downloads the local CA that signed the app's
|
||||
* certificate, so it can trust it (`serveLocalCa`): the certificate
|
||||
* and an iOS/macOS profile that installs it, and the names they
|
||||
* download as. Only served with TLS and `TLS_CA_FILE`.
|
||||
*/
|
||||
TlsCaPath: "/ca.crt",
|
||||
TlsCaFilename: `${SiteData["SiteSlug"]}-ca.crt`,
|
||||
TlsCaProfilePath: "/ca.mobileconfig",
|
||||
TlsCaProfileFilename: `${SiteData["SiteSlug"]}-ca.mobileconfig`,
|
||||
AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`,
|
||||
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
|
||||
/**
|
||||
|
||||
@@ -37,10 +37,9 @@ export type KnownEnv = {
|
||||
* their own keys beside these for bots and computers to use.
|
||||
*
|
||||
* Not here, and never in the `envs` table: `ENCRYPTION_KEY` and
|
||||
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR`,
|
||||
* `MAIN_SERVER`, `TLS_DISABLED`, `HTTP_REDIRECT_PORT` and the `TLS_*`
|
||||
* certificate paths (the container's own start-up settings). Those stay in
|
||||
* the process environment.
|
||||
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR` and
|
||||
* `MAIN_SERVER` (the container's own start-up settings). Those stay in the
|
||||
* process environment.
|
||||
*/
|
||||
export const EnvDict: readonly KnownEnv[] = [
|
||||
{
|
||||
@@ -87,11 +86,6 @@ export const ProcessOnlyEnvKeys = [
|
||||
"DATA_DIR",
|
||||
"DB_DIR",
|
||||
"MAIN_SERVER",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"TLS_CA_FILE",
|
||||
"TLS_DISABLED",
|
||||
"HTTP_REDIRECT_PORT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,6 @@ export const HelpPath = "/admin/help";
|
||||
export const HelpSignInTopicId = "sign-in-with-cookies";
|
||||
|
||||
/**
|
||||
* Trusting the local HTTPS certificate on a device.
|
||||
* Reaching the app over HTTPS through a proxy or tunnel.
|
||||
*/
|
||||
export const HelpHttpsTopicId = "https-certificate";
|
||||
export const HelpSecureConnectionTopicId = "secure-connection";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import buildCaProfile from "@/src/utils/build-ca-profile";
|
||||
|
||||
type Params = {
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves the local CA that signed the app's certificate
|
||||
* (`TLS_CA_FILE`), so a device can trust it without copying files across
|
||||
* the network: the certificate itself, and an iOS/macOS profile that
|
||||
* installs it in one tap.
|
||||
*
|
||||
* A certificate is public information; the key never leaves
|
||||
* `$COBBLE_HOME/certs`. Returns `null` for every other path, and for an
|
||||
* install using its own certificate, which has no local CA.
|
||||
*/
|
||||
export default async function serveLocalCa({
|
||||
pathname,
|
||||
}: Params): Promise<Response | null> {
|
||||
const certificate_path = pathname == AppData["TlsCaPath"];
|
||||
const profile_path = pathname == AppData["TlsCaProfilePath"];
|
||||
|
||||
if (!certificate_path && !profile_path) return null;
|
||||
|
||||
const ca_file = process.env.TLS_CA_FILE?.trim();
|
||||
|
||||
if (!ca_file) return null;
|
||||
|
||||
const file = Bun.file(ca_file);
|
||||
|
||||
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
|
||||
|
||||
if (certificate_path) {
|
||||
return new Response(file, {
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/x-x509-ca-cert",
|
||||
"Content-Disposition": `attachment; filename="${AppData["TlsCaFilename"]}"`,
|
||||
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const profile = buildCaProfile({
|
||||
certificate_pem: await file.text(),
|
||||
display_name: `${SiteData["SiteName"]} local CA`,
|
||||
identifier: `org.${SiteData["SiteSlug"]}.ca`,
|
||||
});
|
||||
|
||||
return new Response(profile, {
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/x-apple-aspen-config",
|
||||
"Content-Disposition": `attachment; filename="${AppData["TlsCaProfileFilename"]}"`,
|
||||
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import buildHttpsRedirectUrl from "@/src/utils/build-https-redirect-url";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The port the app serves HTTPS on.
|
||||
*/
|
||||
https_port: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A plain-HTTP listener on the port above the HTTPS one, which sends the
|
||||
* browser to the same page over HTTPS.
|
||||
*
|
||||
* A port that speaks TLS cannot answer plain HTTP, so this cannot sit on
|
||||
* the HTTPS port itself: `http://<host>:<port>` still fails, but
|
||||
* `http://<host>:<port + 1>` lands on the right page. `HTTP_REDIRECT_PORT`
|
||||
* picks another port. Every environment starts it; the container publishes
|
||||
* only the HTTPS port, so there it is reachable only if that second port is
|
||||
* published too.
|
||||
*/
|
||||
export default function startHttpRedirect({ https_port }: Params) {
|
||||
const base = Number(https_port);
|
||||
const configured = Number(process.env.HTTP_REDIRECT_PORT);
|
||||
const redirect_port =
|
||||
Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: Number.isInteger(base) && base > 0
|
||||
? base + 1
|
||||
: 7228;
|
||||
|
||||
if (redirect_port == base) return undefined;
|
||||
|
||||
return Bun.serve({
|
||||
port: redirect_port,
|
||||
reusePort: false,
|
||||
fetch(req) {
|
||||
return Response.redirect(
|
||||
buildHttpsRedirectUrl({
|
||||
request_url: new URL(req.url),
|
||||
https_port,
|
||||
}),
|
||||
308,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EditorContent, type Editor } from "@tiptap/react";
|
||||
import ComposerFormatBubble from "./composer-format-bubble";
|
||||
import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
@@ -25,7 +26,9 @@ export default function ComposerEditor({ editor, invalid, title }: Props) {
|
||||
{editor ? (
|
||||
<ComposerFormatBubble editor={editor} />
|
||||
) : (
|
||||
<LoadingBlock className="w-full h-[30px] -ml-6" />
|
||||
<Row className="w-full opacity-50">
|
||||
<LoadingBlock className="w-full h-[30px] -ml-4 lg:-ml-6" />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HelpHttpsTopicId, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
import { HelpSecureConnectionTopicId, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
|
||||
export type HelpTopic = {
|
||||
/**
|
||||
@@ -22,38 +22,21 @@ export type HelpTopic = {
|
||||
*/
|
||||
export const HelpTopics: HelpTopic[] = [
|
||||
{
|
||||
id: HelpHttpsTopicId,
|
||||
title: "The browser says the connection isn't private",
|
||||
summary: `"Your connection is not private", a certificate warning, or an app that won't install or send notifications.`,
|
||||
body: `Cobble serves HTTPS with a certificate signed by a small local authority (a CA) that the installer made on your machine. Browsers only trust it once you have told them to, so the first visit warns.
|
||||
id: HelpSecureConnectionTopicId,
|
||||
title: "The app needs a secure connection",
|
||||
summary: `A browser warning, the app won't install, or notifications and voice stay off.`,
|
||||
body: `Cobble serves plain HTTP and expects HTTPS in front of it: a reverse proxy (Caddy, nginx, Traefik) or a tunnel (Cloudflare Tunnel, Tailscale Serve). Browsers treat plain HTTP as unsafe, so the installable app, its offline support, Web Push notifications and voice recording stay off until the address is HTTPS.
|
||||
|
||||
**Download it on the device.** Open one of these in the device's browser, on the same address you use for Cobble, and accept the warning if it shows:
|
||||
### Point the proxy at Cobble
|
||||
|
||||
- **iPhone or iPad:** \`https://<your address>:7227/ca.mobileconfig\`. iOS offers a profile, which is one tap to install.
|
||||
- **Everything else:** \`https://<your address>:7227/ca.crt\`.
|
||||
The proxy should reach the app at \`http://<this machine>:7227\` (or the port given with \`--port\`), and it should set \`X-Forwarded-Proto: https\`. Cobble reads that header to mark the sign-in cookies \`Secure\` and to build the right links. Caddy does it by default; with nginx add \`proxy_set_header X-Forwarded-Proto $scheme;\` to the location block.
|
||||
|
||||
You can also copy it from the machine Cobble runs on: \`/opt/cobble/certs/ca.crt\` (or wherever it was installed).
|
||||
### If it still doesn't work
|
||||
|
||||
### Install it
|
||||
|
||||
- **iPhone or iPad:** tap **Allow** on the downloaded profile, install it (it also appears under **Settings > General > VPN & Device Management**), then turn it on under **Settings > General > About > Certificate Trust Settings**. Both steps are needed, even with the one-tap profile.
|
||||
- **Android:** **Settings > Security > Encryption & credentials > Install a certificate > CA certificate**, then pick the file.
|
||||
- **macOS:** add it to the login keychain and set it to **Always Trust**.
|
||||
- **Linux:** copy it into \`/usr/local/share/ca-certificates/\` and run \`sudo update-ca-certificates\`.
|
||||
- **Windows:** install it under **Trusted Root Certification Authorities**.
|
||||
|
||||
Reload Cobble afterwards. The warning is gone.
|
||||
|
||||
### Why it matters
|
||||
|
||||
The connection is encrypted either way; the warning is about who signed the certificate. But the browser treats an untrusted address as unsafe, so the installable app, its offline support and Web Push notifications stay off until the CA is trusted. Voice recording and dictation need that safe context too.
|
||||
|
||||
### If it still warns
|
||||
|
||||
- **You installed the profile but iOS still warns.** The second step, Certificate Trust Settings, was missed. It is a separate switch.
|
||||
- **You replaced the CA.** Rerunning the installer with \`--https-regen\` makes a new CA. Remove the old profile and install the new one.
|
||||
- **The warning comes back every few months.** The certificate is renewed when the installer runs, but it is still signed by the same CA, so devices keep trusting it.
|
||||
- **You have your own certificate.** Installs using \`--https-cert\` and \`--https-key\`, or \`--no-https\` behind a proxy or tunnel, do not need any of this.`,
|
||||
- **You opened the plain HTTP address.** Use the HTTPS address your proxy serves. \`http://\` will always be treated as unsafe.
|
||||
- **The certificate warning comes from the proxy.** It is using a self-signed or expired certificate. Fix that on the proxy: Cobble never sees the certificate.
|
||||
- **Sign-in doesn't stick, or downloads fail.** The proxy is not passing \`X-Forwarded-Proto\` (see above), or it is dropping cookies.
|
||||
- **WebSocket updates don't arrive.** The proxy must pass the upgrade on \`/ws\` as well as ordinary requests.`,
|
||||
},
|
||||
{
|
||||
id: HelpSignInTopicId,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Starts the development server over HTTPS, generating the certificate
|
||||
* first, so a phone on the VPN can open the app in one command:
|
||||
*
|
||||
* bun run dev:phone -- --host <the VPN name>
|
||||
*
|
||||
* Without `--host` the machine's own name and addresses are used. The
|
||||
* files land in `certs/` (gitignored); `bun run dev` picks them up on its
|
||||
* own afterwards.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const generated = Bun.spawnSync({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"src/scripts/generate-local-certs.ts",
|
||||
"--out",
|
||||
"certs",
|
||||
...args,
|
||||
],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
if (!generated.success) process.exit(generated.exitCode ?? 1);
|
||||
|
||||
const port = process.env.PORT || "7227";
|
||||
|
||||
console.log(
|
||||
`\nPhone setup: open https://<a name above>:${port}/ca.mobileconfig first,\n` +
|
||||
`then the app at the same address. Trust the profile on the device.\n`,
|
||||
);
|
||||
|
||||
const server = Bun.spawnSync({
|
||||
cmd: [process.execPath, "src/server.ts"],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
TLS_CERT_FILE: "certs/server.crt",
|
||||
TLS_KEY_FILE: "certs/server.key",
|
||||
TLS_CA_FILE: "certs/ca.crt",
|
||||
},
|
||||
});
|
||||
|
||||
process.exit(server.exitCode ?? 0);
|
||||
@@ -1,372 +0,0 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import buildSanList from "../utils/build-san-list";
|
||||
|
||||
/**
|
||||
* Generates the certificate HTTPS runs on: a local CA and a leaf it signs,
|
||||
* so a device trusts the CA once and every re-issued certificate keeps
|
||||
* working.
|
||||
*
|
||||
* The installer runs this inside the app image (which has `openssl`), with
|
||||
* the host's own addresses passed in: a container's interfaces are not the
|
||||
* host's. Development runs it directly (`bun run gen:certs`), where the
|
||||
* machine's own name and addresses are detected.
|
||||
*
|
||||
* The CA is kept until `--regen`. The leaf is re-issued when the names
|
||||
* change or it is within 30 days of expiring, which is what rerunning the
|
||||
* installer on an update does.
|
||||
*
|
||||
* Usage:
|
||||
* bun run gen:certs [--out certs] [--host NAME_OR_IP]... [--no-detect] [--regen]
|
||||
*/
|
||||
|
||||
const CA_DAYS = 3650;
|
||||
const CERT_DAYS = 397;
|
||||
const RENEW_BEFORE_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
type Options = {
|
||||
out: string;
|
||||
hosts: string[];
|
||||
detect: boolean;
|
||||
regen: boolean;
|
||||
};
|
||||
|
||||
function usage() {
|
||||
console.log(`Generates the certificate HTTPS runs on.
|
||||
|
||||
Usage:
|
||||
bun run gen:certs [options]
|
||||
|
||||
Options:
|
||||
--out DIR Where to write the files (default certs)
|
||||
--host VALUE A name or IP the certificate covers (repeatable)
|
||||
--no-detect Do not add this machine's own name and addresses
|
||||
--regen Replace the local CA and the certificate
|
||||
--help Show this help
|
||||
|
||||
Writes ca.crt, ca.key, server.crt, server.key and sans.txt.`);
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseOptions(): Options {
|
||||
const argv = process.argv.slice(2);
|
||||
const hosts: string[] = [];
|
||||
let out = "certs";
|
||||
let detect = true;
|
||||
let regen = false;
|
||||
|
||||
const value = (index: number, name: string) => {
|
||||
const next = argv[index];
|
||||
|
||||
if (!next || next.startsWith("--")) fail(`${name} needs a value`);
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
switch (argv[i]) {
|
||||
case "--out":
|
||||
out = value(++i, "--out");
|
||||
break;
|
||||
case "--host":
|
||||
hosts.push(value(++i, "--host"));
|
||||
break;
|
||||
case "--no-detect":
|
||||
detect = false;
|
||||
break;
|
||||
case "--regen":
|
||||
regen = true;
|
||||
break;
|
||||
case "--":
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
usage();
|
||||
process.exit(0);
|
||||
default:
|
||||
fail(`Unknown option: ${argv[i]}. Use --help.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { out, hosts, detect, regen };
|
||||
}
|
||||
|
||||
function run(command: string[]): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: command,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = result.stdout?.toString() ?? "";
|
||||
const stderr = result.stderr?.toString() ?? "";
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`${command[0]} failed: ${stderr.trim() || stdout.trim() || `exit ${result.exitCode}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function certSecondsLeft(file: string): number | undefined {
|
||||
if (!existsSync(file)) return undefined;
|
||||
|
||||
try {
|
||||
const end = run(["openssl", "x509", "-noout", "-enddate", "-in", file])
|
||||
.trim()
|
||||
.replace(/^notAfter=/, "");
|
||||
const time = Date.parse(end);
|
||||
|
||||
if (Number.isNaN(time)) return undefined;
|
||||
|
||||
return (time - Date.now()) / 1000;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function localNames(): { hostname: string; addresses: string[] } {
|
||||
const addresses: string[] = [];
|
||||
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.address) addresses.push(entry.address);
|
||||
}
|
||||
}
|
||||
|
||||
return { hostname: os.hostname(), addresses };
|
||||
}
|
||||
|
||||
function caConfig(): string {
|
||||
return `[req]
|
||||
distinguished_name = dn
|
||||
prompt = no
|
||||
x509_extensions = v3_ca
|
||||
|
||||
[dn]
|
||||
CN = Cobble Local CA
|
||||
|
||||
[v3_ca]
|
||||
basicConstraints = critical,CA:TRUE,pathlen:0
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
`;
|
||||
}
|
||||
|
||||
function leafRequestConfig({ common_name }: { common_name: string }): string {
|
||||
return `[req]
|
||||
distinguished_name = dn
|
||||
prompt = no
|
||||
|
||||
[dn]
|
||||
CN = ${common_name}
|
||||
`;
|
||||
}
|
||||
|
||||
function leafExtensionsConfig({ sans }: { sans: string }): string {
|
||||
return `[v3_leaf]
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = ${sans}
|
||||
`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!Bun.which("openssl")) {
|
||||
fail(
|
||||
"openssl is required to generate the certificate. Install the openssl package and rerun.",
|
||||
);
|
||||
}
|
||||
|
||||
const options = parseOptions();
|
||||
const invalid_host = options.hosts.find(
|
||||
(host) => !/^[A-Za-z0-9.*:_-]+$/.test(host),
|
||||
);
|
||||
|
||||
if (invalid_host) {
|
||||
fail(`--host is not a hostname or IP address: ${invalid_host}`);
|
||||
}
|
||||
|
||||
const out = path.resolve(options.out);
|
||||
|
||||
mkdirSync(out, { recursive: true, mode: 0o700 });
|
||||
chmodSync(out, 0o700);
|
||||
|
||||
const build = path.join(out, ".build");
|
||||
rmSync(build, { recursive: true, force: true });
|
||||
mkdirSync(build, { recursive: true, mode: 0o700 });
|
||||
|
||||
const ca_crt = path.join(out, "ca.crt");
|
||||
const ca_key = path.join(out, "ca.key");
|
||||
const ca_serial = path.join(out, "ca.srl");
|
||||
const server_crt = path.join(out, "server.crt");
|
||||
const server_key = path.join(out, "server.key");
|
||||
const sans_file = path.join(out, "sans.txt");
|
||||
|
||||
if (options.regen) {
|
||||
for (const file of [
|
||||
ca_crt,
|
||||
ca_key,
|
||||
ca_serial,
|
||||
server_crt,
|
||||
server_key,
|
||||
sans_file,
|
||||
]) {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const detected: { hostname?: string; addresses?: string[] } = options.detect
|
||||
? localNames()
|
||||
: {};
|
||||
const sans = buildSanList({
|
||||
hostname: detected.hostname,
|
||||
addresses: detected.addresses,
|
||||
hosts: options.hosts,
|
||||
});
|
||||
const san_value = sans.join(",");
|
||||
const common_name = (
|
||||
sans.find(
|
||||
(entry) => entry.startsWith("DNS:") && entry != "DNS:localhost",
|
||||
) ?? "DNS:localhost"
|
||||
)
|
||||
.slice(4)
|
||||
.slice(0, 64);
|
||||
|
||||
let ca_created = false;
|
||||
|
||||
if (!existsSync(ca_crt) || !existsSync(ca_key)) {
|
||||
rmSync(ca_crt, { force: true });
|
||||
rmSync(ca_key, { force: true });
|
||||
rmSync(ca_serial, { force: true });
|
||||
|
||||
const config = path.join(build, "ca.cnf");
|
||||
writeFileSync(config, caConfig());
|
||||
|
||||
run([
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:3072",
|
||||
"-nodes",
|
||||
"-sha256",
|
||||
"-days",
|
||||
String(CA_DAYS),
|
||||
"-keyout",
|
||||
ca_key,
|
||||
"-out",
|
||||
ca_crt,
|
||||
"-config",
|
||||
config,
|
||||
]);
|
||||
|
||||
ca_created = true;
|
||||
}
|
||||
|
||||
const current_sans = existsSync(sans_file)
|
||||
? readFileSync(sans_file, "utf8").trim()
|
||||
: "";
|
||||
const seconds_left = certSecondsLeft(server_crt);
|
||||
const issue_certificate =
|
||||
ca_created ||
|
||||
!existsSync(server_crt) ||
|
||||
!existsSync(server_key) ||
|
||||
current_sans != san_value ||
|
||||
seconds_left === undefined ||
|
||||
seconds_left < RENEW_BEFORE_SECONDS;
|
||||
|
||||
if (issue_certificate) {
|
||||
const request_config = path.join(build, "leaf.cnf");
|
||||
const extensions = path.join(build, "leaf-ext.cnf");
|
||||
const csr = path.join(build, "server.csr");
|
||||
|
||||
writeFileSync(request_config, leafRequestConfig({ common_name }));
|
||||
writeFileSync(extensions, leafExtensionsConfig({ sans: san_value }));
|
||||
|
||||
run([
|
||||
"openssl",
|
||||
"req",
|
||||
"-new",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-sha256",
|
||||
"-keyout",
|
||||
server_key,
|
||||
"-out",
|
||||
csr,
|
||||
"-config",
|
||||
request_config,
|
||||
]);
|
||||
|
||||
run([
|
||||
"openssl",
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
csr,
|
||||
"-CA",
|
||||
ca_crt,
|
||||
"-CAkey",
|
||||
ca_key,
|
||||
"-CAcreateserial",
|
||||
"-CAserial",
|
||||
ca_serial,
|
||||
"-days",
|
||||
String(CERT_DAYS),
|
||||
"-sha256",
|
||||
"-extfile",
|
||||
extensions,
|
||||
"-extensions",
|
||||
"v3_leaf",
|
||||
"-out",
|
||||
server_crt,
|
||||
]);
|
||||
|
||||
writeFileSync(sans_file, `${san_value}\n`);
|
||||
}
|
||||
|
||||
run(["openssl", "verify", "-CAfile", ca_crt, server_crt]);
|
||||
|
||||
chmodSync(ca_key, 0o600);
|
||||
chmodSync(server_key, 0o600);
|
||||
if (existsSync(ca_serial)) chmodSync(ca_serial, 0o600);
|
||||
chmodSync(ca_crt, 0o644);
|
||||
chmodSync(server_crt, 0o644);
|
||||
chmodSync(sans_file, 0o644);
|
||||
|
||||
rmSync(build, { recursive: true, force: true });
|
||||
|
||||
console.log(`Certificate directory: ${out}`);
|
||||
console.log(`Covers: ${sans.join(", ")}`);
|
||||
|
||||
if (ca_created) console.log("Created a new local CA and certificate.");
|
||||
else if (issue_certificate)
|
||||
console.log("Issued a new certificate from the local CA.");
|
||||
else console.log("Kept the existing certificate.");
|
||||
|
||||
console.log(
|
||||
`certs: ${ca_created ? "created" : issue_certificate ? "renewed" : "unchanged"}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
+5
-80
@@ -1,5 +1,4 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import { existsSync } from "fs";
|
||||
import handleMediaServer from "./functions/server/media/handle-media-server";
|
||||
import cron from "./cron";
|
||||
import { websocket } from "./websocket";
|
||||
@@ -15,10 +14,6 @@ import handleBuildInfo from "./functions/server/pwa/handle-build-info";
|
||||
import handlePrecacheGate from "./functions/server/pwa/handle-precache-gate";
|
||||
import grabPrecacheManifest from "./functions/server/pwa/grab-precache-manifest";
|
||||
import compressResponse from "./functions/server/compress-response";
|
||||
import serveLocalCa from "./functions/server/serve-local-ca";
|
||||
import startHttpRedirect from "./functions/server/start-http-redirect";
|
||||
import resolveTlsOptions from "./utils/resolve-tls-options";
|
||||
import pickTlsFiles from "./utils/pick-tls-files";
|
||||
import { ComputerProxyPrefix } from "./dict/computer-dict";
|
||||
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
|
||||
import { ensureVapidKeys } from "./push/vapid";
|
||||
@@ -70,54 +65,11 @@ try {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which certificate to serve with, the same way in every environment: the
|
||||
* `TLS_*` variables when set, else `certs/` (which is also where the
|
||||
* container's `/app/certs` mount lands). Plain HTTP needs `TLS_DISABLED`.
|
||||
* The app always serves plain HTTP and leaves TLS to whatever sits in
|
||||
* front of it (a reverse proxy or tunnel). `isSecureRequest` reads the
|
||||
* proxy's `X-Forwarded-Proto` so cookies still get `Secure`.
|
||||
*/
|
||||
const picked = pickTlsFiles({
|
||||
cert_file: process.env.TLS_CERT_FILE,
|
||||
key_file: process.env.TLS_KEY_FILE,
|
||||
ca_file: process.env.TLS_CA_FILE,
|
||||
disabled: process.env.TLS_DISABLED,
|
||||
fallback_cert_file: "certs/server.crt",
|
||||
fallback_key_file: "certs/server.key",
|
||||
fallback_ca_file: "certs/ca.crt",
|
||||
file_exists: existsSync,
|
||||
});
|
||||
|
||||
if (picked.ca_file) process.env.TLS_CA_FILE = picked.ca_file;
|
||||
else delete process.env.TLS_CA_FILE;
|
||||
|
||||
switch (picked.source) {
|
||||
case "fallback":
|
||||
bunext.bunextLog.info(`Using the certificate in certs/`);
|
||||
break;
|
||||
case "disabled":
|
||||
bunext.bunextLog.info(`Serving plain HTTP (TLS_DISABLED is set)`);
|
||||
break;
|
||||
case "none":
|
||||
bunext.bunextLog.info(
|
||||
"Serving plain HTTP (no certificate; run `bun run gen:certs`, or set TLS_CERT_FILE and TLS_KEY_FILE)",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let tls: ReturnType<typeof resolveTlsOptions> = undefined;
|
||||
|
||||
try {
|
||||
tls = resolveTlsOptions({
|
||||
cert_file: picked.cert_file,
|
||||
key_file: picked.key_file,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Can't start without a working TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
...(tls ? { tls } : {}),
|
||||
async fetch(req, server) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
@@ -134,10 +86,6 @@ const server = Bun.serve({
|
||||
|
||||
if (loading_page) return loading_page;
|
||||
|
||||
const local_ca = await serveLocalCa({ pathname: url.pathname });
|
||||
|
||||
if (local_ca) return local_ca;
|
||||
|
||||
if (url.pathname.startsWith("/media")) {
|
||||
try {
|
||||
return await handleMediaServer({ req });
|
||||
@@ -193,39 +141,16 @@ const server = Bun.serve({
|
||||
port,
|
||||
/**
|
||||
* Fail loudly on a second dev server instead of letting Bun's default
|
||||
* port sharing split requests between an old and a new process (one
|
||||
* might be plain HTTP while the other is HTTPS).
|
||||
* port sharing split requests between an old and a new process.
|
||||
*/
|
||||
reusePort: false,
|
||||
idleTimeout: development ? 80 : undefined,
|
||||
});
|
||||
|
||||
bunext.bunextLog.info(
|
||||
`Server running on ${tls ? "https" : "http"}://${server.hostname}:${server.port} ...`,
|
||||
`Server running on http://${server.hostname}:${server.port} ...`,
|
||||
);
|
||||
|
||||
/**
|
||||
* A TLS port cannot answer plain HTTP, so a plain `http://` visit is sent
|
||||
* to the HTTPS page from the port above it (`startHttpRedirect`). The
|
||||
* container publishes only the HTTPS port, so there it is only reachable
|
||||
* if that second port is published.
|
||||
*/
|
||||
if (tls) {
|
||||
try {
|
||||
const redirect = startHttpRedirect({ https_port: port });
|
||||
|
||||
if (redirect) {
|
||||
bunext.bunextLog.info(
|
||||
`HTTP redirect on port ${redirect.port} (HTTP_REDIRECT_PORT to change it)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
bunext.bunextLog.info(
|
||||
`Could not start the HTTP redirect on port ${Number(port) + 1}: ${error instanceof Error ? error.message : String(error)}. Stop the other server or set HTTP_REDIRECT_PORT.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background services start after the server is up, and one failing to
|
||||
* start never takes the server (or the others) down with it.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import buildCaProfile from "./build-ca-profile";
|
||||
|
||||
const pem = `-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIUTEST
|
||||
-----END CERTIFICATE-----
|
||||
`;
|
||||
|
||||
const profile = (display_name = "Cobble local CA") =>
|
||||
buildCaProfile({
|
||||
certificate_pem: pem,
|
||||
display_name,
|
||||
identifier: "org.example.cobble.ca",
|
||||
});
|
||||
|
||||
describe("buildCaProfile", () => {
|
||||
test("is a root certificate profile", () => {
|
||||
expect(profile()).toContain("<string>com.apple.security.root</string>");
|
||||
expect(profile()).toContain("<string>Configuration</string>");
|
||||
});
|
||||
|
||||
test("embeds the PEM body as DER base64", () => {
|
||||
const body = profile();
|
||||
|
||||
expect(body).toContain("MIIBhTCCASugAwIBAgIUTEST");
|
||||
expect(body).not.toContain("BEGIN CERTIFICATE");
|
||||
expect(body).not.toContain("END CERTIFICATE");
|
||||
});
|
||||
|
||||
test("names the profile with its identifier", () => {
|
||||
const body = profile();
|
||||
|
||||
expect(body).toContain("<string>Cobble local CA</string>");
|
||||
expect(body).toContain("<string>org.example.cobble.ca</string>");
|
||||
expect(body).toContain(
|
||||
"<string>org.example.cobble.ca.certificate</string>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes XML in the name", () => {
|
||||
expect(profile("Ben & Co <CA>")).toContain("Ben & Co <CA>");
|
||||
});
|
||||
|
||||
test("refuses an empty certificate", () => {
|
||||
expect(() =>
|
||||
buildCaProfile({
|
||||
certificate_pem: " ",
|
||||
display_name: "Cobble",
|
||||
identifier: "org.example.cobble.ca",
|
||||
}),
|
||||
).toThrow("The CA certificate is empty.");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The CA certificate in PEM, as `ca.crt` holds it.
|
||||
*/
|
||||
certificate_pem: string;
|
||||
/**
|
||||
* The name iOS and macOS show for the profile.
|
||||
*/
|
||||
display_name: string;
|
||||
/**
|
||||
* Reverse-DNS identifier of the profile, e.g. `org.example.cobble.ca`.
|
||||
*/
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixed UUIDs, so installing the profile again replaces it on the device
|
||||
* instead of leaving an older Cobble CA behind.
|
||||
*/
|
||||
const ProfileUUID = "B7F3C2A1-6D4E-4F8A-9C1B-2E5A7D9F0B31";
|
||||
const CertificateUUID = "D2A8E4C6-1B3F-4A7D-8E5C-9F0B6A2C4D18";
|
||||
|
||||
const XmlEscapes: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
function escapeXml({ value }: { value: string }): string {
|
||||
return value.replace(/[&<>"']/g, (character) => XmlEscapes[character]!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificate as base64 DER, which is what a plist `<data>` holds:
|
||||
* the PEM body itself, with its markers and line breaks removed.
|
||||
*/
|
||||
function certificateData({ certificate_pem }: { certificate_pem: string }) {
|
||||
const data = certificate_pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||
.replace(/-----END CERTIFICATE-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
|
||||
if (!data) {
|
||||
throw new Error("The CA certificate is empty.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* An iOS/macOS configuration profile carrying the local CA
|
||||
* (`com.apple.security.root`). Opening it on an iPhone offers a one-tap
|
||||
* install instead of a file to find and install by hand; iOS still asks
|
||||
* for the separate trust switch afterwards, which no profile can skip on
|
||||
* an unmanaged device.
|
||||
*/
|
||||
export default function buildCaProfile({
|
||||
certificate_pem,
|
||||
display_name,
|
||||
identifier,
|
||||
}: Params): string {
|
||||
const name = escapeXml({ value: display_name });
|
||||
const id = escapeXml({ value: identifier });
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>${name}.crt</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>
|
||||
${certificateData({ certificate_pem })}
|
||||
</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Lets this device trust the HTTPS certificate ${name} serves.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>${name}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>${id}.certificate</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${CertificateUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Installs the local certificate authority that signs ${name}, then turn on trust under Certificate Trust Settings.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>${name}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>${id}</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${ProfileUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import buildHttpsRedirectUrl from "./build-https-redirect-url";
|
||||
|
||||
const redirect = (url: string, https_port: string | number = 7227) =>
|
||||
buildHttpsRedirectUrl({ request_url: new URL(url), https_port });
|
||||
|
||||
describe("buildHttpsRedirectUrl", () => {
|
||||
test("moves the request to the HTTPS port", () => {
|
||||
expect(redirect("http://localhost:7228/")).toBe(
|
||||
"https://localhost:7227/",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the path and the query", () => {
|
||||
expect(redirect("http://localhost:7228/admin/bots?a=1&b=2")).toBe(
|
||||
"https://localhost:7227/admin/bots?a=1&b=2",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an IPv6 literal valid", () => {
|
||||
expect(redirect("http://[::1]:7228/login")).toBe(
|
||||
"https://[::1]:7227/login",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the host the browser asked for", () => {
|
||||
expect(redirect("http://cobble.tailnet.ts.net:7228/")).toBe(
|
||||
"https://cobble.tailnet.ts.net:7227/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The plain-HTTP request that should move to HTTPS.
|
||||
*/
|
||||
request_url: URL;
|
||||
/**
|
||||
* The port the app serves HTTPS on.
|
||||
*/
|
||||
https_port: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The HTTPS address for a plain-HTTP request: same host, path and query,
|
||||
* on the HTTPS port. `request_url.hostname` keeps the brackets of an IPv6
|
||||
* literal, so the result stays a valid URL.
|
||||
*/
|
||||
export default function buildHttpsRedirectUrl({
|
||||
request_url,
|
||||
https_port,
|
||||
}: Params): string {
|
||||
return `https://${request_url.hostname}:${https_port}${request_url.pathname}${request_url.search}`;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import buildSanList from "./build-san-list";
|
||||
|
||||
describe("buildSanList", () => {
|
||||
test("always covers localhost", () => {
|
||||
expect(buildSanList({})).toEqual([
|
||||
"DNS:localhost",
|
||||
"IP:127.0.0.1",
|
||||
"IP:::1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds the machine's name, its short form and its mDNS name", () => {
|
||||
const list = buildSanList({ hostname: "studio.tailnet.ts.net" });
|
||||
|
||||
expect(list).toContain("DNS:studio.tailnet.ts.net");
|
||||
expect(list).toContain("DNS:studio");
|
||||
expect(list).toContain("DNS:studio.local");
|
||||
});
|
||||
|
||||
test("does not invent localhost.local", () => {
|
||||
expect(buildSanList({ hostname: "localhost" })).toEqual([
|
||||
"DNS:localhost",
|
||||
"IP:127.0.0.1",
|
||||
"IP:::1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("classifies addresses and names", () => {
|
||||
const list = buildSanList({
|
||||
addresses: ["192.168.1.20", "fe80::1"],
|
||||
hosts: ["cobble.tailnet.ts.net", "10.8.0.1"],
|
||||
});
|
||||
|
||||
expect(list).toContain("IP:192.168.1.20");
|
||||
expect(list).toContain("IP:fe80::1");
|
||||
expect(list).toContain("DNS:cobble.tailnet.ts.net");
|
||||
expect(list).toContain("IP:10.8.0.1");
|
||||
expect(list).not.toContain("DNS:10.8.0.1");
|
||||
});
|
||||
|
||||
test("deduplicates names regardless of case", () => {
|
||||
const list = buildSanList({
|
||||
hostname: "Studio",
|
||||
hosts: ["studio", "STUDIO.local"],
|
||||
});
|
||||
|
||||
expect(list.filter((entry) => entry == "DNS:studio")).toHaveLength(1);
|
||||
expect(
|
||||
list.filter((entry) => entry == "DNS:studio.local"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("ignores empty entries", () => {
|
||||
expect(buildSanList({ hosts: ["", " "] })).toEqual([
|
||||
"DNS:localhost",
|
||||
"IP:127.0.0.1",
|
||||
"IP:::1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import net from "net";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The machine's own name, as `os.hostname()` gives it. Its short form
|
||||
* and `<short>.local` are added too, since that is how a LAN reaches
|
||||
* it.
|
||||
*/
|
||||
hostname?: string;
|
||||
/**
|
||||
* Addresses the app answers on.
|
||||
*/
|
||||
addresses?: string[];
|
||||
/**
|
||||
* Names or addresses given by the user, e.g. a VPN's DNS name.
|
||||
*/
|
||||
hosts?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The `subjectAltName` entries a certificate is issued for: `DNS:name` for
|
||||
* a name, `IP:address` for an address, deduplicated. `localhost` and both
|
||||
* loopback addresses are always there, so `https://localhost` (and a
|
||||
* forwarded port) keep working.
|
||||
*
|
||||
* A name is classified by `net.isIP`, so a user can pass an address
|
||||
* wherever a host is expected without it becoming a useless DNS entry.
|
||||
*/
|
||||
export default function buildSanList({
|
||||
hostname,
|
||||
addresses,
|
||||
hosts,
|
||||
}: Params): string[] {
|
||||
const entries: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = ({ type, value }: { type: "DNS" | "IP"; value: string }) => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
const entry = `${type}:${
|
||||
type == "DNS" ? trimmed.toLowerCase() : trimmed
|
||||
}`;
|
||||
const key = entry.toLowerCase();
|
||||
|
||||
if (seen.has(key)) return;
|
||||
|
||||
seen.add(key);
|
||||
entries.push(entry);
|
||||
};
|
||||
|
||||
const addHost = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
add({ type: net.isIP(trimmed) ? "IP" : "DNS", value: trimmed });
|
||||
};
|
||||
|
||||
add({ type: "DNS", value: "localhost" });
|
||||
add({ type: "IP", value: "127.0.0.1" });
|
||||
add({ type: "IP", value: "::1" });
|
||||
|
||||
const own = (hostname ?? "").trim();
|
||||
|
||||
if (own) {
|
||||
addHost(own);
|
||||
|
||||
const short = own.split(".")[0];
|
||||
|
||||
if (short && short != "localhost") {
|
||||
add({ type: "DNS", value: short });
|
||||
|
||||
if (short != own) add({ type: "DNS", value: `${short}.local` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const address of addresses ?? []) addHost(address);
|
||||
for (const host of hosts ?? []) addHost(host);
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import pickTlsFiles from "./pick-tls-files";
|
||||
|
||||
const fallback = {
|
||||
fallback_cert_file: "certs/server.crt",
|
||||
fallback_key_file: "certs/server.key",
|
||||
fallback_ca_file: "certs/ca.crt",
|
||||
};
|
||||
|
||||
const exists = (paths: string[]) => (path: string) => paths.includes(path);
|
||||
|
||||
const pick = (
|
||||
options: Partial<Parameters<typeof pickTlsFiles>[0]> = {},
|
||||
paths: string[] = ["certs/server.crt", "certs/server.key", "certs/ca.crt"],
|
||||
) =>
|
||||
pickTlsFiles({
|
||||
...fallback,
|
||||
file_exists: exists(paths),
|
||||
...options,
|
||||
});
|
||||
|
||||
describe("pickTlsFiles", () => {
|
||||
test("prefers the TLS variables", () => {
|
||||
const picked = pick({
|
||||
cert_file: "/etc/cert.pem",
|
||||
key_file: "/etc/key.pem",
|
||||
ca_file: "/etc/ca.pem",
|
||||
});
|
||||
|
||||
expect(picked).toEqual({
|
||||
cert_file: "/etc/cert.pem",
|
||||
key_file: "/etc/key.pem",
|
||||
ca_file: "/etc/ca.pem",
|
||||
source: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses certs/ when the variables are unset, in any environment", () => {
|
||||
const picked = pick({ cert_file: "", key_file: "" });
|
||||
|
||||
expect(picked).toEqual({
|
||||
cert_file: "certs/server.crt",
|
||||
key_file: "certs/server.key",
|
||||
ca_file: "certs/ca.crt",
|
||||
source: "fallback",
|
||||
});
|
||||
});
|
||||
|
||||
test("serves HTTP when the certificate is explicitly disabled", () => {
|
||||
expect(
|
||||
pick({
|
||||
cert_file: "/etc/cert.pem",
|
||||
key_file: "/etc/key.pem",
|
||||
disabled: "1",
|
||||
}),
|
||||
).toEqual({ source: "disabled" });
|
||||
expect(pick({ disabled: "true" })).toEqual({ source: "disabled" });
|
||||
expect(pick({ disabled: "no" }).source).toBe("fallback");
|
||||
});
|
||||
|
||||
test("serves HTTP when there is nothing to serve with", () => {
|
||||
expect(pick({}, []).source).toBe("none");
|
||||
expect(pick({}, ["certs/server.crt"]).source).toBe("none");
|
||||
});
|
||||
|
||||
test("keeps half a configuration for the resolver to reject", () => {
|
||||
expect(pick({ cert_file: "/etc/cert.pem" }, []).source).toBe("explicit");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
type Params = {
|
||||
/**
|
||||
* `TLS_CERT_FILE`, when the environment names one.
|
||||
*/
|
||||
cert_file?: string;
|
||||
/**
|
||||
* `TLS_KEY_FILE`, when the environment names one.
|
||||
*/
|
||||
key_file?: string;
|
||||
/**
|
||||
* `TLS_CA_FILE`, served for devices to trust.
|
||||
*/
|
||||
ca_file?: string;
|
||||
/**
|
||||
* `TLS_DISABLED`, the explicit opt-out.
|
||||
*/
|
||||
disabled?: string;
|
||||
/**
|
||||
* Where a certificate is looked for when the environment names none:
|
||||
* `certs/` at the project root, which is also where the container's
|
||||
* `/app/certs` mount lands.
|
||||
*/
|
||||
fallback_cert_file: string;
|
||||
fallback_key_file: string;
|
||||
fallback_ca_file: string;
|
||||
file_exists: (path: string) => boolean;
|
||||
};
|
||||
|
||||
export type TlsFiles = {
|
||||
cert_file?: string;
|
||||
key_file?: string;
|
||||
ca_file?: string;
|
||||
/**
|
||||
* Where the choice came from, for the startup log: the environment,
|
||||
* the `certs/` fallback, an explicit disable, or nothing at all.
|
||||
*/
|
||||
source: "explicit" | "fallback" | "disabled" | "none";
|
||||
};
|
||||
|
||||
const DisabledValues = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
/**
|
||||
* Which certificate files to serve with, in every environment: the
|
||||
* `TLS_*` variables when they are set, else `certs/` when it exists, and
|
||||
* plain HTTP only when `TLS_DISABLED` says so or there is nothing to serve
|
||||
* with.
|
||||
*
|
||||
* Nothing here is tied to `NODE_ENV`: a production process that has a
|
||||
* certificate serves HTTPS, and the only way to turn it off is to say so.
|
||||
*/
|
||||
export default function pickTlsFiles({
|
||||
cert_file,
|
||||
key_file,
|
||||
ca_file,
|
||||
disabled,
|
||||
fallback_cert_file,
|
||||
fallback_key_file,
|
||||
fallback_ca_file,
|
||||
file_exists,
|
||||
}: Params): TlsFiles {
|
||||
if (disabled && DisabledValues.has(disabled.trim().toLowerCase())) {
|
||||
return { source: "disabled" };
|
||||
}
|
||||
|
||||
const cert = cert_file?.trim();
|
||||
const key = key_file?.trim();
|
||||
|
||||
if (cert || key) {
|
||||
return { cert_file: cert, key_file: key, ca_file: ca_file?.trim(), source: "explicit" };
|
||||
}
|
||||
|
||||
if (file_exists(fallback_cert_file) && file_exists(fallback_key_file)) {
|
||||
return {
|
||||
cert_file: fallback_cert_file,
|
||||
key_file: fallback_key_file,
|
||||
ca_file: ca_file?.trim() ||
|
||||
(file_exists(fallback_ca_file) ? fallback_ca_file : undefined),
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import resolveTlsOptions from "./resolve-tls-options";
|
||||
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "cobble-tls-"));
|
||||
const cert = path.join(dir, "server.crt");
|
||||
const key = path.join(dir, "server.key");
|
||||
|
||||
writeFileSync(cert, "certificate");
|
||||
writeFileSync(key, "key");
|
||||
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
describe("resolveTlsOptions", () => {
|
||||
test("plain HTTP when neither is set", () => {
|
||||
expect(resolveTlsOptions({})).toBeUndefined();
|
||||
expect(
|
||||
resolveTlsOptions({ cert_file: "", key_file: " " }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reads both files when they are set", async () => {
|
||||
const tls = resolveTlsOptions({ cert_file: cert, key_file: key });
|
||||
|
||||
expect(tls).toBeDefined();
|
||||
expect(await tls!.cert.text()).toBe("certificate");
|
||||
expect(await tls!.key.text()).toBe("key");
|
||||
});
|
||||
|
||||
test("refuses half a configuration", () => {
|
||||
expect(() => resolveTlsOptions({ cert_file: cert })).toThrow(
|
||||
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||
);
|
||||
expect(() => resolveTlsOptions({ key_file: key })).toThrow(
|
||||
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a missing file", () => {
|
||||
expect(() =>
|
||||
resolveTlsOptions({
|
||||
cert_file: path.join(dir, "missing.crt"),
|
||||
key_file: key,
|
||||
}),
|
||||
).toThrow("Can't read the TLS file");
|
||||
});
|
||||
|
||||
test("refuses a directory", () => {
|
||||
expect(() =>
|
||||
resolveTlsOptions({ cert_file: dir, key_file: key }),
|
||||
).toThrow("Can't read the TLS file");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { accessSync, constants, statSync } from "fs";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* `TLS_CERT_FILE`, the certificate (or chain) the app serves.
|
||||
*/
|
||||
cert_file?: string;
|
||||
/**
|
||||
* `TLS_KEY_FILE`, its private key.
|
||||
*/
|
||||
key_file?: string;
|
||||
};
|
||||
|
||||
export type TlsOptions = {
|
||||
cert: ReturnType<typeof Bun.file>;
|
||||
key: ReturnType<typeof Bun.file>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `Bun.serve`'s TLS options from the certificate paths the installer sets,
|
||||
* or `undefined` when the app runs over plain HTTP.
|
||||
*
|
||||
* A half-configured or unreadable certificate is an error, never a silent
|
||||
* fall back to HTTP: downgrading the connection quietly would drop the
|
||||
* `Secure` cookies and the secure context the installed app and Web Push
|
||||
* need.
|
||||
*/
|
||||
export default function resolveTlsOptions({
|
||||
cert_file,
|
||||
key_file,
|
||||
}: Params): TlsOptions | undefined {
|
||||
const cert = cert_file?.trim();
|
||||
const key = key_file?.trim();
|
||||
|
||||
if (!cert && !key) return undefined;
|
||||
|
||||
if (!cert || !key) {
|
||||
throw new Error(
|
||||
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of [cert, key]) {
|
||||
try {
|
||||
if (!statSync(file).isFile()) throw new Error("not a file");
|
||||
|
||||
accessSync(file, constants.R_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Can't read the TLS file ${file}. Check that it is mounted and readable.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { cert: Bun.file(cert), key: Bun.file(key) };
|
||||
}
|
||||
Reference in New Issue
Block a user