8fce86559eb88791c0059212de3b26dcb32454f3
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8fce86559e |
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 |
||
|
|
cc63efa968 |
Docs: the real DSM fix is bridge-nf-call-iptables=0, not an iptables rule
Confirmed on the operator's NAS. The earlier advice (insert an ACCEPT rule ahead of DSM's chain) does not hold: DSM reloads its firewall periodically, flushing and rebuilding the chains. You can watch FORWARD_FIREWALL's DROP counter reset to zero and the inserted rule vanish with it, having matched no packets. That is also why the symptom looked intermittent — 60/40 success on repeated connections, with RELATED,ESTABLISHED keeping existing flows alive. The fix is one level down: net.bridge.bridge-nf-call-iptables=0 takes same-bridge traffic out of iptables entirely, so DSM's reloading cannot affect it. Persisted with a Boot-up Triggered Task. Section 8 now also answers "is that safe", accurately: the flag affects only packets bridged within one network. Traffic between different Docker networks is routed, so DOCKER-ISOLATION still applies; inbound traffic to published ports is still routed and still firewalled; INPUT, which protects the NAS itself, is untouched. The one real caveat is that it is host-wide, so VMs sharing a bridge lose same-bridge filtering too. The migrate failure message points at the sysctl now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw |
||
|
|
d29c46c229 |
Docs: DSM firewall blocks container-to-container traffic
Diagnosed on a real Synology: with the DSM firewall enabled, iptables reads
-A FORWARD -j FORWARD_FIREWALL <- DSM, first, default deny
-A FORWARD -j DEFAULT_FORWARD <- Docker's ACCEPT rules, never reached
and bridge-nf-call-iptables=1 sends container-to-container packets through
FORWARD. A new connection to postgres:5432 matches no DSM allow rule and is
dropped before Docker's own "-i docker-xxx -o docker-xxx -j ACCEPT" is
consulted. RELATED,ESTABLISHED is accepted, which is why the port could test
open from a throwaway container while every new connection timed out — the
symptom looked intermittent and pointed nowhere near the firewall.
- docs §8: the diagnosis, the one-line fix
(iptables -I FORWARD 1 -i docker+ -o docker+ -j ACCEPT), a boot-up Triggered
Task so it survives reboots and firewall reloads, and a verification command.
- The migrate failure message now names this cause and the fix, since that is
the error an operator actually sees.
Nothing in the stack is at fault here; this is host firewall configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
|
||
|
|
f3dcd938b1 |
Deploy: fix the two things that broke the Container Manager build
DSM reported "invalid interpolation format for x-backend-env.MASTER_KEY" and
"required variable MASTER_KEY is missing a value". Both were mine.
1. DSM's bundled Compose cannot parse `${VAR:?message}`. Those guards are gone;
interpolation now sticks to `${VAR}` and `${VAR:-default}`, the forms every
Compose understands. Bind mounts keep a `${DATA_ROOT:-/DATA_ROOT-is-unset}`
sentinel so a missing config still cannot scatter directories across the
root of the NAS filesystem, and the header says to keep it this way.
2. Config was at docker/.env.prod with a `.env` symlink beside it. Container
Manager did not pick that up. The real file is now `.env` in the project
folder — what every compose front end reads without being told — and
docker/.env.prod becomes a symlink to it. `init` and `require_env` migrate
an existing install in place, keeping its MASTER_KEY.
Also, while testing the migration: `init` only generated secrets when creating
a file from scratch, so a migrated or hand-written .env with an empty
POSTGRES_PASSWORD started Postgres in trust-auth mode. It now tops up empty
secrets — and never touches a MASTER_KEY that already has a value, since
replacing it would make every stored credential undecryptable. doctor flags an
empty password too.
Verified: parses cleanly with no .env at all (the failure DSM hit), migration
preserves an existing key, and `docker compose up -d --wait` from the project
folder brings the stack up healthy and serving.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
|
||
|
|
7f6b76ec12 |
Deploy: explain the root-owned env file instead of leaking "Permission denied"
init runs as root on a NAS, so docker/.env.prod is mode 600 owned by root and the data directories are root-owned. Running any subcommand as a normal user produced a bare redirect error naming an internal line number, with no hint about the cause. require_env now checks readability first and says who you are running as, the exact sudo command to repeat, and that `sudo -i` avoids it for the session. Docs note it beside the show-key step and above the command reference. Reproduced with a root-owned 600 env file read as a normal user; the message is what that case now prints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw |
||
|
|
24031f1daa |
Deploy: make a no-SSH install possible from the DSM GUI
The install guide was SSH-first and left the GUI as a footnote, but the two things it could not do were both fixable rather than inherent. - `init` takes --data-root / --domain / --tz / --port / --bind, so configuring is one command instead of hand-editing a dotfile that File Station hides by default. Re-running with flags updates an existing .env.prod. - `show-key` prints MASTER_KEY, so it can be backed up without opening that hidden file — intended to be redirected to a file, copied into a password manager, and deleted. - usage() printed a hardcoded line range that spilled into the code once the header grew; it now prints the leading comment block however long it gets. - docs §2.5 is a complete GUI walkthrough: Gitea "Download ZIP" + File Station (with the chmod a zip download needs), two one-off Task Scheduler jobs for config and key backup, then the Container Manager project on the repo root named webmail-prod. Plus a table for running update/rollback/backup/restore /doctor/reindex as Task Scheduler jobs, and why the UI's own Build action is not a substitute for `update` (no pre-backup, no health gate, no rollback). Verified by running the documented sequence verbatim: flag-driven init, show-key, then `docker compose up -d --wait` from the project folder as the GUI does — all services healthy, account created, backup and doctor tasks working against the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw |
||
|
|
cc26ae7eaf |
Deploy: make the NAS GUI path work, and stop sourcing the env file
Synology Container Manager and QNAP Container Station build a project from a folder and look for `docker-compose.yml` in it. Pointing either at `docker/` finds the DEV service stack and starts the wrong thing, so production now has a root-level entry point. - docker-compose.yml at the repo root: `include`s the prod stack (relative paths resolve against the included file's directory, so the build context and backup.sh still resolve) and pins `name: webmail-prod`. A project name typed into the GUI overrides the file's, so the docs say to type the same one. - webmail.sh init now drops a `.env` symlink to docker/.env.prod in the repo root: the GUI reads `.env` from the project folder, and a symlink keeps one file to edit instead of two copies that drift. - Required-variable guards on DATA_ROOT / MASTER_KEY / POSTGRES_PASSWORD / APP_DOMAIN. A bare `docker compose up` with no env file used to interpolate DATA_ROOT to "" and bind-mount /postgres at the root of the filesystem; it now aborts with a message naming the variable. - require_env no longer sources the env file. Sourcing executed it as shell: ENABLED_PLUGINS=calendar contacts labels filters ran `contacts` as a command and broke every subcommand, and anything else in the file that holds the master key would have been executed too. Replaced with a compose-compatible KEY=VALUE parser; the template quotes the value as well. - doctor: no longer aborts on a not-yet-created DATA_ROOT (set -e + pipefail killed it inside a `df` assignment — on exactly the pre-install run where its NFS/SMB check is the one that matters), and probes the nearest existing ancestor so that check works before the directory exists. - docs §2.5 rewritten with what the GUI can and cannot do: no compose profiles (so no Meilisearch), no health-gated update, no restore; plus the Task Scheduler route for generating MASTER_KEY without enabling SSH, and why init/migrate showing "Exited (0)" is correct. Verified by running `docker compose up -d --wait` from the repo root with no -f and no --env-file (what the GUI does): all services healthy, migrations applied, app served, and deploy/webmail.sh manages the same project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw |
||
|
|
5553b5febe |
Deploy: production Docker stack for a single host (Synology/QNAP NAS)
Everything needed to run the app on an Intel NAS that is also doing other jobs: one published port, no host networking, memory/CPU caps and log rotation on every container, and all state in bind mounts under DATA_ROOT so mail survives image updates, project deletion in the NAS UI, and full reinstalls — and so Hyper Backup/HBS3 can actually see it. - docker/Dockerfile.backend: multi-stage, non-root (uid 10001), tini for the <10s graceful lease release. Installs the four plugins as entry-point distributions. One image, several roles via docker/entrypoint.sh (api | worker-sync | worker-jobs | migrate | reindex | plugins). - docker/Dockerfile.frontend + nginx.conf: SPA + /api reverse proxy with the WebSocket upgrade for /api/ws, and X-Forwarded-Proto preserved so the API still emits __Host-/Secure cookies when TLS is terminated by the NAS reverse proxy (identity/cookies.py keys off request scheme). - docker/docker-compose.prod.yml: project name webmail-prod, NOT webmail — docker-compose.yml (dev services) owns that name, and two compose files sharing one project name fight over each other's containers. Meilisearch is an opt-in profile; SEARCH_BACKEND=pg is the default so a small NAS pays nothing for search. - migrate runs as a one-shot (core Alembic head + every plugin branch) that everything else waits on, so a bad migration can never leave new code running against an old schema. - docker/backup.sh: nightly pg_dump verified with `pg_restore --list` before it counts as a backup, with retention and a baseline on first start. - deploy/webmail.sh: init/doctor/up/down/status/logs/update/rollback/ backup/restore/reindex/psql/shell. `update` is backup -> images -> migrate -> roll workers, api, web -> health-gate, with automatic rollback to the previous version if the gate fails. `doctor` refuses to be quiet when DATA_ROOT is on NFS/SMB (silent Postgres corruption). - deploy/build-push.sh: build images off-NAS (the frontend build wants ~2GB RAM) and push to Gitea, or --save a tar to carry over. - scripts/reindex_search.py: rebuild the search index from Postgres alone. Every SearchDoc field is already stored, so the index is derived data that never needs backing up — this closes the only real objection to running Meilisearch on a box whose volumes may be wiped. - pyproject: package-data for accounts/presets.yaml and the shared plugin Alembic env. Without it a non-editable install (i.e. any image) silently ships a package that fails at runtime. - GET /api/version, used by the deploy health gate. Verified end to end on a real stack: fresh install, all services healthy, core + 4 plugin migration branches applied, login/SPA/plugin bundles through nginx, WS upgrade returning 101, __Host- cookies under a simulated TLS proxy, backup -> restore round-trip preserving user and plugin schema, failed-update auto-rollback (both a bad migration and a boot-crashing API), and Meilisearch profile serving a live query. ruff + mypy --strict clean, 363/363 backend tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw |