merge: deploy pipeline launch hardening (admin bootstrap, DB backups, image pinning, reproducible builds)
This commit is contained in:
@@ -35,3 +35,15 @@ LIVEKIT_API_SECRET=
|
||||
# ─── Docker Compose ────────────────────────────────────────
|
||||
# Uncomment to enable the LiveKit service:
|
||||
# COMPOSE_PROFILES=voice
|
||||
|
||||
# ─── Database Backups ──────────────────────────────────────
|
||||
# Automatic SQLite snapshots: pre-migration (always, when a migration is pending),
|
||||
# scheduled (every BACKUP_INTERVAL_HOURS), and manual (./backup.sh). Rotated per type.
|
||||
# BACKUP_DIR=./data/backups # default: <data>/backups
|
||||
# BACKUP_INTERVAL_HOURS=24
|
||||
# BACKUP_KEEP_SCHEDULED=7
|
||||
# BACKUP_KEEP_PREMIGRATION=5
|
||||
# BACKUP_KEEP_MANUAL=10
|
||||
# Off-box replication hook — receives the new snapshot path as $1 (e.g. rclone/rsync/aws s3 cp):
|
||||
# BACKUP_OFFSITE_CMD=
|
||||
# BACKUP_DISABLED=false
|
||||
|
||||
@@ -128,6 +128,7 @@ Data: `packages/server/data/` (backspace.db + uploads/)
|
||||
- `./install.sh` — Interactive first-time setup
|
||||
- `./deploy.sh [pi|vm|all]` — Rsync + rebuild on target(s)
|
||||
- Instances: `nova.ddns.net` (Pi), `orbit.ddns.net` (VM)
|
||||
- First registered user becomes admin (no default credentials). DB auto-backups to data/backups/; restore via ./restore.sh. See docs/systems/deployment.md.
|
||||
|
||||
---
|
||||
|
||||
@@ -157,6 +158,7 @@ Before modifying any subsystem, read its spec from `docs/systems/`. After making
|
||||
| [desktop.md](docs/systems/desktop.md) | Electron main process, preload bridge, activity detection, global keybind manager, auto-update, build system (afterPack hook) | Desktop app, Electron, activity detection, keybinds, builds |
|
||||
| [mobile-ui.md](docs/systems/mobile-ui.md) | MobileShell, MobileScreenStack state machine, bottom nav, swipe gestures, responsive breakpoint, voice overlay | Mobile UI, responsive layout, mobile navigation, screen stack |
|
||||
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
|
||||
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
|
||||
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ COPY packages/web/package.json packages/web/
|
||||
COPY patches/ patches/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code (excluding desktop — not needed in Docker)
|
||||
COPY packages/shared/ packages/shared/
|
||||
@@ -58,7 +58,7 @@ COPY packages/web/package.json packages/web/
|
||||
COPY patches/ patches/
|
||||
|
||||
# Install production dependencies only (tsx is in server dependencies)
|
||||
RUN pnpm install --prod --frozen-lockfile || pnpm install --prod
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy shared source (needed at runtime since server imports types directly)
|
||||
COPY packages/shared/ packages/shared/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger a manual DB snapshot inside the running Backspace container.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -q '^backspace$'; then
|
||||
echo "Error: backspace container is not running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec -w /app/packages/server backspace \
|
||||
node --import tsx/esm src/scripts/snapshot.ts
|
||||
+2
-2
@@ -32,7 +32,7 @@ services:
|
||||
|
||||
# ── Caddy reverse proxy (auto-HTTPS) ─────────────────────
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
image: caddy:2.11.1-alpine
|
||||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
# ── LiveKit voice/video server ────────────────────────────
|
||||
# Activated by COMPOSE_PROFILES=voice in .env
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
container_name: livekit
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# Deployment & Operations
|
||||
|
||||
Operator- and contributor-facing reference for hosting Backspace: the Docker build pipeline, admin bootstrap, database backup/restore, image pinning, and the relevant environment variables.
|
||||
|
||||
Source files:
|
||||
- `Dockerfile` -- multi-stage build (builder → runtime)
|
||||
- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
||||
- `Caddyfile` -- reverse proxy / auto-HTTPS config
|
||||
- `install.sh` -- interactive first-time setup
|
||||
- `deploy.sh` -- rsync + rebuild to Jannis's two boxes
|
||||
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
|
||||
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
|
||||
- `packages/server/src/utils/backup.ts` -- `createSnapshot` (VACUUM INTO), `listSnapshots`, `pruneSnapshots`, off-box hook
|
||||
- `packages/server/src/utils/backupWorker.ts` -- scheduled-snapshot interval worker
|
||||
- `packages/server/src/db/index.ts` -- pre-migration snapshot trigger + WAL-checkpointing shutdown
|
||||
- `packages/server/src/db/pendingMigrations.ts` -- `hasPendingMigrations` (gating predicate)
|
||||
- `packages/server/src/db/migrate.ts` -- `ensureDefaults` (admin recovery net)
|
||||
- `packages/server/src/routes/auth.ts` -- first-user-becomes-admin bootstrap
|
||||
- `packages/server/src/scripts/snapshot.ts` -- manual snapshot CLI entrypoint
|
||||
- `packages/server/src/scripts/remediate-seed-admin.ts` -- legacy seed-admin password rotation
|
||||
|
||||
**Out of scope:** voice/LiveKit operational tuning (see `docs/systems/voice.md`), upload/storage janitor (see `docs/systems/uploads.md`), and federation peering/replication (see `docs/systems/federation.md`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Pipeline Overview
|
||||
|
||||
Backspace ships as a single application container fronted by Caddy. Everything is built and run via Docker Compose; there is no separate CI artifact — **the image is built on each target host** from source.
|
||||
|
||||
### Build: multi-stage Dockerfile
|
||||
|
||||
`Dockerfile` has two stages:
|
||||
|
||||
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
||||
2. **`runtime`** (`node:20-slim`) — installs the native toolchain for `better-sqlite3` plus `ffmpeg` (`python3 make g++ ffmpeg`), installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and starts the server with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
||||
|
||||
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
||||
|
||||
### Run: `docker compose up -d --build`
|
||||
|
||||
`docker-compose.yml` defines:
|
||||
|
||||
| Service | Image / Build | Role |
|
||||
|---------|---------------|------|
|
||||
| `backspace` | `build: .` | The application server. Binds `./data:/app/data` (DB + uploads + backups), reads `.env`, sets `DB_PATH=/app/data/backspace.db` and `UPLOAD_DIR=/app/data/uploads`. `restart: unless-stopped`. |
|
||||
| `caddy` | `caddy:2.11.1-alpine` | Reverse proxy with automatic HTTPS. Owns ports 80/443. `depends_on: backspace` with `condition: service_healthy`. |
|
||||
| `livekit` | `livekit/livekit-server:v1.9.11` | Voice/video SFU. `network_mode: host`. Activated only when `COMPOSE_PROFILES=voice`. |
|
||||
|
||||
**Health-gated startup.** The `backspace` service declares a healthcheck that polls `/api/health` (a route registered in `packages/server/src/index.ts`) every 30 s with a 30 s `start_period`. Caddy does not start proxying until the app reports healthy, so a deploy never routes traffic to a half-initialized server. The same healthcheck is duplicated in the `Dockerfile` `HEALTHCHECK` directive so the container reports health even when run outside Compose.
|
||||
|
||||
### Caddy
|
||||
|
||||
`Caddyfile` reads `{$DOMAIN}` from the container environment (injected by Compose from `.env`) and:
|
||||
- Strips a `/livekit/*` prefix and reverse-proxies LiveKit signaling to `host.docker.internal:7880` (LiveKit runs in host networking).
|
||||
- Reverse-proxies everything else — API, WebSocket, and the static frontend — to `backspace:3000` over Docker's internal network.
|
||||
|
||||
Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the persisted ACME state lives in the `caddy-data` / `caddy-config` named volumes.
|
||||
|
||||
### First-time setup: `install.sh`
|
||||
|
||||
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain (or reads `DOMAIN=… ./install.sh`), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up with `docker compose up -d --build`.
|
||||
|
||||
### Redeploy: `deploy.sh [pi|vm|all]`
|
||||
|
||||
`./deploy.sh` is Jannis's redeploy helper for the two live instances — `nova.ddns.net` (Raspberry Pi) and `orbit.ddns.net` (VM). It does **not** build locally; it `rsync`s the working tree to the target (excluding `node_modules`, `.env`, `data/`, build output, and a list of local-only paths) and then runs `docker compose up -d --build` on the remote so the image is rebuilt in place. Targets:
|
||||
|
||||
| Arg | Target |
|
||||
|-----|--------|
|
||||
| `pi` / `--local` / `--remote` | Raspberry Pi (auto-detects LAN vs. public DNS, or forced) |
|
||||
| `vm` / `beta` / `orbit` | Beta VM |
|
||||
| `all` / `both` (default) | Both, in parallel |
|
||||
|
||||
The `data/` directory is excluded from the rsync, so application data on each box is never overwritten by a deploy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Admin Bootstrap
|
||||
|
||||
**There is no default/seed admin account.** A fresh instance starts with zero users. Admin is granted by registration order, with a recovery net for the case where every admin is later deleted.
|
||||
|
||||
### First registered user becomes admin
|
||||
|
||||
In `packages/server/src/routes/auth.ts`, registration computes:
|
||||
|
||||
```ts
|
||||
const userCount = db.select().from(schema.users).all().length;
|
||||
const isFirstUser = userCount === 0 && !homeInstance;
|
||||
// ...
|
||||
isAdmin: isFirstUser ? 1 : 0,
|
||||
```
|
||||
|
||||
The very first **locally-registered** user (`userCount === 0` **and** `!homeInstance`) is created with `isAdmin = 1`. The `!homeInstance` guard is a federation invariant: a replicated user (one whose identity is homed on another instance) is never an admin of *this* instance, even if they happen to be the first row written. This means the operator simply registers the first account after install to obtain admin rights — no credentials are printed, shipped, or stored anywhere.
|
||||
|
||||
### Recovery net: `ensureDefaults` re-promotes the earliest user
|
||||
|
||||
`ensureDefaults` (`packages/server/src/db/migrate.ts`) runs on **every boot**, after migrations. Among its idempotent invariants:
|
||||
|
||||
```ts
|
||||
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
|
||||
if (!anyAdmin) {
|
||||
const firstUser = db.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1').get();
|
||||
if (firstUser) db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id);
|
||||
}
|
||||
```
|
||||
|
||||
If the instance ever ends up with **no admins** (e.g. the sole admin deleted their account), the next restart promotes the earliest-registered remaining user back to admin. This guarantees an instance can never become permanently un-administerable. It does **not** run when an admin already exists, so it never overrides the operator's chosen admin set during normal operation.
|
||||
|
||||
### Seed-admin remediation (legacy instances only)
|
||||
|
||||
Instances installed **before** the no-seed-admin change still carry a local `admin` account whose password may be the old default `admin123`. That account cannot simply be deleted — the seeded admin **owns the default space**, so removing it would orphan the space. Instead, rotate its password with the remediation script:
|
||||
|
||||
```bash
|
||||
docker exec -w /app/packages/server backspace \
|
||||
node --import tsx/esm src/scripts/remediate-seed-admin.ts
|
||||
```
|
||||
|
||||
Behavior (`packages/server/src/scripts/remediate-seed-admin.ts`):
|
||||
|
||||
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
||||
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
||||
- **Never deletes** the account (the default-space ownership constraint above).
|
||||
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`.**
|
||||
|
||||
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
||||
|
||||
Remediation applies only to pre-change instances; newly installed instances never have a seed admin and need none of this.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Backups
|
||||
|
||||
Backspace takes **DB-only** SQLite snapshots via `VACUUM INTO`, which produces a consistent, fully-checkpointed copy of the live database without locking it for the duration of a file copy. Snapshots live in `data/backups/` (configurable). Uploads and other files under `data/` are **not** included — see the same-disk limitation below.
|
||||
|
||||
### Triggers
|
||||
|
||||
| Trigger | Where | Reason tag | When |
|
||||
|---------|-------|-----------|------|
|
||||
| **Pre-migration** | `packages/server/src/db/index.ts` (`initDatabase`) | `pre-migration` | On startup, **only when a migration is actually pending** (see gating). |
|
||||
| **Scheduled** | `packages/server/src/utils/backupWorker.ts` (`startBackupWorker`) | `scheduled` | Every `BACKUP_INTERVAL_HOURS`, via an `unref`'d `setInterval`. |
|
||||
| **Manual** | `./backup.sh` → `src/scripts/snapshot.ts` | `manual` | On demand by the operator. |
|
||||
|
||||
Snapshot filenames encode a millisecond-precision UTC timestamp and the reason tag — `backspace-<ts>-<reason>.db` — so they sort chronologically and never collide (`createSnapshot` disambiguates with a counter on the rare same-millisecond collision, since `VACUUM INTO` refuses to overwrite an existing file).
|
||||
|
||||
### Pre-migration snapshot: gated and fail-closed
|
||||
|
||||
The pre-migration snapshot is deliberately conservative:
|
||||
|
||||
- **Gated on a pending migration.** `initDatabase` snapshots only when **(a)** the DB file already existed before this boot (captured *before* opening the handle, since opening creates the file — a post-open check would snapshot an empty 0-row DB on first boot) **and (b)** `hasPendingMigrations(sqlite, migrationsFolder)` returns true. `hasPendingMigrations` (`db/pendingMigrations.ts`) compares the applied-migration count in `__drizzle_migrations` against the journal's entry count; a missing table (pre-drizzle / empty DB) counts as pending. Because schema history is stable across most restarts, this avoids churning the pre-migration retention with identical copies on every reboot.
|
||||
- **Fail-closed: a snapshot failure aborts startup by design.** If the snapshot throws (e.g. **disk full**), `initDatabase` logs and **re-throws — the migration does not run and the server does not start.** The box stays on the *old* code with its data intact until the operator frees space and restarts. **Backspace never migrates the schema without first securing a backup.** This is intentional: a failed-but-applied migration on an unbacked-up DB is the one unrecoverable scenario, so we refuse to enter it.
|
||||
|
||||
`BACKUP_DISABLED=true` turns off both the pre-migration snapshot **and** the scheduled worker (the gate at the top of `initDatabase` and the early return in `startBackupWorker`). Use it only when an external backup system owns `data/`.
|
||||
|
||||
### WAL checkpoint on shutdown
|
||||
|
||||
On `SIGINT`/`SIGTERM` the server calls `closeDatabase()` (`index.ts` shutdown handler), which checkpoints the WAL so the on-disk `backspace.db` is a complete, self-contained file. This keeps host-side copies of `data/backspace.db` consistent even without going through `VACUUM INTO` (e.g. an off-box host backup of the whole `data/` directory taken while the container is stopped).
|
||||
|
||||
### Configuration
|
||||
|
||||
All vars are parsed in `packages/server/src/config.ts` under `config.backup`. Defaults shown.
|
||||
|
||||
| Var | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `BACKUP_DIR` | `<dir(DB_PATH)>/backups` (i.e. `data/backups`) | Where snapshots are written. |
|
||||
| `BACKUP_INTERVAL_HOURS` | `24` | Scheduled-snapshot cadence. |
|
||||
| `BACKUP_KEEP_SCHEDULED` | `7` | Scheduled snapshots retained (newest-first). |
|
||||
| `BACKUP_KEEP_PREMIGRATION` | `5` | Pre-migration snapshots retained. |
|
||||
| `BACKUP_KEEP_MANUAL` | `10` | Manual snapshots retained. |
|
||||
| `BACKUP_OFFSITE_CMD` | _(unset)_ | Off-box replication hook (see below). |
|
||||
| `BACKUP_DISABLED` | `false` | Disable all automatic snapshots. |
|
||||
|
||||
### Retention / pruning
|
||||
|
||||
`pruneSnapshots()` enforces per-reason retention independently: it lists each reason's snapshots newest-first and unlinks everything past the keep count for that reason. Pruning runs after each **scheduled** and **manual** snapshot. (The pre-migration trigger does not prune inline — its own retention is enforced the next time a scheduled/manual snapshot prunes, and migrations are infrequent.) A failed prune is logged but never fatal.
|
||||
|
||||
### Off-box replication hook
|
||||
|
||||
After writing a snapshot, `createSnapshot` invokes `BACKUP_OFFSITE_CMD` (if set). The command is run as `sh -c '<cmd> "$1"'` with the new snapshot's absolute path passed as `$1`, so your command is **appended** the snapshot path as a trailing argument (and may also reference `"$1"` explicitly for full control over the destination). This is **best-effort**: failures are logged, never fatal, and run asynchronously. Examples:
|
||||
|
||||
```bash
|
||||
# Each resolves to: <cmd> "<absolute snapshot path>"
|
||||
BACKUP_OFFSITE_CMD='rclone copy --quiet' # → rclone copy --quiet "<snapshot>" (dest must be in cmd, see below)
|
||||
BACKUP_OFFSITE_CMD='aws s3 cp' # → aws s3 cp "<snapshot>" (append the bucket, see below)
|
||||
|
||||
# When you need to control the destination, reference "$1" yourself:
|
||||
BACKUP_OFFSITE_CMD='rclone copyto -- "$1" remote:backspace/$(basename "$1")'
|
||||
BACKUP_OFFSITE_CMD='aws s3 cp -- "$1" s3://my-bucket/backspace/'
|
||||
BACKUP_OFFSITE_CMD='rsync -a -- "$1" backup-host:/srv/backspace-backups/'
|
||||
```
|
||||
|
||||
### Same-disk limitation (important)
|
||||
|
||||
Local snapshots live on the **same disk** as the live DB. They protect against:
|
||||
|
||||
- A bad migration (you can restore the pre-migration snapshot).
|
||||
- Logical corruption or accidental data deletion.
|
||||
|
||||
They do **not** protect against **hardware loss** (disk failure, the box being destroyed). The snapshot dies with the disk that held the original.
|
||||
|
||||
> **To survive hardware loss you must replicate off the box.** Either set `BACKUP_OFFSITE_CMD` to push every snapshot to remote storage, **or** run a host-level backup of the `data/` directory (which also captures uploads, not just the DB). One of these is **required** for real durability; the built-in snapshots alone are not a disaster-recovery solution.
|
||||
|
||||
---
|
||||
|
||||
## 4. Restore
|
||||
|
||||
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
||||
|
||||
### List snapshots
|
||||
|
||||
```bash
|
||||
./restore.sh
|
||||
```
|
||||
|
||||
Lists every `*.db` in `data/backups/` newest-first with its size, and prints the restore command. (No arguments = list-and-exit; it never modifies anything.)
|
||||
|
||||
### Restore a snapshot
|
||||
|
||||
```bash
|
||||
./restore.sh <snapshot-filename>
|
||||
```
|
||||
|
||||
The argument is reduced to a basename — restore is always **from** `data/backups/`. After a `y/N` confirmation, the script performs:
|
||||
|
||||
1. **`[1/3]` Stop the `backspace` container** (`docker compose stop backspace`) so nothing is writing to the DB.
|
||||
2. **`[2/3]` Swap inside a root `alpine` container** (`docker run --rm -v ./data:/data alpine sh -c …`):
|
||||
- **Pre-restore copy** — if `data/backspace.db` exists, copy it to `data/backups/backspace-<ts>-pre-restore.db` so the pre-restore state is recoverable.
|
||||
- **Clear WAL/SHM** — `rm -f data/backspace.db-wal data/backspace.db-shm` so stale sidecar files don't corrupt the restored DB.
|
||||
- **Install** — copy the chosen snapshot over `data/backspace.db`.
|
||||
3. **`[3/3]` Start the container** (`docker compose start backspace`). On boot the server checkpoints/opens the restored DB and the healthcheck reports status (`docker compose logs -f backspace`).
|
||||
|
||||
The pre-restore copy means a mistaken restore is itself undoable: the previous DB is preserved as a `*-pre-restore.db` snapshot in `data/backups/`.
|
||||
|
||||
> The `pre-restore` reason tag is **not** in the auto-pruned reason set (`pre-migration` / `scheduled` / `manual`), so pre-restore copies are retained until manually cleaned up. Periodically prune old `*-pre-restore.db` files by hand if disk is tight.
|
||||
|
||||
---
|
||||
|
||||
## 5. Image Pinning & Upgrades
|
||||
|
||||
The two pulled images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
|
||||
|
||||
| Service | Pinned image |
|
||||
|---------|--------------|
|
||||
| `caddy` | `caddy:2.11.1-alpine` |
|
||||
| `livekit` | `livekit/livekit-server:v1.9.11` |
|
||||
|
||||
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you. (The `backspace` image is built from source via the `Dockerfile`, which itself pins the `node:20-slim` base.)
|
||||
|
||||
**Upgrade procedure:** bump the tag in `docker-compose.yml` → test the new version (locally or on one box) → redeploy. Concretely:
|
||||
|
||||
1. Edit the image tag in `docker-compose.yml` (e.g. `caddy:2.11.1-alpine` → `caddy:2.12.0-alpine`).
|
||||
2. Deploy to **one** box first (`./deploy.sh vm`) and verify `/api/health` is healthy and (for LiveKit) that voice still connects.
|
||||
3. Once verified, roll it to the other box (`./deploy.sh pi`, or `./deploy.sh all` going forward).
|
||||
|
||||
Never pin to a floating tag like `latest` or a bare major — it defeats reproducibility and turns every rebuild into an uncontrolled upgrade.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known Limitations (out of scope)
|
||||
|
||||
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
|
||||
|
||||
- **The image is built on each target host, including the ARM Raspberry Pi.** There is no cross-built/registry-pushed artifact. The Pi build is slower and consumes build resources on the box (`deploy.sh` caps the build cache and prunes old images to compensate). A native-module or toolchain regression can surface on ARM but not x86, or vice-versa.
|
||||
- **A deploy causes brief downtime + WebSocket reconnect.** `docker compose up -d --build` rebuilds and recreates the `backspace` container; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail.
|
||||
- **`deploy.sh all` can mask one host failing.** The `all` target runs both deploys in parallel (`deploy … & deploy … & wait`). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick Operator Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com ./install.sh`) |
|
||||
| Redeploy both boxes | `./deploy.sh all` |
|
||||
| Redeploy one box | `./deploy.sh pi` / `./deploy.sh vm` |
|
||||
| Bring stack up manually | `docker compose up -d --build` |
|
||||
| Check health | `curl -fsS https://<domain>/api/health` |
|
||||
| Take a manual snapshot | `./backup.sh` |
|
||||
| List snapshots | `./restore.sh` |
|
||||
| Restore a snapshot | `./restore.sh <snapshot-filename>` |
|
||||
| Rotate legacy seed admin | `docker exec -w /app/packages/server backspace node --import tsx/esm src/scripts/remediate-seed-admin.ts` |
|
||||
| Grant first admin (fresh instance) | Register the first account; it becomes admin automatically. |
|
||||
@@ -69,6 +69,15 @@ export const config = {
|
||||
dbPath: env('DB_PATH', resolve(__dirname, '../../../data/backspace.db')),
|
||||
maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600),
|
||||
registrationOpen: envBool('REGISTRATION_OPEN', true),
|
||||
backup: {
|
||||
dir: envOptional('BACKUP_DIR') ?? resolve(dirname(env('DB_PATH', resolve(__dirname, '../../../data/backspace.db'))), 'backups'),
|
||||
intervalHours: envInt('BACKUP_INTERVAL_HOURS', 24),
|
||||
keepScheduled: envInt('BACKUP_KEEP_SCHEDULED', 7),
|
||||
keepPreMigration: envInt('BACKUP_KEEP_PREMIGRATION', 5),
|
||||
keepManual: envInt('BACKUP_KEEP_MANUAL', 10),
|
||||
offsiteCmd: envOptional('BACKUP_OFFSITE_CMD'),
|
||||
disabled: envBool('BACKUP_DISABLED', false),
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (config.jwtSecret.length < 32) {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { config } from '../config.js';
|
||||
import * as schema from './schema.js';
|
||||
import { ensureDefaults } from './migrate.js';
|
||||
import { setWorkerId } from '../utils/snowflake.js';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { createSnapshot } from '../utils/backup.js';
|
||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -20,12 +22,29 @@ function ensureDirectory(filePath: string): void {
|
||||
|
||||
export function initDatabase() {
|
||||
ensureDirectory(config.dbPath);
|
||||
// Capture existence BEFORE opening — new Database() creates the file, so a
|
||||
// post-open check would always report "exists" and snapshot a 0-row DB on first boot.
|
||||
const dbExisted = existsSync(config.dbPath);
|
||||
|
||||
sqlite = new Database(config.dbPath);
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Apply any pending Drizzle migrations
|
||||
const migrationsFolder = resolve(__dirname, '../../drizzle');
|
||||
|
||||
// Snapshot before migrating — but only when there is a real DB AND a migration
|
||||
// is actually pending. History is stable across most boots, so this avoids
|
||||
// churning the pre-migration retention with identical copies on every restart.
|
||||
if (!config.backup.disabled && dbExisted && hasPendingMigrations(sqlite, migrationsFolder)) {
|
||||
try {
|
||||
const snap = createSnapshot(sqlite, 'pre-migration');
|
||||
console.log(`[backup] pre-migration snapshot written: ${snap}`);
|
||||
} catch (err) {
|
||||
console.error(`[backup] pre-migration snapshot FAILED — aborting migration to protect data: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
migrate(db, { migrationsFolder });
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const migrationsFolder = path.resolve(__dirname, '../../drizzle');
|
||||
|
||||
describe('hasPendingMigrations', () => {
|
||||
it('returns true when __drizzle_migrations is missing', () => {
|
||||
const db = new Database(':memory:');
|
||||
expect(hasPendingMigrations(db, migrationsFolder)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when fewer rows than journal entries are applied', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)');
|
||||
db.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('x', 1);
|
||||
expect(hasPendingMigrations(db, migrationsFolder)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when applied count >= journal entries', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)');
|
||||
const journal = require(path.join(migrationsFolder, 'meta/_journal.json'));
|
||||
const insert = db.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)');
|
||||
for (let i = 0; i < journal.entries.length; i++) insert.run(`h${i}`, i);
|
||||
expect(hasPendingMigrations(db, migrationsFolder)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface DrizzleJournal { entries: Array<{ idx: number; tag: string; when: number }>; }
|
||||
|
||||
/**
|
||||
* True when migrations are pending. Drizzle's better-sqlite3 migrator appends one
|
||||
* row per applied migration to `__drizzle_migrations`, in journal order. Comparing
|
||||
* the applied row count to the journal entry count is sufficient to know whether
|
||||
* `migrate()` will apply anything — without running it. A missing table means a
|
||||
* pre-drizzle or empty DB: treat as pending.
|
||||
*/
|
||||
export function hasPendingMigrations(db: Database.Database, migrationsFolder: string): boolean {
|
||||
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as DrizzleJournal;
|
||||
|
||||
const tableExists = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")
|
||||
.get();
|
||||
if (!tableExists) return true;
|
||||
|
||||
const applied = db.prepare('SELECT COUNT(*) AS n FROM __drizzle_migrations').get() as { n: number };
|
||||
return applied.n < journal.entries.length;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ensureDefaults } from './migrate.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function applyMigrations(db: Database.Database): void {
|
||||
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||
for (const f of files) {
|
||||
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
|
||||
const clean = stmt.trim();
|
||||
if (clean) db.exec(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('fresh instance has no seeded credentials', () => {
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
applyMigrations(sqlite);
|
||||
ensureDefaults(sqlite);
|
||||
});
|
||||
|
||||
it('creates no admin user and no default space on fresh boot', () => {
|
||||
const users = sqlite.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number };
|
||||
const spaces = sqlite.prepare('SELECT COUNT(*) AS n FROM spaces').get() as { n: number };
|
||||
expect(users.n).toBe(0);
|
||||
expect(spaces.n).toBe(0);
|
||||
});
|
||||
|
||||
it('has no user named "admin"', () => {
|
||||
const admin = sqlite.prepare("SELECT id FROM users WHERE username = 'admin'").get();
|
||||
expect(admin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still creates the singleton instance_settings row', () => {
|
||||
const row = sqlite.prepare('SELECT id, worker_id FROM instance_settings WHERE id = 1').get() as
|
||||
{ id: number; worker_id: number | null } | undefined;
|
||||
expect(row?.id).toBe(1);
|
||||
expect(row?.worker_id).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { getDb, schema } from './index.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { hashPassword } from '../utils/auth.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
|
||||
export async function seedDatabase(): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const existingSpaces = db.select().from(schema.spaces).all();
|
||||
if (existingSpaces.length > 0) {
|
||||
console.log('Database already has data, skipping seed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Seeding database with default data...');
|
||||
|
||||
const adminId = generateSnowflake();
|
||||
const adminPasswordHash = await hashPassword('admin123');
|
||||
|
||||
db.insert(schema.users).values({
|
||||
id: adminId,
|
||||
username: 'admin',
|
||||
displayName: 'Admin',
|
||||
passwordHash: adminPasswordHash,
|
||||
status: 'offline',
|
||||
isAdmin: 1,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const spaceId = generateSnowflake();
|
||||
db.insert(schema.spaces).values({
|
||||
id: spaceId,
|
||||
name: 'Backspace',
|
||||
ownerId: adminId,
|
||||
inviteCode: 'backspace',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
db.insert(schema.spaceMembers).values({
|
||||
spaceId: spaceId,
|
||||
userId: adminId,
|
||||
joinedAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const generalChannelId = generateSnowflake();
|
||||
db.insert(schema.channels).values({
|
||||
id: generalChannelId,
|
||||
spaceId: spaceId,
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
topic: 'General discussion',
|
||||
position: 0,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const voiceChannelId = generateSnowflake();
|
||||
db.insert(schema.channels).values({
|
||||
id: voiceChannelId,
|
||||
spaceId: spaceId,
|
||||
name: 'General Voice',
|
||||
type: 'voice',
|
||||
position: 1,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
// Create @everyone role (id === spaceId convention)
|
||||
db.insert(schema.roles).values({
|
||||
id: spaceId,
|
||||
spaceId: spaceId,
|
||||
name: '@everyone',
|
||||
color: '#b9bbbe',
|
||||
position: 0,
|
||||
permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
console.log('Database seeded successfully');
|
||||
console.log(` Default space: Backspace (invite code: backspace)`);
|
||||
console.log(` Admin user: admin / admin123`);
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import rateLimit from '@fastify/rate-limit';
|
||||
import websocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { config } from './config.js';
|
||||
import { getDb, getRawDb } from './db/index.js';
|
||||
import { seedDatabase } from './db/seed.js';
|
||||
import { getDb, getRawDb, closeDatabase } from './db/index.js';
|
||||
import { checkFfmpeg } from './utils/thumbnail.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { userRoutes } from './routes/users.js';
|
||||
@@ -27,6 +26,7 @@ import { adminRoutes } from './routes/admin.js';
|
||||
import { gifRoutes } from './routes/gif.js';
|
||||
import { federationRoutes } from './routes/federation.js';
|
||||
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
||||
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
||||
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
||||
import { registerCallRelayHooks } from './ws/events.js';
|
||||
import { resetStalePresenceOnBoot } from './utils/presenceBoot.js';
|
||||
@@ -108,7 +108,6 @@ async function main(): Promise<void> {
|
||||
|
||||
// Initialize database
|
||||
getDb();
|
||||
await seedDatabase();
|
||||
|
||||
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
||||
// process's in-memory disconnect timers are gone, so any non-offline row
|
||||
@@ -179,10 +178,14 @@ async function main(): Promise<void> {
|
||||
startFederationWorkers();
|
||||
}
|
||||
|
||||
startBackupWorker();
|
||||
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down...');
|
||||
stopFederationWorkers();
|
||||
stopBackupWorker();
|
||||
await app.close();
|
||||
closeDatabase(); // checkpoints WAL — leaves a complete on-disk file
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
||||
@@ -488,3 +488,60 @@ describe('POST /api/auth/register — federation gate split', () => {
|
||||
expect(reds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/register — first-user-admin promotion', () => {
|
||||
beforeEach(() => {
|
||||
// Start from a completely empty users table (no pre-seeded admin from outer beforeEach).
|
||||
testDb.delete(schema.users).run();
|
||||
// Ensure instance_settings row so registration can proceed.
|
||||
testDb.delete(schema.instanceSettings).run();
|
||||
testDb.insert(schema.instanceSettings).values({
|
||||
id: 1,
|
||||
registrationOpen: 1,
|
||||
federatedRegistrationOpen: 1,
|
||||
updatedAt: Date.now(),
|
||||
}).run();
|
||||
});
|
||||
|
||||
it('first locally-registered user receives isAdmin = 1', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { username: 'firstuser', password: 'password123' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const user = testDb.select().from(schema.users).where(eq(schema.users.username, 'firstuser')).get();
|
||||
expect(user?.isAdmin).toBe(1);
|
||||
});
|
||||
|
||||
it('second locally-registered user does NOT receive isAdmin', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { username: 'firstuser', password: 'password123' },
|
||||
});
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { username: 'seconduser', password: 'password123' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const second = testDb.select().from(schema.users).where(eq(schema.users.username, 'seconduser')).get();
|
||||
expect(second?.isAdmin).toBe(0);
|
||||
});
|
||||
|
||||
it('federated user (homeInstance set) is never promoted to admin even if first in DB', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { username: 'feduser@remote.example', password: 'password123', homeInstance: 'remote.example' },
|
||||
});
|
||||
// Federated registration is open (federatedRegistrationOpen: 1)
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const user = testDb.select().from(schema.users).where(eq(schema.users.username, 'feduser@remote.example')).get();
|
||||
expect(user?.isAdmin).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { hashPassword, verifyPassword } from '../utils/auth.js';
|
||||
import { remediateSeedAdmin } from './remediate-seed-admin.js';
|
||||
|
||||
async function freshDbWithUser(opts: {
|
||||
username: string; password: string; isAdmin: number; homeInstance: string | null;
|
||||
}): Promise<Database.Database> {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, username TEXT, display_name TEXT, password_hash TEXT,
|
||||
is_admin INTEGER DEFAULT 0, home_instance TEXT, created_at INTEGER
|
||||
)`);
|
||||
db.prepare(
|
||||
'INSERT INTO users (id, username, password_hash, is_admin, home_instance, created_at) VALUES (?,?,?,?,?,?)'
|
||||
).run('1', opts.username, await hashPassword(opts.password), opts.isAdmin, opts.homeInstance, 1);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('remediateSeedAdmin', () => {
|
||||
it('rotates the password when admin still uses admin123', async () => {
|
||||
const db = await freshDbWithUser({ username: 'admin', password: 'admin123', isAdmin: 1, homeInstance: null });
|
||||
const result = await remediateSeedAdmin(db);
|
||||
expect(result.action).toBe('rotated');
|
||||
expect(result.newPassword).toBeTruthy();
|
||||
const row = db.prepare("SELECT password_hash FROM users WHERE username = 'admin'").get() as { password_hash: string };
|
||||
expect(await verifyPassword('admin123', row.password_hash)).toBe(false);
|
||||
expect(await verifyPassword(result.newPassword!, row.password_hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when the password is already changed', async () => {
|
||||
const db = await freshDbWithUser({ username: 'admin', password: 'a-real-strong-pw', isAdmin: 1, homeInstance: null });
|
||||
const result = await remediateSeedAdmin(db);
|
||||
expect(result.action).toBe('noop');
|
||||
});
|
||||
|
||||
it('ignores a federated user named admin', async () => {
|
||||
const db = await freshDbWithUser({ username: 'admin', password: 'admin123', isAdmin: 0, homeInstance: 'other.example' });
|
||||
const result = await remediateSeedAdmin(db);
|
||||
expect(result.action).toBe('skipped-no-admin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { verifyPassword, hashPassword } from '../utils/auth.js';
|
||||
|
||||
type AdminRow = { id: string; password_hash: string };
|
||||
|
||||
export async function remediateSeedAdmin(
|
||||
db: Database.Database
|
||||
): Promise<{ action: 'rotated' | 'noop' | 'skipped-no-admin'; newPassword?: string }> {
|
||||
// Local admin named 'admin' only — replicated users (home_instance set) are never seed admins.
|
||||
const admin = db
|
||||
.prepare("SELECT id, password_hash FROM users WHERE username = 'admin' AND home_instance IS NULL AND is_admin = 1")
|
||||
.get() as AdminRow | undefined;
|
||||
|
||||
if (!admin) return { action: 'skipped-no-admin' };
|
||||
|
||||
const stillDefault = await verifyPassword('admin123', admin.password_hash);
|
||||
if (!stillDefault) return { action: 'noop' };
|
||||
|
||||
const newPassword = crypto.randomBytes(18).toString('base64url'); // 24-char strong password
|
||||
const newHash = await hashPassword(newPassword);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, admin.id);
|
||||
|
||||
return { action: 'rotated', newPassword };
|
||||
}
|
||||
|
||||
// CLI entrypoint: run inside the container via
|
||||
// docker exec -w /app/packages/server backspace node --import tsx/esm src/scripts/remediate-seed-admin.ts
|
||||
const isMain = process.argv[1] && process.argv[1].endsWith('remediate-seed-admin.ts');
|
||||
if (isMain) {
|
||||
const dbPath = process.env.DB_PATH || '/app/data/backspace.db';
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
remediateSeedAdmin(db)
|
||||
.then((r) => {
|
||||
if (r.action === 'rotated') {
|
||||
// No print-once lockout: also persist to a root-owned file next to the DB
|
||||
// (on the bind-mount → visible on the host as data/seed-admin-rotated.txt).
|
||||
const outFile = path.join(path.dirname(dbPath), 'seed-admin-rotated.txt');
|
||||
fs.writeFileSync(outFile, `${r.newPassword}\n`, { mode: 0o600 });
|
||||
// `mode` only applies when the file is newly created; chmod guarantees 0600
|
||||
// even if a prior run left the file with looser permissions (it holds a password).
|
||||
fs.chmodSync(outFile, 0o600);
|
||||
console.log('Seed admin password ROTATED.');
|
||||
console.log(` New password: ${r.newPassword}`);
|
||||
console.log(` Also written to: ${outFile} (delete after you have stored it)`);
|
||||
} else if (r.action === 'noop') {
|
||||
console.log('Seed admin password already changed — nothing to do.');
|
||||
} else {
|
||||
console.log('No local seed admin found — nothing to do.');
|
||||
}
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Remediation failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { getRawDb } from '../db/index.js';
|
||||
import { createSnapshot, pruneSnapshots } from '../utils/backup.js';
|
||||
|
||||
const p = createSnapshot(getRawDb(), 'manual');
|
||||
pruneSnapshots();
|
||||
console.log('Manual snapshot written: ' + p);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
config: {
|
||||
backup: {
|
||||
get dir() { return tmpDir; },
|
||||
intervalHours: 24, keepScheduled: 2, keepPreMigration: 2, keepManual: 2,
|
||||
offsiteCmd: undefined, disabled: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { createSnapshot, pruneSnapshots, listSnapshots } from './backup.js';
|
||||
|
||||
function seededDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').run('a');
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').run('b');
|
||||
return db;
|
||||
}
|
||||
|
||||
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bk-')); });
|
||||
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
||||
|
||||
describe('createSnapshot', () => {
|
||||
it('writes a valid standalone DB with identical rows', () => {
|
||||
const db = seededDb();
|
||||
const out = createSnapshot(db, 'manual');
|
||||
expect(fs.existsSync(out)).toBe(true);
|
||||
const copy = new Database(out, { readonly: true });
|
||||
const n = (copy.prepare('SELECT COUNT(*) AS n FROM t').get() as { n: number }).n;
|
||||
expect(n).toBe(2);
|
||||
copy.close();
|
||||
});
|
||||
|
||||
it('encodes the reason in the filename', () => {
|
||||
const db = seededDb();
|
||||
const out = createSnapshot(db, 'pre-migration');
|
||||
expect(path.basename(out)).toMatch(/pre-migration\.db$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneSnapshots', () => {
|
||||
it('keeps only keep<Reason> newest per reason', () => {
|
||||
const db = seededDb();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// unique names: createSnapshot uses a timestamp; force distinct mtimes
|
||||
const p = createSnapshot(db, 'manual');
|
||||
fs.utimesSync(p, new Date(1000 + i), new Date(1000 + i));
|
||||
}
|
||||
pruneSnapshots();
|
||||
const remaining = listSnapshots().filter(s => s.reason === 'manual');
|
||||
expect(remaining.length).toBe(2); // keepManual = 2
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export type SnapshotReason = 'pre-migration' | 'scheduled' | 'manual';
|
||||
|
||||
export interface SnapshotInfo {
|
||||
path: string;
|
||||
reason: SnapshotReason;
|
||||
bytes: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
const REASONS: SnapshotReason[] = ['pre-migration', 'scheduled', 'manual'];
|
||||
|
||||
function ensureDir(): string {
|
||||
fs.mkdirSync(config.backup.dir, { recursive: true });
|
||||
return config.backup.dir;
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
// 2026-06-20T14:03:09.123Z -> 20260620T140309123 (ms precision keeps names unique
|
||||
// and sortable; VACUUM INTO throws if the target file already exists).
|
||||
return new Date().toISOString().replace(/[-:.]/g, '').replace(/Z$/, '');
|
||||
}
|
||||
|
||||
/** Synchronous, WAL-safe snapshot via VACUUM INTO. Returns the absolute path. */
|
||||
export function createSnapshot(db: Database.Database, reason: SnapshotReason): string {
|
||||
const dir = ensureDir();
|
||||
const ts = timestamp();
|
||||
// VACUUM INTO throws if the target already exists. Millisecond precision keeps
|
||||
// names unique under normal use, but tight loops can collide within the same
|
||||
// millisecond — append a disambiguating counter on collision to guarantee a
|
||||
// free, sortable path.
|
||||
let file = path.join(dir, `backspace-${ts}-${reason}.db`);
|
||||
for (let n = 1; fs.existsSync(file); n++) {
|
||||
file = path.join(dir, `backspace-${ts}-${String(n).padStart(3, '0')}-${reason}.db`);
|
||||
}
|
||||
db.prepare('VACUUM INTO ?').run(file);
|
||||
runOffsite(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
function runOffsite(snapshotPath: string): void {
|
||||
const cmd = config.backup.offsiteCmd;
|
||||
if (!cmd) return;
|
||||
// Best-effort: failures are logged, never fatal.
|
||||
execFile('/bin/sh', ['-c', `${cmd} "$1"`, 'sh', snapshotPath], (err, _stdout, stderr) => {
|
||||
if (err) console.error(`[backup] off-box hook failed: ${err.message} ${stderr ?? ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
export function listSnapshots(): SnapshotInfo[] {
|
||||
if (!fs.existsSync(config.backup.dir)) return [];
|
||||
return fs.readdirSync(config.backup.dir)
|
||||
.filter(f => f.endsWith('.db'))
|
||||
.map((f): SnapshotInfo | null => {
|
||||
const reason = REASONS.find(r => f.endsWith(`-${r}.db`));
|
||||
if (!reason) return null;
|
||||
const full = path.join(config.backup.dir, f);
|
||||
const st = fs.statSync(full);
|
||||
return { path: full, reason, bytes: st.size, mtimeMs: st.mtimeMs };
|
||||
})
|
||||
.filter((s): s is SnapshotInfo => s !== null)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
|
||||
export function pruneSnapshots(): void {
|
||||
const keep: Record<SnapshotReason, number> = {
|
||||
'pre-migration': config.backup.keepPreMigration,
|
||||
scheduled: config.backup.keepScheduled,
|
||||
manual: config.backup.keepManual,
|
||||
};
|
||||
for (const reason of REASONS) {
|
||||
const ofReason = listSnapshots().filter(s => s.reason === reason); // newest-first
|
||||
for (const stale of ofReason.slice(keep[reason])) {
|
||||
try { fs.unlinkSync(stale.path); } catch (err) {
|
||||
console.error(`[backup] failed to prune ${stale.path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { createSnapshot, pruneSnapshots, state } = vi.hoisted(() => ({
|
||||
createSnapshot: vi.fn(() => '/tmp/snap-scheduled.db'),
|
||||
pruneSnapshots: vi.fn(),
|
||||
state: { disabled: false },
|
||||
}));
|
||||
|
||||
vi.mock('./backup.js', () => ({ createSnapshot, pruneSnapshots }));
|
||||
vi.mock('../db/index.js', () => ({ getRawDb: () => ({}) }));
|
||||
vi.mock('../config.js', () => ({
|
||||
config: { backup: { get disabled() { return state.disabled; }, intervalHours: 1 } },
|
||||
}));
|
||||
|
||||
import { startBackupWorker, stopBackupWorker } from './backupWorker.js';
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); createSnapshot.mockClear(); pruneSnapshots.mockClear(); state.disabled = false; });
|
||||
afterEach(() => { stopBackupWorker(); vi.useRealTimers(); });
|
||||
|
||||
describe('backupWorker', () => {
|
||||
it('snapshots on each interval tick', () => {
|
||||
startBackupWorker();
|
||||
vi.advanceTimersByTime(60 * 60 * 1000); // 1h
|
||||
expect(createSnapshot).toHaveBeenCalledWith(expect.anything(), 'scheduled');
|
||||
expect(pruneSnapshots).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
state.disabled = true;
|
||||
startBackupWorker();
|
||||
vi.advanceTimersByTime(60 * 60 * 1000);
|
||||
expect(createSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { config } from '../config.js';
|
||||
import { getRawDb } from '../db/index.js';
|
||||
import { createSnapshot, pruneSnapshots } from './backup.js';
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startBackupWorker(): void {
|
||||
if (config.backup.disabled) {
|
||||
console.log('[backup] scheduled worker disabled via BACKUP_DISABLED');
|
||||
return;
|
||||
}
|
||||
if (timer) return;
|
||||
const intervalMs = config.backup.intervalHours * 60 * 60 * 1000;
|
||||
timer = setInterval(() => {
|
||||
try {
|
||||
const snap = createSnapshot(getRawDb(), 'scheduled');
|
||||
pruneSnapshots();
|
||||
console.log(`[backup] scheduled snapshot written: ${snap}`);
|
||||
} catch (err) {
|
||||
console.error(`[backup] scheduled snapshot failed: ${(err as Error).message}`);
|
||||
}
|
||||
}, intervalMs);
|
||||
// Do not keep the event loop alive solely for backups.
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
console.log(`[backup] scheduled worker started (every ${config.backup.intervalHours}h)`);
|
||||
}
|
||||
|
||||
export function stopBackupWorker(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore the Backspace SQLite DB from a snapshot in data/backups/.
|
||||
# Usage:
|
||||
# ./restore.sh List available snapshots.
|
||||
# ./restore.sh <snapshot.db> Restore the named snapshot (path or basename).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BACKUP_DIR="data/backups"
|
||||
DB="data/backspace.db"
|
||||
|
||||
if [[ ! -d "$BACKUP_DIR" ]]; then
|
||||
echo "No backups directory at $BACKUP_DIR." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No arg: list snapshots newest-first and exit.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
shopt -s nullglob
|
||||
snaps=("$BACKUP_DIR"/*.db)
|
||||
shopt -u nullglob
|
||||
if [[ ${#snaps[@]} -eq 0 ]]; then
|
||||
echo "No snapshots found in $BACKUP_DIR."
|
||||
exit 0
|
||||
fi
|
||||
echo "Available snapshots (newest first):"
|
||||
for f in $(ls -1t "$BACKUP_DIR"/*.db); do
|
||||
printf " %s (%s)\n" "$(basename "$f")" "$(du -h "$f" | cut -f1)"
|
||||
done
|
||||
echo ""
|
||||
echo "Restore with: ./restore.sh <snapshot-filename>"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve the snapshot to a basename inside BACKUP_DIR (restore is always from data/backups/).
|
||||
SNAP_NAME="$(basename "$1")"
|
||||
if [[ ! -f "$BACKUP_DIR/$SNAP_NAME" ]]; then
|
||||
echo "Snapshot not found in $BACKUP_DIR: $SNAP_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "About to restore: $BACKUP_DIR/$SNAP_NAME"
|
||||
echo "This will REPLACE $DB. The current DB is saved first as a pre-restore snapshot."
|
||||
read -rp "Continue? [y/N] " yn
|
||||
[[ "${yn,,}" == "y" ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
echo "[1/3] Stopping backspace container..."
|
||||
docker compose stop backspace
|
||||
|
||||
# data/backspace.db and data/backups/ are container-owned (root). The host user cannot
|
||||
# cp/rm them directly, so do the swap inside a throwaway root container that mounts data/.
|
||||
# (youruser is in the docker group on both boxes — no sudo prompt.)
|
||||
TS="$(date -u +%Y%m%dT%H%M%S)"
|
||||
echo "[2/3] Swapping DB inside a root container (pre-restore copy + WAL clear + install)..."
|
||||
docker run --rm -v "$(pwd)/data:/data" alpine sh -c '
|
||||
set -e
|
||||
if [ -f /data/backspace.db ]; then
|
||||
cp /data/backspace.db "/data/backups/backspace-$1-pre-restore.db"
|
||||
fi
|
||||
rm -f /data/backspace.db-wal /data/backspace.db-shm
|
||||
cp "/data/backups/$2" /data/backspace.db
|
||||
' sh "$TS" "$SNAP_NAME"
|
||||
|
||||
echo "[3/3] Starting backspace container..."
|
||||
docker compose start backspace
|
||||
echo "Done. Watch health: docker compose logs -f backspace"
|
||||
Reference in New Issue
Block a user