Euler source The code that runs the solver shell and this site, version 4.34.2 Repository on GitHub

Auth service kit (euler-auth): the sign-in service, and the shared /opt/euler venv it builds.

587 lines of Bash Raw file Open on GitHub

#!/usr/bin/env bash
# Auth service kit (euler-auth): the sign-in service, and the shared /opt/euler venv it builds.
# =============================================================================================
#
# Deploys / removes / upgrades the runtime for the auth service: the service
# identity (euler-auth) and wheel-gated admin plane, the root-owned /opt/euler
# system venv the app services run from, the scoped /etc/euler/auth.env,
# the /var/lib/euler-auth state dir, and — once the euler_site.auth module
# exists in the deployed venv — the root-owned euler-auth.service. Sibling to
# frontend.sh / egress.sh / ddns.sh / firewall.sh / smtp.sh; see
# docs/web-server-guide.md § Authentication.
#
# Model:
#   - The app services run from a root-owned system venv at /opt/euler, NOT the
#     repo checkout: the service users cannot traverse the repo owner's 0750 home
#     deploy/upgrade does `pip install` of euler-solver[ai,dev,solutions] + euler-site + euler-webshell into it
#     as root (the shared venv.sh helper — one definition for all app-service kits).
#   - euler-auth: own primary group (state files are euler-auth:euler-auth 0600),
#     supplementary member of euler-web (binds its public socket group euler-web
#     in /run/euler).
#   - The admin plane is WHEEL-GATED: the admin socket is euler-auth-private
#     (0600, in /run/euler-adm) and EULER_ADMIN_TOKEN lives only in the
#     root-readable auth.env — the `users` command re-executes the admin CLI under
#     sudo. No admin group, no operator-held credentials.
#   - /run/euler (root:euler-web 0770) and /run/euler-adm (euler-auth 0750) are
#     provisioned via tmpfiles.d.
#   - Scoped runtime config /etc/euler/auth.env (root:euler-auth 0640) is deployed
#     from ~/.euler/env (the authoring source); EULER_ADMIN_TOKEN is generated
#     directly into auth.env (never ~/.euler/env) and preserved across upgrades.
#     euler-auth never reads the full ~/.euler/env.
#   - The systemd unit is installed only when euler_site.auth is importable from
#     the deployed venv (build-order step 4); until then deploy/upgrade report it
#     as deferred. Unit carries the systemd egress filter (IPAddressDeny=any +
#     IPAddressAllow=localhost): the auth service is loopback-only by design.
#
#   /opt/euler/venv                        root:euler-web 0755  (deployed venv)
#   /etc/euler/auth.env                    root:euler-auth 0640 (generated here)
#   /etc/euler/authorizations.json         root:euler-authz 0640 (authorization SoR; seeded + migrated)
#   /etc/euler/profiles/<account>          root:<account> 0640 (that account's own row of the SoR)
#   /var/lib/euler-auth                    euler-auth:euler-auth 0700 (state)
#   /etc/tmpfiles.d/euler.conf             root:root 0644       (/run/euler 1771 root:euler-edge)
#   /etc/systemd/system/euler-auth.service (root-owned, boot-enabled; deferred until
#                                           euler_site.auth exists in the venv)
#
# Because the unit lives in root's systemd and runs as a locked-down user, lifecycle
# (start/stop/restart) requires sudo.
#
# Actions: deploy | remove | upgrade | status | help
#
# Author: Vikas Munshi <vikas.munshi@gmail.com>
# Copyright (c) 2026. All rights reserved.
# Licensed under the MIT License.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# The authoring env (~/.euler/env, possibly vault-encrypted): ENV_FILE +
# load_authoring_env, shared so every kit reads it one way.
# shellcheck source=scripts/setup/authoring_env.sh
. "${SCRIPT_DIR}/authoring_env.sh"

SYS_DIR="/etc/euler"
AUTH_ENV="${SYS_DIR}/auth.env"                  # scoped runtime config (root:euler-auth 0640)
AUTHZ_FILE="${SYS_DIR}/authorizations.json"     # authorization SoR (root:euler-authz 0640)
# May read the authorization map — it names every collaborator, so no per-user uid does: the
# auth service, the message spool (msg.sh adds itself) and the operator. Everyone else sees
# the ladder alone, which is code (solver.auth.authorizations.Authorizations.read).
ROSTER_DIR="${SYS_DIR}/roster"                  # collaborator roster (root:euler-maint 2775)
ROSTER_FILE="${ROSTER_DIR}/users.json"          # the roster itself (0664, group-writable)
MAINT_GROUP="euler-maint"                       # may write the roster; kept by `users add/change`
STATE_DIR="/var/lib/euler-auth"                 # euler-auth-only state
TMPFILES_CONF="/etc/tmpfiles.d/euler.conf"      # /run/euler socket dir

WEB_GROUP="euler-web"
# Owns /run/euler and reaches the per-user sockets (frontend.sh creates it too): the auth
# service creates auth.sock there and pushes teardowns to the per-user instances.
EDGE_GROUP="euler-edge"
AUTHZ_GROUP="euler-authz"

# The system venv — OPT_DIR / VENV_DIR / VENV_PY / PYTHON / deploy_venv, shared
# by every app-service kit so the location + dependency set have one definition.
# shellcheck source=scripts/setup/venv.sh
. "${SCRIPT_DIR}/venv.sh"
AUTH_USER="euler-auth"
AUTH_GROUP="euler-auth"
LEGACY_ADM_GROUP="euler-adm"   # pre-wheel-gate admin group; removed on deploy/upgrade

# The loopback mail relay the auth service submits to (must match smtp.sh).
SMTP_RELAY="127.0.0.1:8025"

SERVICE_NAME="euler-auth.service"
SERVICE_DEST="/etc/systemd/system/${SERVICE_NAME}"

# Set by load_config.
FQDN=""
ADMIN_TOKEN=""
TERMS_VERSION=""
OWNER_EMAIL=""

usage() {
    cat <<USAGE
Usage: $0 [deploy|remove|upgrade|redeploy|status|help]

  deploy     Create euler-auth, build the /opt/euler system venv (pip install
             the three packages as root), deploy /etc/euler/auth.env (with a generated
             root-only EULER_ADMIN_TOKEN) and /var/lib/euler-auth, and — when
             euler_site.auth exists in the venv — install the root-owned,
             boot-enabled euler-auth.service. Idempotent.
  remove     Remove the service + venv (prompts before removing auth.env, the
             state dir, and the users/groups).
  upgrade    Alias of deploy: re-deploy the venv from the repo, refresh auth.env
             + unit, restart.
  redeploy   Fast path: reinstall the repo into the /opt/euler venv (the shared
             code for auth AND content), re-merge the authorizations SoR, and
             restart the auth service — no identities, token, or unit changes.
  status     Show venv/deps/identities/config/state/unit health.

  Authoring config in ~/.euler/env: EULER_TLS_DOMAIN (base URL), optional
  TERMS_VERSION. The admin plane is wheel-gated: the admin socket and token are
  root-only, and the users shell command wraps its API calls in sudo.
USAGE
}

check_can_sudo() {
    if ! command -v sudo &> /dev/null; then
        echo "Error: sudo is not installed or not found in PATH" >&2
        return 1
    fi
    if ! sudo -v 2>/dev/null; then
        echo "Error: current user does not have sudo privileges" >&2
        return 1
    fi
}

require_systemd() {
    if [ ! -d /run/systemd/system ] || ! command -v systemctl &> /dev/null; then
        echo "Error: systemd is required for the auth service; it is not active here." >&2
        return 1
    fi
}

require_python() {
    if ! command -v "${PYTHON}" &> /dev/null; then
        echo "Error: ${PYTHON} not found — run 'make install-system' (deadsnakes) first." >&2
        return 1
    fi
}

# Source ~/.euler/env (or the deployed auth.env) and resolve the FQDN + admin token.
# On install, a missing EULER_ADMIN_TOKEN is generated and appended to ~/.euler/env so
# the authoring source and the deployed copy stay in sync.
load_config() {
    local src=""
    if [ -r "${ENV_FILE}" ]; then
        src="${ENV_FILE}"
    elif [ -r "${AUTH_ENV}" ]; then
        src="${AUTH_ENV}"
    fi
    if [ -n "${src}" ]; then
        load_authoring_env "${src}" || return 1
    fi
    FQDN="${EULER_TLS_DOMAIN:-}"
    TERMS_VERSION="${TERMS_VERSION:-1}"
    # Where invite-request notices go: the authoring env's EULER_OWNER_EMAIL, else the
    # checkout owner's git email. Empty is fine — the notice is best-effort and the
    # request queue is the system of record regardless.
    OWNER_EMAIL="${EULER_OWNER_EMAIL:-$(git -C "${PROJECT_ROOT}" config user.email 2>/dev/null || true)}"
    if [ -z "${FQDN}" ]; then
        echo "Error: EULER_TLS_DOMAIN not set in ${ENV_FILE}" >&2
        return 1
    fi
}

# Resolve the admin token (root-only material): preserve the one already
# deployed in auth.env, else generate afresh. Also scrub any legacy copy out of
# ~/.euler/env — the operator's uid must hold no admin credential.
ensure_admin_token() {
    ADMIN_TOKEN="$(sudo grep -s '^EULER_ADMIN_TOKEN=' "${AUTH_ENV}" 2>/dev/null | head -1 | cut -d= -f2- || true)"
    if [ -z "${ADMIN_TOKEN}" ]; then
        echo "Generating EULER_ADMIN_TOKEN (kept only in root-readable ${AUTH_ENV})..."
        ADMIN_TOKEN="$(${PYTHON} -c 'import secrets; print(secrets.token_hex(32))')"
    fi
    if [ -w "${ENV_FILE}" ] && grep -q '^EULER_ADMIN_TOKEN=' "${ENV_FILE}" 2>/dev/null; then
        echo "Scrubbing legacy EULER_ADMIN_TOKEN from ~/.euler/env (root-only material now)..."
        sed -i '/^# Admin-plane shared secret for the auth service (X-Admin-Token)\.$/d;/^EULER_ADMIN_TOKEN=/d' \
            "${ENV_FILE}"
    elif [ -n "${EULER_ADMIN_TOKEN:-}" ]; then
        # A vault-encrypted env: load_config surfaced the legacy token, but the grep above
        # cannot see it and sed cannot edit ciphertext in place. Say so rather than no-op
        # silently — a scrub that quietly does nothing is worse than one that never ran.
        echo "Warning: a legacy EULER_ADMIN_TOKEN is still in your vault-encrypted ~/.euler/env." >&2
        echo "         It is root-only material now: remove that line from the env yourself" >&2
        echo "         (the account page's secrets, or your editor with the vault unlocked)." >&2
    fi
}

# Create the service identities (idempotent): euler-auth (own group, in euler-web and
# euler-edge).
# The admin plane is wheel-gated — no admin group; a legacy euler-adm from earlier
# installs is dismantled.
ensure_identities() {
    local group
    for group in "${WEB_GROUP}" "${EDGE_GROUP}" "${AUTHZ_GROUP}"; do
        getent group "${group}" > /dev/null || sudo groupadd --system "${group}"
    done
    if ! getent group "${AUTH_GROUP}" > /dev/null; then
        sudo groupadd --system "${AUTH_GROUP}"
    fi
    if ! getent passwd "${AUTH_USER}" > /dev/null; then
        echo "Creating system user ${AUTH_USER} (group ${AUTH_GROUP}, +${WEB_GROUP} +${EDGE_GROUP})..."
        sudo useradd --system --no-create-home --shell /usr/sbin/nologin \
            -g "${AUTH_GROUP}" -G "${WEB_GROUP},${EDGE_GROUP},${AUTHZ_GROUP}" "${AUTH_USER}"
    else
        sudo usermod -aG "${WEB_GROUP},${EDGE_GROUP},${AUTHZ_GROUP}" "${AUTH_USER}"
    fi
    # The operator — the checkout's owner — reads the map in their own terminal (their rung
    # comes from it). A new group takes effect at their next login, not in a running shell.
    sudo usermod -aG "${AUTHZ_GROUP}" "$(stat -c '%U' "${PROJECT_ROOT}")"
    # Migration: drop the legacy admin group (the admin plane is sudo-gated now).
    if getent group "${LEGACY_ADM_GROUP}" > /dev/null; then
        echo "Removing legacy ${LEGACY_ADM_GROUP} group (admin plane is sudo-gated now)..."
        sudo gpasswd -d "${USER}" "${LEGACY_ADM_GROUP}" 2>/dev/null || true
        sudo gpasswd -d "${AUTH_USER}" "${LEGACY_ADM_GROUP}" 2>/dev/null || true
        sudo groupdel "${LEGACY_ADM_GROUP}" 2>/dev/null || true
    fi
}

# /run/euler — the shared socket dir (root:euler-edge 1771), via tmpfiles.d so it exists
# at boot before any service and no service owns it. euler-edge (the services, Caddy) may
# create sockets in it; the sticky bit stops them unlinking one another's; everyone else —
# the per-user uids above all — may only traverse it, to connect() to a socket whose own
# mode lets them (auth.sock, msg.sock). tmpfiles' `d` also re-modes an existing dir.
deploy_tmpfiles() {
    sudo tee "${TMPFILES_CONF}" > /dev/null <<EOF
# GENERATED by scripts/setup/auth.sh — runtime socket dirs.
# /run/euler: the shared app-service fabric; each service creates its own *.sock
#   (0660, group euler-web for auth/msg, euler-edge for site and the per-user sockets).
#   1771: euler-edge creates, the sticky bit protects each socket from the others, and
#   the rest of the world may only traverse. Operators are deliberately NOT in euler-web.
# /run/euler-adm: the local admin plane — euler-auth binds auth-admin.sock here
#   (0600); it is wheel-gated: only root (sudo) reaches it.
d /run/euler 1771 root ${EDGE_GROUP} -
d /run/euler-adm 0750 ${AUTH_USER} ${AUTH_GROUP} -
EOF
    sudo systemd-tmpfiles --create "${TMPFILES_CONF}"
}

# Deploy the scoped runtime config euler-auth reads — never the full ~/.euler/env.
deploy_auth_env() {
    sudo mkdir -p "${SYS_DIR}"
    sudo tee "${AUTH_ENV}" > /dev/null <<EOF
# GENERATED by scripts/setup/auth.sh — scoped runtime config for euler-auth.
# Authoring source: ~/.euler/env. No Anthropic key, no SMTP creds (mail goes to
# the loopback relay, which holds them).
EULER_BASE_URL=https://${FQDN}
EULER_ADMIN_TOKEN=${ADMIN_TOKEN}
TERMS_VERSION=${TERMS_VERSION}
EULER_SMTP_RELAY=${SMTP_RELAY}
EULER_OWNER_EMAIL=${OWNER_EMAIL}
EOF
    sudo chown root:"${AUTH_GROUP}" "${AUTH_ENV}"
    sudo chmod 0640 "${AUTH_ENV}"
}

# The euler-auth-only state dir. The unit's StateDirectory= re-asserts this.
deploy_state_dir() {
    sudo mkdir -p "${STATE_DIR}"
    sudo chown "${AUTH_USER}:${AUTH_GROUP}" "${STATE_DIR}"
    sudo chmod 0700 "${STATE_DIR}"
}

# Deploy the authorization system of record (re-simplified): the policy is a
# plain profile ladder, so /etc/euler/authorizations.json (root:root 0644 —
# world-readable non-secret, root-write only) carries ONE decision: who has which
# profile. First deploy copies the repo template; every run seeds the checkout owner
# as `admin`, migrates any existing web accounts' profiles out of the
# euler-auth-private SRP DB into the map (old admin/user/guest → new
# maintainer/contributor/reader), and rewrites a legacy grants/objects-shaped file
# to the new {ladder, users} shape (the users map is the only thing carried over —
# the grant vocabulary is retired with the per-profile ACL layer).
deploy_authz() {
    local owner
    owner="$(stat -c '%U' "${PROJECT_ROOT}")"
    sudo mkdir -p "${SYS_DIR}"
    if [ ! -f "${AUTHZ_FILE}" ]; then
        echo "Seeding the authorizations.json SoR (empty users map)..."
        echo '{}' | sudo tee "${AUTHZ_FILE}" >/dev/null
    fi
    # Run from the system venv: the ladder is code (solver.auth.subject.LADDER), so the
    # seed imports it rather than duplicating the rung order here. deploy_venv precedes
    # every deploy_authz call site, so the import resolves.
    sudo "${VENV_PY}" - "${AUTHZ_FILE}" "${owner}" "${STATE_DIR}/users.json" <<'PY'
import json, pathlib, sys
from solver.auth.subject import LADDER
authz_path, owner, users_path = sys.argv[1], sys.argv[2], sys.argv[3]
authz = json.loads(pathlib.Path(authz_path).read_text())
users = dict(authz.get('users', {}))
users.setdefault(owner, 'admin')                         # local owner anchor (seeded for visibility)
migrate = {'admin': 'maintainer', 'user': 'contributor', 'guest': 'reader'}
try:                                                     # migrate existing web accounts from the SRP DB
    srp = json.loads(pathlib.Path(users_path).read_text()).get('users', {})
except (OSError, json.JSONDecodeError):
    srp = {}
for email, rec in srp.items():
    if email not in users:
        old = str(rec.get('profile', 'user'))
        users[email] = migrate.get(old, old)             # already-new names pass through
# Re-simplified policy (plain profile floors): the file carries ONE decision — who
# has which profile. A legacy grants/objects-shaped file is rewritten to the new
# shape here, its users map carried over; the retired sections are dropped (they
# only ever drove the per-profile content-tree ACLs, gone with the per-user model).
if 'profiles' in authz or 'objects' in authz:
    print('authorizations.json: migrating the legacy grants/objects shape → plain profile ladder')
new = {'ladder': list(LADDER), 'users': dict(sorted(users.items()))}
pathlib.Path(authz_path).write_text(json.dumps(new, indent=2) + '\n')
print(f'authorizations.json: {len(users)} user(s) mapped (owner {owner}=admin)')
PY
    sudo chown root:"${AUTHZ_GROUP}" "${AUTHZ_FILE}"
    sudo chmod 0640 "${AUTHZ_FILE}"
    # Each account's own row, beside the map (root:<account> 0640): a per-user uid may not
    # read the map, and a `solver` it runs takes its rung from here (solver.auth.identity).
    sudo "${VENV_PY}" -P -m euler_site.auth.admin sync-rungs
}

# Deploy the collaborator roster: /etc/euler/roster/users.json — who exists, the X25519
# public key each of them holds, and the dates of the operator's acts. Read by everyone
# (`key-rekey`, `key-split`, the `msg` recipient menu) and written by maintainers, which is
# why the DIRECTORY carries the group rather than the file: an atomic replace renames a temp
# file over the target, so it needs write permission on the directory. Setgid (2775) makes
# every file created there inherit euler-maint, including the ones a maintainer writes.
#
# `public_key` is the one field that is *used* rather than displayed — a grant seals half the
# master key to it — so whoever may write this file can redirect a future grant. That is why
# the group is maintainer-and-above (people who already hold the master key) and never
# euler-user or euler-web, which contain every collaborator.
deploy_roster() {
    if ! getent group "${MAINT_GROUP}" > /dev/null; then
        echo "Creating the ${MAINT_GROUP} group (roster write access)..."
        sudo groupadd --system "${MAINT_GROUP}"
    fi
    sudo install -d -m 2775 -o root -g "${MAINT_GROUP}" "${ROSTER_DIR}"
    if [ ! -f "${ROSTER_FILE}" ]; then
        echo "Seeding the collaborator roster (empty)..."
        printf '{\n  "version": 1,\n  "users": {}\n}\n' | sudo tee "${ROSTER_FILE}" >/dev/null
    fi
    sudo chown root:"${MAINT_GROUP}" "${ROSTER_FILE}"
    sudo chmod 0664 "${ROSTER_FILE}"
    # Everyone the policy already ranks maintainer-or-above gets write access, the checkout
    # owner included — they run the account verbs from a terminal, not a web shell, and are
    # deliberately in none of the other euler groups.
    sudo "${VENV_PY}" - "${AUTHZ_FILE}" "${MAINT_GROUP}" <<'ROSTER_PY'
import json, pathlib, subprocess, sys
from solver.auth.identity import system_slug
authz_path, group = sys.argv[1], sys.argv[2]
users = json.loads(pathlib.Path(authz_path).read_text()).get('users', {})
for identity, profile in sorted(users.items()):
    if profile not in ('maintainer', 'admin'):
        continue
    account = system_slug(identity) if '@' in identity else identity
    done = subprocess.run(['gpasswd', '-a', account, group], capture_output=True, text=True)
    print(f'  {account}: {"in " + group if done.returncode == 0 else done.stderr.strip()}')
ROSTER_PY
}

# True when the deployed venv contains the auth service module (build-order step 4).
venv_has_auth() {
    [ -x "${VENV_PY}" ] && sudo "${VENV_PY}" -P -c 'import euler_site.auth' 2>/dev/null
}

# Install + enable the root-owned unit (only called when the module exists).
install_unit() {
    echo "Installing ${SERVICE_NAME} (loopback-only, as ${AUTH_USER})..."
    sudo tee "${SERVICE_DEST}" > /dev/null <<EOF
[Unit]
Description=euler auth service (SRP login, sessions, forward_auth)
Documentation=https://github.com/vikasmunshi/euler/blob/master/docs/web-server-guide.md
After=network.target euler-smtp.service
Wants=euler-smtp.service

[Service]
Type=simple
User=${AUTH_USER}
Group=${AUTH_GROUP}
SupplementaryGroups=${WEB_GROUP} ${EDGE_GROUP} ${AUTHZ_GROUP}
EnvironmentFile=${AUTH_ENV}
ExecStart=${VENV_PY} -m euler_site.auth
Restart=on-failure
RestartSec=5s

# State — euler-auth-only.
StateDirectory=euler-auth
StateDirectoryMode=0700

# Egress layer 1: the auth service is loopback-only (sockets, Squid, the mail relay).
IPAddressDeny=any
IPAddressAllow=localhost

# Hardening. ProtectSystem=strict mounts /run read-only, so the shared socket
# dir (provisioned by tmpfiles.d, not RuntimeDirectory= — no service owns it)
# must be opened up explicitly for the service's two sockets.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/run/euler /run/euler-adm
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
SystemCallArchitectures=native
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
RestrictNamespaces=true
RestrictSUIDSGID=true
RemoveIPC=true
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
UMask=0007

[Install]
WantedBy=multi-user.target
EOF
    sudo systemctl daemon-reload
    sudo systemctl enable --now "${SERVICE_NAME}"
    sudo systemctl restart "${SERVICE_NAME}"
}

# ── install / uninstall ───────────────────────────────────────────────────────────

do_deploy() {
    check_can_sudo || return 1
    require_systemd || return 1
    require_python || return 1
    load_config || return 1
    ensure_admin_token || return 1
    ensure_identities
    deploy_venv "${PROJECT_ROOT}" "${WEB_GROUP}"
    deploy_tmpfiles
    deploy_auth_env
    deploy_state_dir
    deploy_authz
    deploy_roster

    if venv_has_auth; then
        install_unit
    else
        echo "note: euler_site.auth not yet in the deployed package — service unit deferred"
        echo "      (re-run '$0 upgrade' once build-order step 4 lands)."
    fi

    # euler-auth's uid is part of the nftables egress ruleset — regenerate if installed.
    if [ -f /etc/systemd/system/euler-firewall.service ]; then
        echo "Reloading the egress firewall to include ${AUTH_USER}..."
        "${SCRIPT_DIR}/firewall.sh" reload
    fi
    do_status
}

# Fast redeploy: reinstall the repo into the shared /opt/euler venv (refreshing the
# code for both auth and content), re-merge the authorizations SoR (picks up new
# template objects/grants), and restart the auth service. No identities, admin
# token, tmpfiles, state dir, unit re-lay, or firewall reload.
do_redeploy() {
    check_can_sudo || return 1
    require_python || return 1
    load_config || return 1
    deploy_venv "${PROJECT_ROOT}" "${WEB_GROUP}"
    deploy_authz
    deploy_roster
    if [ -f "${SERVICE_DEST}" ] && venv_has_auth; then
        sudo systemctl restart "${SERVICE_NAME}"
        echo "Restarted ${SERVICE_NAME}"
    else
        echo "note: ${SERVICE_NAME} not installed yet — run '$0 deploy'."
    fi
    do_status
}

do_remove() {
    check_can_sudo || return 1
    if [ -f "${SERVICE_DEST}" ]; then
        sudo systemctl disable --now "${SERVICE_NAME}" 2>/dev/null || true
        sudo rm -f "${SERVICE_DEST}"
        sudo systemctl daemon-reload
    fi

    local reply
    read -r -p "Remove the ${VENV_DIR} venv? [y/N] " reply
    if [[ "${reply}" =~ ^[Yy]$ ]]; then
        sudo rm -rf "${OPT_DIR}"
        sudo rm -f "${TMPFILES_CONF}"
    fi
    read -r -p "Remove ${AUTH_ENV} (admin token), the ${STATE_DIR} state (user DB!), and the ${AUTH_USER} identity? [y/N] " reply
    if [[ "${reply}" =~ ^[Yy]$ ]]; then
        sudo rm -f "${AUTH_ENV}" "${AUTHZ_FILE}"
        sudo rm -rf "${STATE_DIR}" "${ROSTER_DIR}"
        sudo groupdel "${MAINT_GROUP}" 2>/dev/null || true
        if getent passwd "${AUTH_USER}" > /dev/null; then sudo userdel "${AUTH_USER}" 2>/dev/null || true; fi
        if getent group "${AUTH_GROUP}" > /dev/null; then sudo groupdel "${AUTH_GROUP}" 2>/dev/null || true; fi
        if getent group "${LEGACY_ADM_GROUP}" > /dev/null; then sudo groupdel "${LEGACY_ADM_GROUP}" 2>/dev/null || true; fi
    fi
    echo "Auth service uninstall complete."
}

# ── status ────────────────────────────────────────────────────────────────────────

do_status() {
    if [ -x "${VENV_PY}" ]; then
        echo "venv:        ✓ ${VENV_DIR} ($(${VENV_PY} --version 2>&1))"
        # The deployed build: the one thing in this report that changes on every release, and
        # the answer to "did redeploy-web actually ship it?". Read from the installed
        # dist-info rather than by importing, so it reports what pip put in the venv.
        local deployed
        deployed="$("${VENV_PY}" -P -c 'from importlib.metadata import version; print(version("euler-solver"))' 2>/dev/null)"
        if [ -n "${deployed}" ]; then
            echo "solver:      ✓ v${deployed} deployed"
        else
            echo "solver:      ✗ version not readable (is the repo installed into the venv?)"
        fi
        # `-P` keeps the cwd off sys.path, as user.sh's probes already do. Without it, run from
        # a checkout, `import solver` resolves the *working tree* — so this line reported on
        # the clone it was run from rather than on the venv it names.
        if "${VENV_PY}" -P -c 'import aiohttp, aiohttp_jinja2, jinja2, solver' 2>/dev/null; then
            echo "deps:        ✓ solver + aiohttp/aiohttp-jinja2/jinja2 importable"
        else
            echo "deps:        ✗ web deps or solver not importable from the venv"
        fi
    else
        echo "venv:        ✗ ${VENV_DIR} not deployed"
    fi
    if getent passwd "${AUTH_USER}" > /dev/null; then
        echo "identity:    ✓ ${AUTH_USER} (groups: $(id -nG "${AUTH_USER}" 2>/dev/null))"
    else
        echo "identity:    ✗ ${AUTH_USER} missing"
    fi
    if [ -d /run/euler-adm ]; then
        echo "admin plane: ✓ /run/euler-adm (wheel-gated: sudo required; no admin group)"
    else
        echo "admin plane: ✗ /run/euler-adm missing (tmpfiles not applied)"
    fi
    if [ -f "${AUTH_ENV}" ]; then
        echo "config:      ✓ ${AUTH_ENV}"
    else
        echo "config:      ✗ ${AUTH_ENV} missing"
    fi
    if [ -f "${AUTHZ_FILE}" ]; then
        echo "authz:       ✓ ${AUTHZ_FILE} ($(sudo python3 -c "import json;print(len(json.load(open('${AUTHZ_FILE}')).get('users',{})))" 2>/dev/null || echo '?') users, $(sudo stat -c '%U:%G %a' "${AUTHZ_FILE}"))"
    else
        echo "authz:       ✗ ${AUTHZ_FILE} missing (local shell uses the repo template)"
    fi
    if [ -f "${ROSTER_FILE}" ]; then
        echo "roster:      ✓ ${ROSTER_FILE} ($(stat -c '%U:%G %a' "${ROSTER_DIR}" 2>/dev/null))"
    else
        echo "roster:      ✗ ${ROSTER_FILE} missing"
    fi
    if [ -d "${STATE_DIR}" ]; then
        echo "state:       ✓ ${STATE_DIR} ($(stat -c '%U:%G %a' "${STATE_DIR}" 2>/dev/null))"
    else
        echo "state:       ✗ ${STATE_DIR} missing"
    fi
    if [ -f "${SERVICE_DEST}" ]; then
        echo "${SERVICE_NAME}: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null)/$(systemctl is-enabled "${SERVICE_NAME}" 2>/dev/null)"
    else
        echo "${SERVICE_NAME}: deferred (euler_site.auth not yet deployed)"
    fi
}

# ── Dispatch ──────────────────────────────────────────────────────────────────────
ACTION="${1:-status}"
case "${ACTION}" in
    deploy)    do_deploy ;;
    upgrade)   do_deploy ;;
    redeploy)  do_redeploy ;;
    remove)    do_remove ;;
    status)    do_status ;;
    -h | --help | help) usage ;;
    *) echo "Unknown action: ${ACTION}"; usage; exit 1 ;;
esac