Make the admin a member with a flag, and drop the second listener

The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
This commit is contained in:
Esa Kataja
2026-09-05 13:40:26 +03:00
parent 00ea7624ca
commit 2af29fe999
16 changed files with 321 additions and 186 deletions
+25 -28
View File
@@ -22,12 +22,12 @@ services:
# running version. Kept in .env so this file carries no host of yours.
image: ${IMAGE:?set IMAGE in .env}
environment:
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
# Only read while the users table is empty: they create the first account and are ignored
# from then on. Safe to remove once that account exists.
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
ADDR: ":8080"
# The admin listener binds the container's own interface. What keeps it private is the
# published port below, bound to the host's loopback.
ADMIN_ADDR: ":8081"
SECURE_COOKIES: ${SECURE_COOKIES:-true}
PUBLIC_URL: ${PUBLIC_URL:-}
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
@@ -35,30 +35,25 @@ services:
- ./storage:/storage
ports:
- "8080:8080"
# Loopback only. The admin panel is Basic Auth and nothing else, so it must never be
# reachable from the network — reach it over an SSH tunnel, below.
- "127.0.0.1:8081:8081"
restart: unless-stopped
```
Three differences from the development file, and the reason for each:
Two differences from the development file, and the reason for each:
| | Development | Server |
|---|---|---|
| Source of the binary | `build:` from the checkout | `image:` pulled from the registry |
| Version | `VERSION` build arg, `dev` by default | baked into the tagged image |
| Admin port | `8081:8081`, reachable, convenient locally | `127.0.0.1:8081:8081`, loopback only |
**The admin port is the one that matters.** Published as `8081:8081` it binds every interface, and
the admin panel has HTTP Basic Auth and nothing else — no session, no lockout, no second factor. On
a server that must be `127.0.0.1:8081:8081`.
There is one port. `/admin` rides the member listener behind the same session cookie as everything
else, so there is nothing extra to publish, tunnel or firewall.
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
```sh
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
ADMIN_USER=admin
ADMIN_PASSWORD=
ADMIN_EMAIL=# first start only
ADMIN_PASSWORD= # first start only
SECURE_COOKIES=true
PUBLIC_URL=https://levyraati.example.com
```
@@ -132,23 +127,24 @@ Proxy your public hostname to `127.0.0.1:8080`. Two things matter beyond the def
- **Response buffering off**, or at least generous timeouts, for `/audio/{id}` — it serves Range
requests so the player can seek.
Do **not** proxy port 8081.
### Admin access
Bound to the host's loopback, so reach it through an SSH tunnel:
Log in as your own account and open `/admin`. Nothing to tunnel, nothing extra to proxy: the page is
part of the site and is gated on the `is_admin` flag on your user row. A signed-in member without the
flag gets a 404 there, so the page does not advertise itself.
```sh
ssh -L 8081:127.0.0.1:8081 you@server
# then open http://localhost:8081
```
TLS at the proxy is what makes the invite *Kopioi* button work — the clipboard API needs a secure
context, and `https://` is one. Over plain HTTP on a real hostname the button will not fire.
Localhost also happens to be a secure context, which is what makes the invite *Kopioi* button work.
From the page: mint invites, reset passwords, ban members, delete songs, read feedback.
From the panel: mint invites, reset passwords, ban members, delete songs, read feedback.
**First start.** On an empty database the app creates one account from `ADMIN_EMAIL` /
`ADMIN_PASSWORD` and marks it admin. Once it exists those variables do nothing; drop them from
`.env` if you would rather not keep a password there.
**Lost the admin password?** Edit `.env`, `docker compose up -d`. There is no recovery endpoint and
no recovery key — the credentials *are* the environment.
**Lost the admin password?** There is no recovery endpoint and no recovery key. Set a new bcrypt
hash directly in the SQLite file — re-running the app with `ADMIN_PASSWORD` will not help, because
seeding only fires on an empty `users` table.
---
@@ -266,7 +262,7 @@ bounded by the two conversion slots.
| Symptom | Cause |
|---|---|
| Container exits immediately | `ADMIN_PASSWORD` unset. The log says so, and it is deliberate — an admin panel that silently opens is worse than one that will not boot |
| Container exits immediately on a first start | `ADMIN_EMAIL` or `ADMIN_PASSWORD` unset on an empty database. The log says so; a site nobody can log into is worse than one that will not boot |
| `set IMAGE in .env` | Compose has no image to run; `IMAGE` is required and unset |
| `/healthz` says `ok dev` | The image was built without `--build-arg VERSION`, so what is deployed cannot be identified |
| Login never sticks | Plain HTTP with `SECURE_COOKIES=true`. Terminate TLS, or set it `false` for a local test |
@@ -274,4 +270,5 @@ bounded by the two conversion slots.
| Invite links are relative | `PUBLIC_URL` unset |
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
| Submissions all fail at download | yt-dlp is stale; rebuild and publish the image |
| Admin panel answers from another machine | The admin port is published on all interfaces — it must be `127.0.0.1:8081:8081` |
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |