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:
+36
-6
@@ -33,14 +33,34 @@ grn() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||
die() { red "error: $*" >&2; exit 1; }
|
||||
|
||||
# Which compose is on this box. Synology DSM and QNAP QTS ship the standalone
|
||||
# `docker-compose` binary and have no `docker compose` subcommand, so detect
|
||||
# rather than assume. COMPOSE_WAIT is empty on v1, which has no `--wait`; the
|
||||
# script's own health_gate covers that case.
|
||||
# QNAP keeps Container Station's `docker` and `docker-compose` inside the QPKG
|
||||
# directory, which is NOT on the PATH of an ordinary SSH session — the script
|
||||
# would otherwise die with "no compose found" on a box that has both. The volume
|
||||
# name differs between QTS (CACHEDEV1_DATA) and QuTS hero (the ZFS pool name),
|
||||
# so glob rather than hard-code it. No-op everywhere else.
|
||||
ensure_docker_on_path() {
|
||||
command -v docker >/dev/null 2>&1 && return 0
|
||||
local dir
|
||||
for dir in /share/*/.qpkg/container-station/bin /share/.qpkg/container-station/bin; do
|
||||
if [ -x "$dir/docker" ]; then
|
||||
PATH="$dir:$PATH"
|
||||
export PATH
|
||||
ylw "using Container Station's docker from $dir"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 0 # not found: let the caller fail with its own, clearer message
|
||||
}
|
||||
|
||||
# Which compose is on this box. Synology DSM and QNAP QTS/QuTS hero ship the
|
||||
# standalone `docker-compose` binary and may have no `docker compose`
|
||||
# subcommand, so detect rather than assume. COMPOSE_WAIT is empty on v1, which
|
||||
# has no `--wait`; the script's own health_gate covers that case.
|
||||
COMPOSE_BIN=()
|
||||
COMPOSE_WAIT=""
|
||||
detect_compose() {
|
||||
[ "${#COMPOSE_BIN[@]}" -gt 0 ] && return 0
|
||||
ensure_docker_on_path
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_BIN=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
@@ -242,6 +262,7 @@ cmd_doctor() {
|
||||
echo " DATA_ROOT ${DATA_ROOT}"
|
||||
echo " APP_DOMAIN ${APP_DOMAIN:-<unset>}"
|
||||
echo " WEB_PORT ${WEB_PORT:-8088}"
|
||||
echo " ADMIN_PORT ${ADMIN_PORT:-8089} (keep this one LAN-only)"
|
||||
echo " SEARCH_BACKEND ${SEARCH_BACKEND:-pg}"
|
||||
echo " IMAGE_TAG ${IMAGE_TAG:-local}"
|
||||
echo
|
||||
@@ -297,8 +318,13 @@ cmd_doctor() {
|
||||
else grn " ${avail} MB free on ${DATA_ROOT}"; fi
|
||||
}
|
||||
|
||||
if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":${WEB_PORT:-8088} "; then
|
||||
ylw " port ${WEB_PORT:-8088} already has a listener — change WEB_PORT if that is not us"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
local p
|
||||
for p in "WEB_PORT:${WEB_PORT:-8088}" "ADMIN_PORT:${ADMIN_PORT:-8089}"; do
|
||||
if ss -ltn 2>/dev/null | grep -q ":${p#*:} "; then
|
||||
ylw " port ${p#*:} already has a listener — change ${p%%:*} if that is not us"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# chmod 700 and owned by uid 999, so count them as root in a container.
|
||||
@@ -341,6 +367,7 @@ then re-run this command."
|
||||
health_gate || die "services did not come up healthy — check: ./deploy/webmail.sh logs"
|
||||
record_version "${IMAGE_TAG:-local}"
|
||||
grn "up. open http://<this-host>:${WEB_PORT:-8088}/"
|
||||
echo " admin: http://<this-host>:${ADMIN_PORT:-8089}/ — restrict this port to your LAN"
|
||||
}
|
||||
|
||||
cmd_down() { require_env; compose --profile meili down; grn "stopped (data kept in ${DATA_ROOT})"; }
|
||||
@@ -526,6 +553,9 @@ usage() { awk 'NR>1 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "${
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
SUBCOMMAND="$cmd"
|
||||
# Before anything touches Docker: on QNAP the binaries are off-PATH (see above).
|
||||
# doctor/backup/restore call `docker` directly, not only through compose.
|
||||
[ -n "$cmd" ] && ensure_docker_on_path
|
||||
case "$cmd" in
|
||||
init) cmd_init "$@" ;;
|
||||
doctor) cmd_doctor "$@" ;;
|
||||
|
||||
Reference in New Issue
Block a user