Setup automatic login for deploy
This commit is contained in:
@@ -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
|
||||
if [ -f "$list" ]; then
|
||||
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
|
||||
rm -f "$list"
|
||||
fi
|
||||
|
||||
- name: Close SSH session
|
||||
if: always()
|
||||
|
||||
+21
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user