96 lines
5.4 KiB
Markdown
96 lines
5.4 KiB
Markdown
# Secrets
|
|
|
|
SOPS-in-repo with one [age](https://github.com/FiloSottile/age) key per host. Encrypted files live next to `docker-compose.yml` in each `Charlie/<stack>` repo. The reusable [`deploy-stack.yml`](../.gitea/workflows/deploy-stack.yml) workflow SSHes into the target host as `sys-gitea-runner` and runs `sops --decrypt` there using `/etc/charlie/age.key`.
|
|
|
|
Operational recipe: [`onboarding.md → Host age key (SOPS)`](onboarding.md#host-age-key-sops).
|
|
Caller-facing reference: [`automation.md`](automation.md).
|
|
|
|
## Goal
|
|
|
|
Every host can:
|
|
|
|
1. Obtain only the secrets it's allowed to decrypt.
|
|
2. Materialize them where compose can consume them (typically `.env`).
|
|
3. Do this non-interactively from a Gitea Actions runner.
|
|
|
|
## Constraints
|
|
|
|
- **Source of truth lives with the stack.** A secret for `mediacompose` belongs in [`Charlie/mediacompose`](https://gitea.alexandru.macocian.me/Charlie/mediacompose), not in a separate vault. Blast radius is bounded by repo access.
|
|
- **Per-machine decryption.** A compromised host leaks only the secrets in repos whose `.sops.yaml` lists that host's key.
|
|
- **Self-identifying host.** `hostname -s | tr '[:upper:]' '[:lower:]'` everywhere.
|
|
- **Versioning is git.** Rotation = commit + (when needed) `sops updatekeys` + push.
|
|
|
|
## Layout in a stack repo
|
|
|
|
```
|
|
Charlie/<stack>/
|
|
├── docker-compose.yml
|
|
├── .env.example # tracked, redacted
|
|
├── secrets/
|
|
│ └── env.sops.yaml # encrypted; decrypts to .env
|
|
├── .sops.yaml # creation rules: which recipients can decrypt
|
|
└── .gitea/workflows/deploy.yaml # calls deploy-stack.yml
|
|
```
|
|
|
|
For shared stacks, `.sops.yaml` lists every host that runs the stack.
|
|
|
|
## Keys
|
|
|
|
- **One age key per host**, at `/etc/charlie/age.key` (`0640 root:charlie-deploy`). Public keys in [`hosts.md`](hosts.md#age-public-keys-sops).
|
|
- **One admin key** held by alex on the admin laptop, listed in *every* repo's `.sops.yaml` so secrets stay decryptable from off-host. Backup as a vaultwarden Secure Note (`Charlie SOPS admin age key`).
|
|
- **Per-host, not per-stack.** Container isolation already provides the per-stack property — `/etc/charlie/age.key` isn't bind-mounted into containers.
|
|
|
|
## Rotation
|
|
|
|
```bash
|
|
# In the stack repo on the admin laptop.
|
|
tmp=$(mktemp) && chmod 600 "$tmp"
|
|
printf 'KEY=new-value\n' > "$tmp"
|
|
sops --encrypt --input-type dotenv --output-type yaml \
|
|
--filename-override secrets/env.sops.yaml "$tmp" > secrets/env.sops.yaml
|
|
shred -u "$tmp"
|
|
git commit -am 'Rotate <KEY>'
|
|
git push # triggers redeploy
|
|
```
|
|
|
|
For per-host secret files, repeat per host.
|
|
Adding/removing recipients (e.g. onboarding mohg): edit `.sops.yaml`, then `sops updatekeys secrets/env.sops.yaml`.
|
|
|
|
## Registry credentials (read-only PAT)
|
|
|
|
`deploy-stack.yml` runs `docker login gitea.alexandru.macocian.me` on the target host immediately before `docker compose pull`. This is independent of any creds cached in `~/.docker/config.json` — necessary because `build-image.yml`'s cleanup wipes the RW caller PAT after each build, which otherwise leaves the next deploy stranded.
|
|
|
|
The login uses a dedicated **read-only** PAT (scope: `read:package`) belonging to `sys-gitea-runner`. A host compromise leaks pull access only; pushes still require the per-namespace RW PATs that callers inject into `build-image.yml`.
|
|
|
|
The ciphertext lives **inlined** in [`deploy-stack.yml`](../.gitea/workflows/deploy-stack.yml) between `SOPS_BLOB` markers in the `Registry login` step. Recipients = every host age key + the admin key (see [`/.sops.yaml`](../.sops.yaml)). Decryption happens on the target host using its `/etc/charlie/age.key`.
|
|
|
|
### Encrypt / paste recipe
|
|
|
|
On the admin laptop, in this repo:
|
|
|
|
```bash
|
|
mkdir -p secrets
|
|
tmp=$(mktemp) && chmod 600 "$tmp"
|
|
printf 'REGISTRY_USER=sys-gitea-runner\nREGISTRY_PAT=<ro-pat>\n' > "$tmp"
|
|
sops --encrypt --input-type dotenv --output-type yaml \
|
|
--filename-override secrets/registry-creds.sops.yaml \
|
|
"$tmp" > secrets/registry-creds.sops.yaml
|
|
shred -u "$tmp"
|
|
```
|
|
|
|
Open the resulting `secrets/registry-creds.sops.yaml`, copy its full contents, and paste it into `.gitea/workflows/deploy-stack.yml` replacing the `REPLACE_ME_WITH_ENCRYPTED_YAML` line (preserve the surrounding comment markers). The `secrets/registry-creds.sops.yaml` file itself is not committed (it's a scratch artifact) — only the inlined paste is the source of truth. `git commit -am 'Rotate registry RO PAT' && git push`.
|
|
|
|
### Rotation
|
|
|
|
Regenerate the PAT in Gitea (`sys-gitea-runner` → Settings → Applications → tokens, `read:package` only), repeat the recipe, repaste, push. Next deploy on every host picks it up automatically.
|
|
|
|
### Adding a host
|
|
|
|
When a new host gets an age key during onboarding, add its pubkey to the `creation_rules` block in [`/.sops.yaml`](../.sops.yaml), then `sops updatekeys secrets/registry-creds.sops.yaml` (regenerate locally first via the recipe above), repaste, push.
|
|
|
|
## What this design intentionally doesn't do
|
|
|
|
- Manage secrets for stacks that aren't runner-deployed.
|
|
- Replace vaultwarden's human-facing UX. Vaultwarden stays for browser autofill + secure notes (including the admin age key backup).
|
|
- Solve secrets for [`Charlie/gitea`](https://gitea.alexandru.macocian.me/Charlie/gitea) auto-deploy — gitea-on-melina is deployed manually (it bootstraps the runners that would deploy it). SOPS is offline crypto though, so its secrets *can* live encrypted in the repo and a manual `sops -d | docker compose up -d` works.
|