Admin panel on its own port, and a registration gate

Three strands that only make sense together, so they land together.

Registration gate (migration 0016). POST /auth/register was open to anyone
who could reach the port. Now the first account on an empty instance is
always allowed, becomes the administrator, and closes registration behind
itself; after that it takes an admin reopening it. The decision is made
under a row lock on app_settings so two simultaneous first signups cannot
both see an empty table and both claim administrator. Existing installs
keep their oldest account as admin and start closed.

Instance default quota (migration 0017). users.quota_bytes is snapshotted
at creation from app_settings.default_quota_bytes, so changing the default
affects later accounts only and never resizes a mailbox that exists.

The panel is a separate app, not a page in the mail client. admin.html is
its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the
vendor chunk), with its own administrator sign-in. One nginx now serves two
listeners: :80 is the mail client and 404s both /admin.html and
/api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT
and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not
process isolation — CurrentAdmin still guards every admin route — so
ADMIN_PORT must be restricted to the LAN, which the runbook now covers for
both DSM and QNAP.

Also QNAP: deploy/webmail.sh finds Container Station's docker under
/share/*/.qpkg/container-station/bin, which is off an SSH session's PATH,
before anything shells out to it. docs/nas-install.md gains a QuTS hero
runbook (§2.6), splits the DSM firewall section into container-to-container
(§8.1) and the egress case that makes account-add time out while
autodiscovery appears to work (§8.2), and scopes both to Synology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-16 20:34:08 +02:00
co-authored by Claude Opus 5
parent cc63efa968
commit 8fce86559e
27 changed files with 1409 additions and 125 deletions
+292 -22
View File
@@ -3,10 +3,12 @@
This is the operator runbook: how to install it, how to keep your mail safe from
anything Docker does to itself, and how to update without holding your breath.
It assumes the NAS is doing other work too, so the stack is deliberately modest:
**one published port, no host networking, memory caps on every container.**
**two published ports, no host networking, memory caps on every container** —
mail on one, the admin panel on the other (§5.1).
Everything here works on any Intel/AMD Docker host — the NAS parts are just the
specifics for DSM and QTS.
specifics for DSM, QTS and QuTS hero. Synology users want §2.5; QNAP users
§2.6.
---
@@ -21,7 +23,7 @@ specifics for DSM and QTS.
| `postgres` | the database | no |
| `redis` | job queues + sync leases | no |
| `backup` | nightly verified database dump | no |
| `meilisearch` | optional better search (see §8) | no |
| `meilisearch` | optional better search (see §7) | no |
| `migrate`, `init` | run once at start, then exit | — |
Plugins (calendar, contacts, labels, filters) are baked into the backend image
@@ -43,7 +45,13 @@ Docker named volume:
| NAS | Use |
|---|---|
| Synology | `/volume1/docker/webmail/data` |
| QNAP | `/share/Container/webmail/data` |
| QNAP (QTS / QuTS hero) | `/share/Container/webmail/data` |
On **QuTS hero** `/share/<shared-folder>` is the stable path — use it rather
than the underlying `/share/<POOL>_DATA/...` form, which carries the pool name
and changes if you ever move the folder. ZFS is a genuine advantage here:
snapshot the `Container` shared folder and you get cheap, instant, restorable
points-in-time for the whole install, on top of the nightly dump in §3.
Why: named volumes live inside Docker's internal storage, where Hyper Backup,
Snapshot Replication and HBS3 cannot see them, and where "delete the project" in
@@ -82,8 +90,10 @@ database password. Then open it and set:
DATA_ROOT=/volume1/docker/webmail/data # or /share/Container/webmail/data
APP_DOMAIN=mail.home.lan # your handles become you@this
TZ=Europe/Warsaw
WEB_PORT=8088 # avoid 5000/5001 (DSM), 8080 (QTS)
WEB_PORT=8088 # mail; avoid 5000/5001 (DSM), 8080 (QTS)
ADMIN_PORT=8089 # admin; keep this one LAN-only (§5.1)
WEB_BIND=0.0.0.0 # or one NAS IP, e.g. 192.168.1.185
ADMIN_BIND=0.0.0.0
```
> ### Copy `MASTER_KEY` into your password manager. Now.
@@ -101,12 +111,13 @@ WEB_BIND=0.0.0.0 # or one NAS IP, e.g. 192.168.1.185
```
First run builds both images. The frontend build wants ~2 GB of RAM: if your NAS
is small or you'd rather not tie it up, build on your PC instead — see §6.3.
is small or you'd rather not tie it up, build on your PC instead — see §4
"Building images elsewhere".
Open `http://your-nas:8088/`, create your account, and add a mail account
through the wizard.
### 2.5 Installing entirely from the DSM GUI (no SSH)
### 2.5 Synology: installing entirely from the DSM GUI (no SSH)
The whole install can be done without ever enabling SSH. The trick is **Task
Scheduler**: it runs any shell command as root from the DSM web UI, which covers
@@ -170,9 +181,11 @@ means the GUI and `deploy/webmail.sh` drive the same stack.
Click through, and it builds. **First build takes 5-15 minutes and wants ~2 GB
of RAM** — it compiles the frontend on the NAS. This is the one place the GUI
route is meaningfully worse than SSH, where you can build on your PC and copy
the images over (§6.3).
the images over (§4 "Building images elsewhere").
**5. Open `http://your-nas:8088/`** and register your account.
**5. Open `http://your-nas:8088/`** and register your account. The first account
created on a fresh install becomes the **administrator**, and registration closes
behind it — see §5.1.
**What you will see in Container Manager:** `init` and `migrate` sitting at
**Exited (0)**. That is correct — they are one-shot jobs that did their work and
@@ -198,11 +211,103 @@ and does apply migrations — but it takes no backup first, has no health gate,
and will not roll back if the new version fails to start. The `update` task
above does all three. Use it.
### 2.6 Starting automatically after a reboot
### 2.6 QNAP: Container Station and QuTS hero
The stack itself is identical — same compose file, same `deploy/webmail.sh`.
Four things differ from the Synology route.
**1. Docker is off the PATH.** Container Station keeps `docker` and
`docker-compose` inside its QPKG directory, so a fresh SSH session finds
neither. `deploy/webmail.sh` locates them itself (it globs
`/share/*/.qpkg/container-station/bin`, which covers both QTS's
`CACHEDEV1_DATA` and QuTS hero's pool name). For your own commands, put it on
the PATH for the session:
```bash
export PATH="$(ls -d /share/*/.qpkg/container-station/bin | head -1):$PATH"
docker ps
```
**2. Drive it from the shell, not the Container Station wizard.** Two reasons,
one of which holds whatever your Container Station version offers. The weaker
one: depending on version, its *Applications* create-flow may want YAML pasted
into a text box rather than a folder path, and pasted YAML has no `.env` beside
it — which is where `MASTER_KEY` and the database password live. The one that
always holds: its update path takes no backup, has no health gate and cannot
roll back, exactly like Synology's project *Build* button. Run the install the
normal way:
```bash
ssh admin@your-qnap
sudo -i
mkdir -p /share/Container/webmail && cd /share/Container/webmail
git clone <your-gitea-url>/webmail.git src && cd src
bash deploy/webmail.sh init --data-root /share/Container/webmail/data \
--domain mail.home.lan --tz Europe/Warsaw --port 8088
bash deploy/webmail.sh up
```
You still get the GUI. Containers started this way use the same Docker daemon,
so Container Station lists them, shows logs and stats, and can stop or restart
them — you simply use `deploy/webmail.sh update` instead of its update button.
**3. Pick ports around QTS.** The web UI owns **8080** (and 443/80 if you have
HTTPS on), so the defaults `WEB_PORT=8088` / `ADMIN_PORT=8089` are already
clear of it. `deploy/webmail.sh doctor` warns if either is taken.
**4. There is no DSM firewall problem.** §8 — the chain that silently drops
container traffic — is Synology-specific. QuTS hero has no such rule by default;
container-to-container and container-to-internet both just work. If you install
**QuFirewall**, re-read §8 first: it describes the same class of failure and,
more usefully, how to prove where a packet dies.
#### Pulling images instead of building on the NAS
Building on the NAS costs 5-15 minutes and ~2 GB of RAM per update. Pulling
pre-built images makes an update a download and a restart. The catch is that
your Gitea speaks plain **HTTP**, and Docker refuses a non-HTTPS registry
unless told otherwise.
On the QNAP, once:
```bash
# find the daemon's config file rather than guessing its path
ps -ef | grep -o '\--config-file[= ][^ ]*' | head -1
```
Add the registry to that `daemon.json`:
```json
{ "insecure-registries": ["192.168.1.185:8418"] }
```
Restart Container Station afterwards so the daemon reloads, then:
```bash
docker login 192.168.1.185:8418
```
From then on, on your PC:
```bash
REGISTRY_PREFIX=192.168.1.185:8418/claude/webmail ./deploy/build-push.sh v1.0.0
```
and on the NAS, with `REGISTRY_PREFIX` and `IMAGE_TAG` set in `.env`:
```bash
bash deploy/webmail.sh update v1.0.0
```
> If you would rather not loosen the daemon, `./deploy/build-push.sh v1.0.0
> --save` writes a tar you copy over and `docker load` — no registry, no
> insecure flag, but a manual copy each time.
### 2.7 Starting automatically after a reboot
Every service is `restart: unless-stopped`, so Docker brings the stack back on
boot by itself. On Synology, make sure Container Manager itself is set to start
on boot (it is by default).
on boot (it is by default); on QNAP, the same goes for Container Station.
---
@@ -217,7 +322,7 @@ Containers are disposable. These are not:
| 3 | **`MASTER_KEY`** | `.env` + your password manager | Every saved mail-account password, permanently |
Everything else is derived and needs no backup: Redis holds queues and leases,
Meilisearch's index rebuilds from Postgres (§8), and images rebuild from source.
Meilisearch's index rebuilds from Postgres (§7), and images rebuild from source.
### Backups
@@ -316,14 +421,100 @@ docker load -i webmail-v1.0.0.tar && ./deploy/webmail.sh update v1.0.0
### 5.1 Read this first
**Anyone who can open the page can create an account on it.** Registration is
open by design (it's a personal server), so do not put this straight on the
public internet. Pick one:
**Registration is open only until the first account exists.** On a fresh install
the signup page is available to anyone who can reach the port; whoever uses it
first becomes the administrator, and signup closes automatically behind them. So
the window is narrow, but it is real: create your account before exposing the
port, or someone else owns the instance.
After that, new accounts need an administrator to reopen registration — which
brings us to the two ports.
#### The two ports
One container, two apps, two nginx listeners:
| Port | Serves | Expose to |
|---|---|---|
| `WEB_PORT` (8088) | The mail client. The admin panel and `/api/admin/*` both return **404** here. | Wherever you like |
| `ADMIN_PORT` (8089) | The admin panel — its own app, its own sign-in. | **LAN only** |
They are two separate applications built from one source tree: `index.html` is
the mail client, `admin.html` is the panel, and nginx serves one per listener.
The panel is a few KB of its own code and never loads the mail client. Opening
`http://your-nas:8089/` lands directly on the administrator sign-in.
Signing in there is the ordinary account login — same session cookie as mail.
An account without administrator rights gets in and is told it is not an
administrator; it sees no settings.
Understand what this boundary is: nginx refusing a path on one listener. That is
enough to keep the admin API off an internet-facing port, and it fails closed if
the app ever forgets a permission check. It is **not** process isolation — both
listeners proxy to the same API container, so anything that reaches `ADMIN_PORT`
reaches every admin route, and the API's own administrator check is still what
guards each one. Restricting `ADMIN_PORT` is therefore not optional.
#### Restricting ADMIN_PORT to the LAN
**On QNAP** there is usually no firewall in the way at all, which cuts both
ways: nothing filters `ADMIN_PORT` for you either. Two things keep it off the
internet, and you want both:
1. **Do not forward 8089 on your router.** This is what actually matters. A
published port is only reachable from outside if something routes to it, and
a home router forwards nothing by default.
2. **Bind it to the LAN interface.** Set `ADMIN_BIND` in `.env` to the NAS's LAN
address, e.g. `ADMIN_BIND=192.168.1.50`. The port then isn't listening on any
other interface — useful if the box ever gains a second one (a VPN tunnel, a
second NIC, a Docker macvlan).
For an actual source-IP rule on QNAP you need the optional **QuFirewall** app;
its allow/deny-by-subnet model works like the DSM recipe below.
**On Synology:** *Control Panel → Security → Firewall → Edit Rules → Create*
| Field | Value |
|---|---|
| Ports | Custom → TCP → `8089` |
| Source IP | Specific IP → Subnet → `192.168.1.0` / `255.255.255.0` |
| Action | Allow |
Then a second rule for the same port with Source IP **All** and Action **Deny**,
placed directly below it. Order is everything: DSM evaluates top-down, so the
allow must come first. Verify from off-LAN — or just trust the deny and check
that `curl http://nas:8089/healthz` still answers from a LAN machine.
If you would rather not publish it at all, comment the `ADMIN_PORT` line out of
the `web` service's `ports:` in `docker-compose.yml` and reach admin over VPN,
or temporarily `docker compose port` it when you need it.
Reopening registration, once you are on `ADMIN_PORT`:
```bash
# the panel does this for you; this is the same call by hand, from a
# signed-in browser session:
curl -X PUT http://your-nas:8089/api/admin/settings \
-H 'Content-Type: application/json' -H "X-CSRF: $CSRF" \
--cookie "session=$SESSION; csrf=$CSRF" \
-d '{"registration_open": true}'
```
`GET /api/admin/settings` reads the current state, and `{"registration_open":
false}` closes it again. The same endpoint carries `default_quota_bytes`, the
quota handed to accounts created from then on — changing it never resizes a
mailbox that already exists.
#### And the mail port
Even with admin split off, do not put `WEB_PORT` straight on the public
internet unguarded. Pick one:
- **VPN** (Tailscale, WireGuard, or the NAS's own VPN Server) — simplest and
safest. The port stays LAN-only.
safest. Both ports stay LAN-only.
- **NAS reverse proxy with HTTPS**, restricted by firewall rules to the networks
you trust.
you trust. Proxy `WEB_PORT` only — never create a proxy entry for
`ADMIN_PORT`, or you have undone the split.
### 5.2 HTTPS through the NAS reverse proxy
@@ -401,7 +592,15 @@ backends, or search ever looks stale, just rebuild it:
---
## 8. DSM firewall breaks container-to-container traffic
## 8. Synology only: the DSM firewall drops container traffic
**On QNAP, skip this section** unless you have installed *QuFirewall* — QTS and
QuTS hero add no such rule of their own. It is still worth skimming §8.2, which
is mostly a method for proving *where* a packet dies; that part is portable.
There are two separate failures here with the same silent-timeout signature.
### 8.1 Containers cannot reach each other
**Symptom:** everything reports healthy, but `migrate` fails with an asyncpg
`TimeoutError`, or services intermittently cannot reach `postgres:5432`. Testing
@@ -459,7 +658,7 @@ sysctl -w net.bridge.bridge-nf-call-iptables=0
Re-check it after a DSM upgrade with `sysctl net.bridge.bridge-nf-call-iptables`.
### Is that safe?
#### Is that safe?
Yes, for this deployment, and the scope is narrower than it first sounds.
@@ -474,7 +673,9 @@ What does **not** change:
bridged, so it still traverses `FORWARD` and Docker's
`DOCKER-ISOLATION-STAGE-1/2` rules still apply.
- **Traffic from your LAN or the internet into a published port.** Still routed,
still filtered by DSM's firewall. The one published port here is `WEB_PORT`.
still filtered by DSM's firewall. The published ports here are `WEB_PORT` and
`ADMIN_PORT` — and that filtering is exactly what keeps `ADMIN_PORT` on the
LAN, so this is a place you *want* DSM's firewall doing its job (§5.1).
- **Traffic from containers out to the network.** Still routed and filtered.
- **DSM's own protection.** The `INPUT` chain, which guards the NAS itself, is
untouched.
@@ -484,6 +685,71 @@ bridge on the box. If you run virtual machines on a bridged network *and* rely
on DSM's firewall to filter traffic between VMs on that same bridge, that
filtering stops too. Filtering of VM traffic to anywhere else is unaffected.
### 8.2 Containers cannot reach the internet
The `bridge-nf` fix above does **not** cover this one, and the symptom is easy
to misread.
**Symptom:** adding a mail account fails with `connection timed out`, while the
setup wizard happily auto-discovers the provider's server names. That looks like
"the app found the server, so the network is fine" — it is not. Autodiscovery
answers from a local presets file for the common providers, and never touches
the network at all.
**Cause:** `FORWARD_FIREWALL` is an allow-list of destination ports ending in
`DROP`. Inspect it and the shape is obvious:
```
23 RETURN udp -- udp dpt:53 <- DNS allowed
24 RETURN tcp -- multiport dports 80,443 <- HTTPS allowed
... no 993, no 465, no 587 ...
DROP all <- IMAP/SMTP dies here
```
So DNS resolves and HTTPS works, while IMAP's SYN goes unanswered. A *timeout*
rather than a refusal is the tell: a closed port answers with RST, a firewall
says nothing.
Note that `bridge-nf-call-iptables=0` cannot help, because this traffic is
**routed** (bridge → eth0), not bridged, so it crosses `FORWARD` regardless.
**Prove it** — the host reaches the server, a container does not:
```bash
# A: from the NAS itself
timeout 5 bash -c 'exec 3<>/dev/tcp/imap.gmail.com/993' && echo "HOST OK" || echo "HOST FAIL"
# B: from inside the stack's network
docker run --rm --network webmail-prod postgres:16 \
bash -c 'timeout 5 bash -c "exec 3<>/dev/tcp/imap.gmail.com/993" && echo "CTR OK" || echo "CTR FAIL"'
```
A ok + B fail confirms it. To see precisely which rule kills it, zero the
counters, re-run B, and look for the one that moved:
```bash
iptables -Z FORWARD_FIREWALL
iptables -vnL FORWARD_FIREWALL --line-numbers
```
**Fix — allow the Docker subnets outbound, in DSM's own GUI.** Not with
`iptables`: a hand-inserted rule is flushed the next time DSM rebuilds its
chains (§8.1), whereas a GUI rule is part of what gets rebuilt.
*Control Panel → Security → Firewall → Edit Rules → Create*
| Field | Value |
|---|---|
| Ports | All |
| Protocol | All |
| Source IP | Specific IP → Subnet → `172.16.0.0` / `255.240.0.0` |
| Action | Allow |
Place it above the deny-all. Use the whole `/12` rather than the stack's current
subnet: Docker assigns those dynamically, so a rule naming `172.21.0.0/16` stops
matching the day the network is recreated on a different subnet — a trap that is
easy to fall into and hard to spot afterwards.
## 9. Troubleshooting
| Symptom | Cause | Fix |
@@ -491,8 +757,12 @@ filtering stops too. Filtering of VM traffic to anywhere else is unaffected.
| `up` hangs on `postgres` | `DATA_ROOT` on NFS/SMB, or wrong permissions | `./deploy/webmail.sh doctor` |
| UI loads, no live updates | WebSocket not enabled in the NAS reverse proxy | §5.2 Custom Header → WebSocket |
| Login works, logs out on reload | HTTPS terminated upstream but `X-Forwarded-Proto` stripped | Use the NAS reverse proxy as documented; don't strip headers |
| "port is already allocated" | 8088 taken by another app | change `WEB_PORT` |
| `migrate` fails with asyncpg TimeoutError | DSM firewall dropping container traffic (§8) | `sysctl -w net.bridge.bridge-nf-call-iptables=0` |
| "port is already allocated" | 8088/8089 taken by another app | change `WEB_PORT` / `ADMIN_PORT` |
| Admin panel 404s | you are on `WEB_PORT`; the panel is only on `ADMIN_PORT` | open `http://nas:8089/` (§5.1) |
| Panel says "Not an administrator" | signed in with a non-admin account | sign out, sign in as the first-created account (§5.1) |
| `migrate` fails with asyncpg TimeoutError | Synology: DSM firewall dropping container traffic (§8.1) | `sysctl -w net.bridge.bridge-nf-call-iptables=0` |
| Adding a mail account times out, but autodiscovery worked | Synology: DSM firewall dropping container *egress* (§8.2) | allow `172.16.0.0/12` outbound in the DSM firewall GUI |
| `no compose found` on QNAP over SSH | Container Station's binaries are off-PATH | nothing to do — the script globs for them (§2.6) |
| Mail not syncing | worker stopped, or credentials rejected | `./deploy/webmail.sh logs worker-sync` |
| Search finds nothing after a restore | index not rebuilt | `./deploy/webmail.sh reindex --purge` |
| App won't start after editing `.env` | malformed `MASTER_KEY` | `./deploy/webmail.sh doctor` |