diff --git a/.gitignore b/.gitignore index 36fb55c..d79e865 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,10 @@ plan.log *.junit.xml coverage/ -# Tofu remote-state secrets (R2 creds + encryption passphrase + account-specific backend cfg). -# The *.example templates ARE committed; the real files are not. +# RETIRED plaintext Tofu secret files. Superseded by the COMMITTED, SOPS/age-encrypted +# infra/tofu/secrets.sops.env + the wrapper infra/tofu/tf.sh (see REMOTE-STATE.md). These +# lines are KEPT as a safety net so any leftover plaintext copy on an operator's machine can +# NEVER be committed; delete the local files once you've migrated to the encrypted flow. infra/tofu/backend.hcl infra/tofu/.r2.env diff --git a/.sops.yaml b/.sops.yaml index 424b3a1..ab351ae 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -34,3 +34,11 @@ creation_rules: encrypted_regex: "^(data|stringData)$" # same age PUBLIC recipient as above. age: "age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg" + + # OpenTofu runtime secrets (dotenv). Holds the R2 S3 keys, the state-encryption + # passphrase, and the Proxmox API token that tf.sh injects into the provider's + # ENVIRONMENT (never a tofu variable/state). Dotenv, so `sops exec-env` / tf.sh + # can export cleanly; NO encrypted_regex here → SOPS encrypts EVERY value (the + # keys stay cleartext for reviewable diffs). Encrypted to the same age recipient. + - path_regex: (^|/)infra/tofu/secrets\.sops\.env$ + age: "age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg" diff --git a/infra/tofu/.r2.env.example b/infra/tofu/.r2.env.example deleted file mode 100644 index 0de1166..0000000 --- a/infra/tofu/.r2.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# Copy to `.r2.env` (gitignored), fill in, then: source .r2.env -# These are the ONLY secrets. They never enter git or tofu state. - -# --- R2 S3 credentials (from Cloudflare R2 -> Manage API Tokens -> Object Read & Write) --- -export AWS_ACCESS_KEY_ID="" -export AWS_SECRET_ACCESS_KEY="" - -# --- State-at-rest encryption (OpenTofu native, AES-GCM via PBKDF2 passphrase) --- -# CRITICAL: store this passphrase in your PASSWORD MANAGER. It is required to read the state on any -# machine and after any rebuild. It must be >= 16 characters (OpenTofu enforces a minimum). -export TF_ENCRYPTION=' -key_provider "pbkdf2" "state" { - passphrase = "" -} -method "aes_gcm" "state" { - keys = key_provider.pbkdf2.state -} -state { - method = method.aes_gcm.state -} -plan { - method = method.aes_gcm.state -} -' diff --git a/infra/tofu/README.md b/infra/tofu/README.md index 6e5653b..3d2c20c 100644 --- a/infra/tofu/README.md +++ b/infra/tofu/README.md @@ -64,24 +64,26 @@ All sizing/IPs/counts/placement are variables — see `variables.tf`. ## Auth (env only — never in tofu vars/state) The provider reads these from the environment; the **API token is never a tofu -variable**, so it never enters state, vars, or plan output: +variable**, so it never enters state, vars, or plan output. You do NOT export these by hand — +they live encrypted in `secrets.sops.env` and the `tf.sh` wrapper decrypts + exports them +(and sets `TF_VAR_proxmox_endpoint` for scheme normalization) at runtime: ``` -export PROXMOX_VE_ENDPOINT="https://192.168.2.102:8006/" -export PROXMOX_VE_API_TOKEN="user@realm!tokenid=SECRET" -export PROXMOX_VE_INSECURE="true" -export TF_VAR_proxmox_endpoint="$PROXMOX_VE_ENDPOINT" # optional; enables normalization +PROXMOX_VE_ENDPOINT=https://192.168.2.102:8006/ +PROXMOX_VE_API_TOKEN=user@realm!tokenid=SECRET +PROXMOX_VE_INSECURE=true ``` +See `REMOTE-STATE.md` for the encrypted-secrets + wrapper flow. ## Usage (operator — NOT run here) +All secrets come from the encrypted `secrets.sops.env` via the `tf.sh` wrapper, run inside the +dev container (which has `sops` + the age key): ``` -cd infra/tofu -cp terraform.tfvars.example terraform.tfvars # set ssh_authorized_key -# source the operator env file (exports the PROXMOX_VE_* above) -tofu init -tofu plan -tofu apply +cp infra/tofu/terraform.tfvars.example infra/tofu/terraform.tfvars # set ssh_authorized_key +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh apply # hand the node IPs to Ansible: -tofu output -raw ansible_inventory > ../ansible/inventory/hosts.ini +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh output -raw ansible_inventory > infra/ansible/inventory/hosts.ini ``` ## Hand-off contract diff --git a/infra/tofu/REMOTE-STATE.md b/infra/tofu/REMOTE-STATE.md index e367201..1f78bfe 100644 --- a/infra/tofu/REMOTE-STATE.md +++ b/infra/tofu/REMOTE-STATE.md @@ -18,22 +18,38 @@ rest** (OpenTofu native AES-GCM) so R2 only ever holds ciphertext. that bucket. Copy the **Access Key ID** and **Secret Access Key** (shown once). 3. Note your **Account ID** (32 hex chars, in the dashboard URL / the R2 S3 endpoint). -### 2. Fill the two gitignored files (secrets stay out of git and out of chat) +### 2. Fill in the encrypted secrets file (edited in place; ciphertext is committed) +All five secrets live in ONE committed, SOPS/age-encrypted dotenv, `infra/tofu/secrets.sops.env`. +Edit it inside the dev container (it has `sops` + the age key). `sops` decrypts to your `$EDITOR` +and re-encrypts on save — plaintext never touches disk: ```bash -cd infra/tofu -cp backend.hcl.example backend.hcl # set bucket/key + endpoints.s3 = https://.r2.cloudflarestorage.com -cp .r2.env.example .r2.env # set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY + a strong TF_ENCRYPTION passphrase +./infra/devcontainer/dev.sh run sops infra/tofu/secrets.sops.env ``` -**Store the encryption passphrase in your password manager.** It is required to read state on any -machine and after any rebuild. Also record the R2 keys there. (Both files are in `.gitignore`.) +Replace every `REPLACE-ME`: +- `PROXMOX_VE_API_TOKEN` — `user@realm!tokenid=secret` (injected into the provider's env by tf.sh; + never a tofu variable, so it never lands in state). +- `PROXMOX_VE_ENDPOINT` / `PROXMOX_VE_INSECURE` — already set to `https://192.168.2.102:8006/` / `true`. +- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — the R2 Object-Read/Write token from step 1. +- `TF_STATE_PASSPHRASE` — the state-at-rest passphrase (≥ 16 chars). **tf.sh builds the multi-line + `TF_ENCRYPTION` blob from this one scalar.** + +> ⚠️ **The passphrase and R2 keys MUST be the SAME values that already encrypt the live R2 state.** +> The state in R2 is already AES-GCM-encrypted with the current passphrase; a *different* +> `TF_STATE_PASSPHRASE` makes the existing state UNREADABLE (and wrong R2 keys can't reach it at +> all). Reuse your existing values from your password manager — do NOT invent new ones here. + +The non-secret R2 bucket/key/endpoint live in the COMMITTED `infra/tofu/backend.r2.hcl` (an R2 +account id is not a credential). Still keep the passphrase + R2 keys in your password manager as +the authoritative backup. The old plaintext `.r2.env` / `backend.hcl` are retired — delete any +local copies (they stay in `.gitignore` only as a safety net). ### 3. Migrate the existing local state -> R2 (one shot, moves state only — touches NO infra) +> The R2 state has ALREADY been migrated and encrypted. This step is historical — a fresh machine +> adopting the existing encrypted state does NOT run it; it just does the Day-2 flow below. ```bash -cd infra/tofu -cp terraform.tfstate terraform.tfstate.premigrate.bak # extra belt-and-suspenders backup -source .r2.env -tofu init -backend-config=backend.hcl -migrate-state # answer "yes" to copy local -> R2 -tofu plan # MUST report "No changes." — proves parity +cp infra/tofu/terraform.tfstate infra/tofu/terraform.tfstate.premigrate.bak # belt-and-suspenders +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init -migrate-state # answer "yes" +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan # MUST say "No changes." ``` If `plan` shows no changes, the migration is correct. If it wants to create/destroy nodes, STOP — the state did not carry over; re-check backend.hcl/creds and do not apply. @@ -48,20 +64,34 @@ the state did not carry over; re-check backend.hcl/creds and do not apply. > R2 state does NOT need this — the encrypt-only `.r2.env.example` is correct as-is. ## Day-2 use (any machine) +Everything goes through the `tf.sh` wrapper inside the dev container. It decrypts +`secrets.sops.env`, exports `PROXMOX_VE_*` / `AWS_*`, builds `TF_ENCRYPTION` from +`TF_STATE_PASSPHRASE`, and passes `backend.r2.hcl` to `init`. NO manual secret entry, no plaintext +env file: ```bash -cd infra/tofu -source .r2.env # R2 creds + TF_ENCRYPTION passphrase -tofu init -backend-config=backend.hcl -tofu plan +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan # expect "No changes." +./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh apply ``` -That's the whole "work from a different computer" story: clone the repo, create the two local files -from the templates (creds + passphrase from your password manager), `init`, done. +To CHANGE a secret, edit the encrypted file in place (re-encrypts on save): +```bash +./infra/devcontainer/dev.sh run sops infra/tofu/secrets.sops.env +``` + +That's the whole "work from a different computer" story: clone the repo, have the age PRIVATE key in +`~/.config/sops/age/keys.txt` (the dev container mounts it), then `tf.sh init` / `plan`. The secrets +ride along in git as ciphertext; only the age key lives outside the repo. ## DR: rebuild from nothing -Because state is in R2 (not the cluster), a destroyed cluster is recoverable: on a fresh box, install -OpenTofu, create `backend.hcl` + `.r2.env`, `tofu init -backend-config=backend.hcl`, then `tofu apply` -to rebuild the VMs against the preserved state. R2 itself should ALSO be covered by the off-cluster -backup target (OQ-BKP) so state survives even an R2/account loss. +Because state is in R2 (not the cluster) AND the tofu secrets are committed (encrypted), a destroyed +cluster is recoverable from just **the repo + the age private key**. On a fresh box: install the dev +container toolchain, drop the age key at `~/.config/sops/age/keys.txt`, then +`./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init` and `... tf.sh apply` to rebuild the VMs +against the preserved state. Two things must survive off-cluster and off-repo: the **age private +key** (in `flux-system/sops-age` in a live cluster; also keep an offline copy) and the **R2 state +bucket** itself (covered by the off-cluster backup target OQ-BKP so state survives an R2/account loss). +The `TF_STATE_PASSPHRASE` and R2 keys are recoverable from the encrypted `secrets.sops.env` as long +as you hold the age key — keep them in your password manager too, belt and suspenders. ## Follow-up (tracked) - **Locking.** OpenTofu 1.9.0 has no `use_lockfile`; state locking is currently OFF (safe for a single diff --git a/infra/tofu/backend.hcl.example b/infra/tofu/backend.hcl.example deleted file mode 100644 index 58a59bf..0000000 --- a/infra/tofu/backend.hcl.example +++ /dev/null @@ -1,12 +0,0 @@ -# Copy to `backend.hcl` (gitignored) and fill in from your Cloudflare R2 bucket + API token. -# Passed to tofu via: source .r2.env && tofu init -backend-config=backend.hcl -migrate-state -# -# The R2 is the 32-hex string in your Cloudflare dashboard URL / R2 endpoint. It is -# not a credential, but is kept out of git here as defense-in-depth. - -bucket = "homelab-tofu-state" -key = "k3s-dev/terraform.tfstate" - -endpoints = { - s3 = "https://.r2.cloudflarestorage.com" -} diff --git a/infra/tofu/backend.r2.hcl b/infra/tofu/backend.r2.hcl new file mode 100644 index 0000000..79e771d --- /dev/null +++ b/infra/tofu/backend.r2.hcl @@ -0,0 +1,17 @@ +# Committed R2 backend config — NON-SECRET account-specific bits, passed to +# `tofu init -backend-config=` by tf.sh. The S3 keys and the state-encryption +# passphrase are NOT here — they come from secrets.sops.env via tf.sh. +# +# The endpoint contains the R2 account id (the 32-hex string). An account id is +# NOT a credential (it does nothing without the R2 API keys), so committing it is +# fine and keeps the "work from any machine" story to: clone repo + age key. +# +# The rest of the backend "s3" block (region, skip_* flags, encryption) lives in +# backend.tf; this file supplies only bucket/key/endpoints. + +bucket = "homelab-tofu-state" +key = "k3s-dev/terraform.tfstate" + +endpoints = { + s3 = "https://7ce452a1b3d6c86daf2c020edcb82d37.r2.cloudflarestorage.com" +} diff --git a/infra/tofu/backend.tf b/infra/tofu/backend.tf index 3f85192..b7ba43e 100644 --- a/infra/tofu/backend.tf +++ b/infra/tofu/backend.tf @@ -6,13 +6,16 @@ # gone, `tofu apply` to rebuild. R2 sits entirely outside that chain: a full cluster rebuild can # still read state. It also survives total-site loss (off-site) and gives multi-machine access. # -# SECRETS: the account-specific bucket/endpoint come from a gitignored backend.hcl; the S3 keys -# come from the environment (.r2.env: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY). State bytes are -# encrypted at rest via the TF_ENCRYPTION env blob (see REMOTE-STATE.md) — the passphrase lives ONLY -# in your password manager and the gitignored .r2.env, never in git or in the state itself. +# SECRETS: driven by infra/tofu/tf.sh, which decrypts the COMMITTED, SOPS/age-encrypted +# secrets.sops.env and exports AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (R2 S3 keys) plus builds +# the TF_ENCRYPTION blob from the single TF_STATE_PASSPHRASE. The non-secret bucket/key/endpoint +# live in the committed backend.r2.hcl (an R2 account id is not a credential). State bytes are +# encrypted at rest via TF_ENCRYPTION (see REMOTE-STATE.md) — the passphrase lives ONLY inside the +# encrypted secrets.sops.env (and your password manager), never in git plaintext or in the state. # -# INIT (partial config — non-secret bits here, secret bits from the gitignored file): -# cd infra/tofu && source .r2.env && tofu init -backend-config=backend.hcl -migrate-state +# INIT (run via the wrapper, inside the dev container — non-secret bits from backend.r2.hcl, +# secret bits decrypted from secrets.sops.env): +# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init # # NOTE: this `terraform {}` block is MERGED with the one in versions.tf (required_version + # required_providers). OpenTofu allows multiple terraform blocks across files; `backend` appears once. @@ -27,7 +30,7 @@ terraform { skip_metadata_api_check = true skip_s3_checksum = true # R2 rejects AWS trailing (streaming) checksums. - # bucket, key, endpoints{ s3 } -> backend.hcl (gitignored; keeps the R2 account id out of git). + # bucket, key, endpoints{ s3 } -> committed backend.r2.hcl, passed by tf.sh via -backend-config. # # use_lockfile = true -> ENABLE after bumping OpenTofu to >= 1.10. Native S3 conditional-write # locking (no DynamoDB); works on R2 via If-None-Match. 1.9.0 has no lockfile support, so state diff --git a/infra/tofu/secrets.sops.env b/infra/tofu/secrets.sops.env new file mode 100644 index 0000000..dff6fde --- /dev/null +++ b/infra/tofu/secrets.sops.env @@ -0,0 +1,24 @@ +#ENC[AES256_GCM,data:phVXkjO3EXMu1N0PjbJc3ezD9Fv6OjYXV8SR7kVJkCU5mNegSsSxqla5yK2pnkXe/2bKl4zH2jHNc1Mcjwrkxbq2rbdCl87HkDYU4GQ=,iv:T+NCH5piQKyu33pC+VDmkGKFDw8BFyKU0SGyXU0Oyc0=,tag:9rTfz7kjbG14I542CJ20JQ==,type:comment] +#ENC[AES256_GCM,data:ouvXF4JpcjNKh19m7pmbsMGaYCJnt8g8j3u6KOS5qqLwPUQXWpap5/iMFOJ99Zg7P5Mr++pFBcsDHehaLdmk3zRPejAn2hiZdrU0uDBPHrNOs5ivAV/xplWbeU5RoQ==,iv:RHIswt1NJ5SFx4zJCy3lLHHW29kh+5EXM9SMXTafzQY=,tag:Y7B+MSE1reAkvdLf5rG73Q==,type:comment] +#ENC[AES256_GCM,data:N8YDuSbxFKEGYqBAj/lcJiq1sVXkc6QHyv+PAPeflCqSkuJnYxRK5PmUdvrUlJ5UQ2JtgZSpLPQeunGYkXTHUKxQKn30HGU/vH7PnFUaaGcdQio=,iv:dzllLfLcFUsXyXRiUkWOzVq4ByCxuQyOthIuGuAvc50=,tag:Bnu2MKNAQBUf2CYVeM/ouw==,type:comment] +#ENC[AES256_GCM,data:S+H2PEFoMYlHFqG3BJNbT/iQxa38pZ1vgtG1mF/1Oq+UOF7jOiD8NSxayUnu2wuzqWrFkQtDCNRGPwiogq+nyeTQTkMJgm9iTKOkgFKn9gL06A==,iv:X61ZbHvs5yMcu7oyJvZgvUXeQ8IjjM3/BsZZ4oveRts=,tag:2xGsmutQCr4DOJWJm1cOHw==,type:comment] +#ENC[AES256_GCM,data:4LI7KckpBHs01cebeZjjqs8/WZbKKalPnx8aj77wT3JDtPifdAE0N1/4CpJYGOKJsJdm0sYm/3sh+dZ+F/PGfk3lfkTsKobvjS8Gs6WVOrQhJkq2LucBWw==,iv:8nJsWdKo57NsNIb0slM7OHakMu0u1sVpECRHE9BWVno=,tag:9xuw36Cd/Ex33FhYwGWfBA==,type:comment] +# +#ENC[AES256_GCM,data:Ne1QnjtM2NxPEgjHCgKTU6tand+4yaZWFTq3qxiNFcOucJVL134ycsE+41B7NlEz0+UztRXJ+Zz1/wpcIBQsswDfw1pVP+t3lGl7Mq+GcoElB/E=,iv:esdC6pJPkvirFm3Bt5c1q719o6fbWHMR617j86D9WCk=,tag:hfkQyK4Pf+mhMhAmiT4mhQ==,type:comment] +#ENC[AES256_GCM,data:olZoi67DYWBRnKv3SEcp9AY7J+jcw57LPZdff4tjgrTdPHos9uyE+s0JCWDAGVrKLu6JFvEIifKIaGz59FLo+qv1HHP6gWXhpxTS0RFTNAYwn/GVl5eUFzLWJmN8Fqw=,iv:K7V/q1De9D5hCKXXTkFfHSaHPtP/INWwdZ8V1tupMtQ=,tag:VPXiDQgMjdgXuswXRmbHRA==,type:comment] +PROXMOX_VE_API_TOKEN=ENC[AES256_GCM,data:F55qeDT3HBgfYA==,iv:UdBvzph1/5FFj5UpoJimHyvtZVWvBOyohn+V2HuXZFo=,tag:VBhe8TePhdGyfQVOJH/i9g==,type:str] +PROXMOX_VE_ENDPOINT=ENC[AES256_GCM,data:XJ1I+RMh6YBasYkJSFEgTMrHpGnooDiCCGx/,iv:4SCkmvxCkKRBP0YxzrpQ4bUEdlPvnwOqVh5S+jO5pqo=,tag:fmIg/5r2VKn/YTjVubwk1A==,type:str] +PROXMOX_VE_INSECURE=ENC[AES256_GCM,data:6VjGNw==,iv:gkR7QY5tNacpnquR7vVg14zJwjNMfR3iCkvI+T7g5d4=,tag:TvZCbaejUu+50sa3n+b4XQ==,type:str] +#ENC[AES256_GCM,data:wXUDqRFrcgE1vdKCXK/hq5ZerZ+qp/Dp48iP4sJBgVrIleEL8NEerk1WahuBXg7DZLm1r0yAZz7bACUHZST0x/Y=,iv:trlf6jEWST/ur2AobLuTxXAk4qIkoM+EHe542+aHZGY=,tag:mlr6H0E6OM6KcW1zntiQvg==,type:comment] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:lPyvLMNcKBptwA==,iv:zihVqD9XGE3gFbzyHd3wWTDLTVgZJRaHxsHIcef7M3A=,tag:fuVnjVRahoo1viSzoCqROA==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:upXXeOauT56tKg==,iv:Rfj8HyjrMygqLLRMnuxzJGqDVzbUppPUYz1WQiJqPdI=,tag:y+8zcTTbDeOk/tx2dWQl0Q==,type:str] +#ENC[AES256_GCM,data:ztI5Cl8qRqpgnAw5e/fODKXJ3f8lmsbY9EXMIEaIQQBkyAlaFAEmbYUbb/1MgFhEGyMHFE6DROLAITBBf8nvIf8OYmsaufcvQeM=,iv:l8Ve0/cAvM6BzJkMeIw/0JFKZsB/4lrKYp6LZDz3BAQ=,tag:Ycbmwau8Slp8gvP6DOlXPw==,type:comment] +#ENC[AES256_GCM,data:rI8MK9Nuc58hX8zBEtqITCcLu5/4pBKmLKWbYE/NzfGN9VB1K2lDv7P5CHFyHN20BVWiUx0nOmXzPZCJ/3NecNr0pJBBn2psrHK8BgZkRDE+yKU=,iv:fDCzQy05zUCoYI82cxxRZkvcpBT4xDHsjNaTfAkosjo=,tag:G7yoxa/Zpt/8ylvaLB3OHQ==,type:comment] +#ENC[AES256_GCM,data:VdqDxRFlnn85hTj4Oz00+noc5nV9Yp19wKxk3Ql+eKDtMtm2ftLIow4vjuR3YIwGJTi04nkFdgkzmuOrOJBlkbhAmSjvVKi/0QxxHUhDAcslNvH8,iv:Fwo89vAPfPZXXk8y1Xn3yaxBKnGJgR4Y0eaYUOLmOjM=,tag:hvc/WJmbgD2QsCser+djlw==,type:comment] +TF_STATE_PASSPHRASE=ENC[AES256_GCM,data:Uw0sZEqe2dpSxw==,iv:0edbfCL73a+pMF2KYAv9bZbNA+PgHO53+o8HeCfanAI=,tag:qHLfOttXK9b56/e6Py0mpg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXS1Q1dHdNUkdEN2tWWDNj\ncHR6RVZ5ckhYS0VkL1FqNGtNSWREb1hCNDNrCkVCMjFJU2JOV3I0MTI0Z3JHT2J2\nMUJTbDFoWHJyM1FjK05BSHhIOHVIK00KLS0tIG4vemVTNVhFajhSQ1dkY252Ly9q\nTGl2ZFV1ek5QQTBtcVZ6SnBZbXdERUUKKyz3ZNRrXn9OWWpV++fURpNHEuiRohko\nXJL496ln+4eJclnFDj6FJzqJJggKrd4F7zLb/XiEkDynP6fE6qXIQQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg +sops_lastmodified=2026-08-28T00:03:49Z +sops_mac=ENC[AES256_GCM,data:LpNDyo0OGvMUXNBk1CN3a14P9EbDRGewXqmX/x50Japg+LDXW+ca/rgVGEhBO7YfCg9bsN3xWB8ZALj7HZsfAS8gf3UAYYENP6djs9em7/ZmExieTgVY1eq9LTkHVAuJo5ylPtOinr5jP56a0v7xKSXT08TKEVPOsmJ362ZIqfo=,iv:Jg1jYq2wkbZUnH2HvT/Kfoc4Shpo5qwIG10U+EU0r+I=,tag:Q9Ruy22FhQ/eZrcilw90KA==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.9.4 diff --git a/infra/tofu/tf.sh b/infra/tofu/tf.sh new file mode 100755 index 0000000..4c9382c --- /dev/null +++ b/infra/tofu/tf.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# infra/tofu/tf.sh — run OpenTofu with EVERY secret sourced from the SOPS/age-encrypted +# secrets.sops.env. No plaintext .r2.env, no manual secret entry, secrets live encrypted +# in git like the repo's other secrets. +# +# MUST run inside the dev container (it has sops + tofu, and the age PRIVATE key mounted +# from ~/.config/sops/age/keys.txt): +# +# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init +# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan +# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh apply +# +# What it does: +# 1. `sops exec-env` decrypts secrets.sops.env and puts the dotenv keys in the ENV +# (exec-env parses the dotenv itself, so values with shell-special chars — ! ( ) ; — +# are set safely, unlike `source`). It then re-execs THIS script (phase 2). +# 2. Phase 2 exports PROXMOX_VE_* / AWS_* (already in env) + TF_VAR_proxmox_endpoint, +# builds the multi-line TF_ENCRYPTION HCL from the single TF_STATE_PASSPHRASE scalar +# (same pbkdf2 → aes_gcm → state/plan structure the old .r2.env hardcoded), and runs +# `tofu`. For `init` it appends `-backend-config=backend.r2.hcl` (the non-secret R2 +# bucket/key/endpoint); other subcommands are passed through unchanged. +# +# The Proxmox API token is injected ONLY as PROXMOX_VE_API_TOKEN (provider reads it from +# the env) — it is NEVER a tofu variable, so it never enters state, vars, or plan output. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS="${HERE}/secrets.sops.env" +BACKEND="${HERE}/backend.r2.hcl" + +# ── Phase 1: decrypt secrets into the environment, then re-exec self ────────────────── +if [[ "${_TF_SH_SECRETS_LOADED:-}" != "1" ]]; then + command -v sops >/dev/null 2>&1 || { + echo "tf.sh: 'sops' not found — run me inside the dev container:" >&2 + echo " ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh $*" >&2 + exit 127 + } + [[ -f "$SECRETS" ]] || { echo "tf.sh: missing $SECRETS" >&2; exit 1; } + export _TF_SH_SECRETS_LOADED=1 + # Re-exec THIS script (with its args) under sops exec-env so the dotenv values are in + # the process environment. printf %q keeps args intact through exec-env's shell. + exec sops exec-env "$SECRETS" "$(printf '%q ' "${BASH_SOURCE[0]}" "$@")" +fi + +# ── Phase 2: secrets are now in the environment ─────────────────────────────────────── +: "${TF_STATE_PASSPHRASE:?tf.sh: TF_STATE_PASSPHRASE not set (fill in secrets.sops.env)}" +: "${PROXMOX_VE_ENDPOINT:?tf.sh: PROXMOX_VE_ENDPOINT not set (fill in secrets.sops.env)}" + +# Enable versions.tf endpoint scheme-normalization (harmless if already has https://). +export TF_VAR_proxmox_endpoint="${PROXMOX_VE_ENDPOINT}" + +# Build the state-at-rest encryption blob from the single passphrase scalar. +export TF_ENCRYPTION=" +key_provider \"pbkdf2\" \"state\" { + passphrase = \"${TF_STATE_PASSPHRASE}\" +} +method \"aes_gcm\" \"state\" { + keys = key_provider.pbkdf2.state +} +state { + method = method.aes_gcm.state +} +plan { + method = method.aes_gcm.state +} +" + +cd "$HERE" + +# `-backend-config` is only valid for `init`; inject it there, pass everything else through. +if [[ "${1:-}" == "init" ]]; then + shift + exec tofu init -backend-config="$BACKEND" "$@" +fi +exec tofu "$@"