commit c0382d5d3130933b59dc1f8922ee0b24d85a84f2 Author: JMR-dev Date: Tue Aug 18 21:45:33 2026 -0500 Gitea on GCE: podman quadlets, Pulumi, Cloud Build Self-hosted Gitea on a single e2-small AlmaLinux 10 VM in us-east1, serving gitea.jasonmross.dev. Runtime is podman quadlets (systemd .container/.network/.volume units). Both images are built on Debian 13: Gitea from a GPG-verified release binary, and Caddy from an xcaddy build carrying the Google Cloud DNS provider (ACME DNS-01) and the Coraza WAF with the OWASP CRS embedded. Infrastructure is a Pulumi program in Go against a GCS state backend. Cloud Build handles CI: a push trigger for images, one for infra, and a weekly scheduled rebuild. Everything Cloud Build touches is 2nd gen. Notable design decisions, each documented where it lives: - Quadlets track a floating :prod tag. AutoUpdate=registry compares digests for a tag, so a digest-pinned image silently disables auto-updates. - Git transport and LFS bypass the WAF. With the bypass removed, a plain git push returns 403 -- packfiles trip CRS reliably. - gitea:wafMode drives both SecRuleEngine and whether the fail2ban jail acting on WAF verdicts exists. Banning on detections that were never blocks would turn a tuning false positive into an nftables ban. - fail2ban bans at the nftables prerouting hook. Published container ports are DNAT'd and never traverse INPUT, where the stock actions install their rules. - The DNS zone, backup bucket, and Gitea signing secrets are not Pulumi-owned, so pulumi destroy cannot take them with it. - The podman subnet is pinned because it is what Gitea's REVERSE_PROXY_TRUSTED_PROXIES names. Three update layers: dnf5-automatic for the OS, podman-auto-update with health-gated rollback for containers, and a weekly image rebuild that gives the second layer something to pull. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59638fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Compiled Pulumi program (Pulumi.yaml points the go runtime at this binary) +/infra/gitea-infra + +# Pulumi local state / plugin cache +.pulumi/ + +# Never commit a rendered app.ini -- it contains SECRET_KEY and INTERNAL_TOKEN +**/app.ini +!vm/config/app.ini.tmpl + +*.swp +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9b7ecef --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +# Convenience wrappers. Everything here is also runnable by hand; nothing in the +# deployment depends on make. + +PROJECT ?= $(shell cd infra && pulumi config get gcp:project 2>/dev/null) +REGION ?= $(shell cd infra && pulumi config get gcp:region 2>/dev/null || echo us-east1) +ZONE ?= $(shell cd infra && pulumi config get gitea:zone 2>/dev/null || echo $(REGION)-a) +VM ?= gitea-vm + +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +.PHONY: bootstrap +bootstrap: ## One-time project setup (run before the first `make up`) + scripts/bootstrap.sh $(PROJECT) $(REGION) + +.PHONY: fmt +fmt: ## Format Go sources + cd infra && gofmt -w . + +.PHONY: check +check: ## Build and vet the Pulumi program, and syntax-check the shell scripts + cd infra && go build ./... && go vet ./... + bash -n vm/bootstrap.sh scripts/bootstrap.sh + @command -v shellcheck >/dev/null && shellcheck -S warning vm/bootstrap.sh scripts/bootstrap.sh || echo "shellcheck not installed -- skipped" + +.PHONY: preview +preview: check ## Show what `pulumi up` would change + cd infra && pulumi preview + +.PHONY: up +up: check ## Apply the infrastructure + cd infra && pulumi up + +# --region: the triggers are 2nd-gen and therefore regional. Without this flag +# `builds submit` runs in the global region -- a different worker pool from +# every automated build. +# --service-account: the build's last step reaches the VM over an IAP tunnel, +# needing iap.tunnelResourceAccessor + compute.osAdminLogin. Those are granted +# to cb-image@, not to whatever default Cloud Build account this project +# happens to have -- and on newer projects the legacy default does not exist. +# Without this the images push fine and the rollout step fails. +.PHONY: build +build: ## Build and roll out the container images via Cloud Build + gcloud builds submit --config cloudbuild/image.yaml --project $(PROJECT) \ + --region=$(REGION) \ + --service-account=projects/$(PROJECT)/serviceAccounts/cb-image@$(PROJECT).iam.gserviceaccount.com \ + --substitutions=_REGION=$(REGION),_ZONE=$(ZONE) + +.PHONY: rollout +rollout: ## Pull the latest :prod images onto the VM right now + gcloud compute ssh $(VM) --zone=$(ZONE) --tunnel-through-iap --project=$(PROJECT) \ + --command 'sudo systemctl start podman-auto-update.service && sudo podman ps' + +.PHONY: sync +sync: ## Re-render VM config from the bucket and restart what changed + gcloud compute ssh $(VM) --zone=$(ZONE) --tunnel-through-iap --project=$(PROJECT) \ + --command 'sudo systemctl start gitea-config-sync.service && sudo journalctl -u gitea-config-sync -n 40 --no-pager' + +.PHONY: ssh +ssh: ## Shell on the VM through IAP + gcloud compute ssh $(VM) --zone=$(ZONE) --tunnel-through-iap --project=$(PROJECT) + +.PHONY: logs +logs: ## Tail Gitea and Caddy logs + gcloud compute ssh $(VM) --zone=$(ZONE) --tunnel-through-iap --project=$(PROJECT) \ + --command 'sudo journalctl -u gitea -u caddy -f' + +.PHONY: status +status: ## Health summary from the VM + gcloud compute ssh $(VM) --zone=$(ZONE) --tunnel-through-iap --project=$(PROJECT) \ + --command 'sudo systemctl status --no-pager gitea caddy nftables fail2ban; sudo podman ps; sudo systemctl list-timers --no-pager' + +.PHONY: backup +backup: ## Take an on-demand Gitea dump to the backup bucket + gcloud compute ssh $(VM) --zone=$(ZONE) --tunnel-through-iap --project=$(PROJECT) \ + --command 'sudo systemctl start gitea-backup.service && sudo journalctl -u gitea-backup -n 20 --no-pager' diff --git a/README.md b/README.md new file mode 100644 index 0000000..161b3bd --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Gitea on GCE — podman quadlets, Pulumi, Cloud Build + +A self-hosted [Gitea](https://gitea.com) instance on a single Google Compute +Engine VM, served at `gitea.jasonmross.dev`. + +| Layer | Choice | +|---|---| +| Host | AlmaLinux 10 (`almalinux-cloud/almalinux-10`), Shielded VM | +| Size | `e2-small` (2 shared vCPU, 2 GB RAM) in `us-east1`, 20 GB boot + 30 GB pd-balanced data | +| Runtime | Podman **quadlets** — systemd `.container`/`.network`/`.volume` units, not compose | +| Images | Gitea and Caddy, both built on **Debian 13 (trixie)** | +| TLS | Caddy with ACME **DNS-01** via Google Cloud DNS (custom `xcaddy` build) | +| WAF | **Coraza** + OWASP CRS compiled into Caddy, feeding fail2ban → nftables | +| Database | SQLite in WAL mode on a dedicated persistent disk | +| Firewall | nftables + fail2ban on the host, VPC firewall outside it | +| IaC | Pulumi, Go, self-managed GCS state backend | +| CI/CD | Cloud Build — push triggers plus a weekly rebuild | + +## Layout + +``` +infra/ Pulumi program (Go). One package per slice of infrastructure. +image/ Dockerfiles for the Gitea and Caddy images, plus pinned versions. +vm/ Everything that lands on the VM: quadlets, systemd units, + nftables, fail2ban, config templates, and bootstrap.sh. +cloudbuild/ The two build pipelines. +scripts/ One-time project bootstrap. +docs/ Runbook, WAF tuning guide, migration-to-Gitea-SCM plan. +``` + +`vm/` is uploaded to a GCS bucket by Pulumi and pulled down by the instance, so +changing a quadlet is a normal pull request. + +## First-time setup + +Pulumi cannot create the bucket holding its own state, the identity that runs +it, or Gitea's signing secrets — so there is one manual step first. + +```bash +# 1. Project bootstrap: APIs, state bucket, Pulumi runner SA, Gitea secrets. +scripts/bootstrap.sh us-east1 + +# 2. Install the Cloud Build GitHub App on this repository, note the +# installation id, and store a PAT (repo + read:user scope): +printf %s '' | gcloud secrets versions add github-pat --data-file=- --project + +# 3. Confirm the Cloud DNS zone is authoritative. DNS-01 cannot work otherwise. +dig NS gitea.jasonmross.dev + +# 4. Configure and apply. +cd infra +pulumi login gs://-pulumi-state +pulumi stack init prod +pulumi config set gcp:project +pulumi config set gitea:domain gitea.jasonmross.dev +pulumi config set gitea:dnsZone # gcloud dns managed-zones list +pulumi config set gitea:acmeEmail you@example.com +pulumi config set gitea:githubOwner +pulumi config set gitea:githubAppInstallationId +pulumi config set gitea:infraBuildServiceAccount cb-infra@.iam.gserviceaccount.com +# WAF starts in DetectionOnly. Tune, then switch to On -- see docs/waf.md. +pulumi config set gitea:wafMode DetectionOnly +pulumi up + +# 5. First image build. Until this runs, the :prod images do not exist. +cd .. && make build + +# 6. Create the admin user. +make ssh +sudo podman exec -u 1000 gitea gitea admin user create \ + -c /etc/gitea/app.ini --admin --username --email --random-password +``` + +### Expected on the first run, not a bug + +Between step 4 and step 5 the `:prod` images do not exist yet, so `gitea.service` +and `caddy.service` crash-loop. That is intentional: the units carry +`Restart=always` with `StartLimitIntervalSec=0`, so they recover on their own +within 30 seconds of the first successful push. Likewise, `app.ini` is not +rendered until Gitea's secrets are readable — `bootstrap.sh` skips rendering +rather than writing a config with empty signing keys. + +## Day-to-day + +```bash +make status # services, containers, timers +make logs # tail gitea + caddy +make rollout # pull the latest :prod images now +make sync # re-render VM config after a vm/ change +make backup # on-demand gitea dump to GCS +make ssh # shell via IAP +``` + +A push to `main` under `image/**` builds, pushes, rolls out, and gates on +`/api/healthz`. A push under `infra/**` or `vm/**` runs `pulumi up` and then +re-syncs the VM configuration. Anything else does nothing. + +## How updates happen + +Three independent layers, because no single one covers everything: + +1. **OS packages** — `dnf5-automatic` applies updates nightly. It never reboots; + `gitea-reboot-window.timer` does that weekly, and only when + `needs-restarting -r` says a reboot is genuinely required. +2. **Container images** — `podman-auto-update.timer` polls the `:prod` tag daily. + `Notify=healthy` on the quadlets means systemd withholds "started" until the + healthcheck passes, which is what arms podman's automatic rollback. +3. **Image contents** — a Cloud Scheduler job re-runs the image build every + Sunday, rebuilding from a floating `debian:13-slim` so base-OS and Go + security fixes reach the running containers. Without this, `:prod` never + changes and layer 2 has nothing to pull. + +Bumping the Gitea or Caddy version itself stays a deliberate change to +`image/gitea.version` / `image/caddy.version`. + +> **Fire the weekly rebuild once by hand after the first deploy.** A broken +> scheduler request fails silently at 04:00 on a Sunday and stops layer 3 +> from feeding layer 2. See *The weekly rebuild* in +> [docs/runbook.md](docs/runbook.md). + +## Things worth knowing before you change something + +- **The `:prod` tag is load-bearing.** `AutoUpdate=registry` compares digests + *for a tag*. Pinning a digest in the quadlet silently disables auto-updates. +- **`app.ini` is fully managed** and `INSTALL_LOCK=true`. Gitea settings changed + in the web UI that map to `app.ini` will not survive a config sync. Edit + `vm/config/app.ini.tmpl` instead. +- **The podman subnet is pinned** (`10.89.10.0/24`). It is what + `REVERSE_PROXY_TRUSTED_PROXIES` names; an unpinned subnet would silently make + fail2ban ban Caddy instead of the attacker. +- **Never put `flush ruleset` in the nftables config.** It would wipe netavark's + rules and break all container networking on reload. +- **The DNS zone, the backup bucket, and the Gitea secrets are not Pulumi-owned** + by design, so `pulumi destroy` cannot take them with it. +- **Git and LFS deliberately bypass the WAF.** Remove that bypass and `git push` + returns 403 — verified, not theoretical. See [docs/waf.md](docs/waf.md). +- **`image/caddy.version` and `image/coraza.version` are coupled.** coraza-caddy + pins a minimum Caddy version; bump them together or the build fails. +- **`us-east1` has no `-a` zone** (it is b/c/d). The stack pins `us-east1-b`. +- **2 GB of RAM is the real constraint**, not disk or CPU. A 2 GB swap file is + provisioned as ballast; sustained swap use means move to `e2-medium`. Measured + numbers are in [docs/runbook.md](docs/runbook.md). + +See [docs/runbook.md](docs/runbook.md) for verification drills, restores, and +rollbacks, and [docs/waf.md](docs/waf.md) for WAF tuning and the +`DetectionOnly` → `On` rollout. diff --git a/cloudbuild/image.yaml b/cloudbuild/image.yaml new file mode 100644 index 0000000..19369c3 --- /dev/null +++ b/cloudbuild/image.yaml @@ -0,0 +1,128 @@ +# Build the Gitea and Caddy images, push them, and roll them out. +# +# The rollout works by kicking podman-auto-update on the VM. That is why the +# quadlets track a floating :prod tag rather than a digest: AutoUpdate=registry +# compares the local digest against the registry's digest FOR A TAG, so a +# digest-pinned image would give it nothing to poll. +# +# The :$SHORT_SHA and version tags are the audit trail and the rollback targets. + +substitutions: + _REGION: us-east1 + _ZONE: us-east1-b + _DOMAIN: gitea.jasonmross.dev + _REPO: gitea + _VM: gitea-vm + +options: + # A user-specified service account cannot write to the legacy default log + # bucket. Without this the very first build fails on storage.objects.create. + logging: CLOUD_LOGGING_ONLY + machineType: E2_HIGHCPU_8 + +timeout: 2400s + +steps: + - id: read-versions + name: bash + script: | + #!/usr/bin/env bash + set -euo pipefail + mkdir -p /workspace/vars + tr -d '[:space:]' < image/gitea.version > /workspace/vars/gitea_version + tr -d '[:space:]' < image/caddy.version > /workspace/vars/caddy_version + tr -d '[:space:]' < image/coraza.version > /workspace/vars/coraza_version + echo "gitea=$(cat /workspace/vars/gitea_version) caddy=$(cat /workspace/vars/caddy_version) coraza=$(cat /workspace/vars/coraza_version)" + + - id: build-gitea + name: gcr.io/cloud-builders/docker + entrypoint: bash + args: + - -c + - | + set -euo pipefail + V=$(cat /workspace/vars/gitea_version) + IMG="${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/gitea" + docker build \ + --build-arg "GITEA_VERSION=$${V}" \ + --tag "$${IMG}:prod" \ + --tag "$${IMG}:$SHORT_SHA" \ + --tag "$${IMG}:$${V}" \ + image/gitea + + - id: build-caddy + name: gcr.io/cloud-builders/docker + entrypoint: bash + # xcaddy runs inside the Dockerfile's golang builder stage, so the plain + # docker builder is all this step needs -- no Go toolchain out here. + args: + - -c + - | + set -euo pipefail + V=$(cat /workspace/vars/caddy_version) + C=$(cat /workspace/vars/coraza_version) + IMG="${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/caddy" + # The Dockerfile asserts both the DNS and WAF modules are present, so a + # dropped --with fails the build rather than shipping an unprotected + # server. Caddy and coraza-caddy versions are coupled: coraza-caddy + # pins a minimum Caddy, and a mismatch fails here at `go get`. + docker build \ + --build-arg "CADDY_VERSION=$${V}" \ + --build-arg "CORAZA_VERSION=$${C}" \ + --tag "$${IMG}:prod" \ + --tag "$${IMG}:$SHORT_SHA" \ + --tag "$${IMG}:$${V}" \ + image/caddy + + - id: push + name: gcr.io/cloud-builders/docker + entrypoint: bash + args: + - -c + - | + set -euo pipefail + BASE="${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}" + docker push --all-tags "$${BASE}/gitea" + docker push --all-tags "$${BASE}/caddy" + # Record the digests actually published. Pulumi does not pin these, so + # the build log is where you look to find a rollback target. + docker image inspect "$${BASE}/gitea:prod" --format '{{index .RepoDigests 0}}' + docker image inspect "$${BASE}/caddy:prod" --format '{{index .RepoDigests 0}}' + + - id: rollout + name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim + entrypoint: bash + env: + # gcloud needs a writable HOME to generate the ephemeral SSH key it pushes + # through OS Login. The default HOME in this image is not writable. + - HOME=/workspace + args: + - -c + - | + set -euo pipefail + gcloud compute ssh "${_VM}" \ + --zone="${_ZONE}" \ + --tunnel-through-iap \ + --quiet \ + --command 'sudo systemctl start podman-auto-update.service' + + - id: verify + name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim + entrypoint: bash + # A build that pushes a broken image and reports success is worse than a + # failed build. Gate on the app actually answering. + args: + - -c + - | + set -euo pipefail + for i in $(seq 1 30); do + if curl -fsS --max-time 10 "https://${_DOMAIN}/api/healthz" >/dev/null; then + echo "healthz passed after $${i} attempt(s)" + exit 0 + fi + echo "waiting for https://${_DOMAIN}/api/healthz ($${i}/30)" + sleep 10 + done + echo "healthz never passed -- check 'journalctl -u podman-auto-update' on the VM;" >&2 + echo "podman should have rolled back automatically if the new image failed to start." >&2 + exit 1 diff --git a/cloudbuild/infra.yaml b/cloudbuild/infra.yaml new file mode 100644 index 0000000..1e4470d --- /dev/null +++ b/cloudbuild/infra.yaml @@ -0,0 +1,64 @@ +# Run Pulumi, then push the refreshed VM configuration onto the instance. +# +# Pulumi uploads the vm/ tree as bucket objects; the last step is what makes the +# VM actually pick them up, instead of waiting for the next reboot. + +substitutions: + _REGION: us-east1 + _ZONE: us-east1-b + _VM: gitea-vm + _STACK: prod + +options: + logging: CLOUD_LOGGING_ONLY + +timeout: 1800s + +availableSecrets: + secretManager: + - versionName: projects/$PROJECT_ID/secrets/pulumi-config-passphrase/versions/latest + env: PULUMI_CONFIG_PASSPHRASE + +steps: + - id: pulumi + name: pulumi/pulumi-go:latest + dir: infra + entrypoint: bash + secretEnv: [PULUMI_CONFIG_PASSPHRASE] + args: + - -c + - | + set -euo pipefail + # Self-managed GCS backend: no external SaaS dependency, and the state + # bucket is versioned so history is recoverable. + pulumi login "gs://$PROJECT_ID-pulumi-state" + pulumi stack select "${_STACK}" + + # Google credentials come from the build's metadata server; the GCS + # backend and the gcp provider both pick them up automatically. + if [ "$BRANCH_NAME" = "main" ]; then + pulumi up --yes --non-interactive + else + echo "branch $BRANCH_NAME is not main -- preview only" + pulumi preview --non-interactive + fi + + - id: config-sync + name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim + entrypoint: bash + env: + - HOME=/workspace + args: + - -c + - | + set -euo pipefail + if [ "$BRANCH_NAME" != "main" ]; then + echo "preview build -- skipping VM config sync" + exit 0 + fi + # Re-render templates and restart only what actually changed. + gcloud compute ssh "${_VM}" \ + --zone="${_ZONE}" \ + --tunnel-through-iap \ + --quiet \ + --command 'sudo systemctl start gitea-config-sync.service && sudo systemctl status --no-pager gitea-config-sync.service' diff --git a/docs/migrate-to-gitea-scm.md b/docs/migrate-to-gitea-scm.md new file mode 100644 index 0000000..6ebccaa --- /dev/null +++ b/docs/migrate-to-gitea-scm.md @@ -0,0 +1,48 @@ +# Moving CI off GitHub and onto this Gitea instance + +Today the repository lives on GitHub and Cloud Build watches it through a +`cloudbuildv2` connection. The intent is to move the repository into the Gitea +instance this repo deploys. Doing that well is mostly about not building a loop +that can strand itself. + +## The bootstrapping problem + +**The runner that deploys the VM must not live on the VM it deploys.** If a +Gitea Actions runner on `gitea-vm` executes the infra pipeline, then any change +that restarts Gitea — a config sync, an image rollout, a reboot — kills the job +mid-flight. Worse, a bad change locks you out of the tool needed to fix it. + +Two workable shapes: + +1. **Keep Cloud Build as the executor.** Gitea fires a webhook; Cloud Build runs + the build. Cloud Build has no first-class Gitea trigger, so this means a small + authenticated endpoint (Cloud Run or a Cloud Function) that validates the + webhook signature and calls `cloudbuild.projects.triggers.run`. The deploy + path stays entirely outside the VM. **This is the recommended option.** +2. **A Gitea Actions runner on separate infrastructure** — a second small VM or a + Cloud Run job. Self-contained, but you now operate a runner, and the infra + pipeline still needs GCP credentials. + +Either way, keep the GitHub trigger working until the replacement has +successfully deployed at least once. + +## Order of operations + +1. `[actions] ENABLED = true` is already set in `vm/config/app.ini.tmpl`, so no + Gitea-side config change is needed to start. +2. Mirror the repository into Gitea and verify history, LFS objects, and tags. +3. Stand up the chosen executor and prove it can run `cloudbuild/image.yaml` + end to end, including the IAP rollout step. +4. Point `gitea:githubOwner` at nothing / remove the GitHub trigger. The Pulumi + program already tolerates GitHub being unconfigured — `build.New` logs a + warning and skips the triggers — so the stack still applies cleanly. +5. Keep the GitHub repo as an archived mirror for a while. It is the cheapest + possible disaster recovery for the repo that contains the deployment. + +## Do not forget + +- The `cb-infra@` service account and the Pulumi state bucket are created by + `scripts/bootstrap.sh`, not by Pulumi. They survive this migration untouched. +- Backups (`gitea-backup.timer`) become considerably more important once the + repository that describes the infrastructure lives *on* the infrastructure. + Verify a restore before you cut over, not after. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..f248723 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,296 @@ +# Runbook + +## Post-deploy verification + +Work top to bottom the first time. Several of these controls fail *silently*, so +the drills matter more than the status output. + +### Infrastructure + +```bash +cd infra && pulumi preview # clean, no diff +dig +short A gitea.jasonmross.dev # the static IP +gcloud compute instances describe gitea-vm --zone \ + --format='value(disks[].deviceName, shieldedInstanceConfig)' +``` + +### Host + +```bash +make ssh +mount | grep /var/lib/gitea # PD mounted, xfs +systemctl list-dependencies gitea.service | grep mount # the ordering dep exists +ls -Zd /var/lib/gitea # container_file_t +systemctl status gitea caddy nftables fail2ban +podman ps # both healthy +``` + +**The nftables reload check** — this is what catches an accidental global flush: + +```bash +sudo nft list ruleset | grep -E '^table (inet gitea_filter|inet netavark|ip netavark)' +sudo systemctl reload nftables +sudo podman ps # container networking must still work +curl -fsS https://gitea.jasonmross.dev/api/healthz +``` + +**Subnet agreement** — a mismatch here is what silently breaks fail2ban: + +```bash +sudo podman network inspect gitea --format '{{range .Subnets}}{{.Subnet}}{{end}}' +sudo grep REVERSE_PROXY_TRUSTED_PROXIES /etc/gitea/app.ini +``` + +### TLS / DNS-01 + +```bash +sudo journalctl -u caddy | grep -i 'acme\|challenge' +``` + +Look for the **dns-01** challenge. If Caddy fell back to http-01, the +googleclouddns plugin or its credentials are not working — check +`/etc/gitea/caddy-network` and see *Caddy cannot get a certificate* below. + +```bash +curl -vI https://gitea.jasonmross.dev # valid Let's Encrypt cert +``` + +DNS-01 means renewal does not need inbound port 80 at all. That is testable: +temporarily remove the `gitea-allow-web` port 80 rule and force a renewal. + +### fail2ban — drill it, do not trust the status output + +```bash +# 1. Fail a web login, then read the log line. It must show YOUR ip, +# not a 10.89.x address. If it shows Caddy, REVERSE_PROXY_TRUSTED_PROXIES is wrong. +sudo journalctl CONTAINER_NAME=gitea | grep -i 'failed authentication' + +# 2. The jail is live. +sudo fail2ban-client status gitea + +# 3. Ban a throwaway address you control, then verify from that host that +# both 443 and 2222 are genuinely unreachable. +sudo fail2ban-client set gitea banip +sudo nft list set inet f2b-prerouting f2b-gitea-v4 +sudo fail2ban-client set gitea unbanip +``` + +A ban that appears in `fail2ban-client status` but still lets traffic through +means the prerouting action is not in effect — the default INPUT-hook actions +never see DNAT'd container traffic. + +### WAF — git must still work, and blocks must still happen + +Two drills. The first is the one that catches a broken product; run it after any +change to the Caddyfile matcher. + +```bash +# 1. git still works through the proxy (the bypass is intact) +git clone https://gitea.jasonmross.dev//.git /tmp/wafdrill +cd /tmp/wafdrill && dd if=/dev/urandom of=blob.bin bs=1M count=20 +git add -A && git commit -qm 'waf drill' && git push +``` + +A `403` on push means the `@gittransport` matcher no longer covers the git +routes. See [waf.md](waf.md). + +```bash +# 2. the WAF is actually inspecting the web branch +curl -s -o /dev/null -w '%{http_code}\n' \ + 'https://gitea.jasonmross.dev/?file=../../../../etc/passwd' +``` + +Expect `403` when `gitea:wafMode` is `On`, and `200` in `DetectionOnly` — in +detection mode, confirm it was *recorded* instead: + +```bash +make ssh +sudo journalctl CONTAINER_NAME=caddy --since '5 min ago' | grep 949110 +``` + +If neither blocks nor records, the WAF module is not in the request path — check +`order coraza_waf first` survived the last Caddyfile edit. + +### fail2ban's WAF jail follows the mode + +```bash +sudo fail2ban-client status # caddy-coraza listed only when wafMode=On +grep -A2 '^\[caddy-coraza\]' /etc/fail2ban/jail.d/gitea.local +``` + +`enabled = false` in `DetectionOnly` is correct, not a bug: the WAF is not +refusing anything, so there is no verdict to escalate into a ban. + +### Auto-update and rollback + +```bash +sudo podman auto-update --dry-run # lists both units; UPDATED = false +``` + +If that errors on authentication, `/etc/containers/ar-auth.json` is stale or +missing — `sudo systemctl start gitea-ar-auth.service` and check the timer. + +**Rollback drill.** Push a deliberately broken `:prod` (a bad `CMD` is enough), +run `sudo systemctl start podman-auto-update.service`, and confirm: + +```bash +sudo journalctl -u podman-auto-update | grep -i rollback +sudo podman inspect gitea --format '{{.ImageName}}' +``` + +If it does **not** roll back, `Notify=healthy` is not taking effect on this +podman version. Fall back to `HealthCmd` plus `HealthOnFailure=stop` in +`vm/quadlets/gitea.container` and record it here. + +### The weekly rebuild — fire it once by hand + +**Do this immediately after the first `pulumi up`.** It is the only check here +that cannot wait for its schedule, because a failure is completely silent: the +job fires at 04:00 on a Sunday, gets a 400, and layer 3 of the update story +quietly stops feeding layer 2. Nothing alerts. + +```bash +gcloud scheduler jobs run gitea-weekly-rebuild --location +sleep 15 +gcloud builds list --region --limit 5 # a build must have started + # (2nd-gen triggers are regional; + # the default is the global region) +gcloud scheduler jobs describe gitea-weekly-rebuild --location \ + --format='value(status)' +``` + +The job POSTs an **empty** body to the regional +`.../locations/{region}/triggers/{id}:run` endpoint. That is deliberate: +`RunBuildTriggerRequest.source` is a 1st-generation `RepoSource` that cannot name +a 2nd-gen repository, and omitting it tells Cloud Build to use the trigger's own +configured repository and branch — the REST equivalent of +`gcloud builds triggers run TRIGGER --region=…` with no `--branch`. + +If it returns 400 or 404, check `scheduleRebuild` in +`infra/pkg/build/build.go`: a 404 usually means the URL used the global +`.../projects/{p}/triggers/{id}:run` path instead of the regional one. + +Until it passes, run `make build` manually to pick up base-image security fixes. + +### Memory on a 2 GB instance + +`e2-small` is 2 shared vCPU / 2 GB RAM. Measured on this exact image set: + +| | idle | peak during a 60 MB / 400-object push | +|---|---|---| +| gitea | 105 MB | 375 MB | +| caddy + Coraza + full CRS | 50 MB | 51 MB | + +The WAF is not the memory story — CRS costs about 50 MB and does not grow under +load. Git subprocesses are: `index-pack` and `gc` scale with what is being +pushed, and that number climbs with repo size. + +`bootstrap.sh` provisions a 2 GB swap file with `vm.swappiness = 10` as ballast, +because GCE images ship with none and an OOM kill mid-push is the failure this +prevents. Check it: + +```bash +free -m +swapon --show +``` + +**Swap should be near-idle.** If `free -m` shows sustained swap use, that is the +signal to move to `e2-medium` (`pulumi config set gitea:machineType e2-medium`), +not to enlarge the swap file. Things that will push you over: + +- repositories in the multi-GB range, or many concurrent clones +- switching from SQLite to a PostgreSQL container on the same host +- adding a Gitea Actions runner to this VM (don't — see + [migrate-to-gitea-scm.md](migrate-to-gitea-scm.md)) + +### Resilience + +```bash +make backup # object lands in the backups bucket +gcloud compute instances reset gitea-vm --zone +# after it comes back: data intact, cert valid, nftables and fail2ban up +``` + +--- + +## Common operations + +### Roll back to a previous image + +Every build pushes `:$SHORT_SHA` alongside `:prod`. Find the digest in the build +log, then: + +```bash +make ssh +sudo podman tag -docker.pkg.dev//gitea/gitea: \ + -docker.pkg.dev//gitea/gitea:prod +sudo systemctl restart gitea +``` + +For a durable rollback, re-point `:prod` in Artifact Registry instead — a host- +local retag is undone by the next `podman auto-update`. + +### Restore from a dump + +```bash +gcloud storage cp gs://-gitea-backups/dumps/gitea-.zip . +``` + +A `gitea dump` archive contains the repositories, the SQLite database, custom +files, and config. Restore is documented upstream at +; the short version is +to stop `gitea.service`, unpack over `/var/lib/gitea`, fix ownership to +`1000:1000`, and start it again. **Do this once as a drill before you trust it.** + +### Caddy cannot get a certificate + +The most likely cause is the ACME plugin failing to reach the GCE metadata +server for Application Default Credentials. `vm/bootstrap.sh` probes this at +first boot and caches the answer: + +```bash +cat /etc/gitea/caddy-network # "bridge" or "host" +``` + +To re-probe, delete that file and run `sudo systemctl start gitea-config-sync`. +If the bridge cannot reach `169.254.169.254`, the file will say `host` and Caddy +is switched to the host network, reaching Gitea over `127.0.0.1:3000` instead. +Both paths are supported; only the rendering differs. + +Also verify the zone-scoped grant: + +```bash +gcloud dns managed-zones get-iam-policy +``` + +### Changing the podman firewall driver + +`/etc/containers/containers.conf.d/10-gitea.conf` sets +`firewall_driver = "nftables"`. Changing it on a live host leaves conflicting +rules behind — stop the containers and reboot rather than reloading. + +### A config change did not take effect + +`gitea-config-sync.service` only restarts services when a rendered file actually +changed. To see what it did: + +```bash +sudo journalctl -u gitea-config-sync -n 100 --no-pager +``` + +If the render was skipped, the message will say the Gitea secrets were +unavailable — check `gcloud secrets versions list gitea-internal-token`. + +--- + +## Deferred hardening + +- **Rootless podman** under a dedicated user. Needs `loginctl enable-linger`, + `--user` timers, and subuid mapping; on a single-tenant VM the isolation gain + is small, which is why it was not done up front. +- **PostgreSQL** instead of SQLite. Add a `postgres.container` quadlet plus its + volume and password secret, then follow Gitea's documented dump/restore + migration. Worth doing well before SQLite write contention shows up. +- **Wildcard certificate** for `*.gitea.jasonmross.dev` — trivial now that + DNS-01 works. +- **Ops Agent** for metrics dashboards and log-based alerts. diff --git a/docs/waf.md b/docs/waf.md new file mode 100644 index 0000000..aef567d --- /dev/null +++ b/docs/waf.md @@ -0,0 +1,179 @@ +# The WAF (Coraza + OWASP CRS) + +Caddy is built with [Coraza](https://coraza.io) compiled in, running the OWASP +Core Rule Set. The CRS ships inside the binary via the `coraza-coreruleset` Go +package — there are no rule files on the VM to sync or version. + +Two things about this deployment are unusual and deliberate. Both were verified +by experiment, not assumed. + +## 1. Git and LFS bypass the WAF entirely + +`vm/config/Caddyfile.tmpl` routes these paths to Gitea **without** the WAF: + +``` +/{owner}/{repo}/info/refs +/{owner}/{repo}/git-upload-pack +/{owner}/{repo}/git-receive-pack +/{owner}/{repo}/HEAD +/{owner}/{repo}/objects/... +/{owner}/{repo}/info/lfs/... +``` + +(The matcher uses `[^/]+` for the repo segment, so the `.git` suffix variants are +covered by the same pattern.) + +This is not a hedge. With the bypass removed, a plain `git push` fails: + +``` +fatal: unable to access '.../wafrepo.git/': The requested URL returned error: 403 +``` + +Packfiles are binary and reliably trip CRS's SQLi and XSS rules, and +`coraza.conf-recommended`'s `SecRequestBodyLimit` would refuse large pushes on +its own. Running CRS over git transport does not harden anything — Gitea still +authenticates every one of these requests — it only breaks the product. + +If you change the matcher, re-run the git drill in `docs/runbook.md`. + +## 2. `wafMode` drives both the WAF and the fail2ban jail + +One stack config value, `gitea:wafMode`: + +| Value | `SecRuleEngine` | `caddy-coraza` jail | Use | +|---|---|---|---| +| `DetectionOnly` | logs, does not block | **disabled** | the default; tuning | +| `On` | blocks with 403 | **enabled** | after tuning | +| `Off` | inactive | disabled | debugging only | + +The jail is tied to the mode on purpose. Banning on detections that were never +blocks would turn a tuning false positive into an nftables ban at the prerouting +hook — strictly worse than the 403 that `DetectionOnly` exists to avoid. + +**A WAF ban is not limited to HTTP.** `nft-prerouting` drops by source address at +the prerouting hook and ignores fail2ban's `port` setting, so an address banned +for a WAF verdict also loses git over SSH on 2222. That is intentional — an +attacker should lose every door at once — but it means a WAF false positive in +`On` mode cuts a user off from git entirely, not just from the web UI. One more +reason to tune in `DetectionOnly` first. + +## Rollout + +**Do not start in `On`.** Gitea legitimately carries code, markdown, and SQL in +POST bodies — issue comments, PR descriptions, the wiki, the web file editor. +CRS will flag some of it. + +1. Deploy with the default `DetectionOnly` and use the instance normally for a + week or two. Exercise the parts that carry content: open issues with code + blocks, edit a file in the web UI, use the API. +2. Review what **would have been blocked**. The signal is rule 949110, the CRS + anomaly-score threshold — it fires in both modes, as `Warning` in + `DetectionOnly` and `Access denied` in `On`, and both carry the same + `Inbound Anomaly Score Exceeded` text: + ```bash + make ssh + # score, client, and URI for every would-be block, worst first + sudo journalctl CONTAINER_NAME=caddy --since '7 days ago' \ + | grep 'Inbound Anomaly Score Exceeded' \ + | sed -nE 's/.*\[client \\"([^\\]+)\\".*Total Score: ([0-9]+).*\[uri \\"([^\\]+)\\".*/\2\t\1\t\3/p' \ + | sort -rn | uniq + ``` + Anything you recognise as your own legitimate traffic needs an exclusion. + To see which individual rules contributed to a score: + ```bash + sudo journalctl CONTAINER_NAME=caddy --since '7 days ago' \ + | grep 'http.handlers.waf' \ + | grep -oP '\[id \\"\K[0-9]+' | sort | uniq -c | sort -rn + ``` + Every warn line ends with `[unique_id "..."]`, which matches + `.transaction.id` in the JSON audit record — that is how you get the full + request for one event: + ```bash + sudo journalctl CONTAINER_NAME=caddy | grep '"transaction"' \ + | jq --arg id '' 'select(.transaction.id==$id)' + ``` + Once `wafMode` is `On`, confirmed blocks are simply: + ```bash + sudo journalctl CONTAINER_NAME=caddy | grep '"transaction"' \ + | jq -c 'select(.transaction.is_interrupted) | {ip:.transaction.client_ip, uri:.transaction.request.uri}' + ``` +3. Add exclusions to the `directives` block in `vm/config/Caddyfile.tmpl`, before + the `Include @owasp_crs/*.conf` line for rule-set config, or after it for + `SecRuleRemoveById` / `SecRuleUpdateTargetById`. Document each one — which + rule, which endpoint, and why. +4. `pulumi config set gitea:wafMode On` and push. The jail enables itself. + +### Exclusion examples + +``` +# Rule 942100 (libinjection SQLi) on the issue comment body: users paste SQL +# into issues, that is the point of an issue tracker. +SecRule REQUEST_URI "@rx ^/[^/]+/[^/]+/issues/" \ + "id:1000001,phase:1,pass,nolog,ctl:ruleRemoveById=942100" + +# The web file editor posts arbitrary file content. +SecRule REQUEST_URI "@rx ^/[^/]+/[^/]+/_(edit|new)/" \ + "id:1000002,phase:1,pass,nolog,ctl:ruleRemoveTargetById=949110;ARGS:content" +``` + +Use ids in the 1,000,000+ range — CRS reserves everything below. + +## Reading the logs + +Coraza emits two shapes, both to journald via the `caddy` container: + +**Individual rule matches** — one per rule, noisy, informational: + +``` +"logger":"http.handlers.waf","msg":"[client \"203.0.113.9\"] Coraza: Warning. + Host header is a numeric IP address ... [id \"920350\"]" +``` + +**The blocking decision** — emitted once when the accumulated anomaly score +crosses the threshold and the request is actually refused: + +``` +"logger":"http.handlers.waf","msg":"[client \"203.0.113.9\"] Coraza: Access denied + (phase 2). Inbound Anomaly Score Exceeded (Total Score: 23) ... [id \"949110\"]" +``` + +`vm/fail2ban/filter.d/caddy-coraza.conf` matches **only** the second. Banning on +individual rule hits would ban people for pasting a code snippet. + +In `DetectionOnly` nothing is refused, so no `Access denied` line is ever +emitted — which is precisely why the jail is inert in that mode. The would-be +block still appears, as a `Warning` carrying the same +`Inbound Anomaly Score Exceeded` text and `[id "949110"]`, which is what the +review command above reads. + +The JSON audit record is the companion: it holds the full request, and +`transaction.is_interrupted` is the unambiguous "this was actually refused" +flag. It does **not** contain the matched rule ids — `transaction.messages` comes +back empty in practice — so rule-level tuning reads the warn lines, not the +audit JSON. + +## Tuning knobs already set + +| Directive | Value | Why | +|---|---|---| +| `SecResponseBodyAccess` | `Off` | Inspecting responses on a git host costs CPU and catches nothing worth catching. | +| `SecRequestBodyLimitAction` | `ProcessPartial` | Truncate and inspect rather than reject: a large but legitimate attachment should not 413 because the WAF gave up. | +| `SecAuditEngine` | `RelevantOnly` | Auditing every request would pour full request volume into journald and then Cloud Logging. | +| `SecAuditLogRelevantStatus` | `^(?:5[0-9]{2}|403)$` | The CRS default audits every `401`, and unauthenticated API and web probes generate those constantly on a public host. Narrowed to real refusals and server errors. | + +Note that `RelevantOnly` still audits any transaction that trips a rule, +whatever its status — that is deliberate, since it is what keeps `DetectionOnly` +useful. So the audit log is not silent between blocks; it is bounded by how many +rules your traffic trips, which is exactly what the tuning pass reduces. + +The Caddy-level `request_body max_size 512MB` governs the **git** branch; the +much smaller `SecRequestBodyLimit` governs the **WAF** branch. They apply to +different routes and are not a mismatch to be "fixed". + +## Versions + +`image/caddy.version` and `image/coraza.version` are **coupled**: coraza-caddy +pins a minimum Caddy version, and a mismatch fails the build at `go get` with +`requires github.com/caddyserver/caddy/v2@vX, but vY is requested`. Bump both +together. The weekly scheduled rebuild picks up CRS and dependency fixes without +a version change. diff --git a/image/caddy.version b/image/caddy.version new file mode 100644 index 0000000..7cd5929 --- /dev/null +++ b/image/caddy.version @@ -0,0 +1 @@ +2.11.4 diff --git a/image/caddy/Dockerfile b/image/caddy/Dockerfile new file mode 100644 index 0000000..3e95348 --- /dev/null +++ b/image/caddy/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 +# +# Caddy on a Debian 13 (trixie) base, built with xcaddy so two compile-time +# plugins are baked in: +# +# googleclouddns -- ACME DNS-01, so certificates never depend on inbound 80 +# coraza-caddy -- OWASP Coraza WAF, with the Core Rule Set embedded +# +# The stock caddy binary can do neither: both are compile-time modules. The CRS +# itself needs no files on disk -- coraza-caddy's `load_owasp_crs` pulls in the +# coraza-coreruleset Go package, which embeds the rules in the binary. + +ARG DEBIAN_TAG=13-slim +ARG GOLANG_TAG=1.26-trixie + +# --------------------------------------------------------------------------- +# Stage 1: build caddy with the googleclouddns plugin. +# --------------------------------------------------------------------------- +FROM golang:${GOLANG_TAG} AS build + +ARG CADDY_VERSION +ARG CORAZA_VERSION +ARG XCADDY_VERSION=latest + +ENV CGO_ENABLED=0 \ + GOTOOLCHAIN=local + +RUN set -eux; \ + test -n "${CADDY_VERSION}" || { echo "CADDY_VERSION build-arg is required" >&2; exit 1; }; \ + test -n "${CORAZA_VERSION}" || { echo "CORAZA_VERSION build-arg is required" >&2; exit 1; }; \ + go install "github.com/caddyserver/xcaddy/cmd/xcaddy@${XCADDY_VERSION}" + +RUN set -eux; \ + xcaddy build "v${CADDY_VERSION}" \ + --with github.com/caddy-dns/googleclouddns \ + --with "github.com/corazawaf/coraza-caddy/v2@${CORAZA_VERSION}" \ + --output /out/caddy; \ + /out/caddy version; \ + # Assert BOTH modules landed. It is easy to drop one when editing the build + # line above, and a missing module fails at request time, not build time -- + # by which point it is a silently unprotected server or a broken cert renewal. + /out/caddy list-modules > /tmp/modules.txt; \ + grep -q '^dns.providers.googleclouddns$' /tmp/modules.txt \ + || { echo "googleclouddns module missing"; cat /tmp/modules.txt; exit 1; }; \ + grep -qiE 'coraza|waf' /tmp/modules.txt \ + || { echo "coraza module missing"; cat /tmp/modules.txt; exit 1; }; \ + echo "WAF/DNS modules present:"; grep -iE 'coraza|waf|googleclouddns' /tmp/modules.txt + +# --------------------------------------------------------------------------- +# Stage 2: runtime. +# --------------------------------------------------------------------------- +FROM debian:${DEBIAN_TAG} + +ARG CADDY_VERSION + +LABEL org.opencontainers.image.title="caddy-gitea" \ + org.opencontainers.image.description="Caddy with Google Cloud DNS ACME and the Coraza WAF, on a Debian 13 base" \ + org.opencontainers.image.version="${CADDY_VERSION}" \ + org.opencontainers.image.source="https://github.com/caddy-dns/googleclouddns" \ + org.opencontainers.image.base.name="docker.io/library/debian:13-slim" + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl; \ + rm -rf /var/lib/apt/lists/*; \ + groupadd --gid 1000 caddy; \ + useradd --uid 1000 --gid 1000 --home-dir /config --shell /usr/sbin/nologin caddy; \ + mkdir -p /data /config; \ + chown -R 1000:1000 /data /config + +COPY --from=build --chown=root:root --chmod=0755 /out/caddy /usr/local/bin/caddy + +# Where caddy persists ACME account keys and issued certificates. +ENV XDG_DATA_HOME=/data \ + XDG_CONFIG_HOME=/config + +USER 1000:1000 +WORKDIR /config + +EXPOSE 80 443 443/udp + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsS http://127.0.0.1:2019/config/ >/dev/null || exit 1 + +ENTRYPOINT ["/usr/local/bin/caddy"] +CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/image/coraza.version b/image/coraza.version new file mode 100644 index 0000000..21222ce --- /dev/null +++ b/image/coraza.version @@ -0,0 +1 @@ +v2.5.0 diff --git a/image/gitea.version b/image/gitea.version new file mode 100644 index 0000000..457f038 --- /dev/null +++ b/image/gitea.version @@ -0,0 +1 @@ +1.27.2 diff --git a/image/gitea/Dockerfile b/image/gitea/Dockerfile new file mode 100644 index 0000000..e743a9c --- /dev/null +++ b/image/gitea/Dockerfile @@ -0,0 +1,96 @@ +# syntax=docker/dockerfile:1 +# +# Gitea on a Debian 13 (trixie) base. +# +# Deliberately NOT the upstream image: that one is Alpine-based and its +# GITEA__section__KEY environment support comes from an `environment-to-ini` +# entrypoint helper, not from the gitea binary. We template app.ini on the host +# and bind-mount it read-only instead, so no such helper is needed here. + +ARG DEBIAN_TAG=13-slim + +# --------------------------------------------------------------------------- +# Stage 1: fetch and verify the release binary. +# The checksum alone proves nothing (it is served from the same place as the +# binary); the detached signature is the actual integrity guarantee. +# --------------------------------------------------------------------------- +FROM debian:${DEBIAN_TAG} AS fetch + +ARG GITEA_VERSION +ARG GITEA_GPG_KEY=7C9E68152594688862D62AF62D9AE806EC1592E2 +ARG TARGETARCH=amd64 + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg xz-utils; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp/gitea + +RUN set -eux; \ + test -n "${GITEA_VERSION}" || { echo "GITEA_VERSION build-arg is required" >&2; exit 1; }; \ + base="https://github.com/go-gitea/gitea/releases/download/v${GITEA_VERSION}"; \ + file="gitea-${GITEA_VERSION}-linux-${TARGETARCH}.xz"; \ + curl -fsSL -o "${file}" "${base}/${file}"; \ + curl -fsSL -o "${file}.sha256" "${base}/${file}.sha256"; \ + curl -fsSL -o "${file}.asc" "${base}/${file}.asc"; \ + sha256sum -c "${file}.sha256"; \ + export GNUPGHOME="$(mktemp -d)"; \ + for ks in keys.openpgp.org keyserver.ubuntu.com pgp.mit.edu; do \ + gpg --batch --keyserver "hkps://${ks}" --recv-keys "${GITEA_GPG_KEY}" && break; \ + done; \ + gpg --batch --verify "${file}.asc" "${file}"; \ + gpgconf --kill all; \ + rm -rf "${GNUPGHOME}"; \ + xz -d "${file}"; \ + mv "gitea-${GITEA_VERSION}-linux-${TARGETARCH}" /tmp/gitea/gitea; \ + chmod 0755 /tmp/gitea/gitea; \ + /tmp/gitea/gitea --version + +# --------------------------------------------------------------------------- +# Stage 2: runtime. +# --------------------------------------------------------------------------- +FROM debian:${DEBIAN_TAG} + +ARG GITEA_VERSION + +LABEL org.opencontainers.image.title="gitea" \ + org.opencontainers.image.description="Gitea on a Debian 13 base" \ + org.opencontainers.image.version="${GITEA_VERSION}" \ + org.opencontainers.image.source="https://github.com/go-gitea/gitea" \ + org.opencontainers.image.base.name="docker.io/library/debian:13-slim" + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + git-lfs \ + openssh-client \ + tzdata; \ + rm -rf /var/lib/apt/lists/*; \ + groupadd --gid 1000 git; \ + useradd --uid 1000 --gid 1000 --home-dir /var/lib/gitea --shell /bin/bash git; \ + mkdir -p /var/lib/gitea /etc/gitea; \ + chown -R 1000:1000 /var/lib/gitea + +COPY --from=fetch --chown=root:root --chmod=0755 /tmp/gitea/gitea /usr/local/bin/gitea + +ENV GITEA_WORK_DIR=/var/lib/gitea \ + GITEA_CUSTOM=/var/lib/gitea/custom + +USER 1000:1000 +WORKDIR /var/lib/gitea + +# HTTP (behind Caddy) and the built-in SSH server. +EXPOSE 3000 2222 + +# Informational only -- the quadlet declares the authoritative healthcheck, +# because `Notify=healthy` needs it defined there to gate unit startup. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3000/api/healthz || exit 1 + +ENTRYPOINT ["/usr/local/bin/gitea"] +CMD ["web", "--config", "/etc/gitea/app.ini"] diff --git a/infra/Pulumi.prod.yaml b/infra/Pulumi.prod.yaml new file mode 100644 index 0000000..291fcbf --- /dev/null +++ b/infra/Pulumi.prod.yaml @@ -0,0 +1,42 @@ +# Stack configuration for the `prod` stack. +# +# `pulumi config set --secret` is unnecessary here: every value is public +# infrastructure metadata. The Gitea application secrets live in Secret Manager +# and are never read by this program. +config: + gcp:project: CHANGEME-gitea-project-id + gcp:region: us-east1 + + gitea:domain: gitea.jasonmross.dev + # The Cloud DNS *resource* name of the existing managed zone, which is not + # necessarily the DNS name. `gcloud dns managed-zones list` to find it. + gitea:dnsZone: CHANGEME-managed-zone-name + gitea:acmeEmail: CHANGEME@example.com + + # us-east1 has zones b, c and d -- there is no us-east1-a. + gitea:zone: us-east1-b + # e2-small: 2 shared vCPU, 2 GB RAM. See docs/runbook.md ("Memory on a 2 GB + # instance") before adding anything else to this host. + gitea:machineType: e2-small + gitea:bootDiskGb: "20" + gitea:dataDiskGb: "30" + + gitea:appName: Gitea + gitea:requireSigninView: "false" + gitea:podmanSubnet: 10.89.10.0/24 + + # Coraza WAF: On | DetectionOnly | Off. + # Start in DetectionOnly, review what it flags (docs/waf.md), then switch to + # On. The fail2ban jail that bans on WAF verdicts follows this value. + gitea:wafMode: DetectionOnly + + # Cloud Build source. The GitHub App installation id comes from the URL of the + # app's settings page after you install it on the repository. + gitea:githubOwner: CHANGEME + gitea:githubRepo: gitea + gitea:githubAppInstallationId: "0" + gitea:githubPatSecret: github-pat + + # Created by scripts/bootstrap.sh before the first `pulumi up`, because it is + # the identity that runs Pulumi and therefore cannot be created by Pulumi. + gitea:infraBuildServiceAccount: CHANGEME@CHANGEME.iam.gserviceaccount.com diff --git a/infra/Pulumi.yaml b/infra/Pulumi.yaml new file mode 100644 index 0000000..3eb96c3 --- /dev/null +++ b/infra/Pulumi.yaml @@ -0,0 +1,9 @@ +name: gitea +runtime: + name: go + options: + binary: ./gitea-infra +description: Gitea on a GCE VM running podman quadlets, fronted by Caddy with ACME DNS-01. +config: + pulumi:disable-default-providers: + value: [] diff --git a/infra/go.mod b/infra/go.mod new file mode 100644 index 0000000..40deb37 --- /dev/null +++ b/infra/go.mod @@ -0,0 +1,121 @@ +module gitea-infra + +go 1.25.11 + +require ( + github.com/pulumi/pulumi-gcp/sdk/v9 v9.34.1 + github.com/pulumi/pulumi/sdk/v3 v3.258.0 +) + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/cheggaaa/pb v1.0.29 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/djherbis/times v1.5.0 // indirect + github.com/ebitengine/purego v0.10.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-git/gcfg/v2 v2.0.2 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect + github.com/go-git/go-git/v6 v6.0.0-alpha.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.2.5 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/hcl/v2 v2.22.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/opentracing/basictracer-go v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pgavlin/fx v0.1.6 // indirect + github.com/pgavlin/fx/v2 v2.0.12 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/term v1.1.0 // indirect + github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/spf13/cast v1.4.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/texttheater/golang-levenshtein v1.0.1 // indirect + github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect + github.com/uber/jaeger-lib v2.4.1+incompatible // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zalando/go-keyring v0.2.8 // indirect + github.com/zclconf/go-cty v1.13.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/collector/featuregate v1.53.0 // indirect + go.opentelemetry.io/collector/pdata v1.53.0 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/frand v1.4.2 // indirect +) diff --git a/infra/go.sum b/infra/go.sum new file mode 100644 index 0000000..474541c --- /dev/null +++ b/infra/go.sum @@ -0,0 +1,350 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= +github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU= +github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= +github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/opentracing/basictracer-go v1.1.0 h1:Oa1fTSBvAl8pa3U+IJYqrKm0NALwH9OsgwOqDv4xJW0= +github.com/opentracing/basictracer-go v1.1.0/go.mod h1:V2HZueSJEp879yv285Aap1BS69fQMD+MNP1mRs6mBQc= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pgavlin/fx v0.1.6 h1:r9jEg69DhNoCd3Xh0+5mIbdbS3PqWrVWujkY76MFRTU= +github.com/pgavlin/fx v0.1.6/go.mod h1:KWZJ6fqBBSh8GxHYqwYCf3rYE7Gp2p0N8tJp8xv9u9M= +github.com/pgavlin/fx/v2 v2.0.12 h1:SjjaJ68Dt8Z4zHwOpY/RPijd7lShs6xYupJbF9ra00M= +github.com/pgavlin/fx/v2 v2.0.12/go.mod h1:M/nF/ooAOy+NUBooYYXl2REARzJ/giPJxfMs8fINfKc= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk= +github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435cARxCW6q9gc0S/Yxz7Mkd38pOb0= +github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE= +github.com/pulumi/pulumi-gcp/sdk/v9 v9.34.1 h1:/khW98FHrRGKtzhyqLwgTjJjmA5+ev4aGIvp06AZe7o= +github.com/pulumi/pulumi-gcp/sdk/v9 v9.34.1/go.mod h1:YMIpKrD4W1lhzfiHGCDklsl8qh0jl3dwbc8CRifBzUQ= +github.com/pulumi/pulumi/sdk/v3 v3.258.0 h1:k6EOdMnR7e9SR75t3y7/p9AOxo2SHgNFAPAaimHImmc= +github.com/pulumi/pulumi/sdk/v3 v3.258.0/go.mod h1:pyYSaOHxk1R0bYiSzpcbYzeN3MBtlyckup9tvImBjjo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= +github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= +github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= +github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/collector/featuregate v1.53.0 h1:cgjXdtl7jezWxq6V0eohe/JqjY4PBotZGb5+bTR2OJw= +go.opentelemetry.io/collector/featuregate v1.53.0/go.mod h1:PS7zY/zaCb28EqciePVwRHVhc3oKortTFXsi3I6ee4g= +go.opentelemetry.io/collector/internal/testutil v0.147.0 h1:DFlRxBRp23/sZnpTITK25yqe0d56yNvK+63IaWc6OsU= +go.opentelemetry.io/collector/internal/testutil v0.147.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= +go.opentelemetry.io/collector/pdata v1.53.0 h1:DlYDbRwammEZaxDZHINx5v0n8SEOVNniPbi6FRTlVkA= +go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms= +go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM= +go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= +go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/frand v1.4.2 h1:RzFIpOvkMXuPMBb9maa4ND4wjBn71E1Jpf8BzJHMaVw= +lukechampine.com/frand v1.4.2/go.mod h1:4S/TM2ZgrKejMcKMbeLjISpJMO+/eZ1zu3vYX9dtj3s= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/infra/main.go b/infra/main.go new file mode 100644 index 0000000..b81ea6a --- /dev/null +++ b/infra/main.go @@ -0,0 +1,110 @@ +// Command gitea-infra provisions the Gitea deployment: a single AlmaLinux 10 +// VM running podman quadlets, fronted by Caddy with ACME DNS-01 against an +// existing Cloud DNS zone, with images built and rolled out by Cloud Build. +// +// The program is deliberately thin. Each package owns one slice of the +// infrastructure and the wiring order below is the dependency order. +package main + +import ( + "path/filepath" + + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/build" + "gitea-infra/pkg/compute" + "gitea-infra/pkg/config" + "gitea-infra/pkg/dns" + "gitea-infra/pkg/iam" + "gitea-infra/pkg/network" + "gitea-infra/pkg/project" + "gitea-infra/pkg/registry" + "gitea-infra/pkg/secrets" + "gitea-infra/pkg/storage" +) + +// The vm/ tree and its bootstrap script live one level up from infra/. +const vmDir = "../vm" + +func main() { + pulumi.Run(func(ctx *pulumi.Context) error { + cfg, err := config.Load(ctx) + if err != nil { + return err + } + + // Everything depends on these: a resource created against an API that is + // still enabling fails in confusing ways. + apis, err := project.EnableAPIs(ctx) + if err != nil { + return err + } + + net, err := network.New(ctx, cfg, apis) + if err != nil { + return err + } + + accounts, err := iam.New(ctx, cfg, apis) + if err != nil { + return err + } + + repo, err := registry.New(ctx, cfg, apis) + if err != nil { + return err + } + if err := iam.GrantRegistry(ctx, cfg, accounts, repo); err != nil { + return err + } + + // The secrets themselves are created by scripts/bootstrap.sh; Pulumi + // only grants access to them. + if err := iam.GrantSecrets(ctx, cfg, accounts, secrets.Names); err != nil { + return err + } + + buckets, err := storage.New(ctx, cfg, vmDir, apis) + if err != nil { + return err + } + if err := iam.GrantBuckets(ctx, accounts, buckets.Config, buckets.Backup); err != nil { + return err + } + + // The zone already exists and is delegated; this only adds the A record + // and the zone-scoped permission Caddy needs for DNS-01. + if _, err := dns.New(ctx, cfg, net.Address, accounts.VM.Email, apis); err != nil { + return err + } + + inst, err := compute.New(ctx, cfg, net, accounts.VM, + buckets.Config, buckets.Backup, buckets.ConfigHash, + filepath.Join(vmDir, "bootstrap.sh"), apis) + if err != nil { + return err + } + + if _, err := build.New(ctx, cfg, accounts.Image, apis); err != nil { + return err + } + + ctx.Export("address", net.Address.Address) + ctx.Export("url", pulumi.Sprintf("https://%s/", cfg.Domain)) + ctx.Export("cloneSSH", pulumi.Sprintf("ssh://git@%s:2222/", cfg.Domain)) + ctx.Export("instance", inst.VM.Name) + ctx.Export("zone", pulumi.String(cfg.Zone)) + ctx.Export("registry", pulumi.Sprintf("%s/%s/%s", cfg.ARHost(), cfg.Project, registry.RepoID)) + ctx.Export("configBucket", buckets.Config.Name) + ctx.Export("backupBucket", buckets.Backup.Name) + ctx.Export("configHash", pulumi.String(buckets.ConfigHash)) + ctx.Export("vmServiceAccount", accounts.VM.Email) + ctx.Export("imageServiceAccount", accounts.Image.Email) + // Printed so the runbook never has to guess the flags. + ctx.Export("sshCommand", pulumi.Sprintf( + "gcloud compute ssh %s --zone=%s --tunnel-through-iap --project=%s", + inst.VM.Name, cfg.Zone, cfg.Project)) + + return nil + }) +} diff --git a/infra/pkg/build/build.go b/infra/pkg/build/build.go new file mode 100644 index 0000000..45b3e14 --- /dev/null +++ b/infra/pkg/build/build.go @@ -0,0 +1,224 @@ +// Package build wires Cloud Build to the source repository and schedules the +// weekly image rebuild. +// +// The weekly rebuild is not optional garnish: podman's AutoUpdate=registry only +// pulls when the :prod tag's digest changes, so without something pushing a new +// image the container-update layer has nothing to do. The rebuild is what turns +// Debian and Go security fixes into a running container. +package build + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudbuild" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudbuildv2" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudscheduler" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/projects" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/secretmanager" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/serviceaccount" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" +) + +type Triggers struct { + Image *cloudbuild.Trigger + Infra *cloudbuild.Trigger +} + +// New creates the GitHub connection, the two push triggers, and the scheduled +// rebuild. Returns nil (without error) when GitHub is not configured yet, so a +// fresh stack can be brought up before the GitHub App install is finished. +func New( + ctx *pulumi.Context, + cfg *config.Config, + imageSA *serviceaccount.Account, + deps []pulumi.Resource, +) (*Triggers, error) { + if !cfg.GitHubConfigured() { + ctx.Log.Warn("gitea:githubOwner/githubRepo/githubAppInstallationId not set -- skipping Cloud Build triggers", nil) + return nil, nil + } + opts := pulumi.DependsOn(deps) + + // The PAT is created out of band (it is an OAuth artifact, not + // infrastructure) and referenced by version. + patVersion := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", cfg.Project, cfg.GitHubPATSecret) + + // A 2nd-gen connection is read by the Cloud Build SERVICE AGENT, not by the + // build service account and not by whoever runs Pulumi. Without this grant, + // creating the connection fails with a permission error on the PAT secret. + // + // ServiceIdentity both materialises the agent (it may not exist yet on a + // fresh project) and hands back its email, which avoids having to look up + // the project number just to spell out + // service-{number}@gcp-sa-cloudbuild.iam.gserviceaccount.com. + agent, err := projects.NewServiceIdentity(ctx, "cloudbuild-agent", &projects.ServiceIdentityArgs{ + Project: pulumi.String(cfg.Project), + Service: pulumi.String("cloudbuild.googleapis.com"), + }, opts) + if err != nil { + return nil, err + } + + patAccess, err := secretmanager.NewSecretIamMember(ctx, "cloudbuild-agent-pat", &secretmanager.SecretIamMemberArgs{ + Project: pulumi.String(cfg.Project), + SecretId: pulumi.String(cfg.GitHubPATSecret), + Role: pulumi.String("roles/secretmanager.secretAccessor"), + Member: pulumi.Sprintf("serviceAccount:%s", agent.Email), + }, opts) + if err != nil { + return nil, err + } + + conn, err := cloudbuildv2.NewConnection(ctx, "github", &cloudbuildv2.ConnectionArgs{ + Name: pulumi.String("github"), + Location: pulumi.String(cfg.Region), + GithubConfig: &cloudbuildv2.ConnectionGithubConfigArgs{ + AppInstallationId: pulumi.Int(cfg.GitHubInstallationID), + AuthorizerCredential: &cloudbuildv2.ConnectionGithubConfigAuthorizerCredentialArgs{ + OauthTokenSecretVersion: pulumi.String(patVersion), + }, + }, + }, opts, pulumi.DependsOn([]pulumi.Resource{patAccess})) + if err != nil { + return nil, err + } + + repo, err := cloudbuildv2.NewRepository(ctx, "gitea-repo-link", &cloudbuildv2.RepositoryArgs{ + Name: pulumi.String(cfg.GitHubRepo), + Location: pulumi.String(cfg.Region), + ParentConnection: conn.ID(), + RemoteUri: pulumi.Sprintf("https://github.com/%s/%s.git", cfg.GitHubOwner, cfg.GitHubRepo), + }, opts) + if err != nil { + return nil, err + } + + imageTrigger, err := cloudbuild.NewTrigger(ctx, "gitea-image", &cloudbuild.TriggerArgs{ + Name: pulumi.String("gitea-image"), + Location: pulumi.String(cfg.Region), + Description: pulumi.String("Build and roll out the Gitea and Caddy images"), + RepositoryEventConfig: &cloudbuild.TriggerRepositoryEventConfigArgs{ + Repository: repo.ID(), + Push: &cloudbuild.TriggerRepositoryEventConfigPushArgs{Branch: pulumi.String("^main$")}, + }, + Filename: pulumi.String("cloudbuild/image.yaml"), + // Only rebuild when something that affects the image changed. + IncludedFiles: pulumi.ToStringArray([]string{"image/**", "cloudbuild/image.yaml"}), + ServiceAccount: imageSA.ID(), + Substitutions: pulumi.StringMap{ + "_REGION": pulumi.String(cfg.Region), + "_ZONE": pulumi.String(cfg.Zone), + "_DOMAIN": pulumi.String(cfg.Domain), + }, + }, opts) + if err != nil { + return nil, err + } + + // The infra trigger runs Pulumi, so it uses the bootstrap-created account + // with the broad permissions -- deliberately a different identity from the + // one that merely pushes images. + infraSA := cfg.InfraBuildServiceAccount + if infraSA == "" || strings.Contains(infraSA, "CHANGEME") { + // Fail here rather than letting Cloud Build reject a malformed service + // account path at apply time with an opaque error. + return nil, fmt.Errorf("gitea:infraBuildServiceAccount is not set -- " + + "scripts/bootstrap.sh prints the value to use") + } + infraTrigger, err := cloudbuild.NewTrigger(ctx, "gitea-infra", &cloudbuild.TriggerArgs{ + Name: pulumi.String("gitea-infra"), + Location: pulumi.String(cfg.Region), + Description: pulumi.String("pulumi up, then push VM config to the instance"), + RepositoryEventConfig: &cloudbuild.TriggerRepositoryEventConfigArgs{ + Repository: repo.ID(), + Push: &cloudbuild.TriggerRepositoryEventConfigPushArgs{Branch: pulumi.String("^main$")}, + }, + Filename: pulumi.String("cloudbuild/infra.yaml"), + IncludedFiles: pulumi.ToStringArray([]string{"infra/**", "vm/**", "cloudbuild/infra.yaml"}), + ServiceAccount: pulumi.Sprintf("projects/%s/serviceAccounts/%s", + cfg.Project, infraSA), + Substitutions: pulumi.StringMap{ + "_REGION": pulumi.String(cfg.Region), + "_ZONE": pulumi.String(cfg.Zone), + }, + }, opts) + if err != nil { + return nil, err + } + + if err := scheduleRebuild(ctx, cfg, imageSA, imageTrigger, opts); err != nil { + return nil, err + } + + return &Triggers{Image: imageTrigger, Infra: infraTrigger}, nil +} + +// scheduleRebuild re-runs the image trigger weekly so the :prod tag picks up +// Debian, Go, and Caddy security fixes without anyone opening a PR. +func scheduleRebuild( + ctx *pulumi.Context, + cfg *config.Config, + imageSA *serviceaccount.Account, + trigger *cloudbuild.Trigger, + opts pulumi.ResourceOption, +) error { + // Running a trigger is a write against the Cloud Build API. + if _, err := projects.NewIAMMember(ctx, "img-builds-editor", &projects.IAMMemberArgs{ + Project: pulumi.String(cfg.Project), + Role: pulumi.String("roles/cloudbuild.builds.editor"), + Member: pulumi.Sprintf("serviceAccount:%s", imageSA.Email), + }, opts); err != nil { + return err + } + + // v1 .../locations/{region}/triggers/{id}:run -- the regional (2nd-gen) + // endpoint. The global .../projects/{p}/triggers/{id}:run path is the + // 1st-gen one and would not find a regional trigger. + _, err := cloudscheduler.NewJob(ctx, "gitea-weekly-rebuild", &cloudscheduler.JobArgs{ + Name: pulumi.String("gitea-weekly-rebuild"), + Region: pulumi.String(cfg.Region), + Description: pulumi.String("Weekly rebuild of the Gitea and Caddy images for base-OS security fixes"), + // Sunday 04:00 UTC -- an hour before the maintenance reboot window, so a + // fresh image is waiting when the box restarts. + Schedule: pulumi.String("0 4 * * 0"), + TimeZone: pulumi.String("Etc/UTC"), + HttpTarget: &cloudscheduler.JobHttpTargetArgs{ + HttpMethod: pulumi.String("POST"), + Uri: pulumi.Sprintf("https://cloudbuild.googleapis.com/v1/projects/%s/locations/%s/triggers/%s:run", + cfg.Project, cfg.Region, trigger.TriggerId), + // Empty body, deliberately. + // + // RunBuildTriggerRequest.source is a RepoSource -- a 1st-generation + // shape that names a Cloud Source Repositories repo by project and + // repo name. There is no way to express a 2nd-gen repository + // (projects/*/locations/*/connections/*/repositories/*) in it, and + // these triggers are 2nd-gen: they carry repositoryEventConfig. + // + // source is optional, and omitting it tells Cloud Build to use the + // trigger's own configured repository and branch -- which is exactly + // what a scheduled rebuild of main wants. This is the REST + // equivalent of `gcloud builds triggers run TRIGGER --region=...` + // with no --branch/--tag/--sha, all three of which are optional. + Body: pulumi.String(base64JSON(`{}`)), + Headers: pulumi.StringMap{ + "Content-Type": pulumi.String("application/json"), + }, + OauthToken: &cloudscheduler.JobHttpTargetOauthTokenArgs{ + ServiceAccountEmail: imageSA.Email, + Scope: pulumi.String("https://www.googleapis.com/auth/cloud-platform"), + }, + }, + }, opts) + return err +} + +// base64JSON encodes a Cloud Scheduler HTTP body. The API takes the body as a +// base64 string, and passing raw JSON fails at apply time with an unhelpful +// error rather than at plan time. +func base64JSON(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} diff --git a/infra/pkg/compute/compute.go b/infra/pkg/compute/compute.go new file mode 100644 index 0000000..d200c16 --- /dev/null +++ b/infra/pkg/compute/compute.go @@ -0,0 +1,213 @@ +// Package compute builds the data disk, its snapshot schedule, and the instance. +package compute + +import ( + "fmt" + "os" + "strconv" + + gcpcompute "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/serviceaccount" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/storage" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" + "gitea-infra/pkg/network" + "gitea-infra/pkg/registry" +) + +// DataDiskDeviceName is what the disk shows up as under /dev/disk/by-id/google-* +// inside the guest. vm/bootstrap.sh mounts it by UUID, but it needs this name to +// find the device the first time, before there is a filesystem to have a UUID. +const DataDiskDeviceName = "gitea-data" + +type Instance struct { + VM *gcpcompute.Instance + Disk *gcpcompute.Disk +} + +func New( + ctx *pulumi.Context, + cfg *config.Config, + net *network.Network, + sa *serviceaccount.Account, + buckets *storage.Bucket, + backupBucket *storage.Bucket, + configHash string, + bootstrapPath string, + deps []pulumi.Resource, +) (*Instance, error) { + opts := pulumi.DependsOn(deps) + + // A separate disk from the boot disk, so replacing the instance -- for a + // machine-type change, an image refresh, anything -- never touches the + // repositories. Protect(true) makes an accidental delete a two-step mistake. + // + // This is the disk that holds everything you would miss: repositories, the + // SQLite database, and LFS objects. gitea:dataDiskGb sizes it. The boot disk + // is separate and sized by gitea:bootDiskGb; growing that one buys nothing. + // A pd-balanced disk can be grown in place later, never shrunk. + disk, err := gcpcompute.NewDisk(ctx, "gitea-data", &gcpcompute.DiskArgs{ + Name: pulumi.String("gitea-data"), + Zone: pulumi.String(cfg.Zone), + Size: pulumi.Int(cfg.DataDiskGB), + Type: pulumi.String("pd-balanced"), + Description: pulumi.String("Gitea repositories, SQLite database, and LFS objects"), + }, opts, pulumi.Protect(true)) + if err != nil { + return nil, err + } + + // The safety net for the weekly unattended-reboot window, and for everything + // else that goes wrong at 3am. + policy, err := gcpcompute.NewResourcePolicy(ctx, "gitea-snapshots", &gcpcompute.ResourcePolicyArgs{ + Name: pulumi.String("gitea-daily-snapshots"), + Region: pulumi.String(cfg.Region), + SnapshotSchedulePolicy: &gcpcompute.ResourcePolicySnapshotSchedulePolicyArgs{ + Schedule: &gcpcompute.ResourcePolicySnapshotSchedulePolicyScheduleArgs{ + DailySchedule: &gcpcompute.ResourcePolicySnapshotSchedulePolicyScheduleDailyScheduleArgs{ + DaysInCycle: pulumi.Int(1), + StartTime: pulumi.String("03:00"), + }, + }, + RetentionPolicy: &gcpcompute.ResourcePolicySnapshotSchedulePolicyRetentionPolicyArgs{ + MaxRetentionDays: pulumi.Int(14), + // Keep the history even if the disk itself is deleted. + OnSourceDiskDelete: pulumi.String("KEEP_AUTO_SNAPSHOTS"), + }, + SnapshotProperties: &gcpcompute.ResourcePolicySnapshotSchedulePolicySnapshotPropertiesArgs{ + StorageLocations: pulumi.String(cfg.Region), + Labels: pulumi.StringMap{"app": pulumi.String("gitea")}, + }, + }, + }, opts) + if err != nil { + return nil, err + } + + if _, err := gcpcompute.NewDiskResourcePolicyAttachment(ctx, "gitea-data-snapshots", &gcpcompute.DiskResourcePolicyAttachmentArgs{ + Name: policy.Name, + Disk: disk.Name, + Zone: pulumi.String(cfg.Zone), + }, opts); err != nil { + return nil, err + } + + startupScript, err := os.ReadFile(bootstrapPath) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", bootstrapPath, err) + } + + imageGitea := fmt.Sprintf("%s/%s/%s/gitea:prod", cfg.ARHost(), cfg.Project, registry.RepoID) + imageCaddy := fmt.Sprintf("%s/%s/%s/caddy:prod", cfg.ARHost(), cfg.Project, registry.RepoID) + + vm, err := gcpcompute.NewInstance(ctx, "gitea-vm", &gcpcompute.InstanceArgs{ + Name: pulumi.String("gitea-vm"), + Zone: pulumi.String(cfg.Zone), + MachineType: pulumi.String(cfg.MachineType), + Tags: pulumi.ToStringArray([]string{network.Tag}), + Description: pulumi.String("Gitea, Caddy, and podman quadlets on AlmaLinux 10"), + + BootDisk: &gcpcompute.InstanceBootDiskArgs{ + InitializeParams: &gcpcompute.InstanceBootDiskInitializeParamsArgs{ + // Image family, not a pinned image: new VMs pick up the monthly + // refreshed AlmaLinux build automatically. + Image: pulumi.String("almalinux-cloud/almalinux-10"), + Size: pulumi.Int(cfg.BootDiskGB), + Type: pulumi.String("pd-balanced"), + }, + }, + + AttachedDisks: gcpcompute.InstanceAttachedDiskArray{ + &gcpcompute.InstanceAttachedDiskArgs{ + Source: disk.ID(), + DeviceName: pulumi.String(DataDiskDeviceName), + Mode: pulumi.String("READ_WRITE"), + }, + }, + + NetworkInterfaces: gcpcompute.InstanceNetworkInterfaceArray{ + &gcpcompute.InstanceNetworkInterfaceArgs{ + Network: net.Network.ID(), + Subnetwork: net.Subnetwork.ID(), + AccessConfigs: gcpcompute.InstanceNetworkInterfaceAccessConfigArray{ + &gcpcompute.InstanceNetworkInterfaceAccessConfigArgs{ + NatIp: net.Address.Address, + }, + }, + }, + }, + + ServiceAccount: &gcpcompute.InstanceServiceAccountArgs{ + Email: sa.Email, + // cloud-platform plus per-resource IAM, rather than a hand-rolled + // scope list: scopes are a coarse legacy filter and IAM is where the + // real restrictions live. + Scopes: pulumi.ToStringArray([]string{"cloud-platform"}), + }, + + ShieldedInstanceConfig: &gcpcompute.InstanceShieldedInstanceConfigArgs{ + EnableSecureBoot: pulumi.Bool(true), + EnableVtpm: pulumi.Bool(true), + EnableIntegrityMonitoring: pulumi.Bool(true), + }, + + Metadata: pulumi.StringMap{ + // OS Login means SSH access follows IAM instead of metadata keys. + "enable-oslogin": pulumi.String("TRUE"), + "google-logging-enabled": pulumi.String("true"), + + "config-bucket": buckets.Name, + "backup-bucket": backupBucket.Name, + "gcp-project": pulumi.String(cfg.Project), + "gcp-region": pulumi.String(cfg.Region), + "ar-host": pulumi.String(cfg.ARHost()), + + // Floating :prod tags -- AutoUpdate=registry has nothing to poll if + // these are digests. + "image-gitea": pulumi.String(imageGitea), + "image-caddy": pulumi.String(imageCaddy), + + "domain": pulumi.String(cfg.Domain), + "acme-email": pulumi.String(cfg.ACMEEmail), + "app-name": pulumi.String(cfg.AppName), + "require-signin-view": pulumi.String(strconv.FormatBool(cfg.RequireSigninView)), + + // Drives Coraza's SecRuleEngine and, from the same value, whether + // the fail2ban jail acting on WAF verdicts is enabled. + "waf-mode": pulumi.String(cfg.WAFMode), + + "podman-subnet": pulumi.String(cfg.PodmanCIDR), + "podman-gateway": pulumi.String(gatewayFor(cfg.PodmanCIDR)), + + "data-disk-device": pulumi.String("/dev/disk/by-id/google-" + DataDiskDeviceName), + + // Not read by the guest -- it exists so `gcloud compute instances + // describe` shows which revision of vm/ the box was built against. + "config-hash": pulumi.String(configHash), + }, + + MetadataStartupScript: pulumi.String(string(startupScript)), + + // Lets Pulumi change machine type or metadata in place instead of + // refusing the update. + AllowStoppingForUpdate: pulumi.Bool(true), + + Labels: pulumi.StringMap{"app": pulumi.String("gitea")}, + }, opts) + if err != nil { + return nil, err + } + + return &Instance{VM: vm, Disk: disk}, nil +} + +// gatewayFor returns the .1 address of a /24. The podman network's gateway has +// to be stated explicitly alongside the pinned subnet. +func gatewayFor(cidr string) string { + var a, b, c, d, bits int + if _, err := fmt.Sscanf(cidr, "%d.%d.%d.%d/%d", &a, &b, &c, &d, &bits); err != nil { + return "" + } + return fmt.Sprintf("%d.%d.%d.1", a, b, c) +} diff --git a/infra/pkg/config/config.go b/infra/pkg/config/config.go new file mode 100644 index 0000000..aa1a95d --- /dev/null +++ b/infra/pkg/config/config.go @@ -0,0 +1,143 @@ +// Package config turns loose Pulumi stack config into one typed struct, so the +// rest of the program never reaches back into the config bag and every key has +// exactly one spelling. +package config + +import ( + "fmt" + "strings" + + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" +) + +type Config struct { + Project string + Region string + Zone string + + Domain string + DNSZone string + ACMEEmail string + AppName string + PodmanCIDR string + + MachineType string + BootDiskGB int + DataDiskGB int + + RequireSigninView bool + + // WAFMode is the Coraza SecRuleEngine setting. It also decides whether the + // fail2ban jail that acts on WAF verdicts is enabled at all. + WAFMode string + + GitHubOwner string + GitHubRepo string + GitHubInstallationID int + GitHubPATSecret string + + InfraBuildServiceAccount string +} + +func Load(ctx *pulumi.Context) (*Config, error) { + gcp := config.New(ctx, "gcp") + c := config.New(ctx, "gitea") + + cfg := &Config{ + Project: gcp.Require("project"), + Region: gcp.Get("region"), + + Zone: c.Get("zone"), + Domain: c.Require("domain"), + DNSZone: c.Require("dnsZone"), + ACMEEmail: c.Require("acmeEmail"), + AppName: c.Get("appName"), + PodmanCIDR: c.Get("podmanSubnet"), + + MachineType: c.Get("machineType"), + BootDiskGB: c.GetInt("bootDiskGb"), + DataDiskGB: c.GetInt("dataDiskGb"), + + RequireSigninView: c.GetBool("requireSigninView"), + WAFMode: c.Get("wafMode"), + + GitHubOwner: c.Get("githubOwner"), + GitHubRepo: c.Get("githubRepo"), + GitHubInstallationID: c.GetInt("githubAppInstallationId"), + GitHubPATSecret: c.Get("githubPatSecret"), + + InfraBuildServiceAccount: c.Get("infraBuildServiceAccount"), + } + + applyDefaults(cfg) + return cfg, validate(cfg) +} + +func applyDefaults(c *Config) { + if c.Region == "" { + c.Region = "us-east1" + } + if c.Zone == "" { + // "-b", not "-a": us-east1 has zones b, c and d and NO -a zone, so the + // obvious default would generate an invalid zone in the very region this + // stack targets. "-b" exists in every US region we would plausibly use. + // Set gitea:zone explicitly if you care which one. + c.Zone = c.Region + "-b" + } + if c.AppName == "" { + c.AppName = "Gitea" + } + if c.PodmanCIDR == "" { + c.PodmanCIDR = "10.89.10.0/24" + } + if c.MachineType == "" { + c.MachineType = "e2-small" + } + if c.BootDiskGB == 0 { + c.BootDiskGB = 20 + } + if c.DataDiskGB == 0 { + c.DataDiskGB = 30 + } + if c.GitHubPATSecret == "" { + c.GitHubPATSecret = "github-pat" + } + if c.WAFMode == "" { + // Detect first, tune, then block. Starting a WAF in blocking mode in + // front of an app that legitimately carries code and markdown in POST + // bodies is how you get paged on day one. + c.WAFMode = "DetectionOnly" + } +} + +func validate(c *Config) error { + if !strings.Contains(c.Domain, ".") { + return fmt.Errorf("gitea:domain %q does not look like a hostname", c.Domain) + } + if !strings.HasPrefix(c.Zone, c.Region) { + return fmt.Errorf("gitea:zone %q is not in gcp:region %q", c.Zone, c.Region) + } + switch c.WAFMode { + case "On", "DetectionOnly", "Off": + default: + return fmt.Errorf("gitea:wafMode %q must be On, DetectionOnly, or Off", c.WAFMode) + } + return nil +} + +// GitHubConfigured reports whether enough GitHub settings are present to wire up +// push triggers. Everything else still deploys without them, so a fresh stack can +// come up before the GitHub App install is done. +func (c *Config) GitHubConfigured() bool { + return c.GitHubOwner != "" && + c.GitHubOwner != "CHANGEME" && + c.GitHubRepo != "" && + c.GitHubInstallationID > 0 +} + +// ARHost is the Artifact Registry endpoint for the configured region. +func (c *Config) ARHost() string { return c.Region + "-docker.pkg.dev" } + +// FQDN returns the domain with a trailing dot, as Cloud DNS requires. +func (c *Config) FQDN() string { return c.Domain + "." } diff --git a/infra/pkg/dns/dns.go b/infra/pkg/dns/dns.go new file mode 100644 index 0000000..e3e0c67 --- /dev/null +++ b/infra/pkg/dns/dns.go @@ -0,0 +1,64 @@ +// Package dns manages records in the PRE-EXISTING Cloud DNS zone. +// +// The zone itself is deliberately not a Pulumi resource: it already exists and +// is delegated, so importing it would put a `pulumi destroy` one keystroke away +// from deleting live DNS. +package dns + +import ( + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/dns" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" +) + +type DNS struct { + Zone *dns.LookupManagedZoneResult + Record *dns.RecordSet +} + +// New looks up the existing managed zone, points an A record at the static +// address, and grants the VM service account permission to write ACME challenge +// records -- scoped to this one zone rather than the whole project. +func New( + ctx *pulumi.Context, + cfg *config.Config, + addr *compute.Address, + vmServiceAccountEmail pulumi.StringOutput, + deps []pulumi.Resource, +) (*DNS, error) { + zone, err := dns.LookupManagedZone(ctx, &dns.LookupManagedZoneArgs{ + Name: cfg.DNSZone, + Project: &cfg.Project, + }, pulumi.DependsOn(deps)) + if err != nil { + return nil, err + } + + rec, err := dns.NewRecordSet(ctx, "gitea-a", &dns.RecordSetArgs{ + Name: pulumi.String(cfg.FQDN()), + ManagedZone: pulumi.String(zone.Name), + Type: pulumi.String("A"), + // Short enough that a VM rebuild behind a new address is not a long + // outage, long enough not to hammer the resolvers. + Ttl: pulumi.Int(300), + Rrdatas: pulumi.StringArray{addr.Address}, + }, pulumi.DependsOn(deps)) + if err != nil { + return nil, err + } + + // Caddy writes and deletes _acme-challenge TXT records here for DNS-01. + // Zone-scoped rather than project-wide: a compromised VM should not be able + // to repoint unrelated domains. + if _, err := dns.NewDnsManagedZoneIamMember(ctx, "gitea-vm-dns-admin", &dns.DnsManagedZoneIamMemberArgs{ + ManagedZone: pulumi.String(zone.Name), + Role: pulumi.String("roles/dns.admin"), + Member: pulumi.Sprintf("serviceAccount:%s", vmServiceAccountEmail), + }, pulumi.DependsOn(deps)); err != nil { + return nil, err + } + + return &DNS{Zone: zone, Record: rec}, nil +} diff --git a/infra/pkg/iam/iam.go b/infra/pkg/iam/iam.go new file mode 100644 index 0000000..73436c1 --- /dev/null +++ b/infra/pkg/iam/iam.go @@ -0,0 +1,164 @@ +// Package iam creates the workload identities and grants them the narrowest set +// of roles that still works. +// +// Note what is NOT here: the Cloud Build service account that runs Pulumi +// itself. That one is created by scripts/bootstrap.sh, because it is the +// identity performing the very `pulumi up` that would create it. +package iam + +import ( + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/artifactregistry" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/projects" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/secretmanager" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/serviceaccount" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/storage" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" +) + +type Accounts struct { + // VM runs the containers; it reads secrets, pulls images, writes backups, + // and answers ACME DNS-01 challenges. + VM *serviceaccount.Account + // Image builds and pushes container images, then triggers the rollout over + // an IAP tunnel. + Image *serviceaccount.Account +} + +func New(ctx *pulumi.Context, cfg *config.Config, deps []pulumi.Resource) (*Accounts, error) { + opts := pulumi.DependsOn(deps) + + vm, err := serviceaccount.NewAccount(ctx, "gitea-vm-sa", &serviceaccount.AccountArgs{ + AccountId: pulumi.String("gitea-vm"), + DisplayName: pulumi.String("Gitea VM"), + Description: pulumi.String("Workload identity for the Gitea instance"), + }, opts) + if err != nil { + return nil, err + } + + img, err := serviceaccount.NewAccount(ctx, "cb-image-sa", &serviceaccount.AccountArgs{ + AccountId: pulumi.String("cb-image"), + DisplayName: pulumi.String("Cloud Build: images"), + Description: pulumi.String("Builds and pushes container images, then rolls them out"), + }, opts) + if err != nil { + return nil, err + } + + // Observability only. Everything with real blast radius is granted per + // resource further down. + vmProjectRoles := []string{ + "roles/logging.logWriter", + "roles/monitoring.metricWriter", + } + for _, role := range vmProjectRoles { + if _, err := projects.NewIAMMember(ctx, "vm-"+role, &projects.IAMMemberArgs{ + Project: pulumi.String(cfg.Project), + Role: pulumi.String(role), + Member: pulumi.Sprintf("serviceAccount:%s", vm.Email), + }, opts); err != nil { + return nil, err + } + } + + imageProjectRoles := []string{ + "roles/logging.logWriter", + // Reaching the VM without a public SSH port. + "roles/iap.tunnelResourceAccessor", + // Needed to push an ephemeral SSH key via OS Login; osLogin (non-admin) + // is not enough because the rollout runs `sudo systemctl`. + "roles/compute.osAdminLogin", + } + for _, role := range imageProjectRoles { + if _, err := projects.NewIAMMember(ctx, "img-"+role, &projects.IAMMemberArgs{ + Project: pulumi.String(cfg.Project), + Role: pulumi.String(role), + Member: pulumi.Sprintf("serviceAccount:%s", img.Email), + }, opts); err != nil { + return nil, err + } + } + + // OS Login on an instance that runs as gitea-vm@ requires the caller to be + // able to act as that account. + if _, err := serviceaccount.NewIAMMember(ctx, "img-act-as-vm", &serviceaccount.IAMMemberArgs{ + ServiceAccountId: vm.Name, + Role: pulumi.String("roles/iam.serviceAccountUser"), + Member: pulumi.Sprintf("serviceAccount:%s", img.Email), + }, opts); err != nil { + return nil, err + } + + return &Accounts{VM: vm, Image: img}, nil +} + +// GrantRegistry scopes image pull/push to the one repository rather than +// granting project-wide Artifact Registry roles. +func GrantRegistry(ctx *pulumi.Context, cfg *config.Config, a *Accounts, repo *artifactregistry.Repository) error { + if _, err := artifactregistry.NewRepositoryIamMember(ctx, "vm-ar-reader", &artifactregistry.RepositoryIamMemberArgs{ + Project: pulumi.String(cfg.Project), + Location: repo.Location, + Repository: repo.Name, + Role: pulumi.String("roles/artifactregistry.reader"), + Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email), + }); err != nil { + return err + } + _, err := artifactregistry.NewRepositoryIamMember(ctx, "img-ar-writer", &artifactregistry.RepositoryIamMemberArgs{ + Project: pulumi.String(cfg.Project), + Location: repo.Location, + Repository: repo.Name, + Role: pulumi.String("roles/artifactregistry.writer"), + Member: pulumi.Sprintf("serviceAccount:%s", a.Image.Email), + }) + return err +} + +// GrantSecrets gives the VM read access to each Gitea secret individually -- +// not roles/secretmanager.secretAccessor across the project. +// +// Takes ids rather than resources because the secrets are created by +// scripts/bootstrap.sh, not by Pulumi; see package secrets for why. +func GrantSecrets(ctx *pulumi.Context, cfg *config.Config, a *Accounts, names []string) error { + for _, name := range names { + if _, err := secretmanager.NewSecretIamMember(ctx, "vm-read-"+name, &secretmanager.SecretIamMemberArgs{ + Project: pulumi.String(cfg.Project), + SecretId: pulumi.String(name), + Role: pulumi.String("roles/secretmanager.secretAccessor"), + Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email), + }); err != nil { + return err + } + // vm/bootstrap.sh's safety net adds a version if one is somehow missing. + if _, err := secretmanager.NewSecretIamMember(ctx, "vm-add-"+name, &secretmanager.SecretIamMemberArgs{ + Project: pulumi.String(cfg.Project), + SecretId: pulumi.String(name), + Role: pulumi.String("roles/secretmanager.secretVersionAdder"), + Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email), + }); err != nil { + return err + } + } + return nil +} + +// GrantBuckets: read-only on config, write-only on backups. The VM can create a +// backup but cannot read or delete existing ones, which limits what ransomware +// on the box could do to the backup history. +func GrantBuckets(ctx *pulumi.Context, a *Accounts, configBucket, backupBucket *storage.Bucket) error { + if _, err := storage.NewBucketIAMMember(ctx, "vm-config-reader", &storage.BucketIAMMemberArgs{ + Bucket: configBucket.Name, + Role: pulumi.String("roles/storage.objectViewer"), + Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email), + }); err != nil { + return err + } + _, err := storage.NewBucketIAMMember(ctx, "vm-backup-creator", &storage.BucketIAMMemberArgs{ + Bucket: backupBucket.Name, + Role: pulumi.String("roles/storage.objectCreator"), + Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email), + }) + return err +} diff --git a/infra/pkg/network/network.go b/infra/pkg/network/network.go new file mode 100644 index 0000000..7855f77 --- /dev/null +++ b/infra/pkg/network/network.go @@ -0,0 +1,104 @@ +// Package network builds the VPC, its firewall rules, and the static address the +// DNS A record points at. +package network + +import ( + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" +) + +// Tag is applied to the instance and targeted by every firewall rule below. +const Tag = "gitea" + +// IAPRange is the fixed source range Google's IAP TCP forwarding connects from. +// It is the only path to port 22 -- there is no other SSH ingress. +const IAPRange = "35.235.240.0/20" + +type Network struct { + Network *compute.Network + Subnetwork *compute.Subnetwork + Address *compute.Address +} + +func New(ctx *pulumi.Context, cfg *config.Config, deps []pulumi.Resource) (*Network, error) { + opts := pulumi.DependsOn(deps) + + net, err := compute.NewNetwork(ctx, "gitea-vpc", &compute.NetworkArgs{ + Name: pulumi.String("gitea-vpc"), + // Custom mode: auto mode would create a subnet in every region, which is + // a lot of unused surface for a single VM. + AutoCreateSubnetworks: pulumi.Bool(false), + Description: pulumi.String("Gitea deployment network"), + }, opts) + if err != nil { + return nil, err + } + + subnet, err := compute.NewSubnetwork(ctx, "gitea-subnet", &compute.SubnetworkArgs{ + Name: pulumi.String("gitea-subnet"), + Network: net.ID(), + IpCidrRange: pulumi.String("10.10.0.0/24"), + Region: pulumi.String(cfg.Region), + PrivateIpGoogleAccess: pulumi.Bool(true), + }, opts) + if err != nil { + return nil, err + } + + addr, err := compute.NewAddress(ctx, "gitea-ip", &compute.AddressArgs{ + Name: pulumi.String("gitea-ip"), + Region: pulumi.String(cfg.Region), + AddressType: pulumi.String("EXTERNAL"), + Description: pulumi.String("Static address for gitea-vm; the DNS A record depends on it"), + }, opts) + if err != nil { + return nil, err + } + + if _, err := compute.NewFirewall(ctx, "gitea-allow-web", &compute.FirewallArgs{ + Name: pulumi.String("gitea-allow-web"), + Network: net.ID(), + Allows: compute.FirewallAllowArray{ + &compute.FirewallAllowArgs{Protocol: pulumi.String("tcp"), Ports: pulumi.ToStringArray([]string{"80", "443"})}, + // HTTP/3. + &compute.FirewallAllowArgs{Protocol: pulumi.String("udp"), Ports: pulumi.ToStringArray([]string{"443"})}, + }, + SourceRanges: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + TargetTags: pulumi.ToStringArray([]string{Tag}), + // Port 80 exists only for the HTTP->HTTPS redirect. ACME uses DNS-01, so + // certificates never depend on inbound 80 and it can be dropped if wanted. + Description: pulumi.String("Public HTTPS (and HTTP redirect) for Gitea"), + }, opts); err != nil { + return nil, err + } + + if _, err := compute.NewFirewall(ctx, "gitea-allow-git-ssh", &compute.FirewallArgs{ + Name: pulumi.String("gitea-allow-git-ssh"), + Network: net.ID(), + Allows: compute.FirewallAllowArray{ + &compute.FirewallAllowArgs{Protocol: pulumi.String("tcp"), Ports: pulumi.ToStringArray([]string{"2222"})}, + }, + SourceRanges: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + TargetTags: pulumi.ToStringArray([]string{Tag}), + Description: pulumi.String("git over SSH -- Gitea's built-in server, not host sshd"), + }, opts); err != nil { + return nil, err + } + + if _, err := compute.NewFirewall(ctx, "gitea-allow-iap-ssh", &compute.FirewallArgs{ + Name: pulumi.String("gitea-allow-iap-ssh"), + Network: net.ID(), + Allows: compute.FirewallAllowArray{ + &compute.FirewallAllowArgs{Protocol: pulumi.String("tcp"), Ports: pulumi.ToStringArray([]string{"22"})}, + }, + SourceRanges: pulumi.ToStringArray([]string{IAPRange}), + TargetTags: pulumi.ToStringArray([]string{Tag}), + Description: pulumi.String("Admin SSH via IAP TCP forwarding only"), + }, opts); err != nil { + return nil, err + } + + return &Network{Network: net, Subnetwork: subnet, Address: addr}, nil +} diff --git a/infra/pkg/project/apis.go b/infra/pkg/project/apis.go new file mode 100644 index 0000000..9fac767 --- /dev/null +++ b/infra/pkg/project/apis.go @@ -0,0 +1,44 @@ +// Package project enables the Google APIs the rest of the stack depends on. +package project + +import ( + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/projects" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +// Services are enabled explicitly so a fresh project works without anyone +// clicking through the console first. +var services = []string{ + "compute.googleapis.com", + "dns.googleapis.com", + "artifactregistry.googleapis.com", + "cloudbuild.googleapis.com", + "secretmanager.googleapis.com", + "iap.googleapis.com", + "storage.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "cloudscheduler.googleapis.com", + "iamcredentials.googleapis.com", + "oslogin.googleapis.com", +} + +// EnableAPIs returns resources every other package should depend on, so nothing +// races an API that is still turning on. +func EnableAPIs(ctx *pulumi.Context) ([]pulumi.Resource, error) { + var out []pulumi.Resource + for _, s := range services { + svc, err := projects.NewService(ctx, "api-"+s, &projects.ServiceArgs{ + Service: pulumi.String(s), + // Tearing down the stack should not disable APIs that other things + // in the project may be using. + DisableOnDestroy: pulumi.Bool(false), + DisableDependentServices: pulumi.Bool(false), + }) + if err != nil { + return nil, err + } + out = append(out, svc) + } + return out, nil +} diff --git a/infra/pkg/registry/registry.go b/infra/pkg/registry/registry.go new file mode 100644 index 0000000..9c8c28c --- /dev/null +++ b/infra/pkg/registry/registry.go @@ -0,0 +1,39 @@ +// Package registry holds the Artifact Registry repository the VM pulls from. +package registry + +import ( + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/artifactregistry" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" +) + +const RepoID = "gitea" + +func New(ctx *pulumi.Context, cfg *config.Config, deps []pulumi.Resource) (*artifactregistry.Repository, error) { + return artifactregistry.NewRepository(ctx, "gitea-repo", &artifactregistry.RepositoryArgs{ + RepositoryId: pulumi.String(RepoID), + Location: pulumi.String(cfg.Region), + Format: pulumi.String("DOCKER"), + Description: pulumi.String("Gitea and Caddy images, Debian 13 base"), + CleanupPolicies: artifactregistry.RepositoryCleanupPolicyArray{ + // Untagged layers accumulate fast with a weekly rebuild. + &artifactregistry.RepositoryCleanupPolicyArgs{ + Id: pulumi.String("delete-untagged"), + Action: pulumi.String("DELETE"), + Condition: &artifactregistry.RepositoryCleanupPolicyConditionArgs{ + TagState: pulumi.String("UNTAGGED"), + OlderThan: pulumi.String("604800s"), // 7 days + }, + }, + // Keep enough tagged versions to roll back a few deploys. + &artifactregistry.RepositoryCleanupPolicyArgs{ + Id: pulumi.String("keep-recent"), + Action: pulumi.String("KEEP"), + MostRecentVersions: &artifactregistry.RepositoryCleanupPolicyMostRecentVersionsArgs{ + KeepCount: pulumi.Int(20), + }, + }, + }, + }, pulumi.DependsOn(deps)) +} diff --git a/infra/pkg/secrets/secrets.go b/infra/pkg/secrets/secrets.go new file mode 100644 index 0000000..2940ee1 --- /dev/null +++ b/infra/pkg/secrets/secrets.go @@ -0,0 +1,28 @@ +// Package secrets names Gitea's signing secrets. It deliberately creates +// nothing. +// +// The secrets are created and populated by scripts/bootstrap.sh, outside +// Pulumi, for two reasons: +// +// 1. Ordering. INTERNAL_TOKEN must be a valid Gitea-issued JWT, not a random +// string, so `gitea generate secret` has to produce it. If Pulumi owned the +// containers, the values could only be added after `pulumi up` -- but the VM +// boots during that same `pulumi up` and wants to render app.ini. +// +// 2. Blast radius. `pulumi destroy` should not be able to delete the keys that +// every existing session, OAuth token, and LFS URL is signed with. Same +// reasoning as the pre-existing DNS zone and the backup bucket. +// +// Pulumi still grants the VM access to them by id -- an IAM binding does not +// require owning the resource. +package secrets + +// Names are the Secret Manager secret ids. vm/bootstrap.sh maps each one to its +// `gitea generate secret` argument by suffix, so renaming these means updating +// fetch_or_create_secret too. +var Names = []string{ + "gitea-secret-key", + "gitea-internal-token", + "gitea-oauth2-jwt-secret", + "gitea-lfs-jwt-secret", +} diff --git a/infra/pkg/storage/storage.go b/infra/pkg/storage/storage.go new file mode 100644 index 0000000..6e562d1 --- /dev/null +++ b/infra/pkg/storage/storage.go @@ -0,0 +1,127 @@ +// Package storage holds the two buckets and, importantly, uploads the vm/ tree +// as Pulumi-managed objects. +// +// Uploading the VM configuration through Pulumi (rather than a `gcloud storage +// rsync` in a build step) means `pulumi preview` shows exactly which quadlet or +// template changed, and drift on the bucket is visible in state. +package storage + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/storage" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "gitea-infra/pkg/config" +) + +type Buckets struct { + Config *storage.Bucket + Backup *storage.Bucket + // ConfigHash changes whenever any file under vm/ changes. It is written into + // instance metadata so a config change is visible from `describe`, and so + // there is something to compare against when debugging drift. + ConfigHash string +} + +func New(ctx *pulumi.Context, cfg *config.Config, vmDir string, deps []pulumi.Resource) (*Buckets, error) { + opts := pulumi.DependsOn(deps) + + configBucket, err := storage.NewBucket(ctx, "gitea-config", &storage.BucketArgs{ + Name: pulumi.Sprintf("%s-gitea-config", cfg.Project), + Location: pulumi.String(strings.ToUpper(cfg.Region)), + // Uniform access: per-object ACLs are a footgun and IAM already covers it. + UniformBucketLevelAccess: pulumi.Bool(true), + PublicAccessPrevention: pulumi.String("enforced"), + Versioning: &storage.BucketVersioningArgs{Enabled: pulumi.Bool(true)}, + ForceDestroy: pulumi.Bool(true), + }, opts) + if err != nil { + return nil, err + } + + backupBucket, err := storage.NewBucket(ctx, "gitea-backups", &storage.BucketArgs{ + Name: pulumi.Sprintf("%s-gitea-backups", cfg.Project), + Location: pulumi.String(strings.ToUpper(cfg.Region)), + UniformBucketLevelAccess: pulumi.Bool(true), + PublicAccessPrevention: pulumi.String("enforced"), + Versioning: &storage.BucketVersioningArgs{Enabled: pulumi.Bool(true)}, + LifecycleRules: storage.BucketLifecycleRuleArray{ + &storage.BucketLifecycleRuleArgs{ + Action: &storage.BucketLifecycleRuleActionArgs{ + Type: pulumi.String("SetStorageClass"), + StorageClass: pulumi.String("NEARLINE"), + }, + Condition: &storage.BucketLifecycleRuleConditionArgs{Age: pulumi.Int(30)}, + }, + &storage.BucketLifecycleRuleArgs{ + Action: &storage.BucketLifecycleRuleActionArgs{Type: pulumi.String("Delete")}, + Condition: &storage.BucketLifecycleRuleConditionArgs{Age: pulumi.Int(365)}, + }, + }, + // No ForceDestroy: dumps are the disaster-recovery path and should not + // disappear because someone ran `pulumi destroy`. + }, opts) + if err != nil { + return nil, err + } + + hash, err := uploadTree(ctx, configBucket, vmDir) + if err != nil { + return nil, err + } + + return &Buckets{Config: configBucket, Backup: backupBucket, ConfigHash: hash}, nil +} + +// uploadTree mirrors vmDir into gs:///vm/ and returns a content hash of +// the whole tree. +func uploadTree(ctx *pulumi.Context, bucket *storage.Bucket, vmDir string) (string, error) { + var paths []string + err := filepath.WalkDir(vmDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + paths = append(paths, path) + return nil + }) + if err != nil { + return "", fmt.Errorf("walking %s: %w", vmDir, err) + } + // Deterministic order, so the hash is stable across machines. + sort.Strings(paths) + + sum := sha256.New() + for _, p := range paths { + rel, err := filepath.Rel(vmDir, p) + if err != nil { + return "", err + } + rel = filepath.ToSlash(rel) + + content, err := os.ReadFile(p) + if err != nil { + return "", err + } + sum.Write([]byte(rel)) + sum.Write(content) + + if _, err := storage.NewBucketObject(ctx, "vm/"+rel, &storage.BucketObjectArgs{ + Name: pulumi.String("vm/" + rel), + Bucket: bucket.Name, + Source: pulumi.NewFileAsset(p), + }); err != nil { + return "", err + } + } + return hex.EncodeToString(sum.Sum(nil))[:16], nil +} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..c3f3a00 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# +# One-time project bootstrap, run from a workstation BEFORE the first +# `pulumi up`. +# +# Everything here exists because Pulumi cannot create it: +# +# * the GCS bucket that holds Pulumi's own state +# * the passphrase that encrypts that state +# * the service account that RUNS Pulumi in Cloud Build +# * Gitea's signing secrets -- INTERNAL_TOKEN must be a valid Gitea-issued +# JWT, so `gitea generate secret` has to produce it, and the values must +# exist before the VM first boots and tries to render app.ini +# +# Idempotent: safe to re-run. +# +# Usage: scripts/bootstrap.sh [region] (default region: us-east1) + +set -euo pipefail + +PROJECT="${1:-}" +REGION="${2:-us-east1}" + +if [[ -z "${PROJECT}" ]]; then + echo "usage: $0 [region]" >&2 + exit 1 +fi + +STATE_BUCKET="${PROJECT}-pulumi-state" +INFRA_SA="cb-infra" +INFRA_SA_EMAIL="${INFRA_SA}@${PROJECT}.iam.gserviceaccount.com" +GITEA_IMAGE="docker.io/gitea/gitea:latest" + +log() { echo "==> $*"; } + +command -v gcloud >/dev/null || { echo "gcloud is required" >&2; exit 1; } + +# --------------------------------------------------------------------------- +log "enabling APIs" +# --------------------------------------------------------------------------- +gcloud services enable --project="${PROJECT}" \ + compute.googleapis.com \ + dns.googleapis.com \ + artifactregistry.googleapis.com \ + cloudbuild.googleapis.com \ + secretmanager.googleapis.com \ + iap.googleapis.com \ + storage.googleapis.com \ + logging.googleapis.com \ + monitoring.googleapis.com \ + cloudscheduler.googleapis.com \ + iamcredentials.googleapis.com \ + oslogin.googleapis.com + +# --------------------------------------------------------------------------- +log "creating Pulumi state bucket gs://${STATE_BUCKET}" +# --------------------------------------------------------------------------- +if ! gcloud storage buckets describe "gs://${STATE_BUCKET}" --project="${PROJECT}" >/dev/null 2>&1; then + gcloud storage buckets create "gs://${STATE_BUCKET}" \ + --project="${PROJECT}" \ + --location="${REGION}" \ + --uniform-bucket-level-access \ + --public-access-prevention +fi +# Versioning is the undo button for a corrupted or truncated state file. +gcloud storage buckets update "gs://${STATE_BUCKET}" --versioning --project="${PROJECT}" + +# --------------------------------------------------------------------------- +log "creating secrets" +# --------------------------------------------------------------------------- +ensure_secret() { + local name="$1" + if ! gcloud secrets describe "${name}" --project="${PROJECT}" >/dev/null 2>&1; then + gcloud secrets create "${name}" --project="${PROJECT}" \ + --replication-policy=automatic --labels=app=gitea + fi +} + +has_version() { + gcloud secrets versions list "$1" --project="${PROJECT}" \ + --filter='state:ENABLED' --limit=1 --format='value(name)' 2>/dev/null | grep -q . +} + +# Pulumi's state encryption passphrase. Generated here so it never lives in a +# shell history or a config file. +ensure_secret pulumi-config-passphrase +if ! has_version pulumi-config-passphrase; then + log "generating Pulumi state passphrase" + openssl rand -base64 48 | tr -d '\n' \ + | gcloud secrets versions add pulumi-config-passphrase --project="${PROJECT}" --data-file=- +fi + +# The GitHub PAT for the Cloud Build connection. Created empty on purpose -- +# a PAT is an interactive artifact and cannot be generated here. +ensure_secret github-pat +if ! has_version github-pat; then + echo " NOTE: secret 'github-pat' has no value yet." + echo " Create a GitHub PAT with repo + read:user scope and run:" + echo " printf %s '' | gcloud secrets versions add github-pat --project=${PROJECT} --data-file=-" +fi + +# Gitea's signing secrets. These MUST come from `gitea generate secret`: +# INTERNAL_TOKEN is a JWT, and a random string there produces an instance that +# starts and then fails every internal API call in a confusing way. +declare -A GITEA_SECRETS=( + [gitea-secret-key]=SECRET_KEY + [gitea-internal-token]=INTERNAL_TOKEN + [gitea-oauth2-jwt-secret]=JWT_SECRET + [gitea-lfs-jwt-secret]=LFS_JWT_SECRET +) + +runner="" +for candidate in podman docker; do + command -v "${candidate}" >/dev/null 2>&1 && { runner="${candidate}"; break; } +done + +for name in "${!GITEA_SECRETS[@]}"; do + ensure_secret "${name}" + if has_version "${name}"; then + log "secret ${name} already populated -- leaving it alone" + continue + fi + if [[ -z "${runner}" ]]; then + echo " WARNING: no podman/docker available; cannot generate ${name}." >&2 + echo " Install one and re-run, or the VM will skip rendering app.ini." >&2 + continue + fi + log "generating ${name}" + # The upstream image is used only as a throwaway generator here; the + # deployed image is our own Debian 13 build. + "${runner}" run --rm "${GITEA_IMAGE}" gitea generate secret "${GITEA_SECRETS[$name]}" \ + | tr -d '\n' \ + | gcloud secrets versions add "${name}" --project="${PROJECT}" --data-file=- +done + +# --------------------------------------------------------------------------- +log "creating the Pulumi runner service account ${INFRA_SA_EMAIL}" +# --------------------------------------------------------------------------- +# This is the chicken-and-egg account: it is the identity that runs `pulumi up`, +# so it cannot be created by `pulumi up`. +if ! gcloud iam service-accounts describe "${INFRA_SA_EMAIL}" --project="${PROJECT}" >/dev/null 2>&1; then + gcloud iam service-accounts create "${INFRA_SA}" \ + --project="${PROJECT}" \ + --display-name="Cloud Build: infrastructure (runs Pulumi)" +fi + +# Broad by necessity -- Pulumi manages IAM, compute, DNS, and secrets bindings. +# Deliberately a different identity from cb-image@, which only pushes images. +INFRA_ROLES=( + roles/compute.admin + roles/dns.admin + roles/artifactregistry.admin + roles/secretmanager.admin + roles/iam.serviceAccountAdmin + roles/iam.serviceAccountUser + roles/resourcemanager.projectIamAdmin + roles/serviceusage.serviceUsageAdmin + roles/cloudscheduler.admin + roles/cloudbuild.builds.editor + roles/iap.tunnelResourceAccessor + roles/compute.osAdminLogin + roles/logging.logWriter +) +for role in "${INFRA_ROLES[@]}"; do + gcloud projects add-iam-policy-binding "${PROJECT}" \ + --member="serviceAccount:${INFRA_SA_EMAIL}" \ + --role="${role}" \ + --condition=None \ + --quiet >/dev/null +done + +# Pulumi's state lives in the bucket, so the runner needs write access to it -- +# scoped to that bucket rather than project-wide storage admin. +gcloud storage buckets add-iam-policy-binding "gs://${STATE_BUCKET}" \ + --project="${PROJECT}" \ + --member="serviceAccount:${INFRA_SA_EMAIL}" \ + --role=roles/storage.admin >/dev/null + +cat < + 3. cd infra + pulumi login gs://${STATE_BUCKET} + pulumi stack init prod + pulumi config set gcp:project ${PROJECT} + pulumi config set gitea:infraBuildServiceAccount ${INFRA_SA_EMAIL} + # ...plus domain, dnsZone, acmeEmail, githubOwner, githubAppInstallationId + pulumi up + 4. make build # or, spelled out: + gcloud builds submit --config cloudbuild/image.yaml --project ${PROJECT} \\ + --region ${REGION} \\ + --service-account projects/${PROJECT}/serviceAccounts/cb-image@${PROJECT}.iam.gserviceaccount.com + +SUMMARY diff --git a/vm/bootstrap.sh b/vm/bootstrap.sh new file mode 100755 index 0000000..ffdee18 --- /dev/null +++ b/vm/bootstrap.sh @@ -0,0 +1,616 @@ +#!/usr/bin/env bash +# +# Gitea VM bootstrap. Set as the GCE `startup-script` metadata value by Pulumi, +# and re-run by gitea-config-sync.service with --sync-only after a config push. +# +# MUST be idempotent: GCE runs the startup script on every boot. +# +# (no args) full run -- packages, disk, SELinux, firewall, units, config +# --sync-only re-pull vm/ from GCS, re-render templates, restart what changed +# +set -euo pipefail + +readonly STATE_DIR=/opt/gitea-config +readonly RENDER_DIR=/etc/containers/systemd +readonly LOG_TAG=gitea-bootstrap + +log() { echo "[${LOG_TAG}] $*" >&2; } +die() { echo "[${LOG_TAG}] FATAL: $*" >&2; exit 1; } +warn() { echo "[${LOG_TAG}] WARN: $*" >&2; } + +MODE=full +[[ "${1:-}" == "--sync-only" ]] && MODE=sync + +# --------------------------------------------------------------------------- +# Instance metadata (populated by Pulumi) +# --------------------------------------------------------------------------- +meta() { + curl -fsS -H 'Metadata-Flavor: Google' \ + "http://169.254.169.254/computeMetadata/v1/instance/attributes/$1" 2>/dev/null || true +} + +CONFIG_BUCKET=$(meta config-bucket) +BACKUP_BUCKET=$(meta backup-bucket) +GCP_PROJECT=$(meta gcp-project) +AR_HOST=$(meta ar-host) +IMAGE_GITEA=$(meta image-gitea) +IMAGE_CADDY=$(meta image-caddy) +DOMAIN=$(meta domain) +ACME_EMAIL=$(meta acme-email) +APP_NAME=$(meta app-name) +PODMAN_SUBNET=$(meta podman-subnet) +PODMAN_GATEWAY=$(meta podman-gateway) +REQUIRE_SIGNIN_VIEW=$(meta require-signin-view) +WAF_MODE=$(meta waf-mode) +DATA_DISK_DEVICE=$(meta data-disk-device) + +[[ -n "${CONFIG_BUCKET}" ]] || die "config-bucket metadata is missing; nothing to sync from" +[[ -n "${DOMAIN}" ]] || die "domain metadata is missing" +: "${APP_NAME:=Gitea}" +: "${REQUIRE_SIGNIN_VIEW:=false}" +# DetectionOnly is the safe default: run it, read what it flags, add +# exclusions, then switch to On. See docs/waf.md. +: "${WAF_MODE:=DetectionOnly}" +case "${WAF_MODE}" in + On|DetectionOnly|Off) ;; + *) die "waf-mode must be On, DetectionOnly, or Off (got: ${WAF_MODE})" ;; +esac +: "${DATA_DISK_DEVICE:=/dev/disk/by-id/google-gitea-data}" + +export GCP_PROJECT AR_HOST IMAGE_GITEA IMAGE_CADDY DOMAIN ACME_EMAIL APP_NAME +export PODMAN_SUBNET PODMAN_GATEWAY REQUIRE_SIGNIN_VIEW WAF_MODE + +# --------------------------------------------------------------------------- +# Packages +# --------------------------------------------------------------------------- +install_packages() { + log "installing packages" + dnf -y install \ + podman container-selinux \ + nftables \ + jq gettext \ + policycoreutils-python-utils \ + xfsprogs + + # fail2ban lives in EPEL on RHEL-family distros. The exact package set has + # shifted between EPEL releases, so probe rather than assume -- this is the + # one dependency most likely to be named differently on EPEL 10. + if ! rpm -q epel-release >/dev/null 2>&1; then + dnf -y install epel-release || warn "epel-release unavailable; fail2ban will be skipped" + fi + if dnf -y install fail2ban fail2ban-server 2>/dev/null; then + # The systemd journal backend needs the Python bindings; without them + # fail2ban silently falls back and matches nothing. + dnf -y install python3-systemd || warn "python3-systemd missing; the systemd backend may not work" + else + warn "fail2ban not installable from configured repos -- skipping fail2ban setup" + fi +} + +# --------------------------------------------------------------------------- +# Data disk +# --------------------------------------------------------------------------- +setup_data_disk() { + log "configuring data disk ${DATA_DISK_DEVICE}" + [[ -e "${DATA_DISK_DEVICE}" ]] || die "data disk ${DATA_DISK_DEVICE} not present" + + if ! blkid "${DATA_DISK_DEVICE}" >/dev/null 2>&1; then + log "disk is unformatted -- creating XFS filesystem" + mkfs.xfs -q "${DATA_DISK_DEVICE}" + fi + + local uuid + uuid=$(blkid -s UUID -o value "${DATA_DISK_DEVICE}") + [[ -n "${uuid}" ]] || die "could not read UUID from ${DATA_DISK_DEVICE}" + + mkdir -p /var/lib/gitea + + # By UUID, never by device path: GCE can reorder /dev/sdX across reboots. + if ! grep -q "UUID=${uuid}" /etc/fstab; then + log "adding fstab entry for ${uuid}" + printf 'UUID=%s /var/lib/gitea xfs defaults,nofail,x-systemd.device-timeout=30 0 2\n' \ + "${uuid}" >> /etc/fstab + fi + + systemctl daemon-reload + mountpoint -q /var/lib/gitea || mount /var/lib/gitea + mountpoint -q /var/lib/gitea || die "/var/lib/gitea failed to mount" + + # Set the SELinux label persistently ONCE, rather than putting :Z on the + # quadlet's volume line. :Z would force a recursive relabel of the entire + # repository tree on every container start. + if ! semanage fcontext -l 2>/dev/null | grep -q '^/var/lib/gitea(/\.\*)?'; then + log "setting persistent SELinux fcontext on /var/lib/gitea" + semanage fcontext -a -t container_file_t '/var/lib/gitea(/.*)?' || \ + warn "semanage fcontext failed; check SELinux state" + fi + restorecon -RF /var/lib/gitea || warn "restorecon failed" + + # The mount point itself must belong to the container's uid, not just the + # subdirectories: Gitea creates GITEA_CUSTOM (/var/lib/gitea/custom) at + # startup, and a root-owned 0755 mount point makes that mkdir fail with a + # bare "permission denied" that reads like an SELinux problem. + chown 1000:1000 /var/lib/gitea + chmod 0750 /var/lib/gitea + install -d -o 1000 -g 1000 -m 0750 \ + /var/lib/gitea/data /var/lib/gitea/log /var/lib/gitea/custom +} + +# --------------------------------------------------------------------------- +# Swap +# --------------------------------------------------------------------------- +# GCE instances ship with no swap. On e2-small (2 GB) that is a real risk: a +# steady-state Gitea + Caddy/Coraza pair measures ~155 MB, but git subprocesses +# spawned during a push (git-receive-pack, index-pack, gc) push the total past +# 400 MB on a modest repo and scale with repo size. Without swap, the OOM killer +# picks a victim mid-push. +# +# This is ballast, not working memory -- hence the low swappiness. If the box is +# swapping steadily, the answer is a bigger machine type, not more swap. +setup_swap() { + local swapfile=/swapfile size_mb=2048 + + if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "${swapfile}"; then + log "swap already active" + else + if [[ ! -f "${swapfile}" ]]; then + log "creating ${size_mb}MB swap file" + # dd, not fallocate: a fallocated file can carry unwritten extents + # that mkswap accepts and the kernel then refuses to swap to. + dd if=/dev/zero of="${swapfile}" bs=1M count="${size_mb}" status=none + chmod 0600 "${swapfile}" + mkswap "${swapfile}" >/dev/null + fi + swapon "${swapfile}" || warn "swapon failed" + fi + + grep -q "^${swapfile} " /etc/fstab \ + || printf '%s none swap sw 0 0\n' "${swapfile}" >> /etc/fstab + + echo 'vm.swappiness = 10' > /etc/sysctl.d/90-gitea-swappiness.conf + sysctl -q -p /etc/sysctl.d/90-gitea-swappiness.conf || warn "could not apply swappiness" +} + +# --------------------------------------------------------------------------- +# Podman / netavark +# --------------------------------------------------------------------------- +configure_podman() { + log "configuring podman firewall driver" + mkdir -p /etc/containers/containers.conf.d + # Netavark keeps its rules in a dedicated `netavark` nftables table, which is + # what makes coexistence with our own table workable. Changing this with + # containers running leaves conflicting rules behind -- it is set here, + # before anything starts, and a reboot is the documented fix if it is ever + # changed on a live host. + cat > /etc/containers/containers.conf.d/10-gitea.conf <<'EOF' +[network] +firewall_driver = "nftables" +EOF +} + +# --------------------------------------------------------------------------- +# Host firewall +# --------------------------------------------------------------------------- +# Runs on every invocation so a pushed vm/nftables/gitea.nft change applies +# without waiting for a reboot. +setup_nftables() { + log "installing nftables ruleset" + # firewalld and a hand-managed ruleset will fight. Pick one. + systemctl disable --now firewalld >/dev/null 2>&1 || true + systemctl mask firewalld >/dev/null 2>&1 || true + + install -m 0600 "${STATE_DIR}/nftables/gitea.nft" /etc/sysconfig/nftables.conf + nft -c -f /etc/sysconfig/nftables.conf || die "nftables ruleset failed validation" + systemctl enable --now nftables + systemctl reload nftables + + # If this fails, the ruleset flushed something it should not have. + nft list table inet gitea_filter >/dev/null || die "gitea_filter table missing after reload" +} + +# --------------------------------------------------------------------------- +# Artifact Registry credentials for root podman +# --------------------------------------------------------------------------- +install_ar_auth() { + log "installing Artifact Registry auth refresher" + cat > /usr/local/bin/gitea-ar-auth <<'EOF' +#!/usr/bin/env bash +# Writes a docker-format auth file for Artifact Registry using the VM service +# account's metadata token. +# +# podman-auto-update.service runs as root with no interactive gcloud session, so +# it needs a credential sitting on disk. This is the #1 reason auto-update +# quietly stops working on GCE. +set -euo pipefail + +AR_HOST=$(curl -fsS -H 'Metadata-Flavor: Google' \ + http://169.254.169.254/computeMetadata/v1/instance/attributes/ar-host) +TOKEN=$(curl -fsS -H 'Metadata-Flavor: Google' \ + http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token \ + | jq -r .access_token) + +[[ -n "${TOKEN}" && "${TOKEN}" != "null" ]] || { echo "no access token from metadata server" >&2; exit 1; } + +AUTH=$(printf 'oauth2accesstoken:%s' "${TOKEN}" | base64 -w0) +umask 077 +tmp=$(mktemp /etc/containers/.ar-auth.XXXXXX) +jq -n --arg host "${AR_HOST}" --arg auth "${AUTH}" \ + '{auths: {($host): {auth: $auth}}}' > "${tmp}" +chmod 0600 "${tmp}" +mv "${tmp}" /etc/containers/ar-auth.json +EOF + chmod 0755 /usr/local/bin/gitea-ar-auth + /usr/local/bin/gitea-ar-auth || warn "initial AR auth refresh failed" +} + +# --------------------------------------------------------------------------- +# Helper scripts +# --------------------------------------------------------------------------- +install_helpers() { + log "installing helper scripts" + + cat > /usr/local/bin/gitea-backup <&2; exit 0; } +stamp=\$(date -u +%Y%m%dT%H%M%SZ) +podman exec -u 1000 gitea gitea dump -c /etc/gitea/app.ini -t /tmp -f - \\ + | gcloud storage cp - "gs://\${BUCKET}/dumps/gitea-\${stamp}.zip" +echo "backup complete: gs://\${BUCKET}/dumps/gitea-\${stamp}.zip" +EOF + chmod 0755 /usr/local/bin/gitea-backup + + cat > /usr/local/bin/gitea-reboot-if-needed <<'EOF' +#!/usr/bin/env bash +# Reboots only when the package layer says a reboot is genuinely required. +# +# `needs-restarting -r` exits 0 for "no reboot needed" and 1 for "reboot +# needed". Anything else -- most likely 127 because the dnf5 plugin is packaged +# differently on this release -- means we do not KNOW, and "do not know" must +# never mean "reboot the Gitea host every Sunday". +set -uo pipefail + +dnf needs-restarting -r >/dev/null 2>&1 +rc=$? + +case "${rc}" in + 0) echo "no reboot required" ; exit 0 ;; + 1) echo "reboot required by pending updates -- rebooting" ; systemctl reboot ;; + *) echo "needs-restarting returned ${rc} (plugin missing?) -- NOT rebooting" >&2 + echo "install the dnf needs-restarting plugin, or this check is inert" >&2 + exit 0 ;; +esac +EOF + chmod 0755 /usr/local/bin/gitea-reboot-if-needed +} + +# --------------------------------------------------------------------------- +# Config sync + render +# --------------------------------------------------------------------------- +sync_config() { + log "syncing configuration from gs://${CONFIG_BUCKET}/vm/" + mkdir -p "${STATE_DIR}" + gcloud storage rsync --recursive --delete-unmatched-destination-objects \ + "gs://${CONFIG_BUCKET}/vm" "${STATE_DIR}" \ + || die "config sync failed" +} + +# Reads a Gitea secret from Secret Manager. If it has no version yet, generates +# one -- but ONLY if a Gitea image is available locally to generate it with. +# +# INTERNAL_TOKEN must be a valid Gitea-issued JWT, so this cannot be a random +# string from Pulumi. Normally scripts/bootstrap.sh has already populated these +# before the first `pulumi up`; this is the safety net. +fetch_or_create_secret() { + local name="$1" value="" + if value=$(gcloud secrets versions access latest --secret="${name}" --project="${GCP_PROJECT}" 2>/dev/null); then + printf '%s' "${value}" + return 0 + fi + + if ! podman image exists "${IMAGE_GITEA}" 2>/dev/null; then + return 1 + fi + + local key + case "${name}" in + *secret-key) key=SECRET_KEY ;; + *internal-token) key=INTERNAL_TOKEN ;; + *oauth2-jwt-secret) key=JWT_SECRET ;; + *lfs-jwt-secret) key=LFS_JWT_SECRET ;; + *) return 1 ;; + esac + + log "generating missing secret ${name}" + value=$(podman run --rm "${IMAGE_GITEA}" generate secret "${key}") || return 1 + printf '%s' "${value}" \ + | gcloud secrets versions add "${name}" --project="${GCP_PROJECT}" --data-file=- >/dev/null || return 1 + printf '%s' "${value}" +} + +# Renders src -> dst only if the content actually differs, and reports whether +# it changed. Keeps config-sync from restarting healthy services for no reason. +render() { + local src="$1" dst="$2" owner="$3" mode="$4" vars="$5" + local tmp + tmp=$(mktemp) + envsubst "${vars}" < "${src}" > "${tmp}" + if [[ -f "${dst}" ]] && cmp -s "${tmp}" "${dst}"; then + rm -f "${tmp}" + return 1 + fi + install -o "${owner%:*}" -g "${owner#*:}" -m "${mode}" "${tmp}" "${dst}" + rm -f "${tmp}" + log "rendered ${dst}" + return 0 +} + +# Decides whether Caddy can live on the podman bridge or needs the host network. +# +# The googleclouddns ACME plugin authenticates via Application Default +# Credentials, which on GCE means reaching the metadata server at +# 169.254.169.254. If a container on our bridge cannot reach it, DNS-01 issuance +# fails at certificate time -- long after this script has reported success -- so +# the check happens here, up front, and the answer is cached. +probe_caddy_network() { + local cache=/etc/gitea/caddy-network + if [[ -f "${cache}" ]]; then + cat "${cache}" + return 0 + fi + if ! podman image exists "${IMAGE_GITEA}" 2>/dev/null; then + # Cannot probe yet (first boot, before the first image build). Assume the + # bridge and re-probe on the next config-sync. + echo bridge + return 0 + fi + + # The network normally does not exist yet: on a full boot this runs before + # the quadlet units start. Creating it here with the same arguments quadlet + # uses keeps the probe honest -- otherwise `podman run --network gitea` + # fails and the probe wrongly concludes the metadata server is unreachable. + # stdout is discarded because this function's stdout IS the return value. + podman network create --ignore \ + --subnet "${PODMAN_SUBNET}" --gateway "${PODMAN_GATEWAY}" gitea >/dev/null 2>&1 || true + + local mode=host + if podman run --rm --network gitea --entrypoint curl "${IMAGE_GITEA}" \ + -fsS -m 10 -H 'Metadata-Flavor: Google' \ + http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token \ + >/dev/null 2>&1; then + mode=bridge + else + warn "metadata server unreachable from the podman bridge -- Caddy will use the host network" + fi + mkdir -p /etc/gitea + echo "${mode}" > "${cache}" + echo "${mode}" +} + +render_all() { + log "rendering configuration" + mkdir -p /etc/gitea /etc/caddy "${RENDER_DIR}" + + local secret_key internal_token oauth2_jwt lfs_jwt + if ! secret_key=$(fetch_or_create_secret gitea-secret-key) \ + || ! internal_token=$(fetch_or_create_secret gitea-internal-token) \ + || ! oauth2_jwt=$(fetch_or_create_secret gitea-oauth2-jwt-secret) \ + || ! lfs_jwt=$(fetch_or_create_secret gitea-lfs-jwt-secret); then + # Deliberately NOT a failure: on first boot the image does not exist yet + # and the secrets may not be populated. Writing an app.ini with empty + # SECRET_KEY/INTERNAL_TOKEN would be far worse than doing nothing -- + # Gitea would come up with broken sessions and tokens. + warn "Gitea secrets unavailable -- skipping config render (will retry on the next sync)" + return 0 + fi + + local caddy_mode caddy_network caddy_publish caddy_sysctl gitea_upstream + caddy_mode=$(probe_caddy_network) + if [[ "${caddy_mode}" == "host" ]]; then + caddy_network="host" + caddy_publish="# Network=host: ports are bound directly, publishing would be invalid." + caddy_sysctl="# Network=host: podman rejects net.* sysctls; set on the host instead." + gitea_upstream="127.0.0.1:3000" + # Container-local sysctls are unavailable on the host network, so allow + # unprivileged binds to 80/443 host-wide. Narrower than granting the + # container CAP_NET_BIND_SERVICE. + echo 'net.ipv4.ip_unprivileged_port_start = 80' > /etc/sysctl.d/90-gitea-caddy.conf + sysctl -q -p /etc/sysctl.d/90-gitea-caddy.conf || warn "could not apply unprivileged port sysctl" + else + caddy_network="gitea.network" + caddy_publish=$'PublishPort=80:80\nPublishPort=443:443\nPublishPort=443:443/udp' + caddy_sysctl="Sysctl=net.ipv4.ip_unprivileged_port_start=0" + gitea_upstream="gitea:3000" + rm -f /etc/sysctl.d/90-gitea-caddy.conf + fi + + # Trust both the bridge CIDR and loopback so this value stays correct in + # either Caddy networking mode. Rootful podman SNATs host-loopback traffic + # to the bridge gateway, so the CIDR covers the host-network case too. + local trusted_proxies="${PODMAN_SUBNET},127.0.0.1/32" + + local changed=0 + + export GITEA_SECRET_KEY="${secret_key}" \ + GITEA_INTERNAL_TOKEN="${internal_token}" \ + GITEA_OAUTH2_JWT_SECRET="${oauth2_jwt}" \ + GITEA_LFS_JWT_SECRET="${lfs_jwt}" \ + TRUSTED_PROXIES="${trusted_proxies}" \ + GITEA_UPSTREAM="${gitea_upstream}" \ + CADDY_NETWORK="${caddy_network}" \ + CADDY_PUBLISH_PORTS="${caddy_publish}" \ + CADDY_SYSCTL="${caddy_sysctl}" + + # app.ini is 0400 owned by uid 1000: it holds SECRET_KEY and INTERNAL_TOKEN, + # and the container runs as that uid and must be able to read it. + render "${STATE_DIR}/config/app.ini.tmpl" /etc/gitea/app.ini 1000:1000 0400 \ + '${APP_NAME} ${DOMAIN} ${GITEA_SECRET_KEY} ${GITEA_INTERNAL_TOKEN} ${GITEA_OAUTH2_JWT_SECRET} ${GITEA_LFS_JWT_SECRET} ${TRUSTED_PROXIES} ${REQUIRE_SIGNIN_VIEW}' \ + && changed=1 + + render "${STATE_DIR}/config/Caddyfile.tmpl" /etc/caddy/Caddyfile root:root 0644 \ + '${DOMAIN} ${ACME_EMAIL} ${GITEA_UPSTREAM} ${WAF_MODE}' \ + && changed=1 + + local unit + for unit in gitea.network gitea.container caddy.container caddy-data.volume caddy-config.volume; do + render "${STATE_DIR}/quadlets/${unit}" "${RENDER_DIR}/${unit}" root:root 0644 \ + '${IMAGE_GITEA} ${IMAGE_CADDY} ${PODMAN_SUBNET} ${PODMAN_GATEWAY} ${GCP_PROJECT} ${CADDY_NETWORK} ${CADDY_PUBLISH_PORTS} ${CADDY_SYSCTL}' \ + && changed=1 + done + + for unit in "${STATE_DIR}"/systemd/*; do + [[ -f "${unit}" ]] || continue + local base; base=$(basename "${unit}") + if ! cmp -s "${unit}" "/etc/systemd/system/${base}"; then + install -m 0644 "${unit}" "/etc/systemd/system/${base}" + log "installed unit ${base}" + changed=1 + fi + done + + unset GITEA_SECRET_KEY GITEA_INTERNAL_TOKEN GITEA_OAUTH2_JWT_SECRET GITEA_LFS_JWT_SECRET + + systemctl daemon-reload + if (( changed )); then + log "configuration changed -- restarting services" + systemctl restart gitea.service || warn "gitea did not restart cleanly" + systemctl restart caddy.service || warn "caddy did not restart cleanly" + else + log "configuration unchanged" + fi +} + +# --------------------------------------------------------------------------- +# fail2ban +# --------------------------------------------------------------------------- +# Runs on every invocation, not just full boots: the jail's enabled flag is +# derived from WAF_MODE, and that has to be re-applied whenever the mode changes. +setup_fail2ban() { + command -v fail2ban-server >/dev/null 2>&1 || { warn "fail2ban not installed; skipping"; return 0; } + log "configuring fail2ban" + install -m 0644 "${STATE_DIR}/fail2ban/action.d/nft-prerouting.conf" /etc/fail2ban/action.d/ + install -m 0644 "${STATE_DIR}/fail2ban/filter.d/gitea.conf" /etc/fail2ban/filter.d/ + install -m 0644 "${STATE_DIR}/fail2ban/filter.d/caddy-coraza.conf" /etc/fail2ban/filter.d/ + + # One config value drives both halves: the WAF only blocks in On, and only + # then is it safe to escalate a WAF verdict into an nftables ban. In + # DetectionOnly the jail is inert so tuning cannot lock anyone out. + local coraza_enabled=false + [[ "${WAF_MODE}" == "On" ]] && coraza_enabled=true + export CORAZA_JAIL_ENABLED="${coraza_enabled}" + log "coraza fail2ban jail enabled=${coraza_enabled} (waf-mode=${WAF_MODE})" + + envsubst '${PODMAN_SUBNET} ${CORAZA_JAIL_ENABLED}' \ + < "${STATE_DIR}/fail2ban/jail.d/gitea.local" > /etc/fail2ban/jail.d/gitea.local + chmod 0644 /etc/fail2ban/jail.d/gitea.local + systemctl enable --now fail2ban + systemctl reload fail2ban || systemctl restart fail2ban +} + +# --------------------------------------------------------------------------- +# Automatic updates +# --------------------------------------------------------------------------- +setup_auto_updates() { + log "configuring automatic updates" + install -m 0644 "${STATE_DIR}/dnf/automatic.conf" /etc/dnf/automatic.conf + + # AlmaLinux 10 ships dnf5, where the unit is dnf5-automatic.timer -- but the + # package providing it has moved around between releases, so resolve it + # instead of hardcoding a name that may not exist. + local timer="" + for candidate in dnf5-automatic.timer dnf-automatic.timer; do + if systemctl list-unit-files "${candidate}" >/dev/null 2>&1 \ + && systemctl cat "${candidate}" >/dev/null 2>&1; then + timer="${candidate}"; break + fi + done + if [[ -z "${timer}" ]]; then + log "no dnf automatic timer present -- installing provider" + dnf -y install "$(dnf -q provides '*/dnf5-automatic.timer' 2>/dev/null | awk 'NR==1{print $1}')" \ + || dnf -y install dnf-automatic \ + || warn "could not install a dnf-automatic provider" + for candidate in dnf5-automatic.timer dnf-automatic.timer; do + systemctl cat "${candidate}" >/dev/null 2>&1 && { timer="${candidate}"; break; } + done + fi + [[ -n "${timer}" ]] && systemctl enable --now "${timer}" || warn "no dnf automatic timer enabled" + + systemctl enable --now podman-auto-update.timer +} + +enable_units() { + log "enabling units" + systemctl daemon-reload + systemctl enable --now gitea-ar-auth.timer + systemctl enable --now gitea-backup.timer + systemctl enable --now gitea-reboot-window.timer + # Quadlet-generated units are not "enabled" in the usual sense -- the + # [Install] section is honoured by the generator at daemon-reload time. + systemctl start gitea.service || warn "gitea not started yet (expected before the first image build)" + systemctl start caddy.service || warn "caddy not started yet (expected before the first image build)" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + log "starting (mode=${MODE})" + + if [[ "${MODE}" == "full" ]]; then + install_packages + setup_data_disk + setup_swap + configure_podman + install_helpers + fi + + sync_config + + # Make the synced copy the canonical one, so gitea-config-sync.service always + # runs the version that matches the config in the bucket. + # + # Under --sync-only this file IS the script bash is currently reading. GNU + # install truncates in place and bash reads scripts incrementally, so a + # naive copy can rewrite the interpreter's input mid-execution -- exactly in + # the case this mechanism exists for (a vm/bootstrap.sh change). Skip when + # identical, and otherwise replace via atomic rename onto a fresh inode so + # the running process keeps reading the old one. + if ! cmp -s "${STATE_DIR}/bootstrap.sh" /usr/local/sbin/gitea-bootstrap; then + install -m 0755 "${STATE_DIR}/bootstrap.sh" /usr/local/sbin/.gitea-bootstrap.new + mv -f /usr/local/sbin/.gitea-bootstrap.new /usr/local/sbin/gitea-bootstrap + log "updated /usr/local/sbin/gitea-bootstrap" + fi + + if [[ "${MODE}" == "full" ]]; then + install_ar_auth + fi + + # setup_nftables and setup_fail2ban run in BOTH modes, deliberately. + # + # They apply configuration that lives in the vm/ tree, so gating them on a + # full boot would mean a pushed change never takes effect until the next + # reboot. The fail2ban case is the dangerous one: flipping gitea:wafMode to + # On re-renders the Caddyfile through render_all and Caddy starts issuing + # 403s, but the jail that acts on them would stay enabled=false -- a WAF + # that blocks and a ban that never happens, with nothing in the logs to say + # so. Both functions are idempotent and self-validating (`nft -c` before + # load, `command -v fail2ban-server` before touching fail2ban). + setup_nftables + setup_fail2ban + + render_all + + if [[ "${MODE}" == "full" ]]; then + setup_auto_updates + enable_units + fi + + log "done" +} + +main "$@" diff --git a/vm/config/Caddyfile.tmpl b/vm/config/Caddyfile.tmpl new file mode 100644 index 0000000..4f8f9a4 --- /dev/null +++ b/vm/config/Caddyfile.tmpl @@ -0,0 +1,111 @@ +# Rendered by vm/bootstrap.sh -> /etc/caddy/Caddyfile +# +# Two compile-time plugins are doing the work here: +# +# googleclouddns -- ACME DNS-01, so issuance and renewal never need inbound 80 +# coraza_waf -- OWASP Coraza with the Core Rule Set embedded in the binary +{ + email ${ACME_EMAIL} + admin 127.0.0.1:2019 + # Required by coraza-caddy: Caddy has no built-in ordering for a third-party + # directive, and the WAF must run before anything that could act on the + # request. Applies inside handle blocks too. + order coraza_waf first +} + +${DOMAIN} { + tls { + dns googleclouddns { + # Application Default Credentials come from the GCE metadata server. + # The VM service account holds roles/dns.admin scoped to this zone only. + gcp_project {env.GCP_PROJECT} + } + # Only used for propagation checks. If issuance stalls waiting for + # propagation, this is the knob to turn. + resolvers 8.8.8.8 8.8.4.4 + } + + encode zstd gzip + + # Governs the git/LFS branch below. The WAF branch has its own, much smaller, + # SecRequestBodyLimit -- they apply to different routes and are not a mismatch + # to be "fixed". + request_body { + max_size 512MB + } + + # --------------------------------------------------------------------- + # Git transport and LFS: deliberately NOT behind the WAF. + # + # Packfiles are binary and trip CRS's SQLi/XSS rules constantly, and + # coraza.conf-recommended's SecRequestBodyLimit would reject any push + # larger than ~12MB outright. Running CRS here does not harden anything; + # it just breaks git. Authentication still applies -- Gitea does that. + # --------------------------------------------------------------------- + @gittransport path_regexp ^/[^/]+/[^/]+/(?:info/refs|git-upload-pack|git-receive-pack|HEAD|objects/.*|info/lfs(?:/.*)?)$ + handle @gittransport { + reverse_proxy ${GITEA_UPSTREAM} { + transport http { + read_timeout 900s + write_timeout 900s + } + } + } + + # --------------------------------------------------------------------- + # Everything else -- web UI and API -- goes through the WAF. + # --------------------------------------------------------------------- + handle { + coraza_waf { + load_owasp_crs + directives ` + Include @coraza.conf-recommended + Include @crs-setup.conf.example + Include @owasp_crs/*.conf + + # DetectionOnly logs what it would have blocked without blocking. + # The fail2ban jail is enabled ONLY when this is On -- banning on + # detections that were never blocks would turn a false positive + # into an nftables ban, which is worse than the 403 it avoided. + SecRuleEngine ${WAF_MODE} + + # Inspecting responses on a git host costs CPU and catches nothing + # worth catching. + SecResponseBodyAccess Off + + # Truncate-and-inspect rather than reject: a large but legitimate + # attachment upload should not 413 because the WAF gave up. + SecRequestBodyLimitAction ProcessPartial + + # RelevantOnly, not On. Auditing every request would pour the full + # request volume into journald and then into Cloud Logging. + SecAuditEngine RelevantOnly + + # The default relevant-status is ^(?:5|4(?!04)), which audits every + # 401 -- and on a public git host unauthenticated API and web probes + # produce those constantly. That noise would bury the would-be blocks + # this log exists to surface. Narrowed to real WAF refusals and + # server errors; rule-triggered transactions are still audited on + # severity regardless of status, which is what keeps DetectionOnly + # useful. + SecAuditLogRelevantStatus "^(?:5[0-9]{2}|403)$" + + SecAuditLog /dev/stdout + SecAuditLogFormat json + SecAuditLogParts ABIJDEFHZ + ` + } + + reverse_proxy ${GITEA_UPSTREAM} { + transport http { + read_timeout 900s + write_timeout 900s + } + } + } + + log { + output stderr + format json + } +} diff --git a/vm/config/app.ini.tmpl b/vm/config/app.ini.tmpl new file mode 100644 index 0000000..2b8eaa9 --- /dev/null +++ b/vm/config/app.ini.tmpl @@ -0,0 +1,107 @@ +; Rendered by vm/bootstrap.sh -> /etc/gitea/app.ini (owner 1000:1000, mode 0400). +; +; This file is FULLY MANAGED. Gitea's GITEA__section__KEY environment support +; comes from the upstream image's `environment-to-ini` entrypoint helper, which +; does not exist on our Debian base -- so configuration happens here, on the +; host, and the file is bind-mounted read-only. +; +; INSTALL_LOCK=true means the web installer is never reachable. Editing Gitea +; settings through the UI that map to app.ini will NOT persist; change the +; template in the repo and let the config-sync pipeline re-render it. + +APP_NAME = ${APP_NAME} +RUN_USER = git +RUN_MODE = prod +WORK_PATH = /var/lib/gitea + +[server] +PROTOCOL = http +HTTP_ADDR = 0.0.0.0 +HTTP_PORT = 3000 +DOMAIN = ${DOMAIN} +ROOT_URL = https://${DOMAIN}/ +APP_DATA_PATH = /var/lib/gitea/data +DISABLE_SSH = false +; Built-in Go SSH server -- no sshd inside the container, and the host's sshd +; keeps port 22 for OS Login / IAP admin access. +START_SSH_SERVER = true +BUILTIN_SSH_SERVER_USER = git +SSH_DOMAIN = ${DOMAIN} +SSH_LISTEN_HOST = 0.0.0.0 +SSH_LISTEN_PORT = 2222 +; Advertised in clone URLs; must match the published host port. +SSH_PORT = 2222 +LFS_START_SERVER = true +LFS_JWT_SECRET = ${GITEA_LFS_JWT_SECRET} +OFFLINE_MODE = true + +[database] +DB_TYPE = sqlite3 +PATH = /var/lib/gitea/data/gitea.db +; WAL is what makes SQLite tolerable under concurrent reads. +SQLITE_JOURNAL_MODE = WAL +SQLITE_TIMEOUT = 500 + +[repository] +ROOT = /var/lib/gitea/data/gitea-repositories +DEFAULT_BRANCH = main +DEFAULT_PRIVATE = private +DISABLE_HTTP_GIT = false + +[repository.upload] +TEMP_PATH = /var/lib/gitea/data/tmp/uploads + +[lfs] +PATH = /var/lib/gitea/data/lfs + +[security] +INSTALL_LOCK = true +SECRET_KEY = ${GITEA_SECRET_KEY} +INTERNAL_TOKEN = ${GITEA_INTERNAL_TOKEN} +; Without these two, Gitea sees Caddy's address as the client for every request +; and fail2ban ends up banning the reverse proxy, locking everyone out. +REVERSE_PROXY_TRUSTED_PROXIES = ${TRUSTED_PROXIES} +REVERSE_PROXY_LIMIT = 1 +PASSWORD_HASH_ALGO = argon2 + +[oauth2] +JWT_SECRET = ${GITEA_OAUTH2_JWT_SECRET} + +[service] +DISABLE_REGISTRATION = true +REQUIRE_SIGNIN_VIEW = ${REQUIRE_SIGNIN_VIEW} +REGISTER_EMAIL_CONFIRM = false +ENABLE_NOTIFY_MAIL = false +ALLOW_ONLY_EXTERNAL_REGISTRATION = false +ENABLE_CAPTCHA = false +DEFAULT_KEEP_EMAIL_PRIVATE = true +DEFAULT_ALLOW_CREATE_ORGANIZATION = true +DEFAULT_ENABLE_TIMETRACKING = true + +[session] +PROVIDER = file +PROVIDER_CONFIG = /var/lib/gitea/data/sessions +COOKIE_SECURE = true + +[mailer] +ENABLED = false + +[log] +; console -> journald -> Cloud Logging, and it is what fail2ban's systemd +; backend reads via CONTAINER_NAME=gitea. Do not switch to file logging without +; updating vm/fail2ban/jail.d/gitea.local. +MODE = console +LEVEL = info +ROOT_PATH = /var/lib/gitea/log + +[actions] +; Enabled now so the eventual migration off GitHub Cloud Build triggers onto +; Gitea Actions does not need a config change + restart. +ENABLED = true +DEFAULT_ACTIONS_URL = github + +[cron.update_checker] +ENABLED = false + +[ui] +DEFAULT_THEME = gitea-auto diff --git a/vm/dnf/automatic.conf b/vm/dnf/automatic.conf new file mode 100644 index 0000000..bf8a2dc --- /dev/null +++ b/vm/dnf/automatic.conf @@ -0,0 +1,27 @@ +# Installed by vm/bootstrap.sh as /etc/dnf/automatic.conf. +# Consumed by dnf5-automatic.timer (dnf-automatic.timer on a dnf4 system). + +[commands] +# `default` follows the distro's notion of what an upgrade is; switch to +# `security` if you want to narrow the blast radius of unattended patching. +upgrade_type = default + +# Spread the herd. Every AlmaLinux box on the internet firing at 03:30 is how +# mirrors fall over. +random_sleep = 3600 + +network_online_timeout = 60 +download_updates = yes +apply_updates = yes + +# Never reboot from here. gitea-reboot-window.timer owns that decision, so +# restarts land in a known window with a fresh disk snapshot behind them. +reboot = never + +[emitters] +# stdio -> journald -> Cloud Logging. No mail configured on this host. +emit_via = stdio +system_name = gitea-vm + +[base] +debuglevel = 1 diff --git a/vm/fail2ban/action.d/nft-prerouting.conf b/vm/fail2ban/action.d/nft-prerouting.conf new file mode 100644 index 0000000..3fcdac3 --- /dev/null +++ b/vm/fail2ban/action.d/nft-prerouting.conf @@ -0,0 +1,48 @@ +# fail2ban action: drop banned sources at the nftables PREROUTING hook. +# +# Why this exists instead of the stock `nftables` action: +# +# Traffic to a published container port (443, 2222) is DNAT'd in prerouting and +# then traverses FORWARD -- it never reaches the INPUT hook. fail2ban's default +# actions install their rules on INPUT, so on a podman host they drop nothing +# at all while `fail2ban-client status` cheerfully reports active bans. It fails +# silently, which is the worst way for a security control to fail. +# +# Hooking at prerouting with priority -300 (raw) puts us ahead of both the DNAT +# that redirects the packet and netavark's own chains, so the drop applies to +# host-terminated and container-bound traffic alike. +# +# Verify with a real ban drill -- see docs/runbook.md. + +[Definition] + +actionstart = nft add table inet f2b-prerouting + nft add chain inet f2b-prerouting preroute '{ type filter hook prerouting priority -300 ; policy accept ; }' + nft add set inet f2b-prerouting f2b--v4 '{ type ipv4_addr ; flags interval ; }' + nft add set inet f2b-prerouting f2b--v6 '{ type ipv6_addr ; flags interval ; }' + nft add rule inet f2b-prerouting preroute ip saddr @f2b--v4 drop + nft add rule inet f2b-prerouting preroute ip6 saddr @f2b--v6 drop + +# Flush rather than delete: the preroute rules still reference these sets, so a +# delete would be refused. Emptying them drops every ban, which is what stopping +# the jail means. +actionstop = nft flush set inet f2b-prerouting f2b--v4 2>/dev/null || true + nft flush set inet f2b-prerouting f2b--v6 2>/dev/null || true + +# If something flushed our table out from under us, actionstart runs again. +actioncheck = nft list set inet f2b-prerouting f2b--v4 >/dev/null 2>&1 + +# Family is derived from the address itself rather than a fail2ban tag, so this +# stays correct across fail2ban versions that disagree about . +actionban = case "" in \ + *:*) nft add element inet f2b-prerouting f2b--v6 '{ }' ;; \ + *) nft add element inet f2b-prerouting f2b--v4 '{ }' ;; \ + esac + +actionunban = case "" in \ + *:*) nft delete element inet f2b-prerouting f2b--v6 '{ }' 2>/dev/null || true ;; \ + *) nft delete element inet f2b-prerouting f2b--v4 '{ }' 2>/dev/null || true ;; \ + esac + +[Init] +name = default diff --git a/vm/fail2ban/filter.d/caddy-coraza.conf b/vm/fail2ban/filter.d/caddy-coraza.conf new file mode 100644 index 0000000..c2a7ef9 --- /dev/null +++ b/vm/fail2ban/filter.d/caddy-coraza.conf @@ -0,0 +1,31 @@ +# Matches Coraza's BLOCKING DECISION on the Caddy log (LogDriver=journald). +# +# Written from real output, not documentation. Coraza emits two shapes: +# +# Warning -- one line per individual CRS rule that matched. A single +# one of these means almost nothing; a legitimate issue +# comment containing SQL will produce several. +# Access denied -- emitted once, by the anomaly-score threshold rule (949110), +# when the accumulated score actually crosses the limit and +# the request is refused. +# +# Only the second is matched. Banning on individual rule hits would ban users +# for pasting a code snippet. +# +# Sample line this is built against: +# {"level":"warn",...,"logger":"http.handlers.waf","msg":"[client \"203.0.113.9\"] +# Coraza: Access denied (phase 2). Inbound Anomaly Score Exceeded (Total Score: 23) +# [file \"@owasp_crs/REQUEST-949-BLOCKING-EVALUATION.conf\"] ... [id \"949110\"] ..."} +# +# NOTE: in DetectionOnly mode Coraza never emits "Access denied" -- nothing is +# refused -- so this filter matches nothing and the jail is inert. That is +# intentional and is why vm/bootstrap.sh only enables the jail when +# SecRuleEngine is On. + +[Definition] + +failregex = ^.*"logger":"http\.handlers\.waf".*\[client \\"\\"\] Coraza: Access denied + +ignoreregex = + +datepattern = {^LN-BEG} diff --git a/vm/fail2ban/filter.d/gitea.conf b/vm/fail2ban/filter.d/gitea.conf new file mode 100644 index 0000000..fde279a --- /dev/null +++ b/vm/fail2ban/filter.d/gitea.conf @@ -0,0 +1,29 @@ +# Matches Gitea authentication failures on stdout (LogDriver=journald). +# +# The patterns below were taken from real log output, not from documentation: +# +# [W] Failed authentication attempt for someuser from 203.0.113.77:0: user does not exist +# [W] Failed authentication attempt from 198.51.100.9:48236 +# +# The first is the web UI / API; the second is the built-in SSH server. They +# share an auth path, which is why one jail covers both ports rather than +# guessing at separate per-transport formats. +# +# resolves to the real client ONLY because app.ini sets +# REVERSE_PROXY_TRUSTED_PROXIES to the podman subnet. Without it, Gitea logs +# Caddy's address and every ban would hit the reverse proxy. +# +# Deliberately NOT matched: "Failed connection from with error: EOF". +# Port scanners and health probes produce it constantly and it says nothing +# about credentials. + +[Definition] + +failregex = ^.*Failed authentication attempt for .+? from + ^.*Failed authentication attempt from + ^.*[Ii]nvalid credentials from + ^.*Attempted OAuth2 login .*? from + +ignoreregex = + +datepattern = {^LN-BEG} diff --git a/vm/fail2ban/jail.d/gitea.local b/vm/fail2ban/jail.d/gitea.local new file mode 100644 index 0000000..9b165ac --- /dev/null +++ b/vm/fail2ban/jail.d/gitea.local @@ -0,0 +1,53 @@ +# Installed by vm/bootstrap.sh as /etc/fail2ban/jail.d/gitea.local + +[DEFAULT] +# Podman's journald log driver tags container output with CONTAINER_NAME, so +# there are no log files to bind-mount, rotate, or keep in sync. +backend = systemd + +# Every ban goes through the prerouting action. The stock nftables/iptables +# actions install INPUT rules, which do not see DNAT'd container traffic at all. +banaction = nft-prerouting +banaction_allports = nft-prerouting + +bantime = 1h +findtime = 10m +maxretry = 5 + +# Never lock out loopback, the podman bridge, or the IAP range -- IAP is the +# only way back into this box. +ignoreip = 127.0.0.1/8 ::1 ${PODMAN_SUBNET} 35.235.240.0/20 + +[sshd] +enabled = true +port = 22 +# Belt-and-braces: port 22 already only accepts the IAP range, at both the VPC +# firewall and nftables. +maxretry = 5 + +[gitea] +enabled = true +filter = gitea +# One jail across all three ports rather than separate web/ssh jails: Gitea +# serves them from one process with shared auth logging, and a bad actor should +# lose every door at once, not just the one they knocked on. +port = 80,443,2222 +journalmatch = CONTAINER_NAME=gitea +maxretry = 5 +bantime = 1h + +[caddy-coraza] +# Enabled ONLY when gitea:wafMode is "On". In DetectionOnly the WAF reports what +# it would have blocked but lets it through, and escalating those reports into +# nftables bans would turn a tuning false positive into a locked-out user -- +# strictly worse than the 403 that DetectionOnly was chosen to avoid. +# vm/bootstrap.sh derives this from the same config value as SecRuleEngine. +enabled = ${CORAZA_JAIL_ENABLED} +filter = caddy-coraza +port = 80,443 +journalmatch = CONTAINER_NAME=caddy +# Deliberately looser than the auth jail. A WAF verdict is a weaker signal than +# a failed password, so it takes sustained hostile traffic to earn a ban. +maxretry = 10 +findtime = 10m +bantime = 2h diff --git a/vm/nftables/gitea.nft b/vm/nftables/gitea.nft new file mode 100644 index 0000000..2f547fd --- /dev/null +++ b/vm/nftables/gitea.nft @@ -0,0 +1,54 @@ +#!/usr/sbin/nft -f +# +# Host firewall. Installed by vm/bootstrap.sh as /etc/sysconfig/nftables.conf. +# +# CRITICAL: this file must NEVER contain `flush ruleset`. The stock nftables +# config ships with one, and it would wipe podman/netavark's NAT and forward +# rules -- silently breaking all container networking on every reload. We touch +# exactly one table and nothing else. +# +# Equally deliberate: there is NO forward chain here. Netavark manages forward +# and nat rules in its own `netavark` table. A drop-policy forward chain in this +# table would drop container traffic regardless of what netavark allows, because +# a packet is dropped if any chain drops it. +# +# Traffic to published container ports (443, 2222) is DNAT'd in prerouting and +# traverses forward, never input -- so the input rules below only actually +# govern host-terminated traffic. Blocking abusive clients from container ports +# is fail2ban's job, via the prerouting-hook action in +# vm/fail2ban/action.d/nft-prerouting.conf. + +# Create-then-delete makes this reload idempotent: the bare `table` line is a +# no-op if it already exists, so the delete can never fail on a fresh boot. +table inet gitea_filter +delete table inet gitea_filter + +table inet gitea_filter { + chain input { + type filter hook input priority filter; policy drop; + + iif "lo" accept comment "loopback" + ct state established,related accept + ct state invalid drop + + meta l4proto icmp accept comment "IPv4 ICMP" + meta l4proto ipv6-icmp accept comment "IPv6 ICMP / NDP" + + # DHCP renewal from the GCE metadata server. + udp sport 67 udp dport 68 accept + + # Admin SSH: IAP TCP forwarding range only. There is no other path in -- + # the VPC firewall enforces the same restriction as the outer layer. + ip saddr 35.235.240.0/20 tcp dport 22 accept comment "IAP SSH" + + # Only reached when Caddy runs with Network=host (the metadata-server + # fallback path). Harmless otherwise: on the bridge these are DNAT'd + # before input and never match here. + tcp dport { 80, 443 } accept comment "HTTP/HTTPS" + udp dport 443 accept comment "HTTP/3" + tcp dport 2222 accept comment "git over SSH" + + # Rate-limited logging so a scan cannot fill the disk via journald. + limit rate 5/minute burst 10 packets log prefix "nft-drop-in: " level info + } +} diff --git a/vm/quadlets/caddy-config.volume b/vm/quadlets/caddy-config.volume new file mode 100644 index 0000000..e6f9a59 --- /dev/null +++ b/vm/quadlets/caddy-config.volume @@ -0,0 +1,6 @@ +[Unit] +Description=Caddy autosaved JSON config storage + +[Volume] +VolumeName=caddy-config +Label=app=gitea diff --git a/vm/quadlets/caddy-data.volume b/vm/quadlets/caddy-data.volume new file mode 100644 index 0000000..1d45fb3 --- /dev/null +++ b/vm/quadlets/caddy-data.volume @@ -0,0 +1,9 @@ +# ACME account keys and issued certificates. Losing this means re-issuing certs +# (and burning Let's Encrypt rate limit), so it is a named volume rather than +# a tmpfs or an anonymous mount. +[Unit] +Description=Caddy certificate and ACME account storage + +[Volume] +VolumeName=caddy-data +Label=app=gitea diff --git a/vm/quadlets/caddy.container b/vm/quadlets/caddy.container new file mode 100644 index 0000000..345cbe2 --- /dev/null +++ b/vm/quadlets/caddy.container @@ -0,0 +1,62 @@ +# Rendered by vm/bootstrap.sh into /etc/containers/systemd/ +# +# ${CADDY_NETWORK} is chosen by bootstrap.sh at run time, not by hand: +# the googleclouddns ACME plugin authenticates via Application Default +# Credentials, which on GCE means reaching the metadata server at +# 169.254.169.254. bootstrap.sh probes whether a container on the gitea bridge +# can do that and falls back to Network=host if it cannot. See +# gitea-probe-metadata in bootstrap.sh and docs/runbook.md. +[Unit] +Description=Caddy (TLS termination, ACME DNS-01 via Google Cloud DNS) +Documentation=https://caddyserver.com/docs/ +After=gitea.service gitea-ar-auth.service network-online.target +Wants=gitea.service gitea-ar-auth.service + +[Container] +ContainerName=caddy +Image=${IMAGE_CADDY} +AutoUpdate=registry +# Registry auth for Artifact Registry. Two settings, because two different +# code paths need it: PodmanArgs covers `podman run`'s pull, and the +# io.containers.autoupdate.authfile label is what `podman auto-update` reads +# when it checks the registry digest. (There is no AuthFile= key in the +# [Container] group -- that one only exists for .image and .build units.) +PodmanArgs=--authfile=/etc/containers/ar-auth.json +Label=io.containers.autoupdate.authfile=/etc/containers/ar-auth.json +Network=${CADDY_NETWORK} +LogDriver=journald + +${CADDY_PUBLISH_PORTS} + +Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z +Volume=caddy-data.volume:/data +Volume=caddy-config.volume:/config + +User=1000:1000 +# Caddy binds 80/443 as a non-root user. On the bridge this is a container-local +# sysctl; podman REJECTS net.* sysctls when Network=host, so bootstrap.sh emits +# nothing here in that mode and sets the equivalent host sysctl instead. File +# capabilities are not an option -- NoNewPrivileges blocks them. +${CADDY_SYSCTL} + +# The plugin reads Application Default Credentials; the project must be explicit +# because the metadata server's project and the DNS zone's project need not match. +Environment=GCP_PROJECT=${GCP_PROJECT} + +HealthCmd=curl -fsS http://127.0.0.1:2019/config/ +HealthInterval=30s +HealthTimeout=5s +HealthStartPeriod=30s +HealthRetries=3 +Notify=healthy + +NoNewPrivileges=true + +[Service] +Restart=always +RestartSec=30 +StartLimitIntervalSec=0 +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target diff --git a/vm/quadlets/gitea.container b/vm/quadlets/gitea.container new file mode 100644 index 0000000..ca4d840 --- /dev/null +++ b/vm/quadlets/gitea.container @@ -0,0 +1,69 @@ +# Rendered by vm/bootstrap.sh into /etc/containers/systemd/ +[Unit] +Description=Gitea +Documentation=https://docs.gitea.com/ +# Requires= (not just After=) on the mount: without it podman happily creates an +# empty /var/lib/gitea and Gitea initialises a FRESH install on top of the +# unmounted path, which looks like total data loss. +Requires=var-lib-gitea.mount +# Wants= (not just After=): the credential file has to be written before the +# first pull, and the refresh timer alone would not guarantee that at boot. +Wants=gitea-ar-auth.service +After=var-lib-gitea.mount gitea-ar-auth.service network-online.target + +[Container] +ContainerName=gitea +Image=${IMAGE_GITEA} +# Floating tag, deliberately. AutoUpdate=registry compares the local digest +# against the registry's digest FOR A TAG; a digest-pinned image would give it +# nothing to poll and the feature would be silently dead. +AutoUpdate=registry +# Registry auth for Artifact Registry. Two settings, because two different +# code paths need it: PodmanArgs covers `podman run`'s pull, and the +# io.containers.autoupdate.authfile label is what `podman auto-update` reads +# when it checks the registry digest. (There is no AuthFile= key in the +# [Container] group -- that one only exists for .image and .build units.) +PodmanArgs=--authfile=/etc/containers/ar-auth.json +Label=io.containers.autoupdate.authfile=/etc/containers/ar-auth.json +Network=gitea.network +LogDriver=journald + +# Git over SSH, public. HTTP is loopback-only: Caddy is the only thing that +# should reach it, and binding it to 127.0.0.1 keeps the reverse-proxy wiring +# identical whether Caddy runs on the bridge or on the host network. +PublishPort=2222:2222 +PublishPort=127.0.0.1:3000:3000 + +# No :z/:Z on the data volume -- bootstrap.sh sets a persistent SELinux fcontext +# for it instead. A relabel flag here would force a recursive relabel of the +# entire repository tree on every single container start. +Volume=/var/lib/gitea:/var/lib/gitea +Volume=/etc/gitea/app.ini:/etc/gitea/app.ini:ro,Z + +User=1000:1000 +Environment=GITEA_WORK_DIR=/var/lib/gitea + +# Notify=healthy is what arms auto-update rollback. Rollback only fires when the +# restarted unit fails to START; without this a container that starts and is +# broken would never roll back. +HealthCmd=curl -fsS http://127.0.0.1:3000/api/healthz +HealthInterval=30s +HealthTimeout=5s +HealthStartPeriod=60s +HealthRetries=3 +Notify=healthy + +NoNewPrivileges=true +DropCapability=ALL + +[Service] +Restart=always +# On first boot the :prod image does not exist yet (it is built by the first +# Cloud Build run). StartLimitIntervalSec=0 lets the unit retry indefinitely +# instead of hitting the start limit and parking in `failed` forever. +RestartSec=30 +StartLimitIntervalSec=0 +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target diff --git a/vm/quadlets/gitea.network b/vm/quadlets/gitea.network new file mode 100644 index 0000000..39350d2 --- /dev/null +++ b/vm/quadlets/gitea.network @@ -0,0 +1,15 @@ +# Rendered by vm/bootstrap.sh into /etc/containers/systemd/ +# +# The subnet is PINNED deliberately. Left to podman it is drawn from the default +# pool and is not stable across a network recreate -- and this CIDR is exactly +# what Gitea's REVERSE_PROXY_TRUSTED_PROXIES has to name. An unpinned subnet +# means a network recreate silently reverts every fail2ban ban to targeting +# Caddy instead of the real client. +[Unit] +Description=Gitea container network + +[Network] +NetworkName=gitea +Subnet=${PODMAN_SUBNET} +Gateway=${PODMAN_GATEWAY} +Label=app=gitea diff --git a/vm/systemd/gitea-ar-auth.service b/vm/systemd/gitea-ar-auth.service new file mode 100644 index 0000000..5bad75d --- /dev/null +++ b/vm/systemd/gitea-ar-auth.service @@ -0,0 +1,20 @@ +[Unit] +Description=Refresh Artifact Registry credentials for root podman +Documentation=file:///usr/local/sbin/gitea-bootstrap +After=network-online.target +Wants=network-online.target +# Ordered before auto-update rather than merely wanted by it: a stale token here +# is the single most common reason podman-auto-update silently stops working. +Before=podman-auto-update.service + +[Service] +Type=oneshot +RemainAfterExit=no +ExecStart=/usr/local/bin/gitea-ar-auth +# The token is short-lived; a transient metadata-server hiccup should not leave +# the file missing until the next timer tick. +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/vm/systemd/gitea-ar-auth.timer b/vm/systemd/gitea-ar-auth.timer new file mode 100644 index 0000000..b1146e3 --- /dev/null +++ b/vm/systemd/gitea-ar-auth.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Periodically refresh Artifact Registry credentials + +[Timer] +# Metadata-server access tokens last ~1h. Refreshing every 30m keeps a valid +# credential on disk at all times, including whenever auto-update fires. +OnBootSec=1min +OnUnitActiveSec=30min +AccuracySec=1min + +[Install] +WantedBy=timers.target diff --git a/vm/systemd/gitea-backup.service b/vm/systemd/gitea-backup.service new file mode 100644 index 0000000..3683e24 --- /dev/null +++ b/vm/systemd/gitea-backup.service @@ -0,0 +1,12 @@ +[Unit] +Description=Gitea dump to Cloud Storage +After=gitea.service +Requires=gitea.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/gitea-backup +# A dump of a large instance is not fast, and it should never be killed halfway. +TimeoutStartSec=3600 +Nice=10 +IOSchedulingClass=idle diff --git a/vm/systemd/gitea-backup.timer b/vm/systemd/gitea-backup.timer new file mode 100644 index 0000000..af96168 --- /dev/null +++ b/vm/systemd/gitea-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Nightly Gitea dump to Cloud Storage + +[Timer] +OnCalendar=*-*-* 02:30:00 UTC +RandomizedDelaySec=15min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/vm/systemd/gitea-config-sync.service b/vm/systemd/gitea-config-sync.service new file mode 100644 index 0000000..a4834f6 --- /dev/null +++ b/vm/systemd/gitea-config-sync.service @@ -0,0 +1,16 @@ +[Unit] +Description=Re-sync Gitea VM configuration from GCS and apply it +Documentation=file:///usr/local/sbin/gitea-bootstrap +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=no +# --sync-only skips package installation and disk setup; it re-pulls the vm/ +# tree, re-renders the templates, and restarts only what actually changed. +ExecStart=/usr/local/sbin/gitea-bootstrap --sync-only +TimeoutStartSec=600 + +[Install] +WantedBy=multi-user.target diff --git a/vm/systemd/gitea-reboot-window.service b/vm/systemd/gitea-reboot-window.service new file mode 100644 index 0000000..1b8d3d1 --- /dev/null +++ b/vm/systemd/gitea-reboot-window.service @@ -0,0 +1,10 @@ +[Unit] +Description=Reboot into a maintenance window if unattended updates require it +Documentation=man:needs-restarting(1) + +[Service] +Type=oneshot +# Reboots ONLY when the package layer says a reboot is actually required. +# dnf5-automatic is configured with reboot=never precisely so this unit owns +# the decision and restarts land in a predictable window. +ExecStart=/usr/local/bin/gitea-reboot-if-needed diff --git a/vm/systemd/gitea-reboot-window.timer b/vm/systemd/gitea-reboot-window.timer new file mode 100644 index 0000000..9313aed --- /dev/null +++ b/vm/systemd/gitea-reboot-window.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Weekly maintenance reboot window + +[Timer] +# Sunday 05:00 UTC -- an hour after the weekly image rebuild has pushed :prod, +# so a reboot picks up both OS and container updates in one outage. +OnCalendar=Sun *-*-* 05:00:00 UTC +RandomizedDelaySec=30min +Persistent=false + +[Install] +WantedBy=timers.target