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]>
11 KiB
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; onlygitea.yamldiffers 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/tokenexchange (routers/api/packages/container/container.go).expensivewas 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.txtis 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: theflux-systemGitRepository gainssecretRef: flux-gitea-read. This Secret is created out-of-band, likesops-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 declarescontainer.credentialswith the existingRENOVATE_TOKEN. renovate.yaml's preflight proves that token'sread:packagescope 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
-
Mint a token with scope
read:repositoryonly. 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-adminrenovate-botalso works. Do not use an admin account. -
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"(
$TOKENexpands on the host into the container's argv, which is briefly visible inps. If that matters, use--from-fileon a 0600 file and delete the file.) -
Prove it before merging, in the same shell, then
unset TOKEN. First check that the token can clone. This command listsrefs/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 mainThen 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}}' -
Post
/approve-override <reason>as a PR review. This PR touches sensitive paths (clusters/**,.gitea/**); the author must not post it. -
Merge. Then confirm
kubectl -n flux-system get gitrepository flux-systemis Ready on the merge SHA, and that anonymouscurl https://gitea.home.keithjackson.dev/api/v1/repos/Home-Network/home-network-k3s-implnow returns 401/403. Also check that/robots.txtreturns 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 declaresauth.groups: [platform-admins], but thekeycloakauth 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-adminson 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-immutabilitypassed with the sibling spec repo mounted.lint,l0,render-golden,l1anddecisions-logpassed. Conformance L2/L3 SKIPPED (no cluster). It lists R-IDP-02/06, R-GITOPS-01/02 and others as UNVERIFIED.approval-policyis 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 committedapps/inwave-0/gitea.yamlonly, and only by the new env var and the hook text. - Live, read-only: the GitRepository has no secretRef, and
flux-systemhas onlysops-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/tokenboth return 200 today, and/robots.txtreturns 404. - From upstream Gitea source on
main(not the 28.3.0 tag): strict mode gates githttp anonymous pull,ReqContainerAccessand/v2/tokenAuthenticate./robots.txt,/api/healthz,/user/loginand/user/oauth2/{provider}are registered outside sign-in. robots.txt is read fromCustomPath/public/robots.txton 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.credentialsactually 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_pullfalse), no pull happens, so the auth path is only exercised on a cold runner. - That
RENOVATE_TOKENis 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
teeviasu-exec gitinto/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.