16 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 5.5 f672019c91 Add the GitHub to Gitea migration scripts
These moved every non-fork JMR-dev repository from GitHub into this Gitea
instance and made GitHub a push mirror. They are kept for re-runs and
for rotating the mirror PAT, which expires and fails silently.

- migrate.py    full one-time migration (code, issues, PRs, releases,
                wiki, LFS); skips anything already on Gitea
- verify.py     compares branch/tag shas, issue/PR/release counts, and
                the archived and visibility flags; read-only
- repoint.py    re-points local clones' JMR-dev remotes at Gitea,
                following GitHub renames; dry run unless --apply
- pushmirror.py sync-on-commit push mirrors to GitHub, created only when
                every GitHub branch and tag already matches Gitea, so
                the first force-push/prune cannot delete anything

Tokens come from the environment, sourced from Secret Manager. None
appear in these files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:16:18 +07:00
JMR-devandClaude Opus 5.5 f553a87ce6 README: day-to-day make targets need ADC
The Makefile derives PROJECT and ZONE from `pulumi config get`, which
reads the stack from the GCS backend and so needs Application Default
Credentials. Without them the lookup fails silently and every gcloud
command runs with `--project=`. The passphrase is not needed for
plaintext config values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 69d586bfcc Let the VM list DNS zones so Caddy can present DNS-01 challenges
With DNS resolution fixed, issuance failed at the challenge:

  presenting for challenge: adding temporary record for zone
  "gitea.jasonmross.dev.": googleapi: Error 403: Forbidden

Testing with the VM service account's own token: managedZones/main and
its rrsets return 200, but managedZones (list) returns 403. The
googleclouddns plugin resolves the domain to a zone by listing the
project's managed zones, and listing is a project-level permission that
the zone-scoped dns.admin binding cannot grant.

Grant roles/dns.reader on the project. It adds read access only, in a
project that holds this single zone; every write stays zone-scoped. A
custom role with just dns.managedZones.list was the alternative, but
managing it would need iam.roleAdmin on the cb-infra Pulumi runner,
which widens a far more powerful identity to narrow a read-only one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 25dda00eaf runbook: note that Caddy's certificates live on the boot disk
The caddy-data volume is a podman named volume, so it sits under
/var/lib/containers on the boot disk rather than the separately managed
data disk. An instance replacement re-registers the ACME account and
re-issues, and enough of those in a week hits Let's Encrypt's
duplicate-certificate limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 0959065d82 nftables: let containers reach aardvark-dns
Caddy could not obtain a certificate on first boot: every request to
acme-v02.api.letsencrypt.org timed out. Containers could reach
1.1.1.1:443 by address but resolved no names at all.

Container DNS goes to aardvark-dns on the bridge gateway (10.89.10.1:53).
That traffic terminates on the host, so it takes the input hook, not
forward. Netavark accepts it in its own table, but gitea_filter's input
chain has policy drop, and a packet must be accepted by every base chain
on the hook. Our drop won.

gitea_filter now accepts tcp/udp 53 from the podman subnet to its
gateway. Both come from instance metadata, so the ruleset is rendered
with envsubst, as the fail2ban jail already is. It is not interface-based
because netavark's bridge name (podman1) is not pinned.

setup_nftables also validates the rendered file before installing it.
Previously it installed first and validated second, so a ruleset that
failed to parse stayed in /etc/sysconfig and would fail nftables.service
on the next boot, leaving the host with no gitea_filter table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 3d38240b2f image build: enable BuildKit
The first image build failed in build-gitea:

  the --chmod option requires BuildKit

Both Dockerfiles declare `# syntax=docker/dockerfile:1` and use
`COPY --chmod`, but gcr.io/cloud-builders/docker runs the legacy builder
unless DOCKER_BUILDKIT=1 is set. The image ships the buildx plugin, so
setting it on the two build steps is all that is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 a1669d6be1 Give manual image builds a source bucket cb-image can read
The first `make build` failed before any step ran:

  INVALID_ARGUMENT: could not resolve source: cb-image@... does not
  have storage.objects.get access to ... gitea-496920_cloudbuild/source/...

`gcloud builds submit` uploads the source tarball to <project>_cloudbuild
and the build, running as the user-specified cb-image@, must read it
back. Nothing grants that. Binding on that bucket is not an option: gcloud
creates it on the first submit, after `pulumi up` has already run.
Project-wide objectViewer would also open the backup, config and state
buckets.

Pulumi now owns <project>-gitea-build-source, readable by cb-image@ and
nothing else, with a 7-day delete rule since each tarball is read once.
`make build` stages there via --gcs-source-staging-dir. Triggered builds
fetch source through the GitHub connection and are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 d48f2f5e0c make build: supply SHORT_SHA to manual builds
image.yaml tags each image :$SHORT_SHA as its audit trail and rollback
target. Cloud Build populates SHORT_SHA only for triggered builds; for
`gcloud builds submit` it substitutes an empty string, so the tag becomes
`<image>:` and docker build fails with an invalid reference format. That
is the very first build in the README's setup sequence.

Pass the current commit's short hash explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 e70a5beac4 Configure the prod stack for gitea-496920
Fills in the values scripts/bootstrap.sh and the existing project
provide: the project id, the Cloud DNS zone resource name (main, holding
gitea.jasonmross.dev), and the cb-infra Pulumi runner account.

encryptionsalt is from `pulumi stack init` with the passphrase already in
Secret Manager (pulumi-config-passphrase), so Cloud Build's infra trigger
can open the stack with the same key.

githubAppInstallationId stays "0" for now: GitHubConfigured() is then
false and the Cloud Build triggers are skipped until the GitHub App is
installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 973c1bbcd3 Keep the ACME contact email in Secret Manager
gitea:acmeEmail sat in Pulumi.prod.yaml, which this public repository
publishes, and was then copied into instance metadata. It now lives in a
gitea-acme-email secret instead, read by the VM when it renders the
Caddyfile.

- scripts/bootstrap.sh creates the secret empty and prints how to set
  it, the same as github-pat: the address is chosen, not generated.
- Pulumi grants the VM secretAccessor on it and nothing more. It is kept
  out of secrets.Names, whose members also get secretVersionAdder and are
  mapped to `gitea generate secret` by vm/bootstrap.sh.
- The gitea:acmeEmail config key and the acme-email metadata entry are
  gone.
- vm/bootstrap.sh renders the whole `email` directive. If the secret is
  unreadable it renders a comment instead and warns: Caddy still issues
  certificates under an account with no contact address, whereas an
  empty `email` would fail to parse and leave nothing serving TLS. Same
  directive-or-comment pattern as CADDY_PUBLISH_PORTS and CADDY_SYSCTL.

README setup gains the secret step, plus two that were missing: ADC
login (Pulumi's GCS backend and provider do not use the gcloud login),
and exporting PULUMI_CONFIG_PASSPHRASE from Secret Manager before
`stack init`. Without the latter, init prompts for a new passphrase and
the stack is encrypted with a key Cloud Build's infra trigger never sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 31524e9ddb Build the Pulumi binary before running Pulumi
Pulumi.yaml sets runtime.options.binary to ./gitea-infra, which tells the
Go language host to execute that file instead of compiling the program.
Nothing produced it: `make check` ran `go build ./...`, which discards
output when building multiple packages, and the infra Cloud Build step
went straight to `pulumi up`. Both local preview/up and the first infra
trigger run would fail before planning anything.

`make check` (and therefore preview/up) now builds it with -o, and the
infra pipeline builds it at the top of the pulumi step. `go vet ./...`
still type-checks every package.

Also corrects the Makefile's ZONE fallback from <region>-a to <region>-b.
It applies whenever `pulumi config get` cannot read the stack, and
us-east1 has no -a zone, so make ssh/build would target a zone that does
not exist. -b matches the default in infra/pkg/config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-devandClaude Opus 5.5 b85df9ee7a Fix Dependabot alerts: upgrade grpc and otel, pin go1.26.9
Eight open Dependabot alerts, all transitive through the Pulumi SDK:

  grpc  < 1.82.2 / <= 1.83.0   (#3, #4 high; #5 medium) -> v1.83.2
  otel/sdk, otlptrace, otlptracegrpc  <= 1.44.0  (#6-8)  -> v1.45.0
  otel/sdk/log, otlplog/otlploggrpc   <  0.21.0  (#9-10) -> v0.21.0

This supersedes Dependabot PR #1, which bumps grpc only and predates the
otel alerts.

otel/log v0.21 changed its API, so the otelslog bridge has to move with
it: v0.18.0 no longer compiles against it. v0.20.1 is the release built
for that otel line.

govulncheck then reported ten reachable standard-library and x/net
vulnerabilities disclosed since the last pin (net/http HTTP/2 and CONNECT
handling, net/textproto, crypto/tls ECH, os on Windows), all fixed in
go1.26.9 and golang.org/x/net v0.60.0. Raising the toolchain floor is the
same remedy as before; x/net v0.60.0 requires go 1.26, which lifts the
go directive from 1.25.11 to 1.26.0.

The Pulumi SDK and pulumi-gcp direct dependencies are deliberately left
where they are, so this does not also change provider behaviour ahead of
the first deploy.

govulncheck now reports no reachable vulnerabilities. GO-2026-5932
(x/crypto/openpgp, no fix available, not called) remains, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00
JMR-dev 75ccc9a96b bootstrap: wait for service account propagation before binding roles 2026-08-18 22:11:29 -05:00
JMR-devandClaude Opus 5 9597704597 Fix Dependabot alerts: upgrade go-git, pin a patched Go toolchain
Two open Dependabot alerts, both against github.com/go-git/go-git/v6, a
transitive dependency of github.com/pulumi/pulumi/sdk/v3 by way of
common/workspace:

  CVE-2026-71556 (high, 7.1)  worktree operations may follow symlinks,
                              allowing writes outside the worktree
  CVE-2026-71557 (medium, 6.3) malicious reference names may resolve
                              outside the reference storage

Both are fixed in v6.0.0-alpha.5. The Pulumi SDK is already at its latest
release (v3.258.0) and still requires alpha.4, so bumping the direct
dependency does not help; an explicit minimum-version entry is the
remedy. go-billy comes along as a consequence.

Also pins toolchain go1.26.6. govulncheck reported six reachable
standard-library vulnerabilities from building on go1.26.4 -- ASN.1, TLS,
net/url, net/http and os -- all fixed in 1.26.5/1.26.6. Dependabot does
not track the Go standard library, so nothing alerted on these, but they
were reachable from pulumi.Run and dns.LookupManagedZone. Pinning the
floor in go.mod means every build gets a patched stdlib rather than
whatever the build host happens to have.

govulncheck now reports no vulnerabilities.

Not addressed, deliberately: GO-2026-5932 flags golang.org/x/crypto/openpgp
as unmaintained and unsafe by design. It has no fixed version, and our
code does not call it. Upgrading x/crypto to v0.55.0 was tried and does
not clear it, so that change was dropped to keep this diff minimal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:00:40 -05:00
JMR-dev b3d0bd1b54 Point Cloud Build at JMR-dev/Gitea 2026-08-18 21:50:55 -05:00
JMR-devandClaude Opus 5 c0382d5d31 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) <noreply@anthropic.com>
2026-08-18 21:45:33 -05:00