diff --git a/README.md b/README.md index 161b3bd..5d7d12f 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,23 @@ printf %s '' | gcloud secrets versions add github-pat --data-file=- --pro # 3. Confirm the Cloud DNS zone is authoritative. DNS-01 cannot work otherwise. dig NS gitea.jasonmross.dev -# 4. Configure and apply. +# 4. The ACME contact address. Kept in Secret Manager, not stack config, so it +# stays out of this public repo; the VM reads it when rendering the Caddyfile. +printf %s 'you@example.com' | gcloud secrets versions add gitea-acme-email --data-file=- --project + +# 5. Configure and apply. Pulumi's GCS backend and Google provider use +# Application Default Credentials, not your gcloud login. +gcloud auth application-default login +# Use the passphrase bootstrap.sh generated. Letting `stack init` prompt for a +# new one encrypts the stack with a key Cloud Build's infra trigger never sees. +export PULUMI_CONFIG_PASSPHRASE=$(gcloud secrets versions access latest \ + --secret=pulumi-config-passphrase --project ) 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 @@ -62,10 +71,10 @@ pulumi config set gitea:infraBuildServiceAccount cb-infra@.iam.gserv pulumi config set gitea:wafMode DetectionOnly pulumi up -# 5. First image build. Until this runs, the :prod images do not exist. +# 6. First image build. Until this runs, the :prod images do not exist. cd .. && make build -# 6. Create the admin user. +# 7. 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 @@ -73,7 +82,7 @@ sudo podman exec -u 1000 gitea gitea admin user create \ ### Expected on the first run, not a bug -Between step 4 and step 5 the `:prod` images do not exist yet, so `gitea.service` +Between step 5 and step 6 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 diff --git a/infra/Pulumi.prod.yaml b/infra/Pulumi.prod.yaml index 2005cb3..af8eeb2 100644 --- a/infra/Pulumi.prod.yaml +++ b/infra/Pulumi.prod.yaml @@ -11,7 +11,6 @@ config: # 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 diff --git a/infra/main.go b/infra/main.go index b81ea6a..a2166f9 100644 --- a/infra/main.go +++ b/infra/main.go @@ -63,6 +63,9 @@ func main() { if err := iam.GrantSecrets(ctx, cfg, accounts, secrets.Names); err != nil { return err } + if err := iam.GrantSecretRead(ctx, cfg, accounts, secrets.ACMEEmail); err != nil { + return err + } buckets, err := storage.New(ctx, cfg, vmDir, apis) if err != nil { diff --git a/infra/pkg/compute/compute.go b/infra/pkg/compute/compute.go index d200c16..e5194c7 100644 --- a/infra/pkg/compute/compute.go +++ b/infra/pkg/compute/compute.go @@ -169,7 +169,6 @@ func New( "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)), diff --git a/infra/pkg/config/config.go b/infra/pkg/config/config.go index aa1a95d..865abab 100644 --- a/infra/pkg/config/config.go +++ b/infra/pkg/config/config.go @@ -18,7 +18,6 @@ type Config struct { Domain string DNSZone string - ACMEEmail string AppName string PodmanCIDR string @@ -51,7 +50,6 @@ func Load(ctx *pulumi.Context) (*Config, error) { Zone: c.Get("zone"), Domain: c.Require("domain"), DNSZone: c.Require("dnsZone"), - ACMEEmail: c.Require("acmeEmail"), AppName: c.Get("appName"), PodmanCIDR: c.Get("podmanSubnet"), diff --git a/infra/pkg/iam/iam.go b/infra/pkg/iam/iam.go index 73436c1..8157247 100644 --- a/infra/pkg/iam/iam.go +++ b/infra/pkg/iam/iam.go @@ -123,12 +123,7 @@ func GrantRegistry(ctx *pulumi.Context, cfg *config.Config, a *Accounts, repo *a // 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 { + if err := GrantSecretRead(ctx, cfg, a, name); err != nil { return err } // vm/bootstrap.sh's safety net adds a version if one is somehow missing. @@ -144,6 +139,19 @@ func GrantSecrets(ctx *pulumi.Context, cfg *config.Config, a *Accounts, names [] return nil } +// GrantSecretRead gives the VM read-only access to one secret. On its own it is +// for operator-supplied values the VM must never write; GrantSecrets adds +// version-adder on top for the ones it may generate. +func GrantSecretRead(ctx *pulumi.Context, cfg *config.Config, a *Accounts, name string) error { + _, 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), + }) + return err +} + // 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. diff --git a/infra/pkg/secrets/secrets.go b/infra/pkg/secrets/secrets.go index 2940ee1..4254138 100644 --- a/infra/pkg/secrets/secrets.go +++ b/infra/pkg/secrets/secrets.go @@ -26,3 +26,9 @@ var Names = []string{ "gitea-oauth2-jwt-secret", "gitea-lfs-jwt-secret", } + +// ACMEEmail holds the contact address Caddy registers with Let's Encrypt. It +// is a secret not because it signs anything but to keep it out of this public +// repository and out of instance metadata. Unlike Names it is supplied by the +// operator, never generated, so the VM gets read access only. +const ACMEEmail = "gitea-acme-email" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 36d1b8d..5e563e2 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -99,6 +99,16 @@ if ! has_version github-pat; then echo " printf %s '' | gcloud secrets versions add github-pat --project=${PROJECT} --data-file=-" fi +# The ACME contact address Caddy registers with Let's Encrypt. A secret only to +# keep it out of this public repository and out of instance metadata. Created +# empty: the address is yours to choose, not something to generate. +ensure_secret gitea-acme-email +if ! has_version gitea-acme-email; then + echo " NOTE: secret 'gitea-acme-email' has no value yet. Caddy still issues" + echo " certificates without it, but with no contact address. Set it with:" + echo " printf %s 'you@example.com' | gcloud secrets versions add gitea-acme-email --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. @@ -247,7 +257,7 @@ Next: pulumi stack init prod pulumi config set gcp:project ${PROJECT} pulumi config set gitea:infraBuildServiceAccount ${INFRA_SA_EMAIL} - # ...plus domain, dnsZone, acmeEmail, githubOwner, githubAppInstallationId + # ...plus domain, dnsZone, githubOwner, githubAppInstallationId pulumi up 4. make build # or, spelled out: gcloud builds submit --config cloudbuild/image.yaml --project ${PROJECT} \\ diff --git a/vm/bootstrap.sh b/vm/bootstrap.sh index ffdee18..4636ba3 100755 --- a/vm/bootstrap.sh +++ b/vm/bootstrap.sh @@ -36,7 +36,6 @@ 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) @@ -57,7 +56,7 @@ case "${WAF_MODE}" in 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 GCP_PROJECT AR_HOST IMAGE_GITEA IMAGE_CADDY DOMAIN APP_NAME export PODMAN_SUBNET PODMAN_GATEWAY REQUIRE_SIGNIN_VIEW WAF_MODE # --------------------------------------------------------------------------- @@ -429,6 +428,20 @@ render_all() { rm -f /etc/sysctl.d/90-gitea-caddy.conf fi + # The ACME contact address lives in Secret Manager rather than instance + # metadata, to keep it out of the public repository. Without it Caddy still + # issues certificates, just under an account with no contact address -- far + # better than an empty `email` directive, which fails to parse and leaves + # nothing serving TLS. + local acme_email caddy_email + if acme_email=$(gcloud secrets versions access latest --secret=gitea-acme-email \ + --project="${GCP_PROJECT}" 2>/dev/null) && [[ -n "${acme_email}" ]]; then + caddy_email="email ${acme_email}" + else + warn "secret gitea-acme-email unreadable -- Caddy will register without a contact address" + caddy_email="# no ACME contact address: secret gitea-acme-email was unreadable at render time" + 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. @@ -444,7 +457,8 @@ render_all() { GITEA_UPSTREAM="${gitea_upstream}" \ CADDY_NETWORK="${caddy_network}" \ CADDY_PUBLISH_PORTS="${caddy_publish}" \ - CADDY_SYSCTL="${caddy_sysctl}" + CADDY_SYSCTL="${caddy_sysctl}" \ + CADDY_EMAIL="${caddy_email}" # 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. @@ -453,7 +467,7 @@ render_all() { && changed=1 render "${STATE_DIR}/config/Caddyfile.tmpl" /etc/caddy/Caddyfile root:root 0644 \ - '${DOMAIN} ${ACME_EMAIL} ${GITEA_UPSTREAM} ${WAF_MODE}' \ + '${DOMAIN} ${CADDY_EMAIL} ${GITEA_UPSTREAM} ${WAF_MODE}' \ && changed=1 local unit diff --git a/vm/config/Caddyfile.tmpl b/vm/config/Caddyfile.tmpl index 4f8f9a4..855218e 100644 --- a/vm/config/Caddyfile.tmpl +++ b/vm/config/Caddyfile.tmpl @@ -5,7 +5,7 @@ # 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} + ${CADDY_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