#!/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-euopipefailSCRIPT_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 stateTMPFILES_CONF="/etc/tmpfiles.d/euler.conf"# /run/euler socket dirWEB_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<<USAGEUsage: $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-vsudo&>/dev/null;thenecho"Error: sudo is not installed or not found in PATH">&2return1fiif!sudo-v2>/dev/null;thenecho"Error: current user does not have sudo privileges">&2return1fi}require_systemd(){if[!-d/run/systemd/system]||!command-vsystemctl&>/dev/null;thenecho"Error: systemd is required for the auth service; it is not active here.">&2return1fi}require_python(){if!command-v"${PYTHON}"&>/dev/null;thenecho"Error: ${PYTHON} not found — run 'make install-system' (deadsnakes) first.">&2return1fi}# 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(){localsrc=""if[-r"${ENV_FILE}"];thensrc="${ENV_FILE}"elif[-r"${AUTH_ENV}"];thensrc="${AUTH_ENV}"fiif[-n"${src}"];thenload_authoring_env"${src}"||return1fiFQDN="${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}"configuser.email2>/dev/null||true)}"if[-z"${FQDN}"];thenecho"Error: EULER_TLS_DOMAIN not set in ${ENV_FILE}">&2return1fi}# 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="$(sudogrep-s'^EULER_ADMIN_TOKEN='"${AUTH_ENV}"2>/dev/null|head-1|cut-d=-f2-||true)"if[-z"${ADMIN_TOKEN}"];thenecho"Generating EULER_ADMIN_TOKEN (kept only in root-readable ${AUTH_ENV})..."ADMIN_TOKEN="$(${PYTHON}-c'import secrets; print(secrets.token_hex(32))')"fiif[-w"${ENV_FILE}"]&&grep-q'^EULER_ADMIN_TOKEN='"${ENV_FILE}"2>/dev/null;thenecho"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.">&2echo" It is root-only material now: remove that line from the env yourself">&2echo" (the account page's secrets, or your editor with the vault unlocked).">&2fi}# 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(){localgroupforgroupin"${WEB_GROUP}""${EDGE_GROUP}""${AUTHZ_GROUP}";dogetentgroup"${group}">/dev/null||sudogroupadd--system"${group}"doneif!getentgroup"${AUTH_GROUP}">/dev/null;thensudogroupadd--system"${AUTH_GROUP}"fiif!getentpasswd"${AUTH_USER}">/dev/null;thenecho"Creating system user ${AUTH_USER} (group ${AUTH_GROUP}, +${WEB_GROUP} +${EDGE_GROUP})..."sudouseradd--system--no-create-home--shell/usr/sbin/nologin\-g"${AUTH_GROUP}"-G"${WEB_GROUP},${EDGE_GROUP},${AUTHZ_GROUP}""${AUTH_USER}"elsesudousermod-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.sudousermod-aG"${AUTHZ_GROUP}""$(stat-c'%U'"${PROJECT_ROOT}")"# Migration: drop the legacy admin group (the admin plane is sudo-gated now).ifgetentgroup"${LEGACY_ADM_GROUP}">/dev/null;thenecho"Removing legacy ${LEGACY_ADM_GROUP} group (admin plane is sudo-gated now)..."sudogpasswd-d"${USER}""${LEGACY_ADM_GROUP}"2>/dev/null||truesudogpasswd-d"${AUTH_USER}""${LEGACY_ADM_GROUP}"2>/dev/null||truesudogroupdel"${LEGACY_ADM_GROUP}"2>/dev/null||truefi}# /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(){sudotee"${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} -EOFsudosystemd-tmpfiles--create"${TMPFILES_CONF}"}# Deploy the scoped runtime config euler-auth reads — never the full ~/.euler/env.deploy_auth_env(){sudomkdir-p"${SYS_DIR}"sudotee"${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}EOFsudochownroot:"${AUTH_GROUP}""${AUTH_ENV}"sudochmod0640"${AUTH_ENV}"}# The euler-auth-only state dir. The unit's StateDirectory= re-asserts this.deploy_state_dir(){sudomkdir-p"${STATE_DIR}"sudochown"${AUTH_USER}:${AUTH_GROUP}""${STATE_DIR}"sudochmod0700"${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(){localownerowner="$(stat-c'%U'"${PROJECT_ROOT}")"sudomkdir-p"${SYS_DIR}"if[!-f"${AUTHZ_FILE}"];thenecho"Seeding the authorizations.json SoR (empty users map)..."echo'{}'|sudotee"${AUTHZ_FILE}">/dev/nullfi# 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, sysfrom solver.auth.subject import LADDERauthz_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)')PYsudochownroot:"${AUTHZ_GROUP}""${AUTHZ_FILE}"sudochmod0640"${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-meuler_site.auth.adminsync-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!getentgroup"${MAINT_GROUP}">/dev/null;thenecho"Creating the ${MAINT_GROUP} group (roster write access)..."sudogroupadd--system"${MAINT_GROUP}"fisudoinstall-d-m2775-oroot-g"${MAINT_GROUP}""${ROSTER_DIR}"if[!-f"${ROSTER_FILE}"];thenecho"Seeding the collaborator roster (empty)..."printf'{\n "version": 1,\n "users": {}\n}\n'|sudotee"${ROSTER_FILE}">/dev/nullfisudochownroot:"${MAINT_GROUP}""${ROSTER_FILE}"sudochmod0664"${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, sysfrom solver.auth.identity import system_slugauthz_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})..."sudotee"${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.mdAfter=network.target euler-smtp.serviceWants=euler-smtp.service[Service]Type=simpleUser=${AUTH_USER}Group=${AUTH_GROUP}SupplementaryGroups=${WEB_GROUP} ${EDGE_GROUP} ${AUTHZ_GROUP}EnvironmentFile=${AUTH_ENV}ExecStart=${VENV_PY} -m euler_site.authRestart=on-failureRestartSec=5s# State — euler-auth-only.StateDirectory=euler-authStateDirectoryMode=0700# Egress layer 1: the auth service is loopback-only (sockets, Squid, the mail relay).IPAddressDeny=anyIPAddressAllow=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=trueProtectSystem=strictReadWritePaths=/run/euler /run/euler-admProtectHome=truePrivateTmp=truePrivateDevices=trueRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6CapabilityBoundingSet=LockPersonality=trueMemoryDenyWriteExecute=trueRestrictRealtime=trueSystemCallArchitectures=nativeProtectKernelTunables=trueProtectKernelModules=trueProtectKernelLogs=trueProtectControlGroups=trueProtectClock=trueProtectHostname=trueProtectProc=invisibleProcSubset=pidRestrictNamespaces=trueRestrictSUIDSGID=trueRemoveIPC=trueSystemCallFilter=@system-serviceSystemCallErrorNumber=EPERMUMask=0007[Install]WantedBy=multi-user.targetEOFsudosystemctldaemon-reloadsudosystemctlenable--now"${SERVICE_NAME}"sudosystemctlrestart"${SERVICE_NAME}"}# ── install / uninstall ───────────────────────────────────────────────────────────do_deploy(){check_can_sudo||return1require_systemd||return1require_python||return1load_config||return1ensure_admin_token||return1ensure_identitiesdeploy_venv"${PROJECT_ROOT}""${WEB_GROUP}"deploy_tmpfilesdeploy_auth_envdeploy_state_dirdeploy_authzdeploy_rosterifvenv_has_auth;theninstall_unitelseecho"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];thenecho"Reloading the egress firewall to include ${AUTH_USER}...""${SCRIPT_DIR}/firewall.sh"reloadfido_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||return1require_python||return1load_config||return1deploy_venv"${PROJECT_ROOT}""${WEB_GROUP}"deploy_authzdeploy_rosterif[-f"${SERVICE_DEST}"]&&venv_has_auth;thensudosystemctlrestart"${SERVICE_NAME}"echo"Restarted ${SERVICE_NAME}"elseecho"note: ${SERVICE_NAME} not installed yet — run '$0 deploy'."fido_status}do_remove(){check_can_sudo||return1if[-f"${SERVICE_DEST}"];thensudosystemctldisable--now"${SERVICE_NAME}"2>/dev/null||truesudorm-f"${SERVICE_DEST}"sudosystemctldaemon-reloadfilocalreplyread-r-p"Remove the ${VENV_DIR} venv? [y/N] "replyif[["${reply}"=~^[Yy]$]];thensudorm-rf"${OPT_DIR}"sudorm-f"${TMPFILES_CONF}"firead-r-p"Remove ${AUTH_ENV} (admin token), the ${STATE_DIR} state (user DB!), and the ${AUTH_USER} identity? [y/N] "replyif[["${reply}"=~^[Yy]$]];thensudorm-f"${AUTH_ENV}""${AUTHZ_FILE}"sudorm-rf"${STATE_DIR}""${ROSTER_DIR}"sudogroupdel"${MAINT_GROUP}"2>/dev/null||trueifgetentpasswd"${AUTH_USER}">/dev/null;thensudouserdel"${AUTH_USER}"2>/dev/null||true;fiifgetentgroup"${AUTH_GROUP}">/dev/null;thensudogroupdel"${AUTH_GROUP}"2>/dev/null||true;fiifgetentgroup"${LEGACY_ADM_GROUP}">/dev/null;thensudogroupdel"${LEGACY_ADM_GROUP}"2>/dev/null||true;fifiecho"Auth service uninstall complete."}# ── status ────────────────────────────────────────────────────────────────────────do_status(){if[-x"${VENV_PY}"];thenecho"venv: ✓ ${VENV_DIR} ($(${VENV_PY}--version2>&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.localdeployeddeployed="$("${VENV_PY}"-P-c'from importlib.metadata import version; print(version("euler-solver"))'2>/dev/null)"if[-n"${deployed}"];thenecho"solver: ✓ v${deployed} deployed"elseecho"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;thenecho"deps: ✓ solver + aiohttp/aiohttp-jinja2/jinja2 importable"elseecho"deps: ✗ web deps or solver not importable from the venv"fielseecho"venv: ✗ ${VENV_DIR} not deployed"fiifgetentpasswd"${AUTH_USER}">/dev/null;thenecho"identity: ✓ ${AUTH_USER} (groups: $(id-nG"${AUTH_USER}"2>/dev/null))"elseecho"identity: ✗ ${AUTH_USER} missing"fiif[-d/run/euler-adm];thenecho"admin plane: ✓ /run/euler-adm (wheel-gated: sudo required; no admin group)"elseecho"admin plane: ✗ /run/euler-adm missing (tmpfiles not applied)"fiif[-f"${AUTH_ENV}"];thenecho"config: ✓ ${AUTH_ENV}"elseecho"config: ✗ ${AUTH_ENV} missing"fiif[-f"${AUTHZ_FILE}"];thenecho"authz: ✓ ${AUTHZ_FILE} ($(sudopython3-c"import json;print(len(json.load(open('${AUTHZ_FILE}')).get('users',{})))"2>/dev/null||echo'?') users, $(sudostat-c'%U:%G %a'"${AUTHZ_FILE}"))"elseecho"authz: ✗ ${AUTHZ_FILE} missing (local shell uses the repo template)"fiif[-f"${ROSTER_FILE}"];thenecho"roster: ✓ ${ROSTER_FILE} ($(stat-c'%U:%G %a'"${ROSTER_DIR}"2>/dev/null))"elseecho"roster: ✗ ${ROSTER_FILE} missing"fiif[-d"${STATE_DIR}"];thenecho"state: ✓ ${STATE_DIR} ($(stat-c'%U:%G %a'"${STATE_DIR}"2>/dev/null))"elseecho"state: ✗ ${STATE_DIR} missing"fiif[-f"${SERVICE_DEST}"];thenecho"${SERVICE_NAME}: $(systemctlis-active"${SERVICE_NAME}"2>/dev/null)/$(systemctlis-enabled"${SERVICE_NAME}"2>/dev/null)"elseecho"${SERVICE_NAME}: deferred (euler_site.auth not yet deployed)"fi}# ── Dispatch ──────────────────────────────────────────────────────────────────────ACTION="${1:-status}"case"${ACTION}"indeploy)do_deploy;;upgrade)do_deploy;;redeploy)do_redeploy;;remove)do_remove;;status)do_status;;-h|--help|help)usage;;*)echo"Unknown action: ${ACTION}";usage;exit1;;esac