Files
home-network-k3s-impl/decisions/2026-11-14-gitea-require-signin.md
T
jakman85andClaude Opus 5.5 b855b6b294
gate / gate (pull_request) Failing after 59s
gate / conformance (pull_request) Skipped
feat(gitea): require sign-in to read anything; Flux and CI authenticate first
Gitea stays public on the Cloudflare Tunnel, but anonymous crawlers have kept
the pod at 2-6 cores since PR #156. Operator decision 2026-11-14: reading
anything requires a (Keycloak) login.

- catalog/gitea.yaml (+ re-rendered apps/wave-0/gitea.yaml):
  GITEA__service__REQUIRE_SIGNIN_VIEW=true (strict: web, API, git pull,
  registry /v2/token), and a postStart-written robots.txt (Disallow: /).
- clusters/prod: flux-system GitRepository gets secretRef flux-gitea-read,
  an OUT-OF-BAND read:repository token the operator must create BEFORE merge.
- gate.yml / cve-triage.yaml: the toolbox job image was pulled anonymously
  from the Gitea registry; it now uses container.credentials with the
  existing RENOVATE_TOKEN (read:package, proven by renovate.yaml preflight).

Anonymous-reader inventory and operator steps:
decisions/2026-11-14-gitea-require-signin.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 14:16:50 -06:00

11 KiB
Raw Blame History

date, branch, title
date branch title
2026-11-14 feat/gitea-require-signin 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.<domain> 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 /<owner>/<repo>/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 <reason> 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 <tmp> --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.