Files
Simone e56eae95c7 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.
2026-07-24 12:11:36 +02:00

292 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# setup-web-linux.sh — install/upgrade the ci-orchestrator web dashboard (W7b)
#
# Repeatable / idempotent: safe to re-run any time (e.g. after `git pull` +
# a code change — see CLAUDE.md: the production venv must be reinstalled
# non-editable after every change). Every mutating step checks its own
# state first and skips (with a message) when already satisfied.
#
# What it does:
# 1. pip install '<repo>[web]' into /opt/ci/venv, non-editable, as ci-runner
# 2. Render deploy/systemd/ci-web.service into /etc/systemd/system/,
# substituting the LAN bind IP; write /etc/ci/web.env (CI_CONFIG) if the
# config path was overridden
# 3. Ensure a commented [web]/[web.oidc] template exists in config.toml
# (never overwrites an existing [web] section)
# 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. 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.
set -euo pipefail
# ── Defaults ─────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CI_USER="ci-runner"
OPT_CI="/opt/ci"
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
WEB_HOST="" # resolved below (arg > prompt > 127.0.0.1)
SKIP_PIP=false
SKIP_SECRETS=false
ASSUME_DEFAULT_HOST=false
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}==>${NC} $*"; }
warn() { echo -e "${YELLOW} ! $*${NC}"; }
die() { echo -e "${RED}ERROR: $*${NC}" >&2; exit 1; }
usage() {
cat <<EOF
Usage: sudo bash $(basename "$0") [options]
Options:
--host <ip> LAN IP the dashboard binds to (written into the unit
and into the config.toml template). Default: prompt,
falling back to 127.0.0.1 (loopback-only) on Enter.
--config-path <p> Target config.toml (default: $CONFIG_PATH).
--skip-pip Skip step 1 (pip install '[web]' extra).
--skip-secrets Skip step 4 (interactive keyring secret prompts).
-h, --help Show this help.
Never binds 0.0.0.0. Binding a non-loopback IP without [web.oidc]
configured is refused at runtime by 'ci web serve' itself (open mode is
localhost-only) -- see docs/WEB-UI.md.
Examples:
sudo bash setup-web-linux.sh --host 127.0.0.1
sudo bash setup-web-linux.sh --host 192.168.1.50
sudo bash setup-web-linux.sh --skip-pip --skip-secrets # re-render unit only
EOF
exit 1
}
# ── Parse args ───────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--host) WEB_HOST="$2"; shift 2 ;;
--config-path) CONFIG_PATH="$2"; shift 2 ;;
--skip-pip) SKIP_PIP=true; shift ;;
--skip-secrets) SKIP_SECRETS=true; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
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"
if [[ -z "$WEB_HOST" ]]; then
read -r -p "LAN IP to bind the dashboard to [127.0.0.1]: " WEB_HOST
WEB_HOST="${WEB_HOST:-127.0.0.1}"
fi
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ ci-orchestrator web dashboard — installer ║"
echo "╚══════════════════════════════════════════════════════╝"
echo " Bind : ${WEB_HOST}:${WEB_PORT}"
echo " ci_user : $CI_USER"
echo " venv : $VENV"
echo " config.toml : $CONFIG_PATH"
echo ""
# ── Step 1: pip install '[web]' extra (non-editable, per CLAUDE.md) ─────────
if [[ "$SKIP_PIP" == false ]]; then
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/7] pip install -- SKIPPED (--skip-pip)"
fi
# ── Step 2: render the systemd unit + optional CI_CONFIG env file ───────────
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"
if [[ -f "$UNIT_DST" ]] && cmp -s "$RENDERED" "$UNIT_DST"; then
warn "$UNIT_DST already up to date -- skip"
UNIT_CHANGED=false
else
install -m 644 "$RENDERED" "$UNIT_DST"
echo " Wrote $UNIT_DST"
UNIT_CHANGED=true
fi
mkdir -p /etc/ci
if [[ "$CONFIG_PATH" != "/var/lib/ci/config.toml" ]]; then
if [[ -f "$ENV_FILE" ]] && grep -q '^CI_CONFIG=' "$ENV_FILE"; then
warn "$ENV_FILE already sets CI_CONFIG -- skip (edit manually to change it)"
else
echo "CI_CONFIG=$CONFIG_PATH" >> "$ENV_FILE"
chmod 640 "$ENV_FILE"
chown "root:$CI_USER" "$ENV_FILE" 2>/dev/null || true
echo " Wrote CI_CONFIG=$CONFIG_PATH to $ENV_FILE"
fi
else
echo " Using the auto-discovered default config.toml path -- no $ENV_FILE needed"
fi
# ── Step 3: ensure a [web]/[web.oidc] template exists in config.toml ────────
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"
else
[[ -f "$CONFIG_PATH" ]] || die "$CONFIG_PATH does not exist -- run setup-host-linux.sh first (it creates the base config.toml)"
cat >> "$CONFIG_PATH" <<EOF
$WEB_MARKER
# Uncomment and fill in [web.oidc] once you have registered the Gitea
# OAuth2 application (see the installer's step 5 output / docs/WEB-UI.md).
# session_secret_cred / client_secret_cred are KEYRING KEY NAMES only --
# the secrets themselves are never written to this file.
#
# [web]
# enabled = true
# bind_host = "${WEB_HOST}" # never 0.0.0.0
# bind_port = ${WEB_PORT}
# audit_log = "/var/lib/ci/logs/web-audit.jsonl"
# allowed_users = ["CHANGE-ME"] # Gitea usernames allowed to log in
# session_secret_cred = "WebSessionSecret"
#
# [web.oidc]
# issuer = "https://CHANGE-ME.example"
# client_id = "CHANGE-ME"
# redirect_url = "http://${WEB_HOST}:${WEB_PORT}/auth/callback"
# client_secret_cred = "WebOidcClientSecret"
# scopes = ["openid", "profile"]
EOF
echo " Appended a commented [web]/[web.oidc] template to $CONFIG_PATH"
fi
# ── Step 4: keyring secrets (interactive prompt only, never argv) ───────────
info "[4/7] Keyring secrets (WebSessionSecret, WebOidcClientSecret)"
if [[ "$SKIP_SECRETS" == true ]]; then
echo " SKIPPED (--skip-secrets)"
else
_secret_present() {
sudo -u "$CI_USER" "$VENV/bin/python" -c "
from ci_orchestrator.credentials import KeyringCredentialStore
KeyringCredentialStore().get('$1')
" &>/dev/null
}
_set_secret_if_needed() {
local name="$1" label="$2"
if _secret_present "$name"; then
warn "Keyring key '$name' already set -- skip"
return
fi
read -r -p " Set keyring key '$name' now? [Y/n] " ans
case "$ans" in
[nN]*)
warn "Skipped -- set it later with:" \
"sudo -u $CI_USER $VENV/bin/python -m ci_orchestrator creds set --target $name --user $label"
;;
*)
# Delegates straight to the CLI's own interactive prompt
# (hide_input + confirmation) -- the secret never touches
# this script's variables, argv, or any log.
sudo -u "$CI_USER" "$VENV/bin/python" -m ci_orchestrator creds set \
--target "$name" --user "$label"
;;
esac
}
_set_secret_if_needed "WebSessionSecret" "web-session"
_set_secret_if_needed "WebOidcClientSecret" "web-oidc-client"
fi
# ── Step 5: manual Gitea OIDC app registration instructions ─────────────────
info "[5/7] Gitea OIDC application registration (manual step)"
cat <<EOF
In Gitea: Settings -> Applications -> "Manage OAuth2 Applications" -> Create
Application name : ci-web
Redirect URI : http://${WEB_HOST}:${WEB_PORT}/auth/callback
(must match [web.oidc].redirect_url EXACTLY --
host, port and path, or login will loop)
After creating it, Gitea shows a Client ID and Client Secret ONCE:
1. Client ID -> set [web.oidc].client_id in $CONFIG_PATH
2. Client Secret -> store in the keyring (never in config.toml):
sudo -u $CI_USER $VENV/bin/python -m ci_orchestrator creds set \\
--target WebOidcClientSecret --user web-oidc-client
3. Add your Gitea username(s) to [web].allowed_users in $CONFIG_PATH
4. Uncomment [web] and [web.oidc] in $CONFIG_PATH
EOF
# ── 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
echo " ci-web.service was already running -- restarted to pick up any changes"
else
systemctl enable --now ci-web.service
echo " ci-web.service enabled and started"
fi
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Install complete ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
echo " Status : systemctl status ci-web.service"
echo " Logs : journalctl -u ci-web.service -f"
if [[ "$WEB_HOST" == "127.0.0.1" || "$WEB_HOST" == "localhost" ]]; then
echo " URL : http://${WEB_HOST}:${WEB_PORT}/ (loopback only -- open mode works without OIDC)"
else
echo " URL : http://${WEB_HOST}:${WEB_PORT}/"
echo " NOTE : a LAN bind refuses to start until [web.oidc] is configured"
echo " and enabled in $CONFIG_PATH (see step 5 above) -- this"
echo " matches the same guard 'ci web serve' enforces itself."
fi
echo " Docs : $REPO_DIR/docs/WEB-UI.md"
echo ""