Serve HTTPS by default with a local CA
The installer generates a CA and a 397-day certificate into $COBBLE_HOME/certs (in the app image, with the host's addresses), mounts the certificate, key and CA into the container and serves TLS from Bun on the published port. --no-https opts out; --https-cert/--https-key use a real certificate, --https-host adds names and --https-regen replaces the CA. The installer and the image health check use curl -k when TLS is on. The CA is served at /ca.crt and as an iOS/macOS profile at /ca.mobileconfig for one-tap install. Development picks up certs/ automatically, `bun run dev:phone` generates and starts, and a plain-HTTP listener on PORT + 1 redirects to HTTPS. Port sharing is off, so a stale dev server fails instead of silently answering on the same port.
This commit is contained in:
@@ -9,6 +9,7 @@ node_modules
|
|||||||
.data
|
.data
|
||||||
.bunext
|
.bunext
|
||||||
.bun-sqlite
|
.bun-sqlite
|
||||||
|
certs
|
||||||
db/cobble
|
db/cobble
|
||||||
db/.backups
|
db/.backups
|
||||||
db/.exports
|
db/.exports
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ DEV_EMAIL_RECIPIENT=
|
|||||||
ENCRYPTION_KEY=
|
ENCRYPTION_KEY=
|
||||||
ENCRYPTION_SALT=
|
ENCRYPTION_SALT=
|
||||||
|
|
||||||
|
# HTTPS (optional in development; see README). Generate files with
|
||||||
|
# `bun run gen:certs` and point these at them. The installer sets them on a
|
||||||
|
# real install. Leave all three empty to serve plain HTTP.
|
||||||
|
TLS_CERT_FILE=
|
||||||
|
TLS_KEY_FILE=
|
||||||
|
TLS_CA_FILE=
|
||||||
|
# Plain-HTTP port that redirects to the HTTPS one (default PORT + 1).
|
||||||
|
DEV_HTTP_PORT=
|
||||||
|
|
||||||
# Web Push: generated into Settings > Environment on first start. Set a
|
# Web Push: generated into Settings > Environment on first start. Set a
|
||||||
# fixed pair here only if you want to keep one across installs.
|
# fixed pair here only if you want to keep one across installs.
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
/test
|
/test
|
||||||
/.bunext
|
/.bunext
|
||||||
|
|
||||||
|
# locally generated HTTPS certificates (bun run gen:certs)
|
||||||
|
/certs
|
||||||
|
|
||||||
# bun-sqlite local database + artifacts
|
# bun-sqlite local database + artifacts
|
||||||
/db/cobble
|
/db/cobble
|
||||||
/db/.backups
|
/db/.backups
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ 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`).
|
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`).
|
||||||
|
|
||||||
|
HTTPS is the default. `src/scripts/generate-local-certs.ts` (run inside the app image, since a container's interfaces are not the host's; the host's IPs and name are passed with `--host`) writes a local CA and a 397-day leaf into `$COBBLE_HOME/certs`; the certificate, key and CA are mounted read only into `/app/certs` (never the CA's key) and pointed at by `TLS_CERT_FILE`, `TLS_KEY_FILE` and `TLS_CA_FILE` (process-only envs). `src/utils/resolve-tls-options.ts` turns them into `Bun.serve`'s `tls`; a half-set or unreadable pair stops the process instead of falling back to HTTP, and `isSecureRequest` then sees an `https:` URL so cookies get `Secure`. `GET /ca.crt` (`serve-local-ca.ts`) serves the local CA for devices to trust, and `GET /ca.mobileconfig` (`build-ca-profile.ts`) serves the same CA as an iOS/macOS configuration profile for one-tap install (the trust switch after it is Apple's, not ours). The CA is kept across updates; the leaf is re-issued when the `sans.txt` names change or it is within 30 days of expiring, which a rerun does. `--no-https` removes the mount and envs (for a proxy or tunnel), `--https` turns it back on, `--https-cert`/`--https-key` copy in a real certificate, `--https-host` adds names or addresses, `--https-regen` replaces the CA. The installer and the image's `HEALTHCHECK` use `curl -k` when TLS is on. `bun run gen:certs` generates the same files into `certs/` (gitignored), `bun run dev` uses them when `TLS_*` are unset (development only), and `bun run dev:phone` generates and starts in one command. A TLS port cannot answer plain HTTP, so development also starts a redirect listener one port above (`DEV_HTTP_PORT`) that 308s to the HTTPS page (`startHttpRedirect`, tested by `build-https-redirect-url`); production publishes only the HTTPS port.
|
||||||
|
|
||||||
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
||||||
|
|
||||||
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
||||||
@@ -123,7 +125,7 @@ Images are built by GitHub Actions on a published release (`.github/workflows/re
|
|||||||
|
|
||||||
Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it).
|
Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it).
|
||||||
|
|
||||||
- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens.
|
- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`, `TLS_CERT_FILE`, `TLS_KEY_FILE`, `TLS_CA_FILE`) can't be saved: they encrypt the table or are the container's own start-up settings.
|
||||||
- **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`).
|
- **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).
|
- **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).
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -5,8 +5,10 @@ LABEL org.opencontainers.image.title="Cobble" \
|
|||||||
org.opencontainers.image.source="https://github.com/Moduletrace/cobble" \
|
org.opencontainers.image.source="https://github.com/Moduletrace/cobble" \
|
||||||
org.opencontainers.image.licenses="MIT"
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
# openssl generates the local CA and certificate the installer serves HTTPS
|
||||||
|
# with (`src/scripts/generate-local-certs.ts`).
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl bash nano wget zip unzip \
|
&& apt-get install -y --no-install-recommends curl bash nano wget zip unzip openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -31,6 +33,6 @@ COPY entrypoint.sh /entrypoint.sh
|
|||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
CMD curl -fsS "http://localhost:${PORT:-7227}/api/health" || exit 1
|
CMD if [ -n "$TLS_CERT_FILE" ]; then curl -fsSk "https://localhost:${PORT:-7227}/api/health" || exit 1; else curl -fsS "http://localhost:${PORT:-7227}/api/health" || exit 1; fi
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ missing):
|
|||||||
curl -fsSL https://url.moduletrace.com/cobble | sudo bash
|
curl -fsSL https://url.moduletrace.com/cobble | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the address it prints, usually `http://<your-ip>:7227`, and create the
|
Open the address it prints, usually `https://<your-ip>:7227`. The
|
||||||
super admin account. The first signup also creates the coordinator and the
|
certificate is signed by a local CA the installer made, so the browser warns
|
||||||
main computer, which pulls the sandbox image in the background. Give it a
|
until you trust it: open `https://<your-ip>:7227/ca.crt` on the device and
|
||||||
few minutes.
|
install it (see [HTTPS](#https)), then reload and create the super admin
|
||||||
|
account. The first signup also creates the coordinator and the main
|
||||||
|
computer, which pulls the sandbox image in the background. Give it a few
|
||||||
|
minutes.
|
||||||
|
|
||||||
Then open **Settings > Models**, add an OpenRouter key (or another provider)
|
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.
|
and wait for the model list to sync. This is what the bots think with.
|
||||||
@@ -43,13 +46,47 @@ and wait for the model list to sync. This is what the bots think with.
|
|||||||
- Docker Desktop on macOS works for development and small installs, but
|
- Docker Desktop on macOS works for development and small installs, but
|
||||||
Linux is the supported host.
|
Linux is the supported host.
|
||||||
- Port 7227, or another port with `--port`.
|
- Port 7227, or another port with `--port`.
|
||||||
- HTTPS in front of the app before you expose it to the internet. Web Push
|
- HTTPS is on by default, with a certificate for this machine. Exposing the
|
||||||
and installable PWA features need it. A Cloudflare Tunnel or Caddy in
|
app publicly? Use `--https-cert` and `--https-key` with a real
|
||||||
front of `127.0.0.1:7227` is enough.
|
certificate, or `--no-https` behind a proxy or tunnel.
|
||||||
|
|
||||||
The installer binds to `0.0.0.0` so the app is reachable from your phone on
|
The 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
|
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
||||||
thing that should reach it.
|
thing that should reach it (with `--no-https`).
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
The installer serves HTTPS by default. It generates a local CA and a
|
||||||
|
certificate for this machine into `$COBBLE_HOME/certs` and mounts them into
|
||||||
|
the container read only. The CA is kept across updates; the certificate is
|
||||||
|
re-issued when the machine's addresses change or it is within 30 days of
|
||||||
|
expiring, and a rerun of the updater does both.
|
||||||
|
|
||||||
|
Trust the CA once on each device. Until it is trusted the browser warns, and
|
||||||
|
the installed app, its service worker and Web Push stay off:
|
||||||
|
|
||||||
|
- **Download:** open `https://<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`.
|
||||||
|
|
||||||
## Update and uninstall
|
## Update and uninstall
|
||||||
|
|
||||||
@@ -80,6 +117,12 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu
|
|||||||
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
||||||
| `--version TAG` | Image tag, default `latest` |
|
| `--version TAG` | Image tag, default `latest` |
|
||||||
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
| `--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 |
|
| `--force` | Recreate the container even when it is current |
|
||||||
| `--no-pull` | Use an image that is already on the machine |
|
| `--no-pull` | Use an image that is already on the machine |
|
||||||
| `--skip-backup` | Update without a database backup |
|
| `--skip-backup` | Update without a database backup |
|
||||||
@@ -96,6 +139,7 @@ Everything that must survive an update lives under the install directory:
|
|||||||
.env # generated once, mode 600
|
.env # generated once, mode 600
|
||||||
install.conf # installer state
|
install.conf # installer state
|
||||||
backups/ # pre-update database snapshots
|
backups/ # pre-update database snapshots
|
||||||
|
certs/ # the local CA and the certificate (HTTPS)
|
||||||
data/
|
data/
|
||||||
db/cobble
|
db/cobble
|
||||||
db/.backups/
|
db/.backups/
|
||||||
@@ -103,13 +147,15 @@ Everything that must survive an update lives under the install directory:
|
|||||||
computers/<id>/ # each computer's home directory
|
computers/<id>/ # each computer's home directory
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env` holds the port and the encryption keys. The keys encrypt sessions and
|
`.env` holds the port, the encryption keys and the `TLS_*` certificate
|
||||||
settings, so keep a copy and do not rotate them without re-entering provider
|
paths. The keys encrypt sessions and settings, so keep a copy and do not
|
||||||
keys and other secrets. Provider keys, MCP servers, tools and app settings
|
rotate them without re-entering provider keys and other secrets. Provider
|
||||||
are managed in the app, not in `.env`.
|
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
|
Edit `.env` and run `docker restart cobble` to apply changes. The installer
|
||||||
only appends missing values and keeps what is already there.
|
only appends missing values and keeps what is already there, apart from the
|
||||||
|
port and the `TLS_*` entries it manages.
|
||||||
|
|
||||||
The app container is started with the Docker socket mounted and
|
The app container is started with the Docker socket mounted and
|
||||||
`MAIN_SERVER=1`, so it can create computer containers and run cron, the
|
`MAIN_SERVER=1`, so it can create computer containers and run cron, the
|
||||||
@@ -137,16 +183,42 @@ bun run db:schema
|
|||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:7227`. With no users, every page goes to
|
Open `http://localhost:7227`, or `https://localhost:7227` once `certs/`
|
||||||
`/auth/signup` to create the super admin. For a headless setup:
|
exists. With no users, every page goes to `/auth/signup` to create the super
|
||||||
|
admin. For a headless setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SEED_SUPER_ADMIN_PASSWORD=... bun run seed
|
SEED_SUPER_ADMIN_PASSWORD=... bun run seed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The development server reads the same `TLS_*` variables as the container, so
|
||||||
|
it can serve HTTPS too. That is how to try the app from a phone over a VPN,
|
||||||
|
where the secure context (installed app, Web Push, voice) needs it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev:phone -- --host cobble-laptop.tailnet-name.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
That generates `certs/` and starts the dev server with it, with no `.env`
|
||||||
|
edits. Afterwards a plain `bun run dev` reuses the certificate whenever
|
||||||
|
`certs/server.crt` and `certs/server.key` exist (production never does).
|
||||||
|
`bun run gen:certs` on its own regenerates them if you need to add a name.
|
||||||
|
|
||||||
|
Open `https://<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 `certs/` present the dev server also listens one port above (`7228`)
|
||||||
|
for plain HTTP and redirects to the HTTPS page, so a stray
|
||||||
|
`http://localhost:7228/...` link still lands. The HTTPS port itself cannot
|
||||||
|
answer plain HTTP, so `http://localhost:7227` still fails; use
|
||||||
|
`DEV_HTTP_PORT` to move the redirect.
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
| -------------------- | ------------------------------------ |
|
| -------------------- | ------------------------------------ |
|
||||||
| `bun run dev` | Development server |
|
| `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 start` | Production server on the host |
|
||||||
| `bun run typecheck` | Type-check |
|
| `bun run typecheck` | Type-check |
|
||||||
| `bun run test` | Run the test suite |
|
| `bun run test` | Run the test suite |
|
||||||
@@ -154,6 +226,7 @@ SEED_SUPER_ADMIN_PASSWORD=... bun run seed
|
|||||||
| `bun run seed:bots` | Add a sample crew |
|
| `bun run seed:bots` | Add a sample crew |
|
||||||
| `bun run db:schema` | Sync the schema and types |
|
| `bun run db:schema` | Sync the schema and types |
|
||||||
| `bun run gen:favicon` | Regenerate favicons and PWA assets |
|
| `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),
|
The app is Bun, [`@moduletrace/bunext`](https://github.com/Moduletrace/bunext),
|
||||||
SQLite via [`@moduletrace/bun-sqlite`](https://github.com/Moduletrace/bun-sqlite),
|
SQLite via [`@moduletrace/bun-sqlite`](https://github.com/Moduletrace/bun-sqlite),
|
||||||
|
|||||||
+5
-1
@@ -61,7 +61,11 @@ Before the first public release:
|
|||||||
- [ ] The GHCR package is public.
|
- [ ] The GHCR package is public.
|
||||||
- [ ] The first release is installed on the real host and a signup, a chat
|
- [ ] The first release is installed on the real host and a signup, a chat
|
||||||
reply, a computer and a routine have been exercised.
|
reply, a computer and a routine have been exercised.
|
||||||
- [ ] DNS and the reverse proxy or tunnel point at the container.
|
- [ ] A plain `curl | bash` install ends on `https://`, `/ca.crt`
|
||||||
|
downloads, and a device that trusted the CA installs the app, gets a
|
||||||
|
live chat socket and can turn on notifications.
|
||||||
|
- [ ] `--no-https` behind a proxy, and `--https-cert`/`--https-key` with a
|
||||||
|
real certificate, were each tried once.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
|
|||||||
+325
-15
@@ -8,6 +8,10 @@
|
|||||||
# Docker when it is missing, creates the cobble network and starts the app
|
# Docker when it is missing, creates the cobble network and starts the app
|
||||||
# container with the docker socket mounted, so the app can create the
|
# container with the docker socket mounted, so the app can create the
|
||||||
# computers it runs.
|
# computers it runs.
|
||||||
|
#
|
||||||
|
# HTTPS is on by default: a local CA and a certificate for this machine are
|
||||||
|
# generated into HOME/certs and mounted into the container, which keeps the
|
||||||
|
# data and the certificate across updates. `--no-https` serves plain HTTP.
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
CONTAINER="cobble"
|
CONTAINER="cobble"
|
||||||
@@ -77,6 +81,21 @@ Options:
|
|||||||
--purge With --uninstall, also delete the data directory
|
--purge With --uninstall, also delete the data directory
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
|
HTTPS:
|
||||||
|
It is on by default. A local CA and a certificate for this machine are
|
||||||
|
generated into HOME/certs. Trust the CA on each device first:
|
||||||
|
https://<this machine>:<port>/ca.crt, or HOME/certs/ca.crt.
|
||||||
|
iPhone and iPad install it in one tap from
|
||||||
|
https://<this machine>:<port>/ca.mobileconfig.
|
||||||
|
|
||||||
|
--no-https Serve plain HTTP (for a proxy or tunnel in front)
|
||||||
|
--https Serve HTTPS (the default; re-enables after --no-https)
|
||||||
|
--https-host VALUE A name or IP the certificate covers (repeatable),
|
||||||
|
e.g. a VPN's DNS name
|
||||||
|
--https-cert FILE Use your own certificate instead of the local CA
|
||||||
|
--https-key FILE Its private key (given with --https-cert)
|
||||||
|
--https-regen Replace the local CA and the certificate
|
||||||
|
|
||||||
The data directory holds the database, media and every computer's home
|
The data directory holds the database, media and every computer's home
|
||||||
directory. It is kept when the container is removed. Settings live in
|
directory. It is kept when the container is removed. Settings live in
|
||||||
HOME/.env; restart the container after changing them.
|
HOME/.env; restart the container after changing them.
|
||||||
@@ -137,6 +156,11 @@ IMAGE="${COBBLE_IMAGE:-}"
|
|||||||
VERSION="${COBBLE_VERSION:-}"
|
VERSION="${COBBLE_VERSION:-}"
|
||||||
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
||||||
DOCKER_SOCKET="${COBBLE_DOCKER_SOCKET:-${DOCKER_SOCKET:-/var/run/docker.sock}}"
|
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"
|
ACTION="install"
|
||||||
FORCE=0
|
FORCE=0
|
||||||
@@ -144,6 +168,14 @@ NO_PULL=0
|
|||||||
NO_DOCKER_INSTALL=0
|
NO_DOCKER_INSTALL=0
|
||||||
SKIP_BACKUP=0
|
SKIP_BACKUP=0
|
||||||
PURGE=0
|
PURGE=0
|
||||||
|
HTTPS_DISABLED=0
|
||||||
|
HTTPS_FORCE=0
|
||||||
|
HTTPS_HOSTS_GIVEN=0
|
||||||
|
HTTPS_CERT_GIVEN=0
|
||||||
|
HTTPS_REGEN=0
|
||||||
|
HTTPS_MANAGED=1
|
||||||
|
CERTS_CHANGED=0
|
||||||
|
CERT_EXPIRY=""
|
||||||
|
|
||||||
env_get() {
|
env_get() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
@@ -191,6 +223,38 @@ while [ $# -gt 0 ]; do
|
|||||||
EXTRA_NETWORK="${2:-}"
|
EXTRA_NETWORK="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--https)
|
||||||
|
HTTPS_ENABLED=1
|
||||||
|
HTTPS_FORCE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-https)
|
||||||
|
HTTPS_DISABLED=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--https-host)
|
||||||
|
if [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then
|
||||||
|
HTTPS_HOSTS="$HTTPS_HOSTS ${2:-}"
|
||||||
|
else
|
||||||
|
HTTPS_HOSTS="${2:-}"
|
||||||
|
HTTPS_HOSTS_GIVEN=1
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--https-cert)
|
||||||
|
HTTPS_CERT_SOURCE="${2:-}"
|
||||||
|
HTTPS_CERT_GIVEN=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--https-key)
|
||||||
|
HTTPS_KEY_SOURCE="${2:-}"
|
||||||
|
HTTPS_CERT_GIVEN=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--https-regen)
|
||||||
|
HTTPS_REGEN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--force)
|
--force)
|
||||||
FORCE=1
|
FORCE=1
|
||||||
shift
|
shift
|
||||||
@@ -264,10 +328,45 @@ if [ "$PURGE" = 1 ] && [ "$ACTION" != "uninstall" ]; then
|
|||||||
fail "--purge only works with --uninstall"
|
fail "--purge only works with --uninstall"
|
||||||
fi
|
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"
|
ENV_FILE="$HOME_DIR/.env"
|
||||||
STATE_FILE="$HOME_DIR/install.conf"
|
STATE_FILE="$HOME_DIR/install.conf"
|
||||||
DATA_DIR="${DATA_DIR%/}"
|
DATA_DIR="${DATA_DIR%/}"
|
||||||
BACKUP_DIR="$HOME_DIR/backups"
|
BACKUP_DIR="$HOME_DIR/backups"
|
||||||
|
CERTS_DIR="$HOME_DIR/certs"
|
||||||
|
|
||||||
# ---------------------------------------------------------------- platform
|
# ---------------------------------------------------------------- platform
|
||||||
|
|
||||||
@@ -298,9 +397,19 @@ fi
|
|||||||
|
|
||||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro"
|
ENV_MOUNT="$ENV_FILE:/app/.env:ro"
|
||||||
DATA_MOUNT="$DATA_DIR:/data"
|
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
|
if [ -n "$SELINUX_SUFFIX" ]; then
|
||||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro,z"
|
ENV_MOUNT="$ENV_FILE:/app/.env:ro,z"
|
||||||
DATA_MOUNT="$DATA_DIR:/data,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
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------- helpers
|
# ---------------------------------------------------------------- helpers
|
||||||
@@ -332,6 +441,25 @@ ensure_secret() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unset_env() {
|
||||||
|
local key="$1"
|
||||||
|
[ -f "$ENV_FILE" ] || return 0
|
||||||
|
sed -i.bak -E "/^[[:space:]]*${key}=.*$/d" "$ENV_FILE"
|
||||||
|
rm -f "$ENV_FILE.bak"
|
||||||
|
}
|
||||||
|
|
||||||
|
health_scheme() {
|
||||||
|
if [ "$HTTPS_ENABLED" = 1 ]; then
|
||||||
|
printf 'https'
|
||||||
|
else
|
||||||
|
printf 'http'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
health_url() {
|
||||||
|
printf '%s://%s:%s/api/health' "$(health_scheme)" "$(health_host)" "$PORT"
|
||||||
|
}
|
||||||
|
|
||||||
set_env() {
|
set_env() {
|
||||||
local key="$1" value="$2" escaped
|
local key="$1" value="$2" escaped
|
||||||
escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')"
|
escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')"
|
||||||
@@ -371,6 +499,10 @@ docker_socket_mounted() {
|
|||||||
docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
|
docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mounted_source() {
|
||||||
|
docker inspect -f '{{range .Mounts}}{{if eq .Destination "'"$1"'"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
|
||||||
|
}
|
||||||
|
|
||||||
container_matches() {
|
container_matches() {
|
||||||
container_running || return 1
|
container_running || return 1
|
||||||
[ "$(container_image_id)" = "$TARGET_IMAGE_ID" ] || return 1
|
[ "$(container_image_id)" = "$TARGET_IMAGE_ID" ] || return 1
|
||||||
@@ -386,6 +518,30 @@ container_matches() {
|
|||||||
if [ -n "$EXTRA_NETWORK" ]; then
|
if [ -n "$EXTRA_NETWORK" ]; then
|
||||||
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1
|
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1
|
||||||
fi
|
fi
|
||||||
|
if [ "$HTTPS_ENABLED" = 1 ]; then
|
||||||
|
[ "$(mounted_source /app/certs/server.crt)" = "$CERTS_DIR/server.crt" ] || return 1
|
||||||
|
[ "$(mounted_source /app/certs/server.key)" = "$CERTS_DIR/server.key" ] || return 1
|
||||||
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CERT_FILE=/app/certs/server.crt" || return 1
|
||||||
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_KEY_FILE=/app/certs/server.key" || return 1
|
||||||
|
if [ "$HTTPS_MANAGED" = 1 ]; then
|
||||||
|
[ "$(mounted_source /app/certs/ca.crt)" = "$CERTS_DIR/ca.crt" ] || return 1
|
||||||
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CA_FILE=/app/certs/ca.crt" || return 1
|
||||||
|
else
|
||||||
|
if [ -n "$(mounted_source /app/certs/ca.crt)" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CA_FILE=/app/certs/ca.crt"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ -n "$(mounted_source /app/certs/server.crt)" ] || [ -n "$(mounted_source /app/certs/server.key)" ] || [ -n "$(mounted_source /app/certs/ca.crt)" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -q "^TLS_CERT_FILE="; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,17 +554,18 @@ health_host() {
|
|||||||
|
|
||||||
host_ips() {
|
host_ips() {
|
||||||
if [ "$OS" = "Darwin" ]; then
|
if [ "$OS" = "Darwin" ]; then
|
||||||
ipconfig getifaddr en0 2>/dev/null || true
|
ifconfig 2>/dev/null | awk '/^[[:space:]]+inet / && $2 != "127.0.0.1" { print $2 }' | head -n 5 || true
|
||||||
elif command -v hostname >/dev/null 2>&1; then
|
elif command -v hostname >/dev/null 2>&1; then
|
||||||
hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 3 || true
|
hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 5 || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_health() {
|
wait_for_health() {
|
||||||
local url="http://$(health_host):$PORT/api/health" start=0
|
local url start=0
|
||||||
|
url="$(health_url)"
|
||||||
say "Waiting for Cobble to answer at $url"
|
say "Waiting for Cobble to answer at $url"
|
||||||
while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
|
while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
|
||||||
if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then
|
if curl -fsSk --max-time 3 -o /dev/null "$url" 2>/dev/null; then
|
||||||
ok "Cobble is healthy"
|
ok "Cobble is healthy"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -423,6 +580,89 @@ wait_for_health() {
|
|||||||
fail "Cobble did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s"
|
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() {
|
backup_database() {
|
||||||
local data_source="$DATA_DIR"
|
local data_source="$DATA_DIR"
|
||||||
if container_exists; then
|
if container_exists; then
|
||||||
@@ -505,17 +745,39 @@ connect_extra_network() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_container() {
|
run_container() {
|
||||||
|
local mounts=(
|
||||||
|
-v "$ENV_MOUNT"
|
||||||
|
-v "$DATA_MOUNT"
|
||||||
|
-v "$DOCKER_SOCKET:/var/run/docker.sock"
|
||||||
|
)
|
||||||
|
local envs=(
|
||||||
|
-e MAIN_SERVER=1
|
||||||
|
-e "PORT=$PORT"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$HTTPS_ENABLED" = 1 ]; then
|
||||||
|
mounts+=(
|
||||||
|
-v "$CERT_MOUNT_RO"
|
||||||
|
-v "$KEY_MOUNT_RO"
|
||||||
|
)
|
||||||
|
envs+=(
|
||||||
|
-e TLS_CERT_FILE=/app/certs/server.crt
|
||||||
|
-e TLS_KEY_FILE=/app/certs/server.key
|
||||||
|
)
|
||||||
|
if [ "$HTTPS_MANAGED" = 1 ]; then
|
||||||
|
mounts+=(-v "$CA_MOUNT_RO")
|
||||||
|
envs+=(-e TLS_CA_FILE=/app/certs/ca.crt)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name "$CONTAINER" \
|
--name "$CONTAINER" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--init \
|
--init \
|
||||||
--network "$NETWORK" \
|
--network "$NETWORK" \
|
||||||
-p "$BIND:$PORT:$PORT" \
|
-p "$BIND:$PORT:$PORT" \
|
||||||
-v "$ENV_MOUNT" \
|
"${mounts[@]}" \
|
||||||
-v "$DATA_MOUNT" \
|
"${envs[@]}" \
|
||||||
-v "$DOCKER_SOCKET:/var/run/docker.sock" \
|
|
||||||
-e MAIN_SERVER=1 \
|
|
||||||
-e PORT="$PORT" \
|
|
||||||
--log-driver json-file \
|
--log-driver json-file \
|
||||||
--log-opt max-size=10m \
|
--log-opt max-size=10m \
|
||||||
--log-opt max-file=3 \
|
--log-opt max-file=3 \
|
||||||
@@ -636,6 +898,20 @@ ensure_secret ENCRYPTION_KEY
|
|||||||
ensure_secret ENCRYPTION_SALT
|
ensure_secret ENCRYPTION_SALT
|
||||||
ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
|
ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
|
||||||
|
|
||||||
|
if [ "$HTTPS_ENABLED" = 1 ]; then
|
||||||
|
set_env TLS_CERT_FILE /app/certs/server.crt
|
||||||
|
set_env TLS_KEY_FILE /app/certs/server.key
|
||||||
|
if [ "$HTTPS_MANAGED" = 1 ]; then
|
||||||
|
set_env TLS_CA_FILE /app/certs/ca.crt
|
||||||
|
else
|
||||||
|
unset_env TLS_CA_FILE
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
unset_env TLS_CERT_FILE
|
||||||
|
unset_env TLS_KEY_FILE
|
||||||
|
unset_env TLS_CA_FILE
|
||||||
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR"
|
printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR"
|
||||||
printf 'COBBLE_PORT=%q\n' "$PORT"
|
printf 'COBBLE_PORT=%q\n' "$PORT"
|
||||||
@@ -644,6 +920,10 @@ ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
|
|||||||
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
||||||
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
||||||
printf 'COBBLE_DOCKER_SOCKET=%q\n' "$DOCKER_SOCKET"
|
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"
|
} >"$STATE_FILE"
|
||||||
chmod 600 "$STATE_FILE"
|
chmod 600 "$STATE_FILE"
|
||||||
|
|
||||||
@@ -665,11 +945,15 @@ if [ "$IMAGE_ARCH" != "$ARCH" ]; then
|
|||||||
fail "Image $IMAGE:$VERSION is $IMAGE_ARCH but this machine is $ARCH. Pull the multi-arch image instead of --no-pull."
|
fail "Image $IMAGE:$VERSION is $IMAGE_ARCH but this machine is $ARCH. Pull the multi-arch image instead of --no-pull."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$FORCE" = 0 ] && container_matches; then
|
if [ "$HTTPS_ENABLED" = 1 ]; then
|
||||||
|
prepare_certs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FORCE" = 0 ] && [ "$CERTS_CHANGED" = 0 ] && container_matches; then
|
||||||
connect_extra_network
|
connect_extra_network
|
||||||
if curl -fsS --max-time 3 -o /dev/null "http://$(health_host):$PORT/api/health" 2>/dev/null; then
|
if curl -fsSk --max-time 3 -o /dev/null "$(health_url)" 2>/dev/null; then
|
||||||
ok "Cobble is already up to date"
|
ok "Cobble is already up to date"
|
||||||
say "Healthy at http://$(health_host):$PORT"
|
say "Healthy at $(health_scheme)://$(health_host):$PORT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
warn "The container is running but not answering; recreating it"
|
warn "The container is running but not answering; recreating it"
|
||||||
@@ -689,16 +973,38 @@ connect_extra_network
|
|||||||
wait_for_health
|
wait_for_health
|
||||||
|
|
||||||
ok "Cobble is running"
|
ok "Cobble is running"
|
||||||
|
SCHEME="$(health_scheme)"
|
||||||
printf '\n'
|
printf '\n'
|
||||||
printf ' %s%s%s\n' "$C_BOLD" "Open Cobble" "$C_RESET"
|
printf ' %s%s%s\n' "$C_BOLD" "Open Cobble" "$C_RESET"
|
||||||
if [ "$BIND" = "0.0.0.0" ]; then
|
if [ "$BIND" = "0.0.0.0" ]; then
|
||||||
while IFS= read -r ip; do
|
while IFS= read -r ip; do
|
||||||
[ -n "$ip" ] && printf ' http://%s:%s\n' "$ip" "$PORT"
|
[ -n "$ip" ] && printf ' %s://%s:%s\n' "$SCHEME" "$ip" "$PORT"
|
||||||
done < <(host_ips)
|
done < <(host_ips)
|
||||||
fi
|
fi
|
||||||
printf ' http://127.0.0.1:%s\n' "$PORT"
|
printf ' %s://127.0.0.1:%s\n' "$SCHEME" "$PORT"
|
||||||
printf '\n'
|
printf '\n'
|
||||||
printf ' First visit: create the super admin account.\n'
|
if [ "$HTTPS_ENABLED" = 1 ]; then
|
||||||
|
printf ' Certificate\n'
|
||||||
|
if [ "$HTTPS_MANAGED" = 1 ]; then
|
||||||
|
printf ' Signed by a local CA on this machine. Trust it once on\n'
|
||||||
|
printf ' each device, then reload: the installed app and\n'
|
||||||
|
printf ' notifications need it.\n'
|
||||||
|
printf ' %s://<the address above>:%s/ca.crt\n' "$SCHEME" "$PORT"
|
||||||
|
printf ' %s://<the address above>:%s/ca.mobileconfig (iPhone/iPad)\n' "$SCHEME" "$PORT"
|
||||||
|
printf ' or copy %s\n' "$CERTS_DIR/ca.crt"
|
||||||
|
else
|
||||||
|
printf ' Using your certificate at %s\n' "$HTTPS_CERT_SOURCE"
|
||||||
|
fi
|
||||||
|
if [ -n "$CERT_EXPIRY" ]; then
|
||||||
|
printf ' Expires: %s\n' "$CERT_EXPIRY"
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
fi
|
||||||
|
if [ "$HTTPS_ENABLED" = 1 ] && [ "$HTTPS_MANAGED" = 1 ]; then
|
||||||
|
printf ' First visit: install the certificate, then create the super admin account.\n'
|
||||||
|
else
|
||||||
|
printf ' First visit: create the super admin account.\n'
|
||||||
|
fi
|
||||||
printf ' Data: %s\n' "$DATA_DIR"
|
printf ' Data: %s\n' "$DATA_DIR"
|
||||||
printf ' Settings: %s\n' "$ENV_FILE"
|
printf ' Settings: %s\n' "$ENV_FILE"
|
||||||
printf ' Backups: %s\n' "$BACKUP_DIR"
|
printf ' Backups: %s\n' "$BACKUP_DIR"
|
||||||
@@ -707,5 +1013,9 @@ printf ' Update: curl -fsSL %s | sudo bash\n' "$INSTALL_URL"
|
|||||||
printf ' Uninstall: curl -fsSL %s | sudo bash -s -- --uninstall\n' "$INSTALL_URL"
|
printf ' Uninstall: curl -fsSL %s | sudo bash -s -- --uninstall\n' "$INSTALL_URL"
|
||||||
printf '\n'
|
printf '\n'
|
||||||
if [ "$BIND" = "0.0.0.0" ]; then
|
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"
|
printf ' %sThis port is reachable on your network. Put HTTPS in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun src/server.ts",
|
"dev": "bun src/server.ts",
|
||||||
|
"dev:phone": "bun src/scripts/dev-phone.ts",
|
||||||
"gen:favicon": "bun src/scripts/generate-brand-assets.ts",
|
"gen:favicon": "bun src/scripts/generate-brand-assets.ts",
|
||||||
|
"gen:certs": "bun src/scripts/generate-local-certs.ts",
|
||||||
"build": "bunx bunext build",
|
"build": "bunx bunext build",
|
||||||
"db:schema": "bunx bun-sqlite schema -t",
|
"db:schema": "bunx bun-sqlite schema -t",
|
||||||
"db:admin": "bunx bun-sqlite admin",
|
"db:admin": "bunx bun-sqlite admin",
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`,
|
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`,
|
||||||
ServerHealthMessage: "Server Running Well",
|
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`,
|
AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`,
|
||||||
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
|
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
|
||||||
AuthExpiryDays: 60,
|
AuthExpiryDays: 60,
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export type KnownEnv = {
|
|||||||
* their own keys beside these for bots and computers to use.
|
* their own keys beside these for bots and computers to use.
|
||||||
*
|
*
|
||||||
* Not here, and never in the `envs` table: `ENCRYPTION_KEY` and
|
* Not here, and never in the `envs` table: `ENCRYPTION_KEY` and
|
||||||
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR` and
|
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR`,
|
||||||
* `MAIN_SERVER` (read before the database opens). Those stay in the
|
* `MAIN_SERVER` and the `TLS_*` certificate paths (the container's own
|
||||||
* process environment.
|
* start-up settings). Those stay in the process environment.
|
||||||
*/
|
*/
|
||||||
export const EnvDict: readonly KnownEnv[] = [
|
export const EnvDict: readonly KnownEnv[] = [
|
||||||
{
|
{
|
||||||
@@ -86,6 +86,9 @@ export const ProcessOnlyEnvKeys = [
|
|||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
"DB_DIR",
|
"DB_DIR",
|
||||||
"MAIN_SERVER",
|
"MAIN_SERVER",
|
||||||
|
"TLS_CERT_FILE",
|
||||||
|
"TLS_KEY_FILE",
|
||||||
|
"TLS_CA_FILE",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ export const HelpPath = "/admin/help";
|
|||||||
* Signing in to Google and other strict sites with Cookie-Editor.
|
* Signing in to Google and other strict sites with Cookie-Editor.
|
||||||
*/
|
*/
|
||||||
export const HelpSignInTopicId = "sign-in-with-cookies";
|
export const HelpSignInTopicId = "sign-in-with-cookies";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusting the local HTTPS certificate on a device.
|
||||||
|
*/
|
||||||
|
export const HelpHttpsTopicId = "https-certificate";
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import buildCaProfile from "@/src/utils/build-ca-profile";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
pathname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the local CA that signed the app's certificate
|
||||||
|
* (`TLS_CA_FILE`), so a device can trust it without copying files across
|
||||||
|
* the network: the certificate itself, and an iOS/macOS profile that
|
||||||
|
* installs it in one tap.
|
||||||
|
*
|
||||||
|
* A certificate is public information; the key never leaves
|
||||||
|
* `$COBBLE_HOME/certs`. Returns `null` for every other path, and for an
|
||||||
|
* install using its own certificate, which has no local CA.
|
||||||
|
*/
|
||||||
|
export default async function serveLocalCa({
|
||||||
|
pathname,
|
||||||
|
}: Params): Promise<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",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import buildHttpsRedirectUrl from "@/src/utils/build-https-redirect-url";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The port the app serves HTTPS on.
|
||||||
|
*/
|
||||||
|
https_port: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development convenience: a plain-HTTP listener on the port above the
|
||||||
|
* HTTPS one, which sends the browser to the same page over HTTPS.
|
||||||
|
*
|
||||||
|
* A port that speaks TLS cannot answer plain HTTP, so this cannot sit on
|
||||||
|
* the HTTPS port itself: `http://<host>:<port>` still fails, but
|
||||||
|
* `http://<host>:<port + 1>` lands on the right page. `DEV_HTTP_PORT`
|
||||||
|
* picks another port. Production does not start it (only one port is
|
||||||
|
* published, and it is HTTPS).
|
||||||
|
*/
|
||||||
|
export default function startHttpRedirect({ https_port }: Params) {
|
||||||
|
const base = Number(https_port);
|
||||||
|
const configured = Number(process.env.DEV_HTTP_PORT);
|
||||||
|
const redirect_port =
|
||||||
|
Number.isInteger(configured) && configured > 0
|
||||||
|
? configured
|
||||||
|
: Number.isInteger(base) && base > 0
|
||||||
|
? base + 1
|
||||||
|
: 7228;
|
||||||
|
|
||||||
|
if (redirect_port == base) return undefined;
|
||||||
|
|
||||||
|
return Bun.serve({
|
||||||
|
port: redirect_port,
|
||||||
|
reusePort: false,
|
||||||
|
fetch(req) {
|
||||||
|
return Response.redirect(
|
||||||
|
buildHttpsRedirectUrl({
|
||||||
|
request_url: new URL(req.url),
|
||||||
|
https_port,
|
||||||
|
}),
|
||||||
|
308,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HelpSignInTopicId } from "@/src/dict/help-dict";
|
import { HelpHttpsTopicId, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||||
|
|
||||||
export type HelpTopic = {
|
export type HelpTopic = {
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +21,40 @@ export type HelpTopic = {
|
|||||||
* common first.
|
* common first.
|
||||||
*/
|
*/
|
||||||
export const HelpTopics: 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.
|
||||||
|
|
||||||
|
**Download it on the device.** Open one of these in the device's browser, on the same address you use for Cobble, and accept the warning if it shows:
|
||||||
|
|
||||||
|
- **iPhone or iPad:** \`https://<your address>:7227/ca.mobileconfig\`. iOS offers a profile, which is one tap to install.
|
||||||
|
- **Everything else:** \`https://<your address>:7227/ca.crt\`.
|
||||||
|
|
||||||
|
You can also copy it from the machine Cobble runs on: \`/opt/cobble/certs/ca.crt\` (or wherever it was installed).
|
||||||
|
|
||||||
|
### Install it
|
||||||
|
|
||||||
|
- **iPhone or iPad:** tap **Allow** on the downloaded profile, install it (it also appears under **Settings > General > VPN & Device Management**), then turn it on under **Settings > General > About > Certificate Trust Settings**. Both steps are needed, even with the one-tap profile.
|
||||||
|
- **Android:** **Settings > Security > Encryption & credentials > Install a certificate > CA certificate**, then pick the file.
|
||||||
|
- **macOS:** add it to the login keychain and set it to **Always Trust**.
|
||||||
|
- **Linux:** copy it into \`/usr/local/share/ca-certificates/\` and run \`sudo update-ca-certificates\`.
|
||||||
|
- **Windows:** install it under **Trusted Root Certification Authorities**.
|
||||||
|
|
||||||
|
Reload Cobble afterwards. The warning is gone.
|
||||||
|
|
||||||
|
### Why it matters
|
||||||
|
|
||||||
|
The connection is encrypted either way; the warning is about who signed the certificate. But the browser treats an untrusted address as unsafe, so the installable app, its offline support and Web Push notifications stay off until the CA is trusted. Voice recording and dictation need that safe context too.
|
||||||
|
|
||||||
|
### If it still warns
|
||||||
|
|
||||||
|
- **You installed the profile but iOS still warns.** The second step, Certificate Trust Settings, was missed. It is a separate switch.
|
||||||
|
- **You replaced the CA.** Rerunning the installer with \`--https-regen\` makes a new CA. Remove the old profile and install the new one.
|
||||||
|
- **The warning comes back every few months.** The certificate is renewed when the installer runs, but it is still signed by the same CA, so devices keep trusting it.
|
||||||
|
- **You have your own certificate.** Installs using \`--https-cert\` and \`--https-key\`, or \`--no-https\` behind a proxy or tunnel, do not need any of this.`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: HelpSignInTopicId,
|
id: HelpSignInTopicId,
|
||||||
title: "Google won't let me sign in on a computer",
|
title: "Google won't let me sign in on a computer",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Starts the development server over HTTPS, generating the certificate
|
||||||
|
* first, so a phone on the VPN can open the app in one command:
|
||||||
|
*
|
||||||
|
* bun run dev:phone -- --host <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);
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import buildSanList from "../utils/build-san-list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the certificate HTTPS runs on: a local CA and a leaf it signs,
|
||||||
|
* so a device trusts the CA once and every re-issued certificate keeps
|
||||||
|
* working.
|
||||||
|
*
|
||||||
|
* The installer runs this inside the app image (which has `openssl`), with
|
||||||
|
* the host's own addresses passed in: a container's interfaces are not the
|
||||||
|
* host's. Development runs it directly (`bun run gen:certs`), where the
|
||||||
|
* machine's own name and addresses are detected.
|
||||||
|
*
|
||||||
|
* The CA is kept until `--regen`. The leaf is re-issued when the names
|
||||||
|
* change or it is within 30 days of expiring, which is what rerunning the
|
||||||
|
* installer on an update does.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run gen:certs [--out certs] [--host NAME_OR_IP]... [--no-detect] [--regen]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CA_DAYS = 3650;
|
||||||
|
const CERT_DAYS = 397;
|
||||||
|
const RENEW_BEFORE_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
out: string;
|
||||||
|
hosts: string[];
|
||||||
|
detect: boolean;
|
||||||
|
regen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Generates the certificate HTTPS runs on.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
bun run gen:certs [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--out DIR Where to write the files (default certs)
|
||||||
|
--host VALUE A name or IP the certificate covers (repeatable)
|
||||||
|
--no-detect Do not add this machine's own name and addresses
|
||||||
|
--regen Replace the local CA and the certificate
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Writes ca.crt, ca.key, server.crt, server.key and sans.txt.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(): Options {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const hosts: string[] = [];
|
||||||
|
let out = "certs";
|
||||||
|
let detect = true;
|
||||||
|
let regen = false;
|
||||||
|
|
||||||
|
const value = (index: number, name: string) => {
|
||||||
|
const next = argv[index];
|
||||||
|
|
||||||
|
if (!next || next.startsWith("--")) fail(`${name} needs a value`);
|
||||||
|
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
switch (argv[i]) {
|
||||||
|
case "--out":
|
||||||
|
out = value(++i, "--out");
|
||||||
|
break;
|
||||||
|
case "--host":
|
||||||
|
hosts.push(value(++i, "--host"));
|
||||||
|
break;
|
||||||
|
case "--no-detect":
|
||||||
|
detect = false;
|
||||||
|
break;
|
||||||
|
case "--regen":
|
||||||
|
regen = true;
|
||||||
|
break;
|
||||||
|
case "--":
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
default:
|
||||||
|
fail(`Unknown option: ${argv[i]}. Use --help.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { out, hosts, detect, regen };
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command: string[]): string {
|
||||||
|
const result = Bun.spawnSync({
|
||||||
|
cmd: command,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdout = result.stdout?.toString() ?? "";
|
||||||
|
const stderr = result.stderr?.toString() ?? "";
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
`${command[0]} failed: ${stderr.trim() || stdout.trim() || `exit ${result.exitCode}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certSecondsLeft(file: string): number | undefined {
|
||||||
|
if (!existsSync(file)) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const end = run(["openssl", "x509", "-noout", "-enddate", "-in", file])
|
||||||
|
.trim()
|
||||||
|
.replace(/^notAfter=/, "");
|
||||||
|
const time = Date.parse(end);
|
||||||
|
|
||||||
|
if (Number.isNaN(time)) return undefined;
|
||||||
|
|
||||||
|
return (time - Date.now()) / 1000;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localNames(): { hostname: string; addresses: string[] } {
|
||||||
|
const addresses: string[] = [];
|
||||||
|
|
||||||
|
for (const entries of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const entry of entries ?? []) {
|
||||||
|
if (entry.address) addresses.push(entry.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hostname: os.hostname(), addresses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function caConfig(): string {
|
||||||
|
return `[req]
|
||||||
|
distinguished_name = dn
|
||||||
|
prompt = no
|
||||||
|
x509_extensions = v3_ca
|
||||||
|
|
||||||
|
[dn]
|
||||||
|
CN = Cobble Local CA
|
||||||
|
|
||||||
|
[v3_ca]
|
||||||
|
basicConstraints = critical,CA:TRUE,pathlen:0
|
||||||
|
keyUsage = critical,keyCertSign,cRLSign
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafRequestConfig({ common_name }: { common_name: string }): string {
|
||||||
|
return `[req]
|
||||||
|
distinguished_name = dn
|
||||||
|
prompt = no
|
||||||
|
|
||||||
|
[dn]
|
||||||
|
CN = ${common_name}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafExtensionsConfig({ sans }: { sans: string }): string {
|
||||||
|
return `[v3_leaf]
|
||||||
|
basicConstraints = critical,CA:FALSE
|
||||||
|
keyUsage = critical,digitalSignature,keyEncipherment
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
subjectAltName = ${sans}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!Bun.which("openssl")) {
|
||||||
|
fail(
|
||||||
|
"openssl is required to generate the certificate. Install the openssl package and rerun.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parseOptions();
|
||||||
|
const invalid_host = options.hosts.find(
|
||||||
|
(host) => !/^[A-Za-z0-9.*:_-]+$/.test(host),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalid_host) {
|
||||||
|
fail(`--host is not a hostname or IP address: ${invalid_host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = path.resolve(options.out);
|
||||||
|
|
||||||
|
mkdirSync(out, { recursive: true, mode: 0o700 });
|
||||||
|
chmodSync(out, 0o700);
|
||||||
|
|
||||||
|
const build = path.join(out, ".build");
|
||||||
|
rmSync(build, { recursive: true, force: true });
|
||||||
|
mkdirSync(build, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
const ca_crt = path.join(out, "ca.crt");
|
||||||
|
const ca_key = path.join(out, "ca.key");
|
||||||
|
const ca_serial = path.join(out, "ca.srl");
|
||||||
|
const server_crt = path.join(out, "server.crt");
|
||||||
|
const server_key = path.join(out, "server.key");
|
||||||
|
const sans_file = path.join(out, "sans.txt");
|
||||||
|
|
||||||
|
if (options.regen) {
|
||||||
|
for (const file of [
|
||||||
|
ca_crt,
|
||||||
|
ca_key,
|
||||||
|
ca_serial,
|
||||||
|
server_crt,
|
||||||
|
server_key,
|
||||||
|
sans_file,
|
||||||
|
]) {
|
||||||
|
rmSync(file, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detected: { hostname?: string; addresses?: string[] } = options.detect
|
||||||
|
? localNames()
|
||||||
|
: {};
|
||||||
|
const sans = buildSanList({
|
||||||
|
hostname: detected.hostname,
|
||||||
|
addresses: detected.addresses,
|
||||||
|
hosts: options.hosts,
|
||||||
|
});
|
||||||
|
const san_value = sans.join(",");
|
||||||
|
const common_name = (
|
||||||
|
sans.find(
|
||||||
|
(entry) => entry.startsWith("DNS:") && entry != "DNS:localhost",
|
||||||
|
) ?? "DNS:localhost"
|
||||||
|
)
|
||||||
|
.slice(4)
|
||||||
|
.slice(0, 64);
|
||||||
|
|
||||||
|
let ca_created = false;
|
||||||
|
|
||||||
|
if (!existsSync(ca_crt) || !existsSync(ca_key)) {
|
||||||
|
rmSync(ca_crt, { force: true });
|
||||||
|
rmSync(ca_key, { force: true });
|
||||||
|
rmSync(ca_serial, { force: true });
|
||||||
|
|
||||||
|
const config = path.join(build, "ca.cnf");
|
||||||
|
writeFileSync(config, caConfig());
|
||||||
|
|
||||||
|
run([
|
||||||
|
"openssl",
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:3072",
|
||||||
|
"-nodes",
|
||||||
|
"-sha256",
|
||||||
|
"-days",
|
||||||
|
String(CA_DAYS),
|
||||||
|
"-keyout",
|
||||||
|
ca_key,
|
||||||
|
"-out",
|
||||||
|
ca_crt,
|
||||||
|
"-config",
|
||||||
|
config,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ca_created = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current_sans = existsSync(sans_file)
|
||||||
|
? readFileSync(sans_file, "utf8").trim()
|
||||||
|
: "";
|
||||||
|
const seconds_left = certSecondsLeft(server_crt);
|
||||||
|
const issue_certificate =
|
||||||
|
ca_created ||
|
||||||
|
!existsSync(server_crt) ||
|
||||||
|
!existsSync(server_key) ||
|
||||||
|
current_sans != san_value ||
|
||||||
|
seconds_left === undefined ||
|
||||||
|
seconds_left < RENEW_BEFORE_SECONDS;
|
||||||
|
|
||||||
|
if (issue_certificate) {
|
||||||
|
const request_config = path.join(build, "leaf.cnf");
|
||||||
|
const extensions = path.join(build, "leaf-ext.cnf");
|
||||||
|
const csr = path.join(build, "server.csr");
|
||||||
|
|
||||||
|
writeFileSync(request_config, leafRequestConfig({ common_name }));
|
||||||
|
writeFileSync(extensions, leafExtensionsConfig({ sans: san_value }));
|
||||||
|
|
||||||
|
run([
|
||||||
|
"openssl",
|
||||||
|
"req",
|
||||||
|
"-new",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-nodes",
|
||||||
|
"-sha256",
|
||||||
|
"-keyout",
|
||||||
|
server_key,
|
||||||
|
"-out",
|
||||||
|
csr,
|
||||||
|
"-config",
|
||||||
|
request_config,
|
||||||
|
]);
|
||||||
|
|
||||||
|
run([
|
||||||
|
"openssl",
|
||||||
|
"x509",
|
||||||
|
"-req",
|
||||||
|
"-in",
|
||||||
|
csr,
|
||||||
|
"-CA",
|
||||||
|
ca_crt,
|
||||||
|
"-CAkey",
|
||||||
|
ca_key,
|
||||||
|
"-CAcreateserial",
|
||||||
|
"-CAserial",
|
||||||
|
ca_serial,
|
||||||
|
"-days",
|
||||||
|
String(CERT_DAYS),
|
||||||
|
"-sha256",
|
||||||
|
"-extfile",
|
||||||
|
extensions,
|
||||||
|
"-extensions",
|
||||||
|
"v3_leaf",
|
||||||
|
"-out",
|
||||||
|
server_crt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
writeFileSync(sans_file, `${san_value}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run(["openssl", "verify", "-CAfile", ca_crt, server_crt]);
|
||||||
|
|
||||||
|
chmodSync(ca_key, 0o600);
|
||||||
|
chmodSync(server_key, 0o600);
|
||||||
|
if (existsSync(ca_serial)) chmodSync(ca_serial, 0o600);
|
||||||
|
chmodSync(ca_crt, 0o644);
|
||||||
|
chmodSync(server_crt, 0o644);
|
||||||
|
chmodSync(sans_file, 0o644);
|
||||||
|
|
||||||
|
rmSync(build, { recursive: true, force: true });
|
||||||
|
|
||||||
|
console.log(`Certificate directory: ${out}`);
|
||||||
|
console.log(`Covers: ${sans.join(", ")}`);
|
||||||
|
|
||||||
|
if (ca_created) console.log("Created a new local CA and certificate.");
|
||||||
|
else if (issue_certificate)
|
||||||
|
console.log("Issued a new certificate from the local CA.");
|
||||||
|
else console.log("Kept the existing certificate.");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`certs: ${ca_created ? "created" : issue_certificate ? "renewed" : "unchanged"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
+87
-1
@@ -1,4 +1,5 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
import bunext from "@moduletrace/bunext";
|
||||||
|
import { existsSync } from "fs";
|
||||||
import handleMediaServer from "./functions/server/media/handle-media-server";
|
import handleMediaServer from "./functions/server/media/handle-media-server";
|
||||||
import cron from "./cron";
|
import cron from "./cron";
|
||||||
import { websocket } from "./websocket";
|
import { websocket } from "./websocket";
|
||||||
@@ -10,6 +11,9 @@ import liveRelay from "./live-relay";
|
|||||||
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
||||||
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
|
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
|
||||||
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
|
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
|
||||||
|
import serveLocalCa from "./functions/server/serve-local-ca";
|
||||||
|
import startHttpRedirect from "./functions/server/start-http-redirect";
|
||||||
|
import resolveTlsOptions from "./utils/resolve-tls-options";
|
||||||
import { ComputerProxyPrefix } from "./dict/computer-dict";
|
import { ComputerProxyPrefix } from "./dict/computer-dict";
|
||||||
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
|
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
|
||||||
import { ensureVapidKeys } from "./push/vapid";
|
import { ensureVapidKeys } from "./push/vapid";
|
||||||
@@ -48,7 +52,59 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTPS when the installer (or a development `.env`) points the server at
|
||||||
|
* a certificate. A broken or half-set configuration stops the process
|
||||||
|
* rather than quietly serving plain HTTP.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* In development, a certificate in `certs/` (made by `bun run gen:certs`,
|
||||||
|
* or by `bun run dev:phone`) is used without any `.env` entries, so
|
||||||
|
* trying the app on a phone over a VPN is one command.
|
||||||
|
*/
|
||||||
|
const DevCertFile = "certs/server.crt";
|
||||||
|
const DevKeyFile = "certs/server.key";
|
||||||
|
const DevCaFile = "certs/ca.crt";
|
||||||
|
|
||||||
|
let cert_file = process.env.TLS_CERT_FILE;
|
||||||
|
let key_file = process.env.TLS_KEY_FILE;
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV != "production" &&
|
||||||
|
!cert_file &&
|
||||||
|
!key_file &&
|
||||||
|
existsSync(DevCertFile) &&
|
||||||
|
existsSync(DevKeyFile)
|
||||||
|
) {
|
||||||
|
cert_file = DevCertFile;
|
||||||
|
key_file = DevKeyFile;
|
||||||
|
|
||||||
|
if (!process.env.TLS_CA_FILE && existsSync(DevCaFile)) {
|
||||||
|
process.env.TLS_CA_FILE = DevCaFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
bunext.bunextLog.info(`Using the development certificate in certs/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cert_file && !key_file && process.env.NODE_ENV != "production") {
|
||||||
|
bunext.bunextLog.info(
|
||||||
|
"Serving plain HTTP (run `bun run dev:phone`, or `bun run gen:certs` then restart, for HTTPS)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tls: ReturnType<typeof resolveTlsOptions> = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tls = resolveTlsOptions({ cert_file, key_file });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Can't start without a working TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
|
...(tls ? { tls } : {}),
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -57,6 +113,10 @@ const server = Bun.serve({
|
|||||||
|
|
||||||
if (pwa_file) return pwa_file;
|
if (pwa_file) return pwa_file;
|
||||||
|
|
||||||
|
const local_ca = await serveLocalCa({ pathname: url.pathname });
|
||||||
|
|
||||||
|
if (local_ca) return local_ca;
|
||||||
|
|
||||||
if (url.pathname.startsWith("/media")) {
|
if (url.pathname.startsWith("/media")) {
|
||||||
try {
|
try {
|
||||||
return await handleMediaServer({ req });
|
return await handleMediaServer({ req });
|
||||||
@@ -108,13 +168,39 @@ const server = Bun.serve({
|
|||||||
websocket,
|
websocket,
|
||||||
development,
|
development,
|
||||||
port,
|
port,
|
||||||
|
/**
|
||||||
|
* Fail loudly on a second dev server instead of letting Bun's default
|
||||||
|
* port sharing split requests between an old and a new process (one
|
||||||
|
* might be plain HTTP while the other is HTTPS).
|
||||||
|
*/
|
||||||
|
reusePort: false,
|
||||||
idleTimeout: development ? 80 : undefined,
|
idleTimeout: development ? 80 : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
bunext.bunextLog.info(
|
bunext.bunextLog.info(
|
||||||
`Server running on http://${server.hostname}:${server.port} ...`,
|
`Server running on ${tls ? "https" : "http"}://${server.hostname}:${server.port} ...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TLS port cannot answer plain HTTP, so a development `http://` visit
|
||||||
|
* is sent to the HTTPS page from the port above it (`startHttpRedirect`).
|
||||||
|
*/
|
||||||
|
if (tls && process.env.NODE_ENV != "production") {
|
||||||
|
try {
|
||||||
|
const redirect = startHttpRedirect({ https_port: port });
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
bunext.bunextLog.info(
|
||||||
|
`HTTP redirect on port ${redirect.port} (DEV_HTTP_PORT to change it)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
bunext.bunextLog.info(
|
||||||
|
`Could not start the development HTTP redirect on port ${Number(port) + 1}: ${error instanceof Error ? error.message : String(error)}. Stop the other server or set DEV_HTTP_PORT.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cron();
|
cron();
|
||||||
botsQueue();
|
botsQueue();
|
||||||
liveRelay();
|
liveRelay();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import buildCaProfile from "./build-ca-profile";
|
||||||
|
|
||||||
|
const pem = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBhTCCASugAwIBAgIUTEST
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
|
|
||||||
|
const profile = (display_name = "Cobble local CA") =>
|
||||||
|
buildCaProfile({
|
||||||
|
certificate_pem: pem,
|
||||||
|
display_name,
|
||||||
|
identifier: "org.example.cobble.ca",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCaProfile", () => {
|
||||||
|
test("is a root certificate profile", () => {
|
||||||
|
expect(profile()).toContain("<string>com.apple.security.root</string>");
|
||||||
|
expect(profile()).toContain("<string>Configuration</string>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("embeds the PEM body as DER base64", () => {
|
||||||
|
const body = profile();
|
||||||
|
|
||||||
|
expect(body).toContain("MIIBhTCCASugAwIBAgIUTEST");
|
||||||
|
expect(body).not.toContain("BEGIN CERTIFICATE");
|
||||||
|
expect(body).not.toContain("END CERTIFICATE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names the profile with its identifier", () => {
|
||||||
|
const body = profile();
|
||||||
|
|
||||||
|
expect(body).toContain("<string>Cobble local CA</string>");
|
||||||
|
expect(body).toContain("<string>org.example.cobble.ca</string>");
|
||||||
|
expect(body).toContain(
|
||||||
|
"<string>org.example.cobble.ca.certificate</string>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapes XML in the name", () => {
|
||||||
|
expect(profile("Ben & Co <CA>")).toContain("Ben & Co <CA>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses an empty certificate", () => {
|
||||||
|
expect(() =>
|
||||||
|
buildCaProfile({
|
||||||
|
certificate_pem: " ",
|
||||||
|
display_name: "Cobble",
|
||||||
|
identifier: "org.example.cobble.ca",
|
||||||
|
}),
|
||||||
|
).toThrow("The CA certificate is empty.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The CA certificate in PEM, as `ca.crt` holds it.
|
||||||
|
*/
|
||||||
|
certificate_pem: string;
|
||||||
|
/**
|
||||||
|
* The name iOS and macOS show for the profile.
|
||||||
|
*/
|
||||||
|
display_name: string;
|
||||||
|
/**
|
||||||
|
* Reverse-DNS identifier of the profile, e.g. `org.example.cobble.ca`.
|
||||||
|
*/
|
||||||
|
identifier: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed UUIDs, so installing the profile again replaces it on the device
|
||||||
|
* instead of leaving an older Cobble CA behind.
|
||||||
|
*/
|
||||||
|
const ProfileUUID = "B7F3C2A1-6D4E-4F8A-9C1B-2E5A7D9F0B31";
|
||||||
|
const CertificateUUID = "D2A8E4C6-1B3F-4A7D-8E5C-9F0B6A2C4D18";
|
||||||
|
|
||||||
|
const XmlEscapes: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeXml({ value }: { value: string }): string {
|
||||||
|
return value.replace(/[&<>"']/g, (character) => XmlEscapes[character]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The certificate as base64 DER, which is what a plist `<data>` holds:
|
||||||
|
* the PEM body itself, with its markers and line breaks removed.
|
||||||
|
*/
|
||||||
|
function certificateData({ certificate_pem }: { certificate_pem: string }) {
|
||||||
|
const data = certificate_pem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||||
|
.replace(/-----END CERTIFICATE-----/g, "")
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("The CA certificate is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An iOS/macOS configuration profile carrying the local CA
|
||||||
|
* (`com.apple.security.root`). Opening it on an iPhone offers a one-tap
|
||||||
|
* install instead of a file to find and install by hand; iOS still asks
|
||||||
|
* for the separate trust switch afterwards, which no profile can skip on
|
||||||
|
* an unmanaged device.
|
||||||
|
*/
|
||||||
|
export default function buildCaProfile({
|
||||||
|
certificate_pem,
|
||||||
|
display_name,
|
||||||
|
identifier,
|
||||||
|
}: Params): string {
|
||||||
|
const name = escapeXml({ value: display_name });
|
||||||
|
const id = escapeXml({ value: identifier });
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>PayloadCertificateFileName</key>
|
||||||
|
<string>${name}.crt</string>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<data>
|
||||||
|
${certificateData({ certificate_pem })}
|
||||||
|
</data>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Lets this device trust the HTTPS certificate ${name} serves.</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>${name}</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>${id}.certificate</string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.security.root</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>${CertificateUUID}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Installs the local certificate authority that signs ${name}, then turn on trust under Certificate Trust Settings.</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>${name}</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>${id}</string>
|
||||||
|
<key>PayloadRemovalDisallowed</key>
|
||||||
|
<false/>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>${ProfileUUID}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import buildHttpsRedirectUrl from "./build-https-redirect-url";
|
||||||
|
|
||||||
|
const redirect = (url: string, https_port: string | number = 7227) =>
|
||||||
|
buildHttpsRedirectUrl({ request_url: new URL(url), https_port });
|
||||||
|
|
||||||
|
describe("buildHttpsRedirectUrl", () => {
|
||||||
|
test("moves the request to the HTTPS port", () => {
|
||||||
|
expect(redirect("http://localhost:7228/")).toBe(
|
||||||
|
"https://localhost:7227/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the path and the query", () => {
|
||||||
|
expect(redirect("http://localhost:7228/admin/bots?a=1&b=2")).toBe(
|
||||||
|
"https://localhost:7227/admin/bots?a=1&b=2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an IPv6 literal valid", () => {
|
||||||
|
expect(redirect("http://[::1]:7228/login")).toBe(
|
||||||
|
"https://[::1]:7227/login",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the host the browser asked for", () => {
|
||||||
|
expect(redirect("http://cobble.tailnet.ts.net:7228/")).toBe(
|
||||||
|
"https://cobble.tailnet.ts.net:7227/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The plain-HTTP request that should move to HTTPS.
|
||||||
|
*/
|
||||||
|
request_url: URL;
|
||||||
|
/**
|
||||||
|
* The port the app serves HTTPS on.
|
||||||
|
*/
|
||||||
|
https_port: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTPS address for a plain-HTTP request: same host, path and query,
|
||||||
|
* on the HTTPS port. `request_url.hostname` keeps the brackets of an IPv6
|
||||||
|
* literal, so the result stays a valid URL.
|
||||||
|
*/
|
||||||
|
export default function buildHttpsRedirectUrl({
|
||||||
|
request_url,
|
||||||
|
https_port,
|
||||||
|
}: Params): string {
|
||||||
|
return `https://${request_url.hostname}:${https_port}${request_url.pathname}${request_url.search}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import buildSanList from "./build-san-list";
|
||||||
|
|
||||||
|
describe("buildSanList", () => {
|
||||||
|
test("always covers localhost", () => {
|
||||||
|
expect(buildSanList({})).toEqual([
|
||||||
|
"DNS:localhost",
|
||||||
|
"IP:127.0.0.1",
|
||||||
|
"IP:::1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds the machine's name, its short form and its mDNS name", () => {
|
||||||
|
const list = buildSanList({ hostname: "studio.tailnet.ts.net" });
|
||||||
|
|
||||||
|
expect(list).toContain("DNS:studio.tailnet.ts.net");
|
||||||
|
expect(list).toContain("DNS:studio");
|
||||||
|
expect(list).toContain("DNS:studio.local");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not invent localhost.local", () => {
|
||||||
|
expect(buildSanList({ hostname: "localhost" })).toEqual([
|
||||||
|
"DNS:localhost",
|
||||||
|
"IP:127.0.0.1",
|
||||||
|
"IP:::1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies addresses and names", () => {
|
||||||
|
const list = buildSanList({
|
||||||
|
addresses: ["192.168.1.20", "fe80::1"],
|
||||||
|
hosts: ["cobble.tailnet.ts.net", "10.8.0.1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(list).toContain("IP:192.168.1.20");
|
||||||
|
expect(list).toContain("IP:fe80::1");
|
||||||
|
expect(list).toContain("DNS:cobble.tailnet.ts.net");
|
||||||
|
expect(list).toContain("IP:10.8.0.1");
|
||||||
|
expect(list).not.toContain("DNS:10.8.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates names regardless of case", () => {
|
||||||
|
const list = buildSanList({
|
||||||
|
hostname: "Studio",
|
||||||
|
hosts: ["studio", "STUDIO.local"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(list.filter((entry) => entry == "DNS:studio")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
list.filter((entry) => entry == "DNS:studio.local"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores empty entries", () => {
|
||||||
|
expect(buildSanList({ hosts: ["", " "] })).toEqual([
|
||||||
|
"DNS:localhost",
|
||||||
|
"IP:127.0.0.1",
|
||||||
|
"IP:::1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import net from "net";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The machine's own name, as `os.hostname()` gives it. Its short form
|
||||||
|
* and `<short>.local` are added too, since that is how a LAN reaches
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
hostname?: string;
|
||||||
|
/**
|
||||||
|
* Addresses the app answers on.
|
||||||
|
*/
|
||||||
|
addresses?: string[];
|
||||||
|
/**
|
||||||
|
* Names or addresses given by the user, e.g. a VPN's DNS name.
|
||||||
|
*/
|
||||||
|
hosts?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `subjectAltName` entries a certificate is issued for: `DNS:name` for
|
||||||
|
* a name, `IP:address` for an address, deduplicated. `localhost` and both
|
||||||
|
* loopback addresses are always there, so `https://localhost` (and a
|
||||||
|
* forwarded port) keep working.
|
||||||
|
*
|
||||||
|
* A name is classified by `net.isIP`, so a user can pass an address
|
||||||
|
* wherever a host is expected without it becoming a useless DNS entry.
|
||||||
|
*/
|
||||||
|
export default function buildSanList({
|
||||||
|
hostname,
|
||||||
|
addresses,
|
||||||
|
hosts,
|
||||||
|
}: Params): string[] {
|
||||||
|
const entries: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const add = ({ type, value }: { type: "DNS" | "IP"; value: string }) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const entry = `${type}:${
|
||||||
|
type == "DNS" ? trimmed.toLowerCase() : trimmed
|
||||||
|
}`;
|
||||||
|
const key = entry.toLowerCase();
|
||||||
|
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
entries.push(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addHost = (value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
add({ type: net.isIP(trimmed) ? "IP" : "DNS", value: trimmed });
|
||||||
|
};
|
||||||
|
|
||||||
|
add({ type: "DNS", value: "localhost" });
|
||||||
|
add({ type: "IP", value: "127.0.0.1" });
|
||||||
|
add({ type: "IP", value: "::1" });
|
||||||
|
|
||||||
|
const own = (hostname ?? "").trim();
|
||||||
|
|
||||||
|
if (own) {
|
||||||
|
addHost(own);
|
||||||
|
|
||||||
|
const short = own.split(".")[0];
|
||||||
|
|
||||||
|
if (short && short != "localhost") {
|
||||||
|
add({ type: "DNS", value: short });
|
||||||
|
|
||||||
|
if (short != own) add({ type: "DNS", value: `${short}.local` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of addresses ?? []) addHost(address);
|
||||||
|
for (const host of hosts ?? []) addHost(host);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import resolveTlsOptions from "./resolve-tls-options";
|
||||||
|
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "cobble-tls-"));
|
||||||
|
const cert = path.join(dir, "server.crt");
|
||||||
|
const key = path.join(dir, "server.key");
|
||||||
|
|
||||||
|
writeFileSync(cert, "certificate");
|
||||||
|
writeFileSync(key, "key");
|
||||||
|
|
||||||
|
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe("resolveTlsOptions", () => {
|
||||||
|
test("plain HTTP when neither is set", () => {
|
||||||
|
expect(resolveTlsOptions({})).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
resolveTlsOptions({ cert_file: "", key_file: " " }),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads both files when they are set", async () => {
|
||||||
|
const tls = resolveTlsOptions({ cert_file: cert, key_file: key });
|
||||||
|
|
||||||
|
expect(tls).toBeDefined();
|
||||||
|
expect(await tls!.cert.text()).toBe("certificate");
|
||||||
|
expect(await tls!.key.text()).toBe("key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses half a configuration", () => {
|
||||||
|
expect(() => resolveTlsOptions({ cert_file: cert })).toThrow(
|
||||||
|
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||||
|
);
|
||||||
|
expect(() => resolveTlsOptions({ key_file: key })).toThrow(
|
||||||
|
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a missing file", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveTlsOptions({
|
||||||
|
cert_file: path.join(dir, "missing.crt"),
|
||||||
|
key_file: key,
|
||||||
|
}),
|
||||||
|
).toThrow("Can't read the TLS file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a directory", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveTlsOptions({ cert_file: dir, key_file: key }),
|
||||||
|
).toThrow("Can't read the TLS file");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { accessSync, constants, statSync } from "fs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* `TLS_CERT_FILE`, the certificate (or chain) the app serves.
|
||||||
|
*/
|
||||||
|
cert_file?: string;
|
||||||
|
/**
|
||||||
|
* `TLS_KEY_FILE`, its private key.
|
||||||
|
*/
|
||||||
|
key_file?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TlsOptions = {
|
||||||
|
cert: ReturnType<typeof Bun.file>;
|
||||||
|
key: ReturnType<typeof Bun.file>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Bun.serve`'s TLS options from the certificate paths the installer sets,
|
||||||
|
* or `undefined` when the app runs over plain HTTP.
|
||||||
|
*
|
||||||
|
* A half-configured or unreadable certificate is an error, never a silent
|
||||||
|
* fall back to HTTP: downgrading the connection quietly would drop the
|
||||||
|
* `Secure` cookies and the secure context the installed app and Web Push
|
||||||
|
* need.
|
||||||
|
*/
|
||||||
|
export default function resolveTlsOptions({
|
||||||
|
cert_file,
|
||||||
|
key_file,
|
||||||
|
}: Params): TlsOptions | undefined {
|
||||||
|
const cert = cert_file?.trim();
|
||||||
|
const key = key_file?.trim();
|
||||||
|
|
||||||
|
if (!cert && !key) return undefined;
|
||||||
|
|
||||||
|
if (!cert || !key) {
|
||||||
|
throw new Error(
|
||||||
|
"TLS_CERT_FILE and TLS_KEY_FILE must both be set, or neither.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of [cert, key]) {
|
||||||
|
try {
|
||||||
|
if (!statSync(file).isFile()) throw new Error("not a file");
|
||||||
|
|
||||||
|
accessSync(file, constants.R_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Can't read the TLS file ${file}. Check that it is mounted and readable.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cert: Bun.file(cert), key: Bun.file(key) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user