Setup automatic login for deploy

This commit is contained in:
2026-05-24 12:01:30 +02:00
parent 9042cb143b
commit 8c9a15b175
5 changed files with 180 additions and 9 deletions
+101 -9
View File
@@ -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()
+21
View File
@@ -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
+23
View File
@@ -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
+3
View File
@@ -24,6 +24,7 @@ Canonical list of `Charlie/<stack>` 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/<stack>` 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:<commit>` (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-<commit>` (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/<name>-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:<tag>` |
| `gitea-runner` source | none (laptop only) | image built locally, pushed to `gitea.alexandru.macocian.me/amacocian/gitea-runner:<tag>`. 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`.
+32
View File
@@ -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=<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.