Files
esh-pfi-infrastructure/playbooks/restic-repository-file.yaml
T
vh 6e203dcb99 fix(restic): stop publishing rest-server passwords in systemd units
resticprofile schedule copies env-file values into the generated units, which
are 0644, so RESTIC_REPOSITORY (the rest-server basic-auth password included)
was readable by every local user on every restic host.

New playbooks/restic-repository-file.yaml:
- derives /etc/restic/repository (root 0400) from restic.env;
- uploads the profile switched to repository-file, but only when the live
  profile's sha matches the repo copy it was edited from (drift guard);
- checks the repository is reachable through the new profile (cat config);
- regenerates the units and verifies they exist and contain no rest:http.

Applied to ana-docker, fv-ml1 (configs/restic/ana-ml2), esh-docker-vm,
esh-vm-db, irv-ml1, nh3-dev and nh3-docker. An independent check across all
eight restic hosts (these seven plus esh-ml1) found 0 leaking units. nh3-docker's
scheduled unit ran a real backup afterwards (snapshot a29b889d). Each host's
URL and passphrase are vaulted as <host>/etc/restic/{repository,password}.

restic.env is kept (root 0600) because the per-host READMEs and the freshness
probe source it. A rotation must update the vault, restic.env and repository.

vm-esh-nas has no infra-ops account. Its in-place migration script is staged
for Prime to run with sudo, and its repo profile is pre-edited to match.

Also mirrors augaman-dev's 401df2d (compose header only). The config hash on
esh-ml1 is unchanged.
2026-09-27 01:51:05 -07:00

96 lines
4.1 KiB
YAML

# Move a restic client from `env-file: /etc/restic/restic.env` to
# `repository-file: /etc/restic/repository`.
#
# Why: `resticprofile schedule` copies env-file values into the generated
# systemd units, and those are world-readable (0644). So the RESTIC_REPOSITORY
# URL, rest-server password included, was readable by every local user on every
# env-file host (docs/runbooks/backups.md, Known gaps). With repository-file the
# unit carries only a path.
#
# Run, one host at a time:
# scripts/elway infra-ops@<ip> --playbook playbooks/restic-repository-file.yaml \
# --var cfg=<configs/restic dir> --var expect_sha=<first 12 hex of the LIVE profile's sha256>
#
# expect_sha guards against clobbering drift: the upload only proceeds when the live
# profile is exactly the one the repo edit was made from, or already the new one.
#
# /etc/restic/restic.env is deliberately KEPT. The per-host READMEs and the freshness
# probe source it for manual restic commands. It is root 0600, so it is not the leak.
# On a password rotation, update BOTH files (restic.env and repository).
vars:
cfg: ""
expect_sha: ""
steps:
- name: Guard — live profile is the expected pre-change version (or already migrated)
sudo: true
shell: |
set -eu
test -n "{{ cfg }}" && test -n "{{ expect_sha }}"
live=$(sha256sum /etc/restic/profiles.yaml | cut -c1-12)
if [ "$live" = "{{ expect_sha }}" ]; then echo "live profile = expected pre-change $live"; exit 0; fi
if grep -q '^ *repository-file: /etc/restic/repository' /etc/restic/profiles.yaml; then echo "already migrated ($live)"; exit 0; fi
echo "DRIFT: live profile sha $live != expected {{ expect_sha }}; reconcile before migrating"; exit 1
changed_when: "false"
- name: Create /etc/restic/repository from restic.env (root 0400, value never printed)
sudo: true
shell: |
set -eu
val=$(sh -c 'set -a; . /etc/restic/restic.env; printf %s "$RESTIC_REPOSITORY"')
case "$val" in rest:http*) ;; *) echo "RESTIC_REPOSITORY in restic.env is not a rest: URL"; exit 1;; esac
umask 077
printf '%s\n' "$val" > /etc/restic/repository.new
chown root:root /etc/restic/repository.new
chmod 0400 /etc/restic/repository.new
mv /etc/restic/repository.new /etc/restic/repository
creates: /etc/restic/repository
- name: repository file matches restic.env (compared, not printed)
sudo: true
shell: |
set -eu
a=$(sh -c 'set -a; . /etc/restic/restic.env; printf %s "$RESTIC_REPOSITORY"')
b=$(head -n1 /etc/restic/repository)
[ "$a" = "$b" ] || { echo "repository file differs from restic.env"; exit 1; }
test "$(stat -c %U:%a /etc/restic/repository)" = root:400
changed_when: "false"
- name: Keep the pre-change profile beside the new one
sudo: true
shell: cp -p /etc/restic/profiles.yaml /etc/restic/profiles.yaml.bak-20260927-envfile
creates: /etc/restic/profiles.yaml.bak-20260927-envfile
- name: Upload the repository-file profile
sudo: true
upload:
src: configs/restic/{{ cfg }}/profiles.yaml
dest: /etc/restic/profiles.yaml
mode: "0644"
- name: The new profile reaches the repository (read-only `cat config`)
sudo: true
shell: resticprofile --no-ansi --config /etc/restic/profiles.yaml --name default cat config >/dev/null
changed_when: "false"
- name: Regenerate the systemd units
sudo: true
shell: resticprofile --no-ansi --config /etc/restic/profiles.yaml --name default schedule
verify:
# The units must EXIST before "no match" means anything.
- name: Units exist and carry no repository URL
shell: |
set -eu
d=/etc/systemd/system
for u in resticprofile-backup@profile-default.service resticprofile-check@profile-default.service; do
test -f "$d/$u" || { echo "missing unit $u"; exit 1; }
if grep -q 'rest:http' "$d/$u"; then echo "$u still embeds the repository URL"; exit 1; fi
done
changed_when: "false"
- name: Backup and check timers are active
shell: systemctl is-active --quiet resticprofile-backup@profile-default.timer && systemctl is-active --quiet resticprofile-check@profile-default.timer
changed_when: "false"