diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example index 613cc61..bf96141 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,17 @@ SESSION_SECRET= INSTANCE_URL=https://chat.yourdomain.com INSTANCE_NAME=My Stoat Instance -# Admin API listen port and admin web origin for CORS. +# Admin API listen port and the HTTPS browser origin allowed by CORS. ADMIN_API_PORT=5181 -ADMIN_WEB_ORIGIN=https://localhost:5180 +ADMIN_WEB_ORIGIN=https://admin.example.com -# Compose-level convenience variables. +# Admin hostname terminated by the dedicated Caddy proxy. This hostname only +# needs to resolve on your WireGuard/private network. +ADMIN_HOSTNAME=admin.example.com + +# Compose-level convenience variables for the dedicated admin proxy. ADMIN_BIND_IP=127.0.0.1 -ADMIN_WEB_PORT=5180 -ADMIN_WEB_API_URL=http://127.0.0.1:5181 - +ADMIN_HTTP_PORT=80 +ADMIN_HTTPS_PORT=443 +# Leave blank to use same-origin /api requests through admin-proxy. +ADMIN_WEB_API_URL= diff --git a/README.md b/README.md index 927aa13..e4f90f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stoat Admin -Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a Stoat chat instance. It runs as a separate Podman Compose stack, joins the Stoat network to talk to MongoDB directly, and is intended to be reachable only over WireGuard. +Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a Stoat chat instance. It runs as a separate Podman Compose stack, joins the Stoat network to talk to MongoDB directly, and now ships with a dedicated `admin-proxy` Caddy service that terminates HTTPS for the admin stack with Caddy's internal CA. ## Features @@ -24,6 +24,12 @@ Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a S └── .env.example ``` +## Admin Setup + +To create the admin user, run this command inside the admin container + +`node dist/seed.js --username admin --password ` + ## Prerequisites - Node 22+ @@ -32,7 +38,8 @@ Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a S - A Stoat deployment with MongoDB reachable on the shared container network - `invite_only = true` in Stoat's `Revolt.toml` - Podman or Docker-compatible compose support -- WireGuard or another private network boundary for admin access +- A hostname for the admin dashboard that resolves on your WireGuard/private network +- A way to trust Caddy's internal root CA on the admin devices that will access the dashboard ## Quick Start @@ -45,7 +52,7 @@ corepack enable 2. Install dependencies: ```sh -COREPACK_HOME=/tmp/corepack pnpm install +pnpm install ``` 3. Copy the environment template and fill in the real values: @@ -57,20 +64,20 @@ cp .env.example .env 4. Seed the admin account from the root workspace: ```sh -COREPACK_HOME=/tmp/corepack pnpm seed -- --username admin --password '' +pnpm seed -- --username admin --password '' ``` 5. Run both packages together through Turborepo: ```sh -COREPACK_HOME=/tmp/corepack pnpm dev +pnpm dev ``` Useful targeted variants: ```sh -COREPACK_HOME=/tmp/corepack pnpm dev:api -COREPACK_HOME=/tmp/corepack pnpm dev:web +pnpm dev:api +pnpm dev:web ``` ## Configuration @@ -84,15 +91,33 @@ COREPACK_HOME=/tmp/corepack pnpm dev:web | `INSTANCE_URL` | Public Stoat URL used in invite links | | `INSTANCE_NAME` | Human-readable instance name used in copy | | `ADMIN_API_PORT` | Listen port for `admin-api` | -| `ADMIN_WEB_ORIGIN` | Exact browser origin allowed by CORS | -| `ADMIN_BIND_IP` | Compose bind IP for admin services | -| `ADMIN_WEB_PORT` | Host port for the frontend | -| `ADMIN_WEB_API_URL` | API base URL baked into the frontend build | +| `ADMIN_WEB_ORIGIN` | Exact HTTPS browser origin allowed by CORS | +| `ADMIN_HOSTNAME` | Hostname served by the dedicated Caddy proxy | +| `ADMIN_BIND_IP` | Compose bind IP for the proxy's `80`/`443` ports | +| `ADMIN_HTTP_PORT` | Host port for ACME HTTP and redirect handling | +| `ADMIN_HTTPS_PORT` | Host port for HTTPS | +| `ADMIN_WEB_API_URL` | Optional frontend API base URL override | ## Deployment The repo ships with a standalone `compose.yml` that expects an external `stoat_default` network. Update the network name if your Stoat stack uses a different one. +The deployment topology is: + +- `admin-proxy` publishes ports `80` and `443`, issues a private certificate from Caddy's internal CA, and reverse-proxies `/api/*` to `admin-api` and everything else to `admin-web`. +- `admin-web` and `admin-api` are no longer published directly on the host. +- `admin-api` stays attached to the shared Stoat network for MongoDB access and also joins a private admin network used by the proxy. + +Before starting the stack, point `ADMIN_HOSTNAME` at the host running `admin-proxy` on your WireGuard/private network and set `ADMIN_WEB_ORIGIN` to `https://`. + +After the proxy has started once, install Caddy's root CA on each admin device before browsing to the dashboard. One way to export it is: + +```sh +docker compose exec admin-proxy sh -c 'cat /data/caddy/pki/authorities/local/root.crt' > admin-proxy-root.crt +``` + +Then import `admin-proxy-root.crt` into the OS/browser trust store for the devices that should access the dashboard. + For supervised deployments: 1. Install `s6` @@ -119,7 +144,7 @@ tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal ## Development Notes - The backend uses SQLite for admin credentials, audit logs, and invite metadata, and MongoDB for Stoat state. -- The frontend talks directly to the API with cookie-based auth and TanStack Query. +- In the composed deployment, the frontend uses same-origin `/api` requests through `admin-proxy`, while local development can still override `VITE_API_URL`. - Root task orchestration is handled by Turborepo through [turbo.json](/home/jasonross/workspace/stoat-admin/turbo.json). - The current repo state is a first implementation slice based on the design docs in [docs/stoat-admin-design.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-design.md) and [docs/stoat-admin-tasks.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-tasks.md). diff --git a/SECURITY.md b/SECURITY.md index e889755..6fe0218 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,15 @@ ## Security Model -Stoat Admin is designed for private administration over a WireGuard-restricted network. The intended deployment model is: +Stoat Admin is designed to keep the application containers private even when the admin entrypoint is fronted by its own HTTPS proxy. The intended deployment model is: -- `admin-web` and `admin-api` bind only to a private interface such as `wg0` +- `admin-proxy` is the only published service and terminates HTTPS for the admin stack with Caddy's internal CA +- `admin-web` and `admin-api` are reachable only on the private admin container network +- `admin-api` joins the Stoat network only so it can reach MongoDB - the dashboard is not exposed through the public Stoat reverse proxy - the API still requires session-based authentication with an Argon2id-hashed admin credential -This means WireGuard limits network access and the application session limits user access. Both layers are expected in production. +This means the reverse proxy limits what is exposed, the shared Stoat network is used only where needed, and the application session still limits user access. ## Reporting @@ -24,4 +26,6 @@ If you discover a security issue, avoid opening a public issue with exploit deta - Keep `SESSION_SECRET` and `RESEND_API_KEY` out of the repository. - Restrict permissions on the SQLite database file mounted at `/data/admin.db`. - Set `ADMIN_WEB_ORIGIN` precisely. Do not use `*`. -- Verify the compose port bindings are limited to the intended private interface. +- Verify the compose port bindings expose only `admin-proxy`, not `admin-web` or `admin-api`. +- Trust Caddy's internal root CA only on the admin devices that should access the dashboard. +- Protect the `admin_proxy_data` volume. It contains the private CA material used to issue the dashboard certificate. diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..1a12205 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.turbo/ +.env +.env.* +secrets.env +*.db +*.db-* +*.sqlite +*.sqlite* +data/ +coverage/ +.DS_Store diff --git a/api/package.json b/api/package.json index 3542fac..a25cd0e 100644 --- a/api/package.json +++ b/api/package.json @@ -5,6 +5,13 @@ "type": "module", "license": "AGPL-3.0-only", "packageManager": "pnpm@10.6.3", + "pnpm": { + "onlyBuiltDependencies": [ + "argon2", + "better-sqlite3", + "esbuild" + ] + }, "engines": { "node": ">=22" }, diff --git a/api/src/db/sqlite.ts b/api/src/db/sqlite.ts index 42d34c9..daa90cb 100644 --- a/api/src/db/sqlite.ts +++ b/api/src/db/sqlite.ts @@ -1,16 +1,27 @@ import { existsSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; import type { AdminUserRecord, InviteRecord } from "./types.js"; +// Keep local development aligned with the compose-mounted ./data directory. +const workspaceSqlitePath = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + "data", + "admin.db" +); + function resolveSqlitePath(): string { if (existsSync("/data")) { return "/data/admin.db"; } - return resolve(process.cwd(), "data", "admin.db"); + return workspaceSqlitePath; } export const sqlitePath = resolveSqlitePath(); diff --git a/api/src/index.ts b/api/src/index.ts index 332d46e..462a180 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -20,6 +20,7 @@ async function main(): Promise { await connectMongo(); const app = express(); + app.set("trust proxy", 1); app.use( helmet({ diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts index 97a43d3..160723f 100644 --- a/api/src/middleware/auth.ts +++ b/api/src/middleware/auth.ts @@ -7,6 +7,14 @@ import { sqlite } from "../db/sqlite.js"; const SQLiteStore = connectSqlite3(session); export const SESSION_COOKIE_NAME = "stoat-admin.sid"; +const SESSION_COOKIE_SECURE = + new URL(env.ADMIN_WEB_ORIGIN).protocol === "https:"; +export const SESSION_COOKIE_OPTIONS = { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: SESSION_COOKIE_SECURE +} as const; export const sessionMiddleware = session({ name: SESSION_COOKIE_NAME, @@ -21,10 +29,8 @@ export const sessionMiddleware = session({ } }), cookie: { - maxAge: 2 * 60 * 60 * 1000, - httpOnly: true, - sameSite: "strict", - secure: false + ...SESSION_COOKIE_OPTIONS, + maxAge: 2 * 60 * 60 * 1000 } }); diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 39cbce6..e7ca08d 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -4,7 +4,11 @@ import { z } from "zod"; import { statements } from "../db/sqlite.js"; import { asyncHandler } from "../lib/async-handler.js"; -import { requireAuth, SESSION_COOKIE_NAME } from "../middleware/auth.js"; +import { + requireAuth, + SESSION_COOKIE_NAME, + SESSION_COOKIE_OPTIONS +} from "../middleware/auth.js"; const loginSchema = z.object({ username: z.string().min(1), @@ -56,7 +60,7 @@ authRouter.post( }); }); - res.clearCookie(SESSION_COOKIE_NAME); + res.clearCookie(SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS); res.status(200).json({ success: true }); }) ); diff --git a/api/src/seed.ts b/api/src/seed.ts index b22aba1..82dc80e 100644 --- a/api/src/seed.ts +++ b/api/src/seed.ts @@ -32,7 +32,11 @@ async function promptForMissing( } async function main(): Promise { + const rawArgs = process.argv.slice(2); + const normalizedArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + const parsed = parseArgs({ + args: normalizedArgs, options: { username: { type: "string" diff --git a/compose.override.example.yml b/compose.override.example.yml index 3659be5..0f0c4d1 100644 --- a/compose.override.example.yml +++ b/compose.override.example.yml @@ -1,20 +1,18 @@ services: - admin-api: + admin-proxy: ports: - - "10.0.0.1:5181:5181" + - "10.0.0.1:80:80" + - "10.0.0.1:443:443" + environment: + ADMIN_HOSTNAME: admin.example.com + + admin-api: environment: ADMIN_API_PORT: 5181 - ADMIN_WEB_ORIGIN: http://10.0.0.1:5180 - - admin-web: - build: - args: - VITE_API_URL: http://10.0.0.1:5181 - ports: - - "10.0.0.1:5180:80" + ADMIN_WEB_ORIGIN: https://admin.example.com # Common customizations: # - Change the external network name in compose.yml if your Stoat stack uses a different name. -# - Bind to your WireGuard interface IP instead of localhost. +# - Bind admin-proxy to the interface IP that should answer ports 80/443. +# - Ensure ADMIN_HOSTNAME resolves to that IP on your WireGuard/private network. # - Add resource limits or alternate image tags per deployment. - diff --git a/compose.yml b/compose.yml index e9cf824..f071a87 100644 --- a/compose.yml +++ b/compose.yml @@ -1,30 +1,54 @@ +volumes: + admin_proxy_data: + admin_proxy_config: + networks: stoat: external: true name: stoat_default + admin: services: admin-api: build: context: ./api - image: ghcr.io/OWNER/stoat-admin-api:latest + image: ghcr.io/owner/stoat-admin-api:latest restart: unless-stopped - ports: - - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_API_PORT:-5181}:5181" + expose: + - "5181" volumes: - ./data:/data - ./.env:/app/.env:ro networks: + - admin - stoat admin-web: build: context: ./web args: - VITE_API_URL: ${ADMIN_WEB_API_URL:-http://127.0.0.1:5181} - image: ghcr.io/OWNER/stoat-admin-web:latest + VITE_API_URL: ${ADMIN_WEB_API_URL:-} + image: ghcr.io/owner/stoat-admin-web:latest restart: unless-stopped - ports: - - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_WEB_PORT:-5180}:80" + expose: + - "80" networks: - - stoat + - admin + + admin-proxy: + image: caddy:latest + restart: unless-stopped + depends_on: + - admin-api + - admin-web + environment: + ADMIN_HOSTNAME: ${ADMIN_HOSTNAME} + ports: + - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-80}:80" + - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTPS_PORT:-443}:443" + volumes: + - ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro + - admin_proxy_data:/data + - admin_proxy_config:/config + networks: + - admin diff --git a/proxy/Caddyfile b/proxy/Caddyfile new file mode 100644 index 0000000..ca2a94d --- /dev/null +++ b/proxy/Caddyfile @@ -0,0 +1,14 @@ +{$ADMIN_HOSTNAME} { + tls internal + + encode zstd gzip + + @api path /api/* + handle @api { + reverse_proxy admin-api:5181 + } + + handle { + reverse_proxy admin-web:80 + } +} diff --git a/web/package.json b/web/package.json index 1553709..94ccf12 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,11 @@ "type": "module", "license": "AGPL-3.0-only", "packageManager": "pnpm@10.6.3", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, "engines": { "node": ">=22" },