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
+2
View File
@@ -13,6 +13,7 @@ def register_routers(app: FastAPI) -> None:
"""
from app.api.accounts import router as accounts_router
from app.api.actions import router as actions_router
from app.api.admin import router as admin_router
from app.api.avatar import router as avatar_router
from app.api.compose import router as compose_router
from app.api.mailboxes import router as mailboxes_router
@@ -40,6 +41,7 @@ def register_routers(app: FastAPI) -> None:
saved_searches_router,
avatar_router,
plugins_router,
admin_router,
):
app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api")
+56
View File
@@ -0,0 +1,56 @@
"""Instance administration (SPEC §6.1).
The backend seam for the admin panel. Today it carries only the registration
gate — the first account on a fresh install becomes the administrator and closes
signup behind itself, and this is how that gets reopened. Further instance-wide
options land here as the panel grows.
"""
from __future__ import annotations
import datetime as dt
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.identity.deps import CurrentAdmin, require_csrf
from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
from app.identity.service import app_settings
from app.models import AppSettings
router = APIRouter(prefix="/admin", tags=["admin"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
def _out(row: AppSettings) -> AdminSettingsOut:
return AdminSettingsOut(
registration_open=row.registration_open,
default_quota_bytes=row.default_quota_bytes,
)
@router.get("/settings", response_model=AdminSettingsOut)
async def read_settings(admin: CurrentAdmin, db: DbSession) -> AdminSettingsOut:
return _out(await app_settings(db))
@router.put("/settings", response_model=AdminSettingsOut, dependencies=[Depends(require_csrf)])
async def write_settings(
body: AdminSettingsIn, admin: CurrentAdmin, db: DbSession
) -> AdminSettingsOut:
"""Merge the supplied fields into the settings row.
Changing ``default_quota_bytes`` affects users created afterwards only —
nothing here rewrites an existing ``users.quota_bytes``.
"""
row = await app_settings(db, for_update=True)
if body.registration_open is not None:
row.registration_open = body.registration_open
if body.default_quota_bytes is not None:
row.default_quota_bytes = body.default_quota_bytes
row.updated_at = dt.datetime.now(dt.UTC)
await db.commit()
return _out(row)
+8
View File
@@ -46,5 +46,13 @@ async def require_csrf(request: Request) -> None:
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
async def current_admin(user: Annotated[User, Depends(current_user)]) -> User:
"""Load the authenticated user and require the administrator flag."""
if not user.is_admin:
raise_api_error(403, "not_admin", "administrator privileges required")
return user
CurrentUser = Annotated[User, Depends(current_user)]
CurrentAdmin = Annotated[User, Depends(current_admin)]
CurrentSession = Annotated[Session, Depends(current_session)]
+14 -2
View File
@@ -28,6 +28,7 @@ from app.identity.schemas import (
PasswordResetIn,
PasswordResetRequestIn,
RegisterRequest,
RegistrationStatusOut,
SessionOut,
TotpDisableRequest,
TotpEnableRequest,
@@ -41,6 +42,7 @@ from app.identity.service import (
TotpRequired,
authenticate,
register_user,
registration_status,
resolve_handle,
)
from app.identity.sessions import (
@@ -77,9 +79,20 @@ def _user_out(user: User) -> UserOut:
recovery_email=user.recovery_email,
settings=user.settings,
totp_enabled=user.totp_secret is not None,
is_admin=user.is_admin,
)
@router.get("/registration", response_model=RegistrationStatusOut)
async def registration(db: DbSession) -> RegistrationStatusOut:
"""Whether signup is open. Unauthenticated: the signup page needs it to render."""
status = await registration_status(db)
return RegistrationStatusOut(open=status.open, bootstrap=status.bootstrap)
_REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
@router.post("/register", response_model=UserOut)
async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession
@@ -92,8 +105,7 @@ async def register(
recovery_email=body.recovery_email,
)
except RegistrationError as exc:
status = 409 if exc.code == "handle_taken" else 400
raise_api_error(status, exc.code, exc.message)
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
await db.commit()
await _establish_session(request, response, user)
return _user_out(user)
+22
View File
@@ -55,6 +55,28 @@ class UserOut(BaseModel):
recovery_email: str | None
settings: dict[str, object]
totp_enabled: bool = False
is_admin: bool = False
class RegistrationStatusOut(BaseModel):
"""Public, unauthenticated: what the signup page should show."""
open: bool
# True when the instance has no users yet, so signup creates the administrator.
bootstrap: bool
class AdminSettingsOut(BaseModel):
registration_open: bool
default_quota_bytes: int
class AdminSettingsIn(BaseModel):
"""Merge semantics: omitted fields are left as they are."""
registration_open: bool | None = None
# Applies to users created after the change; existing mailboxes keep theirs.
default_quota_bytes: int | None = Field(default=None, gt=0)
class MessageOut(BaseModel):
+59 -2
View File
@@ -5,13 +5,14 @@ from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
from app.identity.passwords import hash_password, verify_password
from app.models import User
from app.models import AppSettings, User
from app.redis_client import get_redis
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
@@ -28,6 +29,15 @@ class RegistrationError(Exception):
self.message = message
@dataclass(frozen=True)
class RegistrationStatus:
"""What the signup page may offer right now."""
open: bool
# True when the instance has no users: the next signup creates the administrator.
bootstrap: bool
@dataclass(frozen=True)
class RateLimited(Exception):
"""Raised when the login rate limit is exceeded."""
@@ -51,19 +61,64 @@ def resolve_handle(handle_input: str) -> str:
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
async def app_settings(db: AsyncSession, *, for_update: bool = False) -> AppSettings:
"""Load the singleton settings row, creating it if an old database lacks one.
``for_update`` takes a row lock, which is what serialises concurrent signups.
A row conjured here defaults to *closed*: the safe answer when we cannot tell
what the operator intended.
"""
stmt = select(AppSettings).where(AppSettings.id == 1)
row = await db.scalar(stmt.with_for_update() if for_update else stmt)
if row is None:
await db.execute(
pg_insert(AppSettings).values(id=1, registration_open=False).on_conflict_do_nothing()
)
row = await db.scalar(stmt.with_for_update() if for_update else stmt)
assert row is not None # just inserted in this transaction
return row
async def _has_users(db: AsyncSession) -> bool:
return await db.scalar(select(User.id).limit(1)) is not None
async def registration_status(db: AsyncSession) -> RegistrationStatus:
"""Whether signup is currently accepted, and whether it would bootstrap an admin."""
bootstrap = not await _has_users(db)
settings = await app_settings(db)
return RegistrationStatus(open=bootstrap or settings.registration_open, bootstrap=bootstrap)
async def register_user(
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
) -> User:
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.
The first user on an empty instance is always allowed and becomes the
administrator, which closes registration behind them; after that it takes an
admin reopening it. The settings row is locked across the whole decision so
two simultaneous first signups cannot both see an empty table and both claim
administrator.
"""
try:
handle = full_handle(localpart)
except InvalidHandle as exc:
raise RegistrationError(exc.code, exc.message) from exc
settings = await app_settings(db, for_update=True)
bootstrap = not await _has_users(db)
if not bootstrap and not settings.registration_open:
raise RegistrationError("registration_closed", "registration is closed on this server")
user = User(
handle=handle,
password_hash=hash_password(password),
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
is_admin=bootstrap,
# Snapshotted at creation: later changes to the instance default leave
# this user's quota alone.
quota_bytes=settings.default_quota_bytes,
)
db.add(user)
try:
@@ -71,6 +126,8 @@ async def register_user(
except IntegrityError as exc:
await db.rollback()
raise RegistrationError("handle_taken", "that handle is already registered") from exc
if bootstrap:
settings.registration_open = False
return user
+30
View File
@@ -12,6 +12,7 @@ from typing import Any
from sqlalchemy import (
BigInteger,
CheckConstraint,
ForeignKey,
Index,
Integer,
@@ -45,6 +46,32 @@ def _now() -> Mapped[dt.datetime]:
return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
class AppSettings(Base):
"""Instance-wide settings, exactly one row (id = 1).
Distinct from :func:`app.config.get_settings`, which is deployment environment
read at boot; these are options an administrator changes at runtime from the
admin panel. Locking this row serialises the registration decision below.
"""
__tablename__ = "app_settings"
__table_args__ = (
CheckConstraint("id = 1", name="ck_app_settings_singleton"),
CheckConstraint("default_quota_bytes > 0", name="ck_app_settings_quota_positive"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
# Whether POST /auth/register accepts new users. Closed automatically once the
# first account (the administrator) is created; an admin reopens it later.
registration_open: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
# Quota handed to the NEXT user created. Existing users keep whatever
# users.quota_bytes already says — changing this never resizes a mailbox.
default_quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120")
)
updated_at: Mapped[dt.datetime] = _now()
class User(Base):
__tablename__ = "users"
@@ -61,6 +88,9 @@ class User(Base):
quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120")
)
# The first account created on a fresh install bootstraps as administrator;
# the admin panel can grant it to others later.
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
created_at: Mapped[dt.datetime] = _now()