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]>
This commit is contained in:
1 parent
93c4994bf1
commit
b855b6b294
6 files changed
+238
-12
No files matched your search
@@ -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.<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.
|
||||
Reference in new issue
Block a user