diff --git a/.github/workflows/license-guard.yml b/.github/workflows/license-guard.yml index 5d625f23a..7223b9fe9 100644 --- a/.github/workflows/license-guard.yml +++ b/.github/workflows/license-guard.yml @@ -65,6 +65,16 @@ jobs: - name: Check Markdown/MDX lineage contract run: python3 scripts/docs/catalog_lineage_docs.py --check-frontmatter + health-url-references: + name: Verify health-endpoint references resolve to real routes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.12' } + - name: Check health-endpoint references + run: python3 scripts/checks/check_health_urls.py + license-text: name: Verify LICENSE is AGPL-3.0 runs-on: ubuntu-latest diff --git a/backend/app/Http/Controllers/LegacyAtlasRedirectController.php b/backend/app/Http/Controllers/LegacyAtlasRedirectController.php index 823cbcff8..b1a60c50a 100644 --- a/backend/app/Http/Controllers/LegacyAtlasRedirectController.php +++ b/backend/app/Http/Controllers/LegacyAtlasRedirectController.php @@ -72,7 +72,7 @@ public function atlasRedirect(Request $request, ?string $path = null): RedirectR public function webApiRedirect(Request $request, ?string $path = null): RedirectResponse { if (! $path) { - return redirect('/api/v1/health', 301); + return redirect('/api/health', 301); } $segments = explode('/', trim($path, '/')); @@ -140,6 +140,6 @@ public function webApiRedirect(Request $request, ?string $path = null): Redirect } // Unknown WebAPI paths → health check - return redirect('/api/v1/health', 301); + return redirect('/api/health', 301); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index cb44d869e..93d167100 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -170,8 +170,14 @@ use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Route; -// Public health check +// Public health check. routes/api.php is mounted under the "/api" prefix, so +// this is GET /api/health (the canonical endpoint). Route::get('/health', [HealthController::class, 'index']); +// Backward-compatible alias → GET /api/v1/health. Consumers that assume the +// /api/v1 prefix (the installer readiness probe, external uptime monitors, +// already-shipped installer binaries, legacy WebAPI redirects) get the same +// payload instead of a 404. Direct route, NOT a redirect: probes check for 200. +Route::get('/v1/health', [HealthController::class, 'index']); // Broadcasting auth — registered under Sanctum so SPA bearer tokens work. // Must use /api/broadcasting/auth path; Echo is configured to match. diff --git a/backend/tests/Feature/Api/V1/HealthCheckTest.php b/backend/tests/Feature/Api/V1/HealthCheckTest.php index e68ea82c7..dbf495e31 100644 --- a/backend/tests/Feature/Api/V1/HealthCheckTest.php +++ b/backend/tests/Feature/Api/V1/HealthCheckTest.php @@ -1,7 +1,14 @@ getJson('/api/health'); +// Both the canonical /api/health and its backward-compatible /api/v1/health alias +// must return 200 with the same shape. If the alias route is ever dropped or the +// health route regresses, this fails — and the reference guard +// (scripts/checks/check_health_urls.py) then flags any consumer still on the +// dropped path. Add a path here whenever a new health route is added in api.php. +dataset('health_paths', ['/api/health', '/api/v1/health']); + +test('health endpoint returns ok', function (string $path) { + $response = $this->getJson($path); $response->assertStatus(200) ->assertJsonPath('status', 'ok') @@ -18,4 +25,4 @@ 'darkstar', ], ]); -}); +})->with('health_paths'); diff --git a/deploy.sh b/deploy.sh index c09b2299f..6099d59c7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -855,6 +855,11 @@ else if $DO_PHP || $DO_DB || $DO_OPENAPI; then smoke_check "api /sanctum/csrf-cookie" "/sanctum/csrf-cookie" "204" smoke_check "api /api/v1/nonexistent-endpoint" "/api/v1/nonexistent-endpoint" "404" + # Canonical health endpoint and its backward-compatible alias must both + # serve 200. Catches a route/prefix regression that would 404 the + # installer readiness probe and external monitors. + smoke_check "api /api/health" "/api/health" "200" + smoke_check "api /api/v1/health (alias)" "/api/v1/health" "200" fi if $DO_PHP || $DO_DB; then diff --git a/docs/compliance/compliance-remediation-plan.md b/docs/compliance/compliance-remediation-plan.md index 6614265f6..2b0c12ee6 100644 --- a/docs/compliance/compliance-remediation-plan.md +++ b/docs/compliance/compliance-remediation-plan.md @@ -284,7 +284,7 @@ docker compose up -d # 9. Verify everything is running docker compose ps -curl -s https://parthenon.acumenus.net/api/v1/health +curl -s https://parthenon.acumenus.net/api/health ``` **Risk mitigation:** Do this on a weekend. Keep the backup copy on a separate physical drive until you've verified the encrypted volume works for 48+ hours. @@ -609,7 +609,7 @@ If ePHI was accessed or disclosed without authorization: 3. Restore: `psql parthenon < backups/YYYY-MM-DD_HH-MM.sql` 4. Verify row counts against backup manifest JSON 5. Restart: `docker compose up -d` -6. Verify: `curl https://parthenon.acumenus.net/api/v1/health` +6. Verify: `curl https://parthenon.acumenus.net/api/health` ### Scenario B: Full Server Loss 1. Provision new Ubuntu 24.04 server diff --git a/docs/compliance/disaster-recovery-plan.md b/docs/compliance/disaster-recovery-plan.md index 4450c6cf6..1c6d102ca 100644 --- a/docs/compliance/disaster-recovery-plan.md +++ b/docs/compliance/disaster-recovery-plan.md @@ -103,7 +103,7 @@ docker compose up -d # 5. Verify health docker compose ps -# For PHP/API: curl https://parthenon.acumenus.net/api/v1/health +# For PHP/API: curl https://parthenon.acumenus.net/api/health ``` **Common container-specific notes:** @@ -149,7 +149,7 @@ docker compose exec -T postgres psql -U parthenon < backups/latest.sql docker compose up -d # 7. Verify -curl https://parthenon.acumenus.net/api/v1/health +curl https://parthenon.acumenus.net/api/health # Spot-check row counts against backup manifest ``` @@ -213,7 +213,7 @@ sudo certbot --apache -d parthenon.acumenus.net # 12. Verify all services docker compose ps -curl https://parthenon.acumenus.net/api/v1/health +curl https://parthenon.acumenus.net/api/health ``` ### Scenario D: Ransomware diff --git a/docs/compliance/incident-response-plan.md b/docs/compliance/incident-response-plan.md index cac12fee4..ef4cf5370 100644 --- a/docs/compliance/incident-response-plan.md +++ b/docs/compliance/incident-response-plan.md @@ -178,7 +178,7 @@ sudo -u postgres pg_dump parthenon > /tmp/incident-db-snapshot.sql - Run Wazuh syscheck scan: wait for next scheduled scan or trigger manually - Run CIS SCA benchmark check - Verify all 29 containers healthy: `docker compose ps` - - Verify API health: `curl https://parthenon.acumenus.net/api/v1/health` + - Verify API health: `curl https://parthenon.acumenus.net/api/health` - Verify database integrity: compare row counts against backup manifest 4. **Monitor closely** for 48 hours post-recovery. Watch for: - Recurring alerts from the same detection source diff --git a/docs/lineage/modules/finngen/runbook.md b/docs/lineage/modules/finngen/runbook.md index 99a5dc67c..ec3ec1562 100644 --- a/docs/lineage/modules/finngen/runbook.md +++ b/docs/lineage/modules/finngen/runbook.md @@ -109,7 +109,7 @@ docker compose exec php sh -c 'cd /var/www/html && php artisan config:clear && p ./deploy.sh --frontend # 8. Post-deploy verification -curl -s https://parthenon.acumenus.net/api/v1/health | jq '.finngen' +curl -s https://parthenon.acumenus.net/api/health | jq '.services.darkstar' docker compose exec php sh -c 'cd /var/www/html && php artisan finngen:smoke-test' # Expected: both pass diff --git a/installer/health.py b/installer/health.py index 04dd864e8..a9d110824 100644 --- a/installer/health.py +++ b/installer/health.py @@ -12,13 +12,13 @@ def probe(app_url: str, attempt: int) -> dict[str, Any]: - """Probe `/api/v1/health` and return the result. + """Probe `/api/health` and return the result. Returns: {"ready": bool, "attempt": int, "last_status": int} last_status is 0 when the connection failed entirely. """ - url = app_url.rstrip("/") + "/api/v1/health" + url = app_url.rstrip("/") + "/api/health" try: status, _body = _http_get(url) except URLError: diff --git a/installer/rust-gui/ui/index.html b/installer/rust-gui/ui/index.html index 489a256c7..d44ec5cce 100644 --- a/installer/rust-gui/ui/index.html +++ b/installer/rust-gui/ui/index.html @@ -371,7 +371,7 @@

Verifying Parthenon

Web server accepting connections
Application runtime responding
Database ready
-
/api/v1/health returns 200
+
/api/health returns 200
Frontend assets served
diff --git a/installer/tests/test_health.py b/installer/tests/test_health.py index 0173e7065..f7bfbd333 100644 --- a/installer/tests/test_health.py +++ b/installer/tests/test_health.py @@ -32,7 +32,7 @@ def test_probe_strips_trailing_slash_from_url(): with patch("installer.health._http_get") as mock_get: mock_get.return_value = (200, "") result = health.probe("http://localhost:8082/", attempt=1) - mock_get.assert_called_once_with("http://localhost:8082/api/v1/health") + mock_get.assert_called_once_with("http://localhost:8082/api/health") assert result == {"ready": True, "attempt": 1, "last_status": 200} @@ -42,7 +42,7 @@ def test_http_get_translates_httperror_to_status_code(): from urllib.error import HTTPError fake_error = HTTPError( - url="http://localhost:8082/api/v1/health", + url="http://localhost:8082/api/health", code=502, msg="Bad Gateway", hdrs=None, # type: ignore[arg-type] @@ -52,7 +52,7 @@ def test_http_get_translates_httperror_to_status_code(): # Exercise _http_get directly — NOT the seam mock — to verify the real urlopen path. with patch("installer.health.urlopen") as mock_urlopen: mock_urlopen.side_effect = fake_error - status, body = health._http_get("http://localhost:8082/api/v1/health") + status, body = health._http_get("http://localhost:8082/api/health") assert status == 502 assert "upstream nginx" in body diff --git a/scripts/checks/check_health_urls.py b/scripts/checks/check_health_urls.py new file mode 100644 index 000000000..8947754d5 --- /dev/null +++ b/scripts/checks/check_health_urls.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Health-endpoint reference guard — permanent + evolving. + +Prevents the "wrong health path" class of bug from recurring (a consumer probing +a backend health URL that does not exist, e.g. the `/api/v1/health` 404 that hung +the installer's readiness loop while the real route was `/api/health`). + +How it stays correct as the codebase evolves: it does NOT hard-code the canonical +path. It DERIVES the set of real backend health routes from the actual route +table (`backend/routes/api.php` — the single source of truth), then scans +live-consumer surfaces for references to the main-app health path family +`/api[/vN]/health` and fails if any reference points at a path the backend does +not actually serve. + +Consequences that make this self-maintaining: + * Add or rename a HealthController route in api.php -> allowed set updates, + no edit here required. + * Remove the `/v1/health` compat alias -> every straggler that + still references it is flagged, forcing consumers onto the canonical path. + * A new typo (`/api/v2/health`, `/api/healthz`, ...) in installer/infra/etc. + -> flagged immediately. + +Out of scope by design: documentation under docs/ (it records historical state), +bare `/health` sidecar endpoints, and unrelated `/api/health` belonging to other +services (Grafana) — the latter happens to coincide with a canonical path and is +harmless. + +Usage: python3 scripts/checks/check_health_urls.py +Exit: 0 = OK, 1 = bad references found, 2 = could not derive canonical set. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +# routes/api.php is mounted under the "/api" prefix by RouteServiceProvider, +# NOT "/api/v1" — which is the entire reason this class of bug exists. +API_PREFIX = "/api" +ROUTE_FILE = REPO / "backend" / "routes" / "api.php" + +# The main-app health path family. Matches /api/health, /api/v1/health, ... +# Deliberately NOT /api/v1/etl/fhir/health (health is not immediately after the +# version segment) and NOT bare /health (sidecars / other services). +HEALTH_REF = re.compile(r"/api(?:/v\d+)?/health\b") + +# A HealthController route line, e.g. Route::get('/v1/health', [HealthController::class, 'index']). +# The negative lookbehind excludes SystemHealthController::class. +_ROUTE_DECL = re.compile(r"(? set[str]: + """Derive the real backend health routes from api.php (source of truth).""" + paths: set[str] = set() + for line in ROUTE_FILE.read_text(encoding="utf-8").splitlines(): + if not _ROUTE_DECL.search(line): + continue + m = _ROUTE_PATH.search(line) + if m: + paths.add(API_PREFIX + m.group(1)) + return paths + + +def find_violations(canonical: set[str]) -> list[tuple[str, int, str]]: + violations: list[tuple[str, int, str]] = [] + seen: set[str] = set() + for pattern in CONSUMER_GLOBS: + for path in REPO.glob(pattern): + if not path.is_file(): + continue + rel = path.relative_to(REPO).as_posix() + if rel in SELF_EXCLUDE or rel in seen: + continue + seen.add(rel) + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for lineno, line in enumerate(text.splitlines(), 1): + for ref in HEALTH_REF.findall(line): + if ref not in canonical: + violations.append((rel, lineno, line.strip()[:160])) + return violations + + +def main() -> int: + canonical = canonical_health_paths() + if not canonical: + print( + "ERROR: no HealthController routes found in backend/routes/api.php.\n" + " The route file changed shape — update this guard's parser.", + file=sys.stderr, + ) + return 2 + + violations = find_violations(canonical) + canon = ", ".join(sorted(canonical)) + + if violations: + print("Health-endpoint reference guard FAILED.") + print(f"Real backend health routes (from api.php): {canon}") + print("These live-consumer references point at a health path the backend does NOT serve:") + for rel, lineno, snippet in violations: + print(f" {rel}:{lineno}: {snippet}") + print( + "\nFix: point the reference at a real route, or add the route to " + "backend/routes/api.php (and a HealthCheckTest assertion)." + ) + return 1 + + print(f"OK: all live-consumer health references resolve to real routes ({canon}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/githooks/pre-commit b/scripts/githooks/pre-commit index cc8acd44d..b70d43167 100755 --- a/scripts/githooks/pre-commit +++ b/scripts/githooks/pre-commit @@ -207,6 +207,22 @@ if [ -d templates ] && command -v uv >/dev/null 2>&1; then fi fi +# ── Health endpoint reference guard ───────────────────────────────────────── +# Prevents a consumer (installer probe, deploy smoke, infra healthcheck, SPA) +# from referencing a backend health URL that does not exist — the /api/v1/health +# 404 that silently hung the installer readiness loop. Derives the real routes +# from backend/routes/api.php, so it stays correct as routes evolve. +if command -v python3 > /dev/null 2>&1; then + echo "Pre-commit: Health URLs..." + if python3 scripts/checks/check_health_urls.py > /tmp/parthenon_health_guard.out 2>&1; then + echo " ✓ Health URLs" + else + cat /tmp/parthenon_health_guard.out + echo " ✗ Health URLs — fix the reference, or add the route to api.php + a HealthCheckTest assertion." + ERRORS=$((ERRORS + 1)) + fi +fi + # ── Result ────────────────────────────────────────────────────────────────── if [ "$ERRORS" -gt 0 ]; then echo ""