wip
This commit is contained in:
+11
-6
@@ -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=
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.env
|
||||
.env.*
|
||||
secrets.env
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite*
|
||||
data/
|
||||
coverage/
|
||||
.DS_Store
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -20,6 +20,7 @@ async function main(): Promise<void> {
|
||||
await connectMongo();
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,6 +5,11 @@
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.6.3",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user