From 95144b2bba6ca7f1ed8eef6488634fa09e9e5a86 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 16 May 2026 04:38:00 -0400 Subject: [PATCH 1/2] fix(caddy): allowlist PUBLIC_PATHS before basic_auth (closes #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard `handle {}` block applied basicauth to every path that didn't match `/chat*` or `/v1/*`, including the entire FastAPI PUBLIC_PATHS frozenset (`/api/install/state`, `/api/config/urls`, `/api/auth/status`, `/api/status`, `/api/metrics`, …). The browser-side first-run wizard hits those endpoints before any credential can exist, so a `hal0 install --auth=basic` deployment was unbootstrappable. Add a `@public` named matcher with the exact PUBLIC_PATHS list and a `handle @public { reverse_proxy 127.0.0.1:8080 }` block placed BEFORE the default basicauth handler. Caddy evaluates `handle` blocks in source order — first match wins — so the matcher must precede the default block (the prior arrangement is what produced the bug). Path matching is exact (Caddy's `path` matcher is full-path match unless a `*` is given), which mirrors `request.url.path in PUBLIC_PATHS` on the API side, so the two stay in lockstep. The harness `auth-basic` row now asserts the rendered `/etc/hal0/Caddyfile` carries the `@public path` matcher + `handle @public` block + at least the `/api/install/state` entry, so a future edit that drops the allowlist (or moves it below the default handle) is caught. Verified locally: rendered the template, started Caddy against a stub backend, ran 26 curl probes. All public paths return 200 without creds; all protected paths return 401 without creds, 401 with wrong creds, 200 with right creds; `/v1/*` Bearer-passthrough unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- packaging/caddy/Caddyfile.template | 29 +++++++++++++++++++++++++++++ tests/harness/installer-test.sh | 15 ++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packaging/caddy/Caddyfile.template b/packaging/caddy/Caddyfile.template index b9f9ad86..7faa8f74 100644 --- a/packaging/caddy/Caddyfile.template +++ b/packaging/caddy/Caddyfile.template @@ -60,6 +60,35 @@ reverse_proxy 127.0.0.1:8080 } + # Public allowlist — paths that MUST be reachable without basic_auth + # so the first-run wizard can bootstrap before any credential exists, + # and so monitoring tools can scrape liveness without holding creds. + # + # This list mirrors the PUBLIC_PATHS frozenset in + # src/hal0/api/middleware/auth.py — keep them in sync. The `path` + # matcher is exact-match (no trailing-slash or prefix expansion), which + # matches the `request.url.path in PUBLIC_PATHS` check on the API side. + # + # This block MUST stay above the default `handle {}` below — Caddy + # evaluates `handle` blocks in source order and the first match wins, + # so listing `@public` later would let basic_auth swallow the wizard + # probes (see issue #28). + @public path /api/health/system \ + /api/status \ + /api/metrics \ + /api/metrics/prometheus \ + /api/features \ + /api/install/state \ + /api/install/complete \ + /api/config/urls \ + /api/auth/status \ + /api/auth/login \ + /api/auth/logout \ + /v1/models + handle @public { + reverse_proxy 127.0.0.1:8080 + } + # Dashboard + management API — Caddy basicauth at the edge. Forwards # the identity as X-Forwarded-Email so the hal0 API trusts the user # as admin (see hal0.api.middleware.auth precedence). diff --git a/tests/harness/installer-test.sh b/tests/harness/installer-test.sh index 2d91d300..fe3ce72d 100755 --- a/tests/harness/installer-test.sh +++ b/tests/harness/installer-test.sh @@ -243,11 +243,20 @@ else HAL0_HOSTNAME=hal0-harness.local HAL0_TLS_EMAIL=harness@hal0.test \ HAL0_NO_PROBE=1 HAL0_PLAIN=1 \ sudo -E bash "${REPO_ROOT}/installer/install.sh" --auth=basic --no-start >"${LOG4}" 2>&1; then + # Regression for #28: the rendered Caddyfile must carry the + # @public matcher + `handle @public` block BEFORE the default + # basicauth handler, else the first-run wizard's pre-auth + # /api/install/state + /api/config/urls probes 401 and the + # SPA can't bootstrap. Asserting both halves (matcher + handle) + # so a future edit that drops one without the other is caught. if [[ -f /etc/hal0/Caddyfile ]] && grep -q basicauth /etc/hal0/Caddyfile \ - && grep -q HAL0_AUTH_ENABLED=1 /etc/hal0/api.env; then - add_row "auth-basic" "pass" "$(since_ms "${start}")" "Caddyfile + api.env auth wiring rendered" + && grep -q HAL0_AUTH_ENABLED=1 /etc/hal0/api.env \ + && grep -q '@public path' /etc/hal0/Caddyfile \ + && grep -q 'handle @public' /etc/hal0/Caddyfile \ + && grep -q '/api/install/state' /etc/hal0/Caddyfile; then + add_row "auth-basic" "pass" "$(since_ms "${start}")" "Caddyfile + api.env auth wiring rendered (incl. @public allowlist)" else - add_row "auth-basic" "fail" "$(since_ms "${start}")" "Caddyfile or api.env auth flag missing post-install" + add_row "auth-basic" "fail" "$(since_ms "${start}")" "Caddyfile or api.env auth flag missing post-install (check @public allowlist for #28)" fi else rc=$? From 023a02e08bcf6ec8a2b31ea50c9d0efe2bc43f29 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 17 May 2026 17:31:37 -0400 Subject: [PATCH 2/2] fix(caddy): drop /api/metrics/prometheus from @public matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinated with #53 which removes prometheus from PUBLIC_PATHS in src/hal0/api/middleware/auth.py — closes the dead /api/metrics/prometheus route entirely rather than allowlisting a 404 at the edge. Co-Authored-By: Claude Opus 4.7 (1M context) --- packaging/caddy/Caddyfile.template | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/caddy/Caddyfile.template b/packaging/caddy/Caddyfile.template index 7faa8f74..12bfbf9e 100644 --- a/packaging/caddy/Caddyfile.template +++ b/packaging/caddy/Caddyfile.template @@ -76,7 +76,6 @@ @public path /api/health/system \ /api/status \ /api/metrics \ - /api/metrics/prometheus \ /api/features \ /api/install/state \ /api/install/complete \