feat(web): service management panel over a sudo allow-list (stage D)

Adds operations/services.py: a fixed ManagedService allow-list (act-runner, act-runner-rust, vmware-host, ci-web) with find_service/service_states/control_service. control_service builds an argument list only (never shell=True), validates the action against ALLOWED_ACTIONS (ValueError->400), refuses stop on restart-only services (ci-web, allow_stop=False) without building a command, and defers ci-web's own restart via 'sudo -n systemd-run --on-active=2 systemctl restart ci-web.service' so the HTTP response returns first.

Route POST /services/{service_id}/{action} in web/routes.py mirrors vms_remove: require_user + require_csrf + two-step confirm gate + one audit entry per stage; unknown id / bad action / disallowed-stop short-circuit to 400 before any subprocess or audit; the vmware-host confirm step warns with the active-VM count. New /services read partial with state-aware Start/Stop/Restart buttons, polled every 15s.

deploy/sudoers.d/ci-web grants ci-runner exact-command NOPASSWD for exactly these nine systemctl lines plus the systemd-run deferral — no wildcards, no bare systemctl, no ci-web stop; setup-web-linux.sh installs it mode 0440 and validates with 'visudo -cf', rolling back on failure. Config gains [web].managed_services override; docs/WEB-UI.md documents the panel + security model.

Gates: 1044 tests pass (46 new security/unit tests), coverage 94.20%, ruff + mypy --strict clean. Not yet deployed: sudoers install + prod-venv reinstall + ci-web restart are a human privilege-boundary step.
This commit is contained in:
2026-07-24 12:11:36 +02:00
parent 9d7bbee35d
commit e56eae95c7
16 changed files with 1828 additions and 20 deletions
+37 -9
View File
@@ -16,7 +16,12 @@
# 4. Prompt to store WebSessionSecret / WebOidcClientSecret in the keyring
# (interactive prompt only — never accepts a secret as a CLI argument)
# 5. Print the manual Gitea OIDC application registration steps
# 6. systemctl daemon-reload && enable (+ restart if already running)
# 6. Install deploy/sudoers.d/ci-web to /etc/sudoers.d/ci-web (mode 0440,
# Stage D service-management panel's exact-command NOPASSWD grant),
# validated with `visudo -cf` -- removed again if validation fails
# (a broken sudoers.d file can lock out sudo, so this step never
# leaves an unvalidated file in place)
# 7. systemctl daemon-reload && enable (+ restart if already running)
#
# Usage: sudo bash setup-web-linux.sh [--host <lan-ip>] [options]
# See --help for the full option list.
@@ -31,6 +36,8 @@ VENV="$OPT_CI/venv"
REPO_DIR="$SCRIPT_DIR"
UNIT_SRC="$REPO_DIR/deploy/systemd/ci-web.service"
UNIT_DST="/etc/systemd/system/ci-web.service"
SUDOERS_SRC="$REPO_DIR/deploy/sudoers.d/ci-web"
SUDOERS_DST="/etc/sudoers.d/ci-web"
ENV_FILE="/etc/ci/web.env"
CONFIG_PATH="/var/lib/ci/config.toml"
WEB_PORT=8800
@@ -84,6 +91,7 @@ done
[[ $EUID -eq 0 ]] || die "run as root (sudo bash $(basename "$0") ...)"
[[ -f "$UNIT_SRC" ]] || die "unit template not found: $UNIT_SRC (run from the repo root)"
[[ -f "$SUDOERS_SRC" ]] || die "sudoers template not found: $SUDOERS_SRC (run from the repo root)"
id "$CI_USER" &>/dev/null || die "user '$CI_USER' does not exist -- run setup-host-linux.sh first"
[[ -x "$VENV/bin/python" ]] || die "$VENV/bin/python not found -- run setup-host-linux.sh first"
@@ -104,17 +112,17 @@ echo ""
# ── Step 1: pip install '[web]' extra (non-editable, per CLAUDE.md) ─────────
if [[ "$SKIP_PIP" == false ]]; then
info "[1/6] Installing ci_orchestrator[web] into $VENV (non-editable)"
info "[1/7] Installing ci_orchestrator[web] into $VENV (non-editable)"
sudo -u "$CI_USER" "$VENV/bin/pip" install -q "${REPO_DIR}[web]"
verify_out=$(sudo -u "$CI_USER" "$VENV/bin/python" -m ci_orchestrator web serve --help 2>&1) \
|| die "'ci web serve --help' failed after install -- see output above"
echo " Verify OK: ${verify_out%%$'\n'*}"
else
info "[1/6] pip install -- SKIPPED (--skip-pip)"
info "[1/7] pip install -- SKIPPED (--skip-pip)"
fi
# ── Step 2: render the systemd unit + optional CI_CONFIG env file ───────────
info "[2/6] Rendering ci-web.service (bind host: $WEB_HOST)"
info "[2/7] Rendering ci-web.service (bind host: $WEB_HOST)"
RENDERED="$(mktemp)"
trap 'rm -f "$RENDERED"' EXIT
sed "s#__CI_WEB_BIND_HOST__#${WEB_HOST}#" "$UNIT_SRC" > "$RENDERED"
@@ -143,7 +151,7 @@ else
fi
# ── Step 3: ensure a [web]/[web.oidc] template exists in config.toml ────────
info "[3/6] config.toml [web] / [web.oidc] template"
info "[3/7] config.toml [web] / [web.oidc] template"
WEB_MARKER="# --- appended by setup-web-linux.sh ---"
if [[ -f "$CONFIG_PATH" ]] && { grep -qF "$WEB_MARKER" "$CONFIG_PATH" || grep -q '^\[web\]' "$CONFIG_PATH"; }; then
warn "[web] section (or a previously appended template) already present in $CONFIG_PATH -- skip, not overwriting"
@@ -176,7 +184,7 @@ EOF
fi
# ── Step 4: keyring secrets (interactive prompt only, never argv) ───────────
info "[4/6] Keyring secrets (WebSessionSecret, WebOidcClientSecret)"
info "[4/7] Keyring secrets (WebSessionSecret, WebOidcClientSecret)"
if [[ "$SKIP_SECRETS" == true ]]; then
echo " SKIPPED (--skip-secrets)"
else
@@ -214,7 +222,7 @@ KeyringCredentialStore().get('$1')
fi
# ── Step 5: manual Gitea OIDC app registration instructions ─────────────────
info "[5/6] Gitea OIDC application registration (manual step)"
info "[5/7] Gitea OIDC application registration (manual step)"
cat <<EOF
In Gitea: Settings -> Applications -> "Manage OAuth2 Applications" -> Create
@@ -233,8 +241,28 @@ cat <<EOF
EOF
# ── Step 6: enable + (re)start the service ───────────────────────────────────
info "[6/6] systemctl daemon-reload + enable/restart ci-web.service"
# ── Step 6: sudoers.d/ci-web (Stage D service-panel NOPASSWD grant) ─────────
info "[6/7] Installing sudoers.d/ci-web (service-panel NOPASSWD grant)"
if [[ -f "$SUDOERS_DST" ]] && cmp -s "$SUDOERS_SRC" "$SUDOERS_DST"; then
warn "$SUDOERS_DST already up to date -- skip"
else
install -m 0440 -o root -g root "$SUDOERS_SRC" "$SUDOERS_DST"
if visudo -cf "$SUDOERS_DST"; then
echo " Wrote $SUDOERS_DST (validated: visudo -cf OK)"
else
rm -f "$SUDOERS_DST"
die "visudo -cf rejected $SUDOERS_DST -- REMOVED it (a broken" \
"sudoers.d file can lock out sudo, so this step never leaves an" \
"unvalidated file in place). Fix deploy/sudoers.d/ci-web and" \
"re-run this installer."
fi
fi
warn "Verify systemctl/systemd-run absolute paths on THIS host match the" \
"sudoers file: command -v systemctl systemd-run -- edit both" \
"deploy/sudoers.d/ci-web and $SUDOERS_DST if either is not /usr/bin."
# ── Step 7: enable + (re)start the service ───────────────────────────────────
info "[7/7] systemctl daemon-reload + enable/restart ci-web.service"
systemctl daemon-reload
if systemctl is-active --quiet ci-web.service; then
systemctl restart ci-web.service