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