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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user