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:
+50
-68
@@ -2,11 +2,26 @@
|
||||
#
|
||||
# This file is dropped into /etc/nginx/conf.d/, i.e. it is already inside the
|
||||
# `http` block — hence the bare `map` below.
|
||||
#
|
||||
# TWO LISTENERS, one app:
|
||||
#
|
||||
# :80 public — the mail client. Neither the admin bundle nor /api/admin/*
|
||||
# is served here; both 404.
|
||||
# :81 admin — the admin panel (its own bundle, its own sign-in), and the
|
||||
# /api/admin/* endpoints it needs.
|
||||
#
|
||||
# The compose file publishes :80 as WEB_PORT and :81 as ADMIN_PORT, and the
|
||||
# firewall is what makes the split mean anything: allow ADMIN_PORT from the LAN
|
||||
# only, leave WEB_PORT as exposed as you like (docs/nas-install.md §5.1).
|
||||
#
|
||||
# Note what this boundary is and is not. It is nginx refusing paths on one
|
||||
# listener — good enough to keep the admin panel and its API off an
|
||||
# internet-facing port, and it fails closed if the app forgets a permission
|
||||
# check. It is NOT process isolation: both listeners proxy to the same API
|
||||
# container, so anything that can reach :81 can reach every admin route. The
|
||||
# panel still demands an administrator sign-in, and the API's own `CurrentAdmin`
|
||||
# dependency still guards each route; this is the outer layer, not the only one.
|
||||
|
||||
# Preserve the original scheme when something else terminates TLS in front of us
|
||||
# (Synology's Login Portal reverse proxy, QNAP's, Caddy, Cloudflare Tunnel...).
|
||||
# The API only emits Secure/__Host- cookies when it believes the request was
|
||||
# https, so losing this header silently downgrades session cookies.
|
||||
map $http_x_forwarded_proto $fwd_proto {
|
||||
default $scheme;
|
||||
https https;
|
||||
@@ -24,81 +39,48 @@ upstream webmail_api {
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# ---- :80 — public -----------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Attachment uploads. Keep this comfortably above MAX_ATTACHMENT_MB (default
|
||||
# 25) to leave room for multipart/base64 overhead.
|
||||
client_max_body_size 64m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# The NAS is doing other jobs too — don't buffer big uploads to disk twice.
|
||||
proxy_request_buffering off;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml application/manifest+json;
|
||||
|
||||
# Clickjacking / sniffing basics. The app's own CSP for mail HTML is applied
|
||||
# server-side in app/indexer/sanitize.py; this is only the shell document.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
# ---- API ----------------------------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://webmail_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $fwd_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# WebSocket (/api/ws) and any long-poll: no upgrade means no live mail.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Live updates hold the connection open; don't cut them at 60 s.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ---- static -------------------------------------------------------------
|
||||
# Vite emits content-hashed filenames into /assets, so they are immutable.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
# Regex locations beat prefix ones, so this wins over `location /api/`
|
||||
# regardless of order. `(/|$)` keeps it to /api/admin and /api/admin/...,
|
||||
# never a future /api/administrators. 404 rather than 403: a port open to
|
||||
# the internet shouldn't confirm which endpoints exist.
|
||||
location ~ ^/api/admin(/|$) {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# SPA fallback: every non-file route renders index.html (react-router).
|
||||
# The admin bundle ships in the same dist; don't hand it out here.
|
||||
location = /admin.html {
|
||||
access_log off;
|
||||
return 404;
|
||||
}
|
||||
|
||||
include /etc/nginx/webmail-app.conf;
|
||||
|
||||
# SPA fallback -> the mail client.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
# index.html itself must never be cached, or a deploy won't be picked up.
|
||||
location = /index.html {
|
||||
# ---- :81 — admin ------------------------------------------------------------
|
||||
server {
|
||||
listen 81;
|
||||
server_name _;
|
||||
|
||||
include /etc/nginx/webmail-app.conf;
|
||||
|
||||
location = /admin.html {
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# SPA fallback -> the admin panel. `/` on this port IS the panel; the mail
|
||||
# client is not reachable here at all.
|
||||
location / {
|
||||
try_files $uri /admin.html;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user