diff --git a/.gitea/workflows/cve-triage.yaml b/.gitea/workflows/cve-triage.yaml index 5de9f2c..bf1de6e 100644 --- a/.gitea/workflows/cve-triage.yaml +++ b/.gitea/workflows/cve-triage.yaml @@ -34,13 +34,19 @@ jobs: runs-on: ubuntu-latest # THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker # daemon, so a job container is the only way to choose an image. The toolbox provides - # python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in + # python3, git, curl and jq, pulls with the gate's credential, and is normally already in # the DinD store. It has no Node, so actions/checkout cannot run; a public python image has # neither Node nor git and would add a second image to keep pinned. The cost is a cold pull # after a runner restart, which the gate pays anyway. container: # renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81 + # Anonymous registry pulls are refused since REQUIRE_SIGNIN_VIEW (catalog/gitea.yaml); this + # job already holds RENOVATE_TOKEN, whose read:package scope renovate.yaml's preflight proves. + # See "PULL AUTH" in gate.yml's header. + credentials: + username: renovate + password: ${{ secrets.RENOVATE_TOKEN }} # Without a bound, a wedged run would hold the concurrency group and block every later run. timeout-minutes: 15 steps: diff --git a/.gitea/workflows/gate.yml b/.gitea/workflows/gate.yml index 74d7af6..aa74b25 100644 --- a/.gitea/workflows/gate.yml +++ b/.gitea/workflows/gate.yml @@ -58,11 +58,16 @@ # Renovate follows the `main` tag and opens a digest-bump PR, whose OWN gate run then # executes on the new image — the bump tests itself. infra/gitea/registry-retention.sh reads # the same marker, so the pinned digest is never pruned. Never hand-edit it to a tag. -# * PULL AUTH: none needed. The Home-Network org is public and the registry issues an -# anonymous pull token (measured 2026-11-08: anonymous /v2/token -> manifest 200), so the -# runner's DinD pulls it like any public image. If the org is ever made private, add -# `credentials:` here with a read:package token secret — the automatic Actions token has no -# package scope at /v2/token (see images.yaml's header). +# * PULL AUTH: REQUIRED since 2026-11-14. Gitea now runs REQUIRE_SIGNIN_VIEW=true +# (catalog/gitea.yaml), whose strict mode refuses the anonymous /v2/token exchange that this +# pull used to rely on (measured 2026-11-08: anonymous /v2/token -> manifest 200). The job +# therefore declares `credentials:` with RENOVATE_TOKEN — an EXISTING secret whose +# read:package scope renovate.yaml's preflight already proves on every run, and the token the +# cve-triage job already uses. The automatic Actions token cannot do it: it has no package scope +# at /v2/token (see images.yaml's header). act interpolates both fields before the pull and +# FAILS THE JOB if either is empty, so a missing secret is a red gate, never a silent +# anonymous fallback. A dedicated read:package-only token would be narrower; swapping it in +# changes this one line. # * COLD PULL: ~467 MB compressed (1.54 GB unpacked). The DinD store is an emptyDir, so the # first job after a runner-pod restart or a digest bump pays one pull; every other run finds # it cached (forcePull=false), exactly as it already did for the catthehacker image. @@ -111,6 +116,10 @@ jobs: container: # renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81 + # The registry refuses anonymous pulls (REQUIRE_SIGNIN_VIEW) — see "PULL AUTH" in the header. + credentials: + username: renovate + password: ${{ secrets.RENOVATE_TOKEN }} steps: # Plain git, because actions/checkout is a Node action and the toolbox has no Node. It # reproduces what actions/checkout@v7 with `fetch-depth: 0` did here (read from its logged diff --git a/apps/wave-0/gitea.yaml b/apps/wave-0/gitea.yaml index 189d946..2ed3cfd 100644 --- a/apps/wave-0/gitea.yaml +++ b/apps/wave-0/gitea.yaml @@ -101,6 +101,8 @@ spec: value: lax - name: GITEA__service__DISABLE_REGISTRATION value: 'true' + - name: GITEA__service__REQUIRE_SIGNIN_VIEW + value: 'true' - name: GITEA_ADMIN_USERNAME valueFrom: secretKeyRef: @@ -131,8 +133,8 @@ spec: command: - /bin/sh - -c - - "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\nelse\n n=0\n until su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"$GITEA_ADMIN_USERNAME@home.local\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; break; }\n sleep 3\n done\n echo \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\nfi\n# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---\n# Registers the confidential `gitea` client as the OAuth2 login source \"keycloak\" against the\n# homelab realm's discovery doc. The `groups` claim maps platform-admins -> Gitea admin\n# (--admin-group). Missing secret => skip (break-glass admin still works), so an unfilled\n# secret never crashes the pod.\n#\n# HARDENED (was a silent first-boot RACE): this step used to fire before the app/network was\n# ready and swallow its failure (|| echo ... >&2, exit 0), so SSO silently never registered.\n# Now it (1) waits \u2014 bounded, ~90s \u2014 for the Keycloak discovery doc to serve HTTP 200,\n# (2) registers (update-in-place if present, else create), (3) VERIFIES the source actually\n# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker\n# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays\n# usable). The verify-then-retry loop is the real guarantee against \"exit 0 but nothing stuck\".\nGITEA_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration\ngitea_oidc_fail() {\n echo \"[gitea-oidc] FAILED to register 'keycloak' login source: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1\"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true\n}\n# NOTE the `$$` escape below \u2014 it is load-bearing. apps-wave-0 declares postBuild.\n# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE\n# time, before any container runs. Written unescaped, `$${GITEA_OIDC_CLIENT_SECRET:-}` is not\n# a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z \"\" ]` \u2014 ALWAYS TRUE\n# \u2014 making every line below (discovery wait, add/update-oauth, verify-then-retry) DEAD CODE on\n# every boot since 8738871 (2026-08-27). SSO kept working only because the login source was\n# already in the DB; a PVC rebuild or fresh cluster would have come up with NO OIDC at all.\n# Flux STRICT substitution cannot catch this \u2014 it EXEMPTS default-operator forms by design.\n# `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion for the shell.\n# Bare `$VAR` and `$(cmd)` are left alone and need NO escaping \u2014 which is why the\n# `$GITEA_OIDC_CLIENT_SECRET` uses below always worked and only the guards broke.\n# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.\nif [ -z \"$${GITEA_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)\"\nelse\n # (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).\n d=0; disc_ok=0\n while [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$GITEA_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1;\ - \ break\n fi\n d=$((d + 1)); sleep 2\n done\n [ \"$disc_ok\" = 1 ] || gitea_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n # (2)+(3) Register (update-in-place or create) then VERIFY it persisted; retry the whole cycle.\n r=0; reg_ok=0\n while [ \"$r\" -lt 10 ]; do\n oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{print $1; exit}')\n if [ -n \"$oidc_id\" ]; then\n su-exec git gitea admin auth update-oauth --id \"$oidc_id\" \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n else\n su-exec git gitea admin auth add-oauth \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n fi\n # VERIFY: the \"keycloak\" source must actually be listed now (guards the exit-0-but-nothing-persisted race).\n if su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{f=1} END{exit !f}'; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\n done\n if [ \"$reg_ok\" = 1 ]; then\n echo \"[gitea-oidc] login source 'keycloak' registered and verified\"\n else\n gitea_oidc_fail \"add/update-oauth ran but the 'keycloak' source did not persist after retries\"\n fi\nfi\n# A postStart hook MUST exit 0 \u2014 a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff). This hook already ends 0 on both paths, but\n# it is made EXPLICIT so the guarantee does not depend on whatever statement happens to\n# be last. nextcloud's equivalent hook did depend on that and crashlooped on 2026-09-01,\n# the moment its OIDC guard was fixed and the block actually ran for the first time.\nexit 0\n" + - "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\nelse\n n=0\n until su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"$GITEA_ADMIN_USERNAME@home.local\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; break; }\n sleep 3\n done\n echo \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\nfi\n# --- robots.txt: ask polite crawlers to stay away entirely (defence in depth) ---\n# REQUIRE_SIGNIN_VIEW already refuses them, but every refused hit is still a redirect to\n# /user/login that Gitea has to serve. Gitea serves $GITEA_CUSTOM/public/robots.txt\n# (/data/gitea in this image), checked on EVERY request (routers/web/misc/misc.go), and\n# the route is outside the sign-in requirement. Written after the DB wait above, so the\n# entrypoint has already created and chowned /data/gitea. Rewritten each boot, so this\n# declaration is the only source of its content.\nif su-exec git mkdir -p /data/gitea/public \\\n && printf 'User-agent: *\\nDisallow: /\\n' | su-exec git tee /data/gitea/public/robots.txt >/dev/null; then\n echo \"[gitea-bootstrap] robots.txt written (Disallow: /)\"\nelse\n echo \"[gitea-bootstrap] could not write /data/gitea/public/robots.txt\" >&2\nfi\n# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---\n# Registers the confidential `gitea` client as the OAuth2 login source \"keycloak\" against the\n# homelab realm's discovery doc. The `groups` claim maps platform-admins -> Gitea admin\n# (--admin-group). Missing secret => skip (break-glass admin still works), so an unfilled\n# secret never crashes the pod.\n#\n# HARDENED (was a silent first-boot RACE): this step used to fire before the app/network was\n# ready and swallow its failure (|| echo ... >&2, exit 0), so SSO silently never registered.\n# Now it (1) waits \u2014 bounded, ~90s \u2014 for the Keycloak discovery doc to serve HTTP 200,\n# (2) registers (update-in-place if present, else create), (3) VERIFIES the source actually\n# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker\n# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays\n# usable). The verify-then-retry loop is the real guarantee against \"exit 0 but nothing stuck\".\nGITEA_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration\ngitea_oidc_fail() {\n echo \"[gitea-oidc] FAILED to register 'keycloak' login source: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1\"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true\n}\n# NOTE the `$$` escape below \u2014 it is load-bearing. apps-wave-0 declares postBuild.\n# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE\n# time, before any container runs. Written unescaped, `$${GITEA_OIDC_CLIENT_SECRET:-}` is not\n# a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z \"\" ]` \u2014 ALWAYS TRUE\n# \u2014 making every line below (discovery wait, add/update-oauth, verify-then-retry) DEAD CODE on\n# every boot since 8738871 (2026-08-27). SSO kept working only because the login source was\n# already in the DB; a PVC rebuild or fresh cluster would have come up with NO OIDC at all.\n\ + # Flux STRICT substitution cannot catch this \u2014 it EXEMPTS default-operator forms by design.\n# `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion for the shell.\n# Bare `$VAR` and `$(cmd)` are left alone and need NO escaping \u2014 which is why the\n# `$GITEA_OIDC_CLIENT_SECRET` uses below always worked and only the guards broke.\n# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.\nif [ -z \"$${GITEA_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)\"\nelse\n # (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).\n d=0; disc_ok=0\n while [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$GITEA_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1; break\n fi\n d=$((d + 1)); sleep 2\n done\n [ \"$disc_ok\" = 1 ] || gitea_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n # (2)+(3) Register (update-in-place or create) then VERIFY it persisted; retry the whole cycle.\n r=0; reg_ok=0\n while [ \"$r\" -lt 10 ]; do\n oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{print $1; exit}')\n if [ -n \"$oidc_id\" ]; then\n su-exec git gitea admin auth update-oauth --id \"$oidc_id\" \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n else\n su-exec git gitea admin auth add-oauth \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n fi\n # VERIFY: the \"keycloak\" source must actually be listed now (guards the exit-0-but-nothing-persisted race).\n if su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{f=1} END{exit !f}'; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\n done\n if [ \"$reg_ok\" = 1 ]; then\n echo \"[gitea-oidc] login source 'keycloak' registered and verified\"\n else\n gitea_oidc_fail \"add/update-oauth ran but the 'keycloak' source did not persist after retries\"\n fi\nfi\n# A postStart hook MUST exit 0 \u2014 a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff). This hook already ends 0 on both paths, but\n# it is made EXPLICIT so the guarantee does not depend on whatever statement happens to\n# be last. nextcloud's equivalent hook did depend on that and crashlooped on 2026-09-01,\n# the moment its OIDC guard was fixed and the block actually ran for the first time.\nexit 0\n" volumes: - name: data persistentVolumeClaim: diff --git a/catalog/gitea.yaml b/catalog/gitea.yaml index c6341b4..4ebaf44 100644 --- a/catalog/gitea.yaml +++ b/catalog/gitea.yaml @@ -60,7 +60,22 @@ env: # ALLOW_ONLY_INTERNAL_REGISTRATION and ENABLE_AUTO_REGISTRATION, not on DISABLE_REGISTRATION. - name: GITEA__service__DISABLE_REGISTRATION value: "true" - - name: GITEA_ADMIN_USERNAME # break-glass local admin creds (consumed by the postStart hook) + # NOTHING IS READABLE ANONYMOUSLY (operator decision 2026-11-14). Since PR #156 put gitea. + # on the public Cloudflare Tunnel, anonymous crawlers kept this pod at 2-6 cores (~24k requests in + # 90 min, mostly ///compare/... at ~13 s each). Gitea stays public, but every page, + # API call, git clone/fetch and container-registry pull now needs a signed-in user or a token. + # `true` is the STRICT mode (setting.Service.RequireSignInViewStrict): it also disables the + # anonymous git pull path (routers/web/repo/githttp.go) and the anonymous /v2/token exchange + # (routers/api/packages/container/container.go). `expensive` would only hide the costly pages and + # leave code readable, which is not what was asked for. + # Still anonymous, by upstream design (routers/web/web.go): /robots.txt, /api/healthz, /user/login + # and /user/oauth2/keycloak — so humans can still reach the Keycloak button. + # EVERY anonymous reader had to gain a credential before this could be turned on; the inventory, + # and the operator prerequisite for Flux's own source, are in + # decisions/2026-11-14-gitea-require-signin.md. Flipping this back to false is safe at any time. + - name: GITEA__service__REQUIRE_SIGNIN_VIEW + value: "true" + - name: GITEA_ADMIN_USERNAME # break-glass local admin creds (consumed by the postStart hook) valueFrom: secretKeyRef: name: gitea-bootstrap @@ -120,6 +135,19 @@ lifecycle: done echo "[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'" fi + # --- robots.txt: ask polite crawlers to stay away entirely (defence in depth) --- + # REQUIRE_SIGNIN_VIEW already refuses them, but every refused hit is still a redirect to + # /user/login that Gitea has to serve. Gitea serves $GITEA_CUSTOM/public/robots.txt + # (/data/gitea in this image), checked on EVERY request (routers/web/misc/misc.go), and + # the route is outside the sign-in requirement. Written after the DB wait above, so the + # entrypoint has already created and chowned /data/gitea. Rewritten each boot, so this + # declaration is the only source of its content. + if su-exec git mkdir -p /data/gitea/public \ + && printf 'User-agent: *\nDisallow: /\n' | su-exec git tee /data/gitea/public/robots.txt >/dev/null; then + echo "[gitea-bootstrap] robots.txt written (Disallow: /)" + else + echo "[gitea-bootstrap] could not write /data/gitea/public/robots.txt" >&2 + fi # --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) --- # Registers the confidential `gitea` client as the OAuth2 login source "keycloak" against the # homelab realm's discovery doc. The `groups` claim maps platform-admins -> Gitea admin diff --git a/clusters/prod/kustomization.yaml b/clusters/prod/kustomization.yaml index 5eb3e65..dfaa233 100644 --- a/clusters/prod/kustomization.yaml +++ b/clusters/prod/kustomization.yaml @@ -242,6 +242,10 @@ patches: # git ls-remote http://192.168.2.132:3000/Home-Network/home-network-k3s-impl.git -> refs/heads/main # If the GitRepository ever reports an auth failure that looks like a network problem, RE-CHECK # THAT FIRST: a private repo here fails exactly that way. + # SUPERSEDED FOR PROD'S OWN GITEA (2026-11-14): the patch below now carries a secretRef, because + # prod's Gitea requires sign-in. If prod is ever re-bootstrapped from the workstation container, + # the `flux-gitea-read` token must be valid THERE too (Gitea rejects a bad credential even on a + # public repo), or drop the secretRef for the bring-up window. # # *** CUTOVER — the exit condition, mirroring dev's Phase-6 cutover (docs/05) *** # WHEN: Gitea runs on prod (reconciled in-cluster) and answers gitea.home.keithjackson.dev @@ -317,12 +321,28 @@ patches: # domain change cannot sever prod from its own source again. # # Plain http is deliberate and safe here: it is cluster-internal traffic to a ClusterIP, - # TLS terminates at the Gateway for external clients only, the GitRepository has no - # secretRef (the repo is cloned anonymously), and every secret in the tree is SOPS-encrypted - # at rest regardless of transport. + # TLS terminates at the Gateway for external clients only, and every secret in the tree is + # SOPS-encrypted at rest regardless of transport. The read token below crosses that same + # pod-to-ClusterIP hop in a basic-auth header; it is read-only by scope. url: http://gitea.gitea.svc.cluster.local:3000/Home-Network/home-network-k3s-impl.git ref: branch: main + # AUTHENTICATED SINCE 2026-11-14 — ANONYMOUS CLONE NO LONGER EXISTS. Gitea now runs with + # [service] REQUIRE_SIGNIN_VIEW=true (catalog/gitea.yaml), whose strict mode refuses the + # anonymous git pull path outright, public repo or not. Without this secretRef prod cannot + # fetch ANY new revision — including the one that would undo this. + # + # The Secret is OUT-OF-BAND, like `sops-age` beside it, and it has to be: it is the + # credential that lets Flux read git at all, so it cannot be delivered BY git (on a fresh + # cluster nothing could decrypt or apply it before the first fetch), and the root + # Kustomization that applies this file has no SOPS decryption anyway. + # keys: username (any value; Gitea resolves the account from the token) + # password (a Gitea access token with scope read:repository ONLY) + # It MUST exist before this line reaches the cluster: a missing Secret stalls the source + # with "secret not found", and the fix is then exactly to create it. Operator steps: + # decisions/2026-11-14-gitea-require-signin.md. + secretRef: + name: flux-gitea-read - target: kind: ConfigMap name: cluster-vars diff --git a/decisions/2026-11-14-gitea-require-signin.md b/decisions/2026-11-14-gitea-require-signin.md new file mode 100644 index 0000000..fbdd101 --- /dev/null +++ b/decisions/2026-11-14-gitea-require-signin.md @@ -0,0 +1,161 @@ +--- +date: 2026-11-14 +branch: feat/gitea-require-signin +title: Gitea requires sign-in to read anything; Flux and CI authenticate first +--- + +## What changed + +Operator decision (2026-11-14): Gitea stays on the public internet, but reading anything needs a +login. Since PR #156 put `gitea.` on the Cloudflare Tunnel wildcard, anonymous crawlers had +kept the Gitea pod at 2–6 cores. The traffic was ~24k requests in 90 minutes from two IPs, mostly +`///compare/...?style=split` at ~13 s each. + +- **`catalog/gitea.yaml` → `apps/wave-0/gitea.yaml`** (re-rendered with the canonical command; only + `gitea.yaml` differs from the committed tree): + - `GITEA__service__REQUIRE_SIGNIN_VIEW=true`. This is the strict mode + (`RequireSignInViewStrict`). It refuses anonymous web pages, API calls, smart-HTTP git pulls + (`routers/web/repo/githttp.go`) and the anonymous container-registry `/v2/token` exchange + (`routers/api/packages/container/container.go`). `expensive` was not chosen because it leaves + code readable. + - The postStart hook writes `/data/gitea/public/robots.txt` (`Disallow: /`) on every boot. Gitea + checks for that file on every request, and `/robots.txt` is routed outside the sign-in + requirement. + - The login path is unchanged. Keycloak was already the native OIDC source `keycloak`, registered + by the same hook with auto-registration on. Local registration was already disabled. The local + break-glass admin stays. OpenID 2.0 sign-in and sign-up are already off by upstream default. + Basic auth stays at its default (on); disabling it would only stop password basic auth, not + tokens. +- **`clusters/prod/kustomization.yaml`**: the `flux-system` GitRepository gains + `secretRef: flux-gitea-read`. This Secret is created out-of-band, like `sops-age`. It cannot come + from git: it is the credential Flux needs to read git at all, and the root Kustomization has no + SOPS decryption. +- **`.gitea/workflows/gate.yml`, `.gitea/workflows/cve-triage.yaml`**: the job container + (`gitea.../home-network/toolbox@sha256:…`) was pulled anonymously. It now declares + `container.credentials` with the existing `RENOVATE_TOKEN`. renovate.yaml's preflight proves + that token's `read:package` scope on every run, and cve-triage already holds it. act fails the job + if either field interpolates empty, so a missing secret shows up as red rather than as a silent + fallback. + +### Anonymous-reader inventory (what would break, and how each is handled) + +Live state was measured read-only on 2026-11-14. Anonymous `GET /api/v1/repos/...impl` returned +200, as did anonymous `/v2/token` for toolbox. `/robots.txt` returned 404. Both repos +(`home-network-k3s`, `-impl`) are public. + +| Reader | How it reads today | Handling | +|---|---|---| +| Flux `GitRepository flux-system` | **anonymous** `http://gitea.gitea.svc.cluster.local:3000/...` (live: no `secretRef`; `flux-system` holds only `sops-age`) | `secretRef: flux-gitea-read`. **Needs a NEW token: OPERATOR PREREQUISITE, see below** | +| gate.yml job container (toolbox) | **anonymous** registry pull by the runner's DinD | `credentials:` with existing `RENOVATE_TOKEN` (this PR) | +| cve-triage.yaml job container (toolbox) | **anonymous** registry pull | same (this PR) | +| `actions/checkout` / gate's git fetch | Actions job token for its own repo | unaffected (signed-in actions user, same repo) | +| merge-gate.yaml cross-repo spec checkout | Actions job token, `vars.SPEC_REPO_SLUG` | dormant (`workflow_dispatch` only, slug unwired). Whether the job token can read *another* repo under strict mode is UNVERIFIED | +| images.yaml | PR: builds toolbox in-run (no pull). main: `REGISTRY_PUSH_TOKEN` in buildx auth and `/v2/token` | unaffected | +| Renovate | `RENOVATE_TOKEN` for API, git and docker hostRules | unaffected | +| pm-agent dispatcher and workers | `GITEA_TOKEN` (API, GIT_ASKPASS clone); image via `imagePullSecrets: pm-agent-registry` | unaffected | +| vuln-scanner | does not pull the Gitea registry: `VULN_PRIVATE_REGISTRIES=gitea.${BASE_DOMAIN}` short-circuits to `credential-required` | unaffected | +| act_runner | registration and runner token | unaffected; `DEFAULT_ACTIONS_URL=github`, so no action is fetched from Gitea | +| kubelet/containerd image pulls | live: the only Gitea-registry image is pm-agent's, which has a pull secret; no `registries.yaml` in infra/ansible | unaffected | +| Flux image-reflector/automation | live: zero ImageRepository/ImagePolicy/ImageUpdateAutomation objects | n/a | +| conformance in-cluster clone (`conformance/incluster/conformance-clone`) | anonymous unless `conformance-git-readonly` is set | dormant (L2/L3 not wired). **When it is wired, the read-only Secret is now mandatory, not optional** | +| ci/gitea-settings.sh, ci/approval-policy.sh, infra/gitea/*.sh | `SETTINGS_API_TOKEN` and other tokens | unaffected | +| Homepage | a plain `href`, no widget API call | unaffected (the browser hits the login page) | +| Prometheus / probes | Gitea has no probes and no ServiceMonitor; `[metrics]` is not enabled | unaffected | +| Operator and agent workstations | git credential helper (both repos) | unaffected | +| Public spec docs linking to the impl repo | anonymous browsers | now land on the login page (see spec conflict below) | + +### OPERATOR STEPS — in this order, BEFORE the merge + +1. Mint a token with **scope `read:repository` only**. Use a dedicated non-admin local account + (e.g. `flux-reader`, no org membership needed: with sign-in required, any signed-in user can + read a public repo). The existing non-admin `renovate-bot` also works. Do not use an admin + account. +2. Create the Secret out-of-band, without the token reaching shell history or a transcript: + + read -rs TOKEN; export TOKEN + ./infra/devcontainer/dev.sh run bash -c "kubectl --context k3s-prod -n flux-system \ + create secret generic flux-gitea-read --from-literal=username=flux-reader \ + --from-literal=password=$TOKEN" + + (`$TOKEN` expands on the host into the container's argv, which is briefly visible in `ps`. If + that matters, use `--from-file` on a 0600 file and delete the file.) +3. Prove it before merging, in the same shell, then `unset TOKEN`. First check that the token can clone. This command lists `refs/heads/main`, and the token stays + out of argv: + + git -c credential.helper='!f(){ echo username=x; echo "password=$TOKEN"; };f' \ + ls-remote https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git main + + Then check that the Secret has the right shape. This prints key names only and should show + `password username`: + + kubectl --context k3s-prod -n flux-system get secret flux-gitea-read \ + -o go-template='{{range $k,$v := .data}}{{$k}} {{end}}' +4. Post `/approve-override ` as a PR review. This PR touches sensitive paths (`clusters/**`, + `.gitea/**`); the author must not post it. +5. Merge. Then confirm `kubectl -n flux-system get gitrepository flux-system` is Ready on the merge + SHA, and that anonymous `curl https://gitea.home.keithjackson.dev/api/v1/repos/Home-Network/home-network-k3s-impl` + now returns 401/403. Also check that `/robots.txt` returns 200. + +**If step 2 is skipped**, the source goes `False` with "secret not found" right after the merge and +prod stops taking new revisions. That includes any revert. Recovery is the same as step 2: create +the Secret. Nothing else needs undoing. Between steps 2 and 5 the token is harmless: an +authenticated read of a public repo works exactly as the anonymous one did. + +### Spec conflicts (for the architect; the spec repo was not modified) + +- **ADR-0027** rejects a CVE-findings issue *because "the repository is publicly readable and the + list would be an attack map"*. That premise is now false for anonymous readers. Any signed-in + Keycloak user can still read it (see the next point), so the rejection arguably still stands, but + the stated reason needs updating. +- **docs/15, R-IDP-06** ("each gate … enforces the service's `auth.groups`"): Gitea's catalog + declares `auth.groups: [platform-admins]`, but the `keycloak` auth source only maps that group to + *admin*. It does not *require* the group. With sign-in required, "read" therefore means "any user + in the homelab realm", which matches what the operator asked for ("a Keycloak login"). Restricting + it further needs `--required-claim-name groups --required-claim-value platform-admins` on the auth + source. That is not done here, because it could lock out non-admin realm users who use Gitea today. + It is an architect/operator call. +- **docs/05 and docs/09** link to the impl repo as a public URL; those links now land on a login + page. + +### Sensitive paths + +`clusters/prod/kustomization.yaml` (`clusters/**`) and `.gitea/workflows/*` (`.gitea/**`) are in +`infra/gitea/approval-policy.json`, so CI's approval-policy stage blocks until the operator posts the +override. + +## What I verified + +- Toolbox gate run from the worktree on the committed tree: **rc 3 (UNDETERMINED, not green)**. The + gate printed "Stages that did not run: gitea-settings". That stage needs the CI-only + `SETTINGS_API_TOKEN`. `spec-immutability` passed with the sibling spec repo mounted. `lint`, `l0`, + `render-golden`, `l1` and `decisions-log` passed. Conformance L2/L3 SKIPPED (no cluster). It + lists R-IDP-02/06, R-GITOPS-01/02 and others as UNVERIFIED. `approval-policy` is N/A offline and + will block in CI until the override is posted. +- The canonical render (`render.py --all catalog -o --wave-layout --no-image-automation`) differs + from the committed `apps/` in `wave-0/gitea.yaml` only, and only by the new env var and the hook + text. +- Live, read-only: the GitRepository has no secretRef, and `flux-system` has only `sops-age`. Zero + image-automation objects exist. The only pod pulling from the Gitea registry is pm-agent, and it + has a pull secret. Anonymous API and `/v2/token` both return 200 today, and `/robots.txt` returns + 404. +- From upstream Gitea source on `main` (not the 28.3.0 tag): strict mode gates githttp anonymous + pull, `ReqContainerAccess` and `/v2/token` `Authenticate`. `/robots.txt`, `/api/healthz`, + `/user/login` and `/user/oauth2/{provider}` are registered outside sign-in. robots.txt is read + from `CustomPath/public/robots.txt` on each request. + +## What I did NOT verify + +- **Nothing is deployed.** No live Gitea has run with `REQUIRE_SIGNIN_VIEW=true`, and none of the + claims above has been observed at runtime. +- The upstream code reading is of `main`, not the 28.3.0 tag that runs here. +- That `container.credentials` actually authenticates on this act_runner (0.6.1). The PR's own CI run + proves the syntax is accepted and the secret interpolates. While the image is cached in DinD + (`force_pull` false), no pull happens, so the auth path is only exercised on a cold runner. +- That `RENOVATE_TOKEN` is currently valid. Its last renovate.yaml preflight was not inspected. +- That the Flux token works. It does not exist yet; that is an operator step. +- Whether an Actions job token can read a *different* repo under strict mode. It only matters for the + dormant merge-gate.yaml spec checkout. +- That the robots.txt write succeeds (busybox `tee` via `su-exec git` into `/data/gitea/public`). + The hook logs either outcome and cannot crash the pod. +- Crawler load actually dropping after the change. Compare the pod's CPU on k3s-prod-2 before and + after.