First deploy: fix Pulumi, image build, container DNS and DNS-01; Dependabot updates #2
@@ -47,14 +47,23 @@ printf %s '<token>' | 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 <project-id>
|
||||
|
||||
# 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 <project-id>)
|
||||
cd infra
|
||||
pulumi login gs://<project-id>-pulumi-state
|
||||
pulumi stack init prod
|
||||
pulumi config set gcp:project <project-id>
|
||||
pulumi config set gitea:domain gitea.jasonmross.dev
|
||||
pulumi config set gitea:dnsZone <cloud-dns-managed-zone-name> # gcloud dns managed-zones list
|
||||
pulumi config set gitea:acmeEmail you@example.com
|
||||
pulumi config set gitea:githubOwner <owner>
|
||||
pulumi config set gitea:githubAppInstallationId <id>
|
||||
pulumi config set gitea:infraBuildServiceAccount cb-infra@<project-id>.iam.gserviceaccount.com
|
||||
@@ -62,10 +71,10 @@ pulumi config set gitea:infraBuildServiceAccount cb-infra@<project-id>.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 <you> --email <you@example.com> --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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
+14
-6
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+11
-1
@@ -99,6 +99,16 @@ if ! has_version github-pat; then
|
||||
echo " printf %s '<token>' | 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} \\
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user