From 8c9a15b175c2a2b332610bdbe23a4445558c7bb0 Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Sun, 24 May 2026 12:01:30 +0200 Subject: [PATCH] Setup automatic login for deploy --- .gitea/workflows/deploy-stack.yml | 110 +++++++++++++++++++++++++++--- .sops.yaml | 21 ++++++ docs/automation.md | 23 +++++++ docs/inventory.md | 3 + docs/secrets.md | 32 +++++++++ 5 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 .sops.yaml diff --git a/.gitea/workflows/deploy-stack.yml b/.gitea/workflows/deploy-stack.yml index ebefa33..fb078c8 100644 --- a/.gitea/workflows/deploy-stack.yml +++ b/.gitea/workflows/deploy-stack.yml @@ -255,6 +255,74 @@ jobs: printf '%s\n' "$args" > /tmp/charlie-deploy-compose-args echo "compose args: $args" + - name: Registry login + # Fresh `docker login` on the target host immediately before pull. + # Avoids relying on creds cached in ~/.docker/config.json — those + # get wiped by build-image.yml's logout-on-cleanup, which was the + # source of the intermittent 401 on pull. Uses a dedicated + # read-only PAT (scope: `read:package`) for `sys-gitea-runner` so + # a host compromise can't push images. + # + # The PAT lives inlined below as SOPS ciphertext, encrypted to + # every host's age key (see /.sops.yaml). The target decrypts + # with its own `/etc/charlie/age.key`. Ciphertext is safe to + # commit; rotation = re-encrypt locally, repaste, push (see + # docs/secrets.md → "Registry credentials (read-only PAT)"). + if: ${{ inputs.pull }} + uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main + with: + run: | + set -euo pipefail + enc="/tmp/charlie-registry-creds.$$.sops.yaml" + umask 077 + cat > "$enc" <<'SOPS_BLOB' + REGISTRY_USER: ENC[AES256_GCM,data:KlMpZ1TfgPNdbgUgNjEZ4g==,iv:RPhUcIca4dA3DgEXDC0YXTK/8jX2x7HFSZnQxQEbBeA=,tag:h7P3CMgQSBRWl72UEy0FfQ==,type:str] + REGISTRY_PAT: ENC[AES256_GCM,data:x7b7UcAWwVrTsRQeAvoWr6o4DyZKSTwSoRP59lhrHzxcSmocnz4uHQ==,iv:OxR2A7yN3Yn6SsgxgOukDVmdHhx/K3vHVF7XPk6qvwQ=,tag:1T2IJUdEDKzw9FH+zhIypA==,type:str] + sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzV1hqNnQvQ1lETmlnTFgw + NCtBeXBzeHZ3bllWeXVOVUJiWmhxTThzZG4wCjE2MS8wZ1pvM2Z0SWZRQWtkb0lF + Tm8zNWtZNEtIYU90UWpJWkxlbHYzOTgKLS0tIHg1bjExaXJ4VVIweVN2bEFlMndU + UkQvZzBueHY1RVUyVXdMWTJDSjFxSzQKfU+QNptiHxqAn/8YIoGRR2GeZ5iZCBDo + GQe3YDI2ifE6IQhoMFIeF4lylVxx5RkCAZjcbfoiSeZHhfhGwWYyXA== + -----END AGE ENCRYPTED FILE----- + recipient: age19d6mc8arznnwnvtp0xrphlseuwf94p0f3pznszrtmd5wgmjpzqdq9mltvs + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0bjlTa29Ic1crTTFJbmJw + bEpqQ0hOc3ZlT0VTTnJEOVdWRzIrWmdobzIwCmFKZzlpTHZCeC9ZQk5KbzA1V2ty + OGpmSFpDNys3c3YrbG5WRGZDUklpcTQKLS0tIDh1bU8xUGppMWdNNjNFUlFFTjg0 + dVIzK1g4K05vM3VzNGtzZWJTMVFicWcKk6z2PUFpm/FhUC2EK6qO//t0g0Tjx+9z + CMzb9uP7UHSAUTVWv76jQp77D4raQK3s6/ihqezkUPoJ22pJKC+c9g== + -----END AGE ENCRYPTED FILE----- + recipient: age1m5ha3fd8psek5pym4spk7encjk4vmsml46eya3eymp7q9d9jre5s7xe84r + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUU3pEdTQrSTk3SU1MY3Uy + c05jWTZ1VG5YUWpHczhseVZXL3pBaXJERUVRCmxUMzMxZWZSSFVzVWYxNGExU3pF + VTVzVjZhRE1jUVBiTDhURG5jZE90ZlkKLS0tIC94ZzNEdnpsQWVqOFVtcXZjWWdj + Q1phNVpjeXVHSlFFYk5XUlAvVm9IRmcKgHeRc5Dj1sxXRFZ//RjoPCUHIsgA9YLx + EsNknYCR2ca+MEHulO2bNEvQx4T1DsXcS3NawEx3ABE9P481HG+/6A== + -----END AGE ENCRYPTED FILE----- + recipient: age1xfp878l4ul5zulqqul0u60c44c5wj94cmladgc6jt5jrlmrdrezsd8twh2 + lastmodified: "2026-05-24T09:56:34Z" + mac: ENC[AES256_GCM,data:Y6XIHhVZtwggKw+/8+MR3SJPAfUv4egIfKiDrhMP143RfjZlwXZhfRzQVgZtNxRTynJjAUzqWyDdgqGPc0QdRjKRO8Wp3faMqJoP1zfD1VZwSWA95OG04mcrLpVb9BhBhg0NyINT8uwjuTcOax/6WEaSwBFurNzfJS9GA4YQrZ0=,iv:LVK6I2LA+IQ2ybs8U9tbcYQnBpGpZJ4yc0N5NLIZw2I=,tag:q2ZJ+/i7cABrJVtbiTK0Aw==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.1 + SOPS_BLOB + export SOPS_AGE_KEY_FILE=/etc/charlie/age.key + # Decrypt to a shell-eval'able dotenv, then immediately scrub + # the ciphertext so it doesn't outlive the in-memory plaintext. + plain=$(sops --decrypt --output-type dotenv "$enc") + rm -f "$enc" + eval "$plain" + unset plain + printf '%s' "$REGISTRY_PAT" | docker login gitea.alexandru.macocian.me \ + --username "$REGISTRY_USER" --password-stdin + unset REGISTRY_PAT REGISTRY_USER + - name: Pull images if: ${{ inputs.pull }} uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main @@ -283,20 +351,44 @@ jobs: # shellcheck disable=SC2086 docker compose "$@" up -d --force-recreate ${{ inputs.compose-args }} - - name: Cleanup decrypted plaintext - if: ${{ always() && inputs.decrypt != '' }} + - name: Cleanup + # Unconditionally runs. Tolerates "never logged in", "never + # decrypted", "step failed before tmp files were written" — all + # the unlinks/logouts are best-effort. Never leak the registry + # PAT or decrypted plaintext on disk, regardless of where in the + # job we bailed. + if: ${{ always() }} uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main with: - workdir: ${{ steps.resolve.outputs.path }} + # No workdir: cleanup operates on /tmp paths and the docker + # daemon — never the stack working tree. If we set workdir to + # the stack path and a very early step (e.g. provisioning) + # failed, charlie-ssh-target would `cd` into a non-existent + # directory and bail before the cleanup commands run, leaving + # PAT/plaintext on disk. Exactly what cleanup must prevent. run: | set -u + # 1. Drop registry creds first. Idempotent / namespaced per + # registry, so it's safe even if Registry login never ran. + docker logout gitea.alexandru.macocian.me >/dev/null 2>&1 || true + # 2. Scrub any leftover ciphertext if the Registry login step + # crashed between `cat > "$enc"` and `rm -f "$enc"`. + # Glob match is intentional — PID-suffixed, multiple + # runs may overlap. + rm -f /tmp/charlie-registry-creds.*.sops.yaml 2>/dev/null || true + # 3. Drop the per-run compose args scratch file. + rm -f /tmp/charlie-deploy-compose-args 2>/dev/null || true + # 4. Decrypted-plaintext scrub: only meaningful when the + # SOPS decrypt step actually ran. The list file's + # absence is the normal "no decrypt requested" path. list=/tmp/charlie-deploy-cleanup.list - [ -f "$list" ] || exit 0 - while IFS= read -r f; do - [ -z "$f" ] && continue - rm -f -- "$f" && echo "removed: $f" - done < "$list" - rm -f "$list" /tmp/charlie-deploy-compose-args + if [ -f "$list" ]; then + while IFS= read -r f; do + [ -z "$f" ] && continue + rm -f -- "$f" && echo "removed: $f" + done < "$list" + rm -f "$list" + fi - name: Close SSH session if: always() diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..c108feb --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,21 @@ +# SOPS creation rules for project-charlie. +# +# Only used locally on the admin laptop when encrypting the read-only +# registry PAT that gets pasted into `.gitea/workflows/deploy-stack.yml`. +# The workflow itself doesn't read this file — it ships the ciphertext +# inlined in the YAML and decrypts on the target host with +# `/etc/charlie/age.key`. +# +# Recipients = every host age pubkey (so any deploy target can decrypt) +# + the admin age pubkey (so we can re-encrypt off-host on rotation). +# Pubkeys mirrored from docs/hosts.md → "Age public keys (SOPS)". +# When a new host gets onboarded with an age key, add it here AND +# re-encrypt: `sops updatekeys secrets/registry-creds.sops.yaml` (then +# re-paste into deploy-stack.yml). + +creation_rules: + - path_regex: secrets/registry-creds\.sops\.yaml$ + age: >- + age19d6mc8arznnwnvtp0xrphlseuwf94p0f3pznszrtmd5wgmjpzqdq9mltvs, + age1m5ha3fd8psek5pym4spk7encjk4vmsml46eya3eymp7q9d9jre5s7xe84r, + age1xfp878l4ul5zulqqul0u60c44c5wj94cmladgc6jt5jrlmrdrezsd8twh2 diff --git a/docs/automation.md b/docs/automation.md index b8544e5..81fcf6b 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -198,6 +198,29 @@ jobs: Requires the `RUNNER_REPO_PAT` secret to be in scope (set at org level; callers pass via `secrets: inherit`). +### Registry login (built-in) + +When `pull == true` (the default), `deploy-stack.yml` runs a fresh `docker +login gitea.alexandru.macocian.me` on the target host immediately before +`docker compose pull`, using a dedicated **read-only** PAT for +`sys-gitea-runner` (scope: `read:package`). The PAT lives as SOPS +ciphertext inlined in `deploy-stack.yml`, encrypted to every host's age +key; decryption happens on the target with `/etc/charlie/age.key`. + +This is independent of `~/.docker/config.json` — necessary because +`build-image.yml`'s cleanup wipes the RW caller PAT after each build, +which would otherwise leave the next deploy 401ing on pull. The cleanup +step at the end of every deploy-stack run also `docker logout`s +unconditionally (`if: always()`) and scrubs any leftover ciphertext under +`/tmp/charlie-registry-creds.*.sops.yaml`, so the PAT never persists on +disk regardless of where the workflow failed. + +Operator-facing details (encrypt recipe, paste location, rotation, host +add): [`secrets.md → Registry credentials (read-only PAT)`](secrets.md#registry-credentials-read-only-pat). + +Callers don't pass anything for this — no input, no secret. It's an +internal implementation detail of `deploy-stack.yml`. + ## Inputs reference — `build-image.yml` Source repos use this workflow to build and push their container image diff --git a/docs/inventory.md b/docs/inventory.md index 2f7bdee..8d9aa53 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -24,6 +24,7 @@ Canonical list of `Charlie/` repos. Working trees live at `/mnt/nas/stack | [`portainer`](https://gitea.alexandru.macocian.me/Charlie/portainer) | `portainer/` | upstream | container UI; CSRF trusted-origins set via CLI | | [`cert-issuer`](https://gitea.alexandru.macocian.me/Charlie/cert-issuer) | `cert-issuer/` | upstream `smallstep/step-ca:0.30.2` | SSH user-cert CA. Design: [cert-issuer.md](cert-issuer.md). | | [`cert-syncer`](https://gitea.alexandru.macocian.me/Charlie/cert-syncer) | `cert-syncer/` | upstream `smallstep/step-cli:0.30.2-trixie` | Active bridge from cert-issuer to `/mnt/nas/ca/`. Renews managed server certs. Design: [cert-issuer.md](cert-issuer.md). | +| [`calendarsync`](https://gitea.alexandru.macocian.me/Charlie/calendarsync) | `calendarsync/` | upstream `ghcr.io/inovex/calendarsync:0.11.0` (+ `mcuadros/ofelia:0.3.22`) | Bidirectional Google ↔ Outlook calendar sync via inovex/CalendarSync. Two one-way pipelines on a 15-min cron, staggered 7 min apart. No UI, no port in steady state. OAuth tokens persisted (AES) under `/mnt/nas/data/calendarsync/{g2o,o2g}/state/`. | | [`trivy-server`](https://gitea.alexandru.macocian.me/Charlie/trivy-server) | `trivy-server/` | upstream `aquasec/trivy:0.70.0` (+ `mcuadros/ofelia:0.3.22`, `mikefarah/yq:4.53.2`) | Central trivy vuln-DB cache on `morgott.lan:4954` (LAN-only, no auth). Daily 03:00 sweep of compose-declared image refs across `/mnt/nas/stacks/*/`. Pairs with `trivy-runner`. | ## Melina (owner host) @@ -35,6 +36,7 @@ Canonical list of `Charlie/` repos. Working trees live at `/mnt/nas/stack | [`searxng`](https://gitea.alexandru.macocian.me/Charlie/searxng) | `searxng/` | upstream | metasearch (+ valkey) | | [`invidious`](https://gitea.alexandru.macocian.me/Charlie/invidious) | `invidious/` | gitea-registry `invidious:oidc-support` | YouTube frontend (+ companion + db + gluetun) | | [`nitter`](https://gitea.alexandru.macocian.me/Charlie/nitter) | `nitter/` | upstream `zedeus/nitter:` (no versioning) | Twitter/X frontend (+ redis). Requires operator-seeded `sessions.jsonl` on host. | +| [`redlib`](https://gitea.alexandru.macocian.me/Charlie/redlib) | `redlib/` | gitea-registry `redlib:sha-` (no versioning) | Reddit frontend. Stateless, no secrets. Built off-host from `Dockerfile.ubuntu` because upstream quay image is stale ([#551](https://github.com/redlib-org/redlib/issues/551)). | | [`planka`](https://gitea.alexandru.macocian.me/Charlie/planka) | `planka/` | upstream | kanban (+ postgres) | | [`audiobookshelf`](https://gitea.alexandru.macocian.me/Charlie/audiobookshelf) | `audiobookshelf/` | upstream | audiobooks | | [`directoryadmin`](https://gitea.alexandru.macocian.me/Charlie/directoryadmin) | `directoryadmin/` | gitea-registry `directoryadmin:latest` | LDAP admin UI | @@ -64,6 +66,7 @@ Naming: `Charlie/-mirror`. | `monica` source | `https://github.com/AlexMacocian/monica` (4.x) | mirror under `amacocian/monica` | | `directoryadmin` source | `https://github.com/AlexMacocian/DirectoryAdmin` | mirror under `amacocian/DirectoryAdmin` | | `invidious` source | `https://github.com/AlexMacocian/invidious` (oidc-support) | mirror under `amacocian/invidious` | +| `redlib` source | `https://github.com/redlib-org/redlib` | mirror under `amacocian/redlib`; image built locally from `Dockerfile.ubuntu`, pushed to `gitea.alexandru.macocian.me/amacocian/redlib:` | | `gitea-runner` source | none (laptop only) | image built locally, pushed to `gitea.alexandru.macocian.me/amacocian/gitea-runner:`. Source: [`amacocian/gitea-runner`](https://gitea.alexandru.macocian.me/amacocian/gitea-runner). | Optional later (upstreams we don't customise but want offline): `caddy`, `authentik`, `paperless-ngx`, `mealie`, `victoriametrics`, `searxng-docker`, `nitter`, `gitea`, `act_runner`. diff --git a/docs/secrets.md b/docs/secrets.md index 13701b2..0a196f0 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -56,6 +56,38 @@ 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=\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.