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
+49
View File
@@ -21,6 +21,8 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from ci_orchestrator.operations.services import MANAGED_SERVICES, ManagedService
_ENV_KEYS = ("CI_ROOT", "CI_TEMPLATES", "CI_BUILD_VMS", "CI_ARTIFACTS", "CI_KEYS")
_IP_POOL_LEASE_DEFAULT_LINUX = "/var/lib/ci/ip-pool.json"
@@ -109,6 +111,12 @@ class WebConfig:
is deliberately no per-run Actions URL here: the exact Gitea Actions
run-URL path is unconfirmed (open risk), so only the top-level instance
URL is ever exposed.
``managed_services`` (Stage D) overrides the default systemd-unit
allow-list (``operations.services.MANAGED_SERVICES``) that the service
management panel is allowed to start/stop/restart. Left unset, the
default applies unchanged. See ``_parse_managed_services`` for the
permissive-parse contract (a malformed row is skipped, never raised).
"""
enabled: bool = False
@@ -119,6 +127,9 @@ class WebConfig:
session_secret_cred: str = "WebSessionSecret"
oidc: WebOidcConfig | None = None
gitea_url: str | None = None
managed_services: tuple[ManagedService, ...] = field(
default_factory=lambda: MANAGED_SERVICES
)
def gitea_base_url(web: WebConfig) -> str | None:
@@ -223,6 +234,43 @@ def _parse_web_oidc(oidc_section: dict[str, Any]) -> WebOidcConfig | None:
)
def _parse_managed_services(raw: Any) -> tuple[ManagedService, ...]:
"""Parse ``[web].managed_services`` -- an optional override of the systemd-unit
allow-list the service-management panel may control (default:
:data:`~ci_orchestrator.operations.services.MANAGED_SERVICES`).
Permissive by design, like :func:`_parse_web_oidc`: a malformed row
(missing key, wrong type) is skipped rather than raised, so one typo'd
entry in ``config.toml`` can't crash the whole app at startup. An
absent, empty, non-list value, or a list where every row was malformed,
all fall back to the built-in default unchanged.
"""
if not isinstance(raw, list) or not raw:
return MANAGED_SERVICES
services: list[ManagedService] = []
for row in raw:
if not isinstance(row, dict):
continue
service_id = row.get("id")
unit = row.get("unit")
label = row.get("label")
allow_stop = row.get("allow_stop")
if (
not isinstance(service_id, str)
or not service_id
or not isinstance(unit, str)
or not unit
or not isinstance(label, str)
or not label
or not isinstance(allow_stop, bool)
):
continue
services.append(ManagedService(id=service_id, unit=unit, label=label, allow_stop=allow_stop))
return tuple(services) if services else MANAGED_SERVICES
def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
"""Parse ``[web]`` (and nested ``[web.oidc]``). Absent section -> disabled defaults."""
if not web_section:
@@ -239,6 +287,7 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
audit_log=Path(audit_log) if audit_log else WebConfig().audit_log,
allowed_users=[str(u) for u in allowed_users] if isinstance(allowed_users, list) else [],
session_secret_cred=str(web_section.get("session_secret_cred", "WebSessionSecret")),
managed_services=_parse_managed_services(web_section.get("managed_services")),
oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}),
gitea_url=str(gitea_url) if gitea_url else None,
)
+262
View File
@@ -0,0 +1,262 @@
"""Managed systemd service control (Stage D, web-UI redesign) -- SECURITY-CRITICAL.
Lets an authenticated web user start/stop/restart a **fixed allow-list** of
systemd units on the host via ``sudo``. Zero ``click``, zero ``print`` --
everything here returns a dataclass, matching the rest of ``operations/``.
**The allow-list is the whole security model.** :func:`find_service` only
ever returns a :class:`ManagedService` drawn from a known tuple (either the
:data:`MANAGED_SERVICES` default or ``config.web.managed_services`` -- see
``config.py``); there is no code path that builds a :class:`ManagedService`
from unvalidated request input. :func:`control_service` never receives a
bare unit-name string from the caller -- only an already-resolved
``ManagedService``, so a crafted ``service_id``/``action`` can reach at most
a 400 (``web/routes.py``), never an arbitrary ``systemctl <verb> <unit>``.
**Never ``shell=True``.** Every command run by this module is an argument
list handed straight to :func:`subprocess.run` -- no user-supplied string
ever reaches a shell or the unit-name position of ``systemctl``/``sudo``.
**``ci-web`` is restart-only** (``allow_stop=False``): stopping the service
that renders this very page would lock the operator out with no way to
start it again from the UI. A ``stop`` request against such a service is a
policy refusal, not a subprocess failure -- :func:`control_service` returns
``ServiceActionResult(ok=False, command=[])`` *without* building or running
any command (documented choice, see that function's docstring for why this
is a returned result rather than a raised exception).
**``ci-web`` restart is deferred.** Restarting ``ci-web.service`` from
inside its own worker process would kill the HTTP response before it can be
sent. For ``service.id == "ci-web"`` and ``action == "restart"`` the built
command is a detached ``systemd-run --on-active=2 systemctl restart
ci-web.service`` instead of a direct ``systemctl restart`` -- the scheduling
call itself returns almost instantly, well before the 2s-deferred restart
actually happens, so the HTTP response reaches the browser first.
``service_states`` reuses ``operations.host.runner_status`` -- the exact
same argument-list/timeout/parse ``systemctl`` invocation already used for
the runner-status card -- rather than a second read-only systemctl code
path.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from ci_orchestrator.operations.host import runner_status
#: Timeout for a real (non-dry-run) start/stop/restart invocation. Generous
#: relative to the read-only status query's timeout (``host.py``'s 10s)
#: since a real ``systemctl start`` can legitimately take a few seconds.
_CONTROL_TIMEOUT = 15.0
#: How many characters of ``stderr`` to keep in a failure ``detail`` -- long
#: enough to be useful in the audit log / result partial, short enough to
#: never balloon an audit-log line.
_STDERR_TAIL_CHARS = 500
ALLOWED_ACTIONS: tuple[str, ...] = ("start", "stop", "restart")
@dataclass(frozen=True)
class ManagedService:
"""One systemd unit the web dashboard is allowed to control.
``id`` is the stable slug used in the URL (``/services/{id}/{action}``)
and as the audit-log ``target`` prefix -- never the raw unit name, so a
typo'd or renamed unit can't silently widen what a URL segment controls.
"""
id: str
unit: str
label: str
allow_stop: bool
#: The default allow-list (overridable via ``config.web.managed_services`` --
#: see ``config.py``). ``ci-web`` deliberately has ``allow_stop=False``:
#: restart only, so an operator can never remove their own access to the
#: panel that would let them start it again.
MANAGED_SERVICES: tuple[ManagedService, ...] = (
ManagedService("act-runner", "act-runner.service", "Runner", True),
ManagedService("act-runner-rust", "act-runner-rust.service", "Runner (Rust)", True),
ManagedService("vmware-host", "vmware-host.service", "VMware host", True),
ManagedService("ci-web", "ci-web.service", "Web UI", False),
)
@dataclass(frozen=True)
class ServiceState:
"""Current ``systemctl`` state of one managed service."""
service: ManagedService
active: bool
sub_state: str
@dataclass(frozen=True)
class ServiceActionResult:
"""Outcome of a (real or dry-run) start/stop/restart request.
``command`` is the exact argv that ran (or would run, on ``dry_run``) --
never a shell string. Empty (``[]``) only for the "stop refused" case,
where no command is ever built.
"""
service_id: str
action: str
ok: bool
detail: str
command: list[str]
def find_service(
service_id: str, services: tuple[ManagedService, ...] = MANAGED_SERVICES
) -> ManagedService | None:
"""Look up ``service_id`` in the allow-list. ``None`` if not found.
The sole gate between a URL path segment and a real ``ManagedService`` --
callers (``web/routes.py``) MUST treat a ``None`` return as a refusal
(400) and never fall back to constructing a ``ManagedService`` by hand.
"""
for service in services:
if service.id == service_id:
return service
return None
def service_states(
services: tuple[ManagedService, ...] = MANAGED_SERVICES,
) -> list[ServiceState]:
"""``systemctl`` active/sub-state for every service in ``services``.
Delegates the actual query to :func:`~ci_orchestrator.operations.host.
runner_status`, which already degrades a unit to
``active=False, sub_state="unknown"`` on any failure (missing
``systemctl``, timeout, ``OSError``) rather than raising -- this
function inherits that guarantee unchanged.
"""
statuses = {status.name: status for status in runner_status([s.unit for s in services])}
out: list[ServiceState] = []
for service in services:
status = statuses.get(service.unit)
if status is None: # pragma: no cover -- runner_status always returns one entry per input
out.append(ServiceState(service=service, active=False, sub_state="unknown"))
else:
out.append(ServiceState(service=service, active=status.active, sub_state=status.sub_state))
return out
def _build_command(service: ManagedService, action: str) -> list[str]:
"""The exact argv for ``action`` against ``service`` -- an arg list, never a shell string.
Special case: ``ci-web`` + ``restart`` -> a detached ``systemd-run``
deferral (see the module docstring) instead of a direct ``systemctl
restart`` -- this is the one case where the running process would
otherwise kill its own response mid-flight.
"""
if service.id == "ci-web" and action == "restart":
return [
"sudo",
"-n",
"systemd-run",
"--on-active=2",
"systemctl",
"restart",
service.unit,
]
return ["sudo", "-n", "systemctl", action, service.unit]
def control_service(
service: ManagedService, action: str, *, dry_run: bool = False
) -> ServiceActionResult:
"""Start/stop/restart ``service``. ``service`` MUST come from :func:`find_service`.
Rules (enforced in this order):
1. ``action`` not in :data:`ALLOWED_ACTIONS` -> raises :class:`ValueError`
(the caller, ``web/routes.py``, maps this to a 400 -- a programmer/API
error, not a user-facing operational outcome).
2. ``action == "stop"`` and ``not service.allow_stop`` -> returns a
refusal :class:`ServiceActionResult` (``ok=False, command=[]``)
*without* building or running any command. A returned result rather
than a raised exception: unlike rule 1 (a malformed request), this is
an expected, policy-driven outcome the caller may want to render
identically to any other "ok=False" result rather than special-case
as an exception.
3. ``dry_run=True`` -> returns the would-run ``command`` without
executing it (``ok=True``).
4. Otherwise, run the command with a timeout and ``check=False``; a
non-zero exit or timeout/OSError degrades to ``ok=False`` with a
stderr tail (or the exception text) in ``detail`` -- never raises.
"""
if action not in ALLOWED_ACTIONS:
raise ValueError(f"unsupported service action: {action!r}")
if action == "stop" and not service.allow_stop:
return ServiceActionResult(
service_id=service.id,
action=action,
ok=False,
detail=f"stop is disabled for '{service.label}' ({service.unit} is restart-only)",
command=[],
)
command = _build_command(service, action)
if dry_run:
return ServiceActionResult(
service_id=service.id,
action=action,
ok=True,
detail=f"would run: {' '.join(command)}",
command=command,
)
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
timeout=_CONTROL_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError) as exc:
return ServiceActionResult(
service_id=service.id,
action=action,
ok=False,
detail=f"failed to invoke {command[0]}: {exc}",
command=command,
)
if result.returncode == 0:
if service.id == "ci-web" and action == "restart":
detail = f"restart of {service.unit} scheduled via systemd-run (deferred ~2s)"
else:
detail = f"{action} succeeded for {service.unit}"
return ServiceActionResult(
service_id=service.id, action=action, ok=True, detail=detail, command=command
)
stderr_tail = (result.stderr or "").strip()[-_STDERR_TAIL_CHARS:]
detail = (
f"{action} failed for {service.unit} (exit {result.returncode}): {stderr_tail}"
if stderr_tail
else f"{action} failed for {service.unit} (exit {result.returncode})"
)
return ServiceActionResult(service_id=service.id, action=action, ok=False, detail=detail, command=command)
__all__ = [
"ALLOWED_ACTIONS",
"MANAGED_SERVICES",
"ManagedService",
"ServiceActionResult",
"ServiceState",
"control_service",
"find_service",
"service_states",
]
+37
View File
@@ -61,6 +61,7 @@ from ci_orchestrator.operations.inventory import (
list_backups,
list_templates,
)
from ci_orchestrator.operations.services import ServiceState, service_states
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
from ci_orchestrator.web import filters
from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user
@@ -202,6 +203,20 @@ def _fetch_artifacts(config: Config) -> list[FileEntry] | None:
return None
def _fetch_service_states(config: Config) -> list[ServiceState]:
"""Managed systemd unit states (Stage D), or ``[]`` if the query could not run at all.
``service_states`` itself already degrades a single unit to
``active=False, sub_state="unknown"`` on a ``systemctl`` hiccup (see
``operations/host.py::runner_status``) -- this ``except Exception`` is a
defensive second layer, matching every other ``_fetch_*`` wrapper here.
"""
try:
return service_states(config.web.managed_services)
except Exception: # degrade, never 500
return []
# ────────────────────────────────────────────────────────── status header
@@ -335,6 +350,7 @@ def dashboard(
"template_names": template_names,
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
"services": _fetch_service_states(config),
"health": _health_summary(
vms,
backend_available=backend_available,
@@ -415,6 +431,23 @@ def files_partial(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "partials/files.html", context)
def services_partial(request: Request) -> HTMLResponse:
"""``GET /services`` (Stage D) -- polled every 15s by the dashboard via htmx.
Needs a fresh ``csrf_token`` on every render (unlike ``resources``/
``vmware``/``templates``/``files``, which are pure read-only cards) --
the panel's start/stop/restart forms are mutating, same as
``vms_partial``/``host_partial``.
"""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"services": _fetch_service_states(config),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/services.html", context)
# ─────────────────────────────────────────────────────────────────── factory
@@ -470,6 +503,9 @@ def create_app(config: Config) -> FastAPI:
app.get("/files", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
files_partial
)
app.get("/services", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
services_partial
)
register_action_routes(app)
# SessionMiddleware is ALWAYS installed (W6) -- CSRF needs
@@ -509,6 +545,7 @@ __all__ = [
"get_backend",
"healthz",
"resources_partial",
"services_partial",
"templates_partial",
"vmware_partial",
]
+112
View File
@@ -49,6 +49,7 @@ from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audi
from ci_orchestrator.operations.cache import purge_cargo_cache
from ci_orchestrator.operations.host import backup_template
from ci_orchestrator.operations.inventory import list_templates
from ci_orchestrator.operations.services import ALLOWED_ACTIONS, control_service, find_service
from ci_orchestrator.operations.vms import RemovalResult, cleanup_orphans, remove_vm
from ci_orchestrator.web.app import get_backend
from ci_orchestrator.web.auth import require_user
@@ -200,13 +201,23 @@ def _confirm_partial(
summary: str,
action_url: str,
extra_fields: dict[str, str],
warning: str | None = None,
) -> HTMLResponse:
"""Render the shared confirm partial.
``warning`` (Stage D) is an optional extra callout rendered above the
Confirm button -- used by the service-control route to surface "N
active VM(s) -- this interrupts running jobs" when confirming a
stop/restart of ``vmware-host.service``. ``None`` (every other caller)
renders nothing extra, unchanged from before Stage D.
"""
context = {
"action_label": action_label,
"target": target,
"summary": summary,
"action_url": action_url,
"extra_fields": extra_fields,
"warning": warning,
"csrf_token": issue_csrf(request),
}
return _render_action(request, "partials/confirm.html", context)
@@ -470,6 +481,100 @@ async def host_backup(
)
# ───────────────────────────────────────────────────── POST /services/{id}/{action}
#: The ``ManagedService.id`` whose stop/restart confirm step warns about
#: active VMs -- stopping the VMware host stack kills every running build.
_VMWARE_HOST_SERVICE_ID = "vmware-host"
def _active_vm_count(backend: VmBackend | None) -> int:
"""Best-effort running-VM count for the ``vmware-host`` confirm warning.
Degrades to ``0`` on a missing backend or any hiccup listing VMs --
this is advisory copy in a confirm screen, never a value a route should
500 over.
"""
if backend is None:
return 0
try:
return len(backend.list_running())
except Exception: # a route must never 500 on a backend hiccup
return 0
async def services_control(
service_id: str,
action: str,
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
backend: VmBackend | None = Depends(get_backend),
) -> HTMLResponse:
"""Start/stop/restart one managed systemd unit, behind the two-step confirm gate.
Mirrors :func:`vms_remove`'s shape exactly. Three refusals short-circuit
**before** ``control_service`` (and therefore any subprocess) or the
audit log ever run -- an unknown ``service_id``, an unsupported
``action``, and a ``stop`` request against a restart-only service (see
``operations/services.py``'s module docstring on why ``ci-web`` is
restart-only). Each is a plain validation refusal, not an action, so
none of them is audited -- same rule as the path-containment check in
:func:`vms_remove`.
"""
config: Config = request.app.state.config
services = config.web.managed_services
service = find_service(service_id, services)
if service is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="unknown service id")
if action not in ALLOWED_ACTIONS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="unsupported action")
if action == "stop" and not service.allow_stop:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"stop is disabled for '{service.label}' (restart-only)",
)
action_label = f"{service.label}: {action}"
target = service.unit
audit_action = f"service.{action}"
form = await request.form()
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
if execute:
result = control_service(service, action, dry_run=False)
_audit(
config,
user=user,
action=audit_action,
target=target,
result="ok" if result.ok else "error",
detail=result.detail,
)
return _result_partial(request, action_label=action_label, ok=result.ok, detail=result.detail)
result = control_service(service, action, dry_run=True)
_audit(config, user=user, action=audit_action, target=target, result="dry-run", detail=result.detail)
warning: str | None = None
if service.id == _VMWARE_HOST_SERVICE_ID and action != "start":
vm_count = _active_vm_count(backend)
if vm_count > 0:
warning = f"{vm_count} active VM(s) -- this interrupts running jobs."
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=result.detail,
action_url=f"/services/{service_id}/{action}",
extra_fields={},
warning=warning,
)
# ──────────────────────────────────────────────────────────────── GET /audit
@@ -492,6 +597,12 @@ def register_action_routes(app: FastAPI) -> None:
app.add_api_route("/vms/cleanup", vms_cleanup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route("/cache/purge", cache_purge, methods=["POST"], response_class=HTMLResponse)
app.add_api_route("/host/backup", host_backup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route(
"/services/{service_id}/{action}",
services_control,
methods=["POST"],
response_class=HTMLResponse,
)
app.add_api_route("/audit", audit_view, methods=["GET"], response_class=HTMLResponse)
@@ -501,6 +612,7 @@ __all__ = [
"encode_vmx_id",
"host_backup",
"register_action_routes",
"services_control",
"vms_cleanup",
"vms_remove",
]
+6
View File
@@ -305,6 +305,12 @@ input[type="text"], input[type="number"], select {
/* One-line field description above a form control (B6 backup dropdown). */
.field-hint { font-size: 0.8rem; color: var(--text-dim); margin: 0 0 0.5rem; }
/* Multiple one-button action forms side-by-side in one table cell (Stage D
* services panel: Start/Stop/Restart). Overrides the default form's
* margin-top so the row stays compact inside a <td>. */
.service-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.service-actions form { margin-top: 0; }
/* Muted inline text (e.g. a relative-time suffix next to a file name). */
.text-dim { color: var(--text-dim); }
@@ -13,5 +13,6 @@
{% include "partials/vmware.html" %}
{% include "partials/templates.html" %}
{% include "partials/files.html" %}
{% include "partials/services.html" %}
</div>
{% endblock %}
@@ -2,6 +2,9 @@
<h3>Confirm: {{ action_label }}</h3>
<p>Target: <code>{{ target }}</code></p>
<p>{{ summary }}</p>
{% if warning %}
<p class="notice notice-warn" role="alert">{{ warning }}</p>
{% endif %}
<form method="post" action="{{ action_url }}" hx-post="{{ action_url }}" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="stage" value="execute">
@@ -0,0 +1,57 @@
<section class="card" id="services-card" hx-get="/services" hx-trigger="every 15s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Services</h2>
{% if services %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Service</th>
<th>Unit</th>
<th>Status</th>
<th>Sub-state</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for state in services %}
<tr class="{{ 'row-alert' if not state.active else '' }}">
<td>{{ state.service.label }}</td>
<td><code>{{ state.service.unit }}</code></td>
<td>
{% if state.active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg> active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg> down</span>
{% endif %}
</td>
<td>{{ state.sub_state }}</td>
<td class="service-actions">
{% if not state.active %}
<form method="post" action="/services/{{ state.service.id }}/start"
hx-post="/services/{{ state.service.id }}/start" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-primary" aria-label="Start {{ state.service.label }}">Start</button>
</form>
{% endif %}
{% if state.active and state.service.allow_stop %}
<form method="post" action="/services/{{ state.service.id }}/stop"
hx-post="/services/{{ state.service.id }}/stop" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Stop {{ state.service.label }}">Stop</button>
</form>
{% endif %}
<form method="post" action="/services/{{ state.service.id }}/restart"
hx-post="/services/{{ state.service.id }}/restart" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn" aria-label="Restart {{ state.service.label }}">Restart</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="notice notice-warn" role="alert">Service status unavailable.</p>
{% endif %}
</section>