This commit is contained in:
Jason Ross
2026-03-31 21:28:12 -05:00
parent 350e43bbfb
commit e34fdde7d6
15 changed files with 170 additions and 49 deletions
View File
+11 -6
View File
@@ -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=
+37 -12
View File
@@ -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 <your-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 '<strong-password>'
pnpm seed -- --username admin --password '<strong-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://<that-hostname>`.
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).
+8 -4
View File
@@ -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.
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.turbo/
.env
.env.*
secrets.env
*.db
*.db-*
*.sqlite
*.sqlite*
data/
coverage/
.DS_Store
+7
View File
@@ -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"
},
+12 -1
View File
@@ -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();
+1
View File
@@ -20,6 +20,7 @@ async function main(): Promise<void> {
await connectMongo();
const app = express();
app.set("trust proxy", 1);
app.use(
helmet({
+10 -4
View File
@@ -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
}
});
+6 -2
View File
@@ -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 });
})
);
+4
View File
@@ -32,7 +32,11 @@ async function promptForMissing(
}
async function main(): Promise<void> {
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
const parsed = parseArgs({
args: normalizedArgs,
options: {
username: {
type: "string"
+10 -12
View File
@@ -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.
+32 -8
View File
@@ -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
+14
View File
@@ -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
}
}
+5
View File
@@ -5,6 +5,11 @@
"type": "module",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.6.3",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"engines": {
"node": ">=22"
},