From 9b3500b3ae33938d351540a48f3dc3f398023ce5 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Sun, 14 Jun 2026 18:24:52 -0700 Subject: [PATCH 1/7] Initial support for restic backup config --- .chezmoi.toml.tmpl | 8 +- .chezmoidata/packages.yaml | 9 ++ .chezmoidata/profiles.yaml | 2 + .chezmoiignore.tmpl | 8 ++ .chezmoitemplates/op-cached-secret | 12 ++ CLAUDE.md | 2 +- README.md | 2 +- .../resticprofile/private_password.tmpl | 5 + .../resticprofile/private_profiles.yaml.tmpl | 119 ++++++++++++++++++ .../resticprofile/private_rest-pass.tmpl | 5 + run_before_01-decrypt.sh.tmpl | 78 +++++------- run_onchange_50-configure-hyprpm.sh.tmpl | 17 +++ run_onchange_70-configure-restic.sh.tmpl | 26 ++++ scripts/decrypt-secrets.sh | 2 +- 14 files changed, 241 insertions(+), 54 deletions(-) create mode 100644 .chezmoitemplates/op-cached-secret create mode 100644 dot_config/resticprofile/private_password.tmpl create mode 100644 dot_config/resticprofile/private_profiles.yaml.tmpl create mode 100644 dot_config/resticprofile/private_rest-pass.tmpl create mode 100644 run_onchange_70-configure-restic.sh.tmpl diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl index 1930458..92e2fa7 100644 --- a/.chezmoi.toml.tmpl +++ b/.chezmoi.toml.tmpl @@ -46,13 +46,14 @@ {{- $tags := $profileData.tags -}} {{- $wantsDecrypt := or (get $profileData "decrypt") false -}} {{- $work := or (get $profileData "work") false -}} +{{- $backup := or (get $profileData "backup") false -}} {{- $de := "" -}} {{- if hasKey $profileData "de" -}}{{- $de = $profileData.de -}} {{- end -}} -{{- /* Enable encryption when the profile declares it. The run_before decrypt - script ensures the key exists (via env var, 1Password, or manual placement) - before chezmoi processes any encrypted files. */ -}} +{{- /* Enable encryption when the profile declares it. run_before_01-decrypt ensures the + age key exists (via env var, 1Password, or manual placement) before chezmoi + processes any encrypted files. */ -}} {{ if $wantsDecrypt -}} encryption = "age" @@ -63,6 +64,7 @@ encryption = "age" tags = "{{ $tags }}" work = {{ $work }} decrypt = {{ $wantsDecrypt }} + backup = {{ $backup }} de = "{{ $de }}" {{ if $wantsDecrypt }} [age] diff --git a/.chezmoidata/packages.yaml b/.chezmoidata/packages.yaml index 653ada4..c4c004f 100644 --- a/.chezmoidata/packages.yaml +++ b/.chezmoidata/packages.yaml @@ -237,6 +237,15 @@ packages: desc: "Multipurpose relay for bidirectional data transfer" brew: false + restic: + tags: [core] + desc: "Fast, secure, deduplicating backup program" + + resticprofile: + tags: [core] + desc: "Configuration profiles and scheduler for restic" + os: linux + # =========================================================================== # macOS GNU Tools # =========================================================================== diff --git a/.chezmoidata/profiles.yaml b/.chezmoidata/profiles.yaml index e4a22c8..cf66042 100644 --- a/.chezmoidata/profiles.yaml +++ b/.chezmoidata/profiles.yaml @@ -8,6 +8,7 @@ # brew/pacman/apt/dnf/rpm_ostree/flatpak/appimage: which package managers to enable # work: work machine (system SSH agent, corporate configs) # decrypt: enable age decryption of private configs +# backup: personal machine — install+schedule restic backups (default false; servers/containers stay off) # # Profile is selected interactively during `chezmoi init`, or via: # DOTFILES_PROFILE=arch chezmoi init @@ -40,6 +41,7 @@ profiles: flatpak: true appimage: true decrypt: true + backup: true # personal machine — runs restic backups to the homelab rest-server # --- Debian/Ubuntu --- debian-server: diff --git a/.chezmoiignore.tmpl b/.chezmoiignore.tmpl index 29ce116..e633ee6 100644 --- a/.chezmoiignore.tmpl +++ b/.chezmoiignore.tmpl @@ -23,6 +23,14 @@ tests/ **/*.age {{ end }} +{{ if not .backup }} +# restic backups only run on personal machines (profile backup: true). +# Skipping the whole config dir keeps the decrypt() in the profile template from +# even running on servers/containers (which lack the age key). +.config/resticprofile +.config/resticprofile/** +{{ end }} + {{ if ne .profile "devpod" }} # Devpod-only tooling dot_local/bin/executable_devpod-linuxbrew-fetch diff --git a/.chezmoitemplates/op-cached-secret b/.chezmoitemplates/op-cached-secret new file mode 100644 index 0000000..7ea30d5 --- /dev/null +++ b/.chezmoitemplates/op-cached-secret @@ -0,0 +1,12 @@ +{{- /* Fetch a secret from 1Password ONCE, then serve it from an on-disk cache. Generic — + usable for any cached op secret, not just restic. Returns the cache file's contents when it + exists and is non-empty, otherwise reads from 1Password (the caller writes the result to the + same path, so the next render is an offline cache hit; op is only hit when the cache is missing). + Args (list, positional): + 0 cache path — absolute, or relative to $HOME (the partial prepends $HOME) + 1 full op:// reference */ -}} +{{- $p := index . 0 -}} +{{- $dest := $p -}} +{{- if not (hasPrefix "/" $p) -}}{{- $dest = printf "%s/%s" (env "HOME") $p -}}{{- end -}} +{{- $s := stat $dest -}} +{{- if and $s (gt $s.size 0) -}}{{- output "cat" $dest -}}{{- else -}}{{- onepasswordRead (index . 1) -}}{{- end -}} diff --git a/CLAUDE.md b/CLAUDE.md index fa5e6d5..0d5a75d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,7 @@ Scripts use category-based numeric prefixes with gaps for future expansion: | Script | Description | |---|---| -| `run_before_00-decrypt.sh.tmpl` | Ensures age key exists (1Password or manual) | +| `run_before_01-decrypt.sh.tmpl` | Ensures the age key exists (env/1Password/manual) before chezmoi decrypts; restic secrets are self-caching templates (`op-cached-secret` partial) | | `run_onchange_00-setup-directories.sh` | Creates required dirs (~/.ssh/sockets, etc.) | | `run_onchange_10-install-packages-homebrew.sh.tmpl` | Homebrew formulas (+ Homebrew install on Linux) | | `run_onchange_11-install-packages-cask.sh.tmpl` | Homebrew casks (macOS only) | diff --git a/README.md b/README.md index e2491ae..af85a6f 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ dotfiles/ ├── .chezmoi.toml.tmpl # Chezmoi config template ├── .chezmoiignore.tmpl # Ignore rules (templated) ├── .age-public-key # Age encryption public key -├── run_before_00-decrypt.sh.tmpl # Age key setup (if decrypt enabled) +├── run_before_01-decrypt.sh.tmpl # Age key from 1Password/env if missing (restic secrets use self-caching templates) ├── run_onchange_01-install-packages.sh.tmpl # brew bundle ├── run_onchange_02-install-fisher.sh.tmpl # Fisher plugins ├── run_onchange_03-configure-tide.sh.tmpl # Tide prompt config diff --git a/dot_config/resticprofile/private_password.tmpl b/dot_config/resticprofile/private_password.tmpl new file mode 100644 index 0000000..69854b7 --- /dev/null +++ b/dot_config/resticprofile/private_password.tmpl @@ -0,0 +1,5 @@ +{{- /* restic repo password — fetched from 1Password once, then cached at this target path. + Consumed by restic at runtime via `password-file` (no chezmoi-render dependency). */ -}} +{{- includeTemplate "op-cached-secret" (list + ".config/resticprofile/password" + (printf "op://dev-keys/restic-%s/encryption-key" .chezmoi.hostname)) | trim -}} diff --git a/dot_config/resticprofile/private_profiles.yaml.tmpl b/dot_config/resticprofile/private_profiles.yaml.tmpl new file mode 100644 index 0000000..590f1f2 --- /dev/null +++ b/dot_config/resticprofile/private_profiles.yaml.tmpl @@ -0,0 +1,119 @@ +{{- /* ============================================================================ + resticprofile — fleet backup config (chezmoi-templated, renders per machine). + + BACKEND: REST (rest-server on the TrueNAS storage LXC), --append-only. + Repo path is ty/ (username/host). "ty" is the rest-server htpasswd user; + --private-repos confines it to /ty/*, so every machine gets /ty/. + + MACHINES: $host namespaces the repo, so each machine has its own repo under + /ty/. Add a machine by giving it a hostname; nothing else changes. + Macs use Time Machine instead and render no profiles (see guard). + + SECRETS (two): self-caching .tmpl files (the op-cached-secret partial) fetch from 1Password + ONLY when the on-disk cache is missing — never committed to this repo. op is hit on the first + apply only; every later render is an offline cache hit. + - repo password (encrypts data): private_password.tmpl -> ~/.config/resticprofile/password (password-file below). + - transport password (REST basic auth): private_rest-pass.tmpl -> ~/.config/resticprofile/rest-pass, + read below and inlined into the repo URL (why THIS file is private_, rendered 0600). + + RETENTION/PRUNE: the server is --append-only, so clients CANNOT forget/prune + (those delete data). Retention runs server-side against the dataset; there is + deliberately no `retention` block here. `check` is read-only and stays. + ============================================================================ */ -}} +{{- $host := .chezmoi.hostname -}} +{{- /* transport password: cached on disk by private_rest-pass.tmpl. The shared partial returns + the cached file, or fetches from 1Password when it's missing (covers the first apply, + before rest-pass is written). Offline on every subsequent render. */ -}} +{{- $rest_pass := includeTemplate "op-cached-secret" (list + ".config/resticprofile/rest-pass" + (printf "op://dev-keys/restic-%s/rest-key" .chezmoi.hostname)) | trim -}} +{{- $repo := printf "rest:https://ty:%s@restic.tysmith.app/ty/%s/" $rest_pass $host -}} +{{- if ne .chezmoi.os "darwin" }} +version: "1" + +global: + default-command: snapshots + initialize: false + priority: low + +# --- $HOME + a staged manifest of system state (user-readable, exits clean) --- +default: + repository: "{{ $repo }}" + password-file: "{{ .chezmoi.homeDir }}/.config/resticprofile/password" + lock: "{{ .chezmoi.homeDir }}/.cache/resticprofile/default.lock" + force-inactive-lock: true + + backup: + run-before: + - "mkdir -p {{ .chezmoi.homeDir }}/.local/state/restic-system" + - "pacman -Qqe > {{ .chezmoi.homeDir }}/.local/state/restic-system/pacman-explicit.txt 2>/dev/null || true" + - "pacman -Qqem > {{ .chezmoi.homeDir }}/.local/state/restic-system/pacman-aur.txt 2>/dev/null || true" + - "systemctl list-unit-files --state=enabled --no-legend > {{ .chezmoi.homeDir }}/.local/state/restic-system/systemd-system-enabled.txt 2>/dev/null || true" + - "systemctl --user list-unit-files --state=enabled --no-legend > {{ .chezmoi.homeDir }}/.local/state/restic-system/systemd-user-enabled.txt 2>/dev/null || true" + source: + - "{{ .chezmoi.homeDir }}" + exclude-caches: true + tag: + - "{{ $host }}" + - home + exclude: + # --- re-fetchable / huge --- + - "{{ .chezmoi.homeDir }}/.cache" + - "{{ .chezmoi.homeDir }}/Dropbox" # mirrored separately on the NAS + - "{{ .chezmoi.homeDir }}/.local/share/Trash" + - "{{ .chezmoi.homeDir }}/.local/share/containers" # 3.4T of podman images + - "{{ .chezmoi.homeDir }}/.var/app/*/cache" + - "{{ .chezmoi.homeDir }}/.var/app/*/.cache" + # --- dev toolchains/registries (reproducible) --- + - "{{ .chezmoi.homeDir }}/.rustup" + - "{{ .chezmoi.homeDir }}/.cargo/registry" + - "{{ .chezmoi.homeDir }}/.cargo/git" + - "{{ .chezmoi.homeDir }}/.npm" + - "{{ .chezmoi.homeDir }}/go/pkg" + - "{{ .chezmoi.homeDir }}/.local/share/mise/installs" + - "**/node_modules" + - "**/.venv" + - "**/__pycache__" + - "**/target/debug" + - "**/target/release" + # --- Steam: drop installs/caches, KEEP saves (compatdata prefixes + Cloud userdata) --- + - "{{ .chezmoi.homeDir }}/.local/share/Steam/steamapps/common" + - "{{ .chezmoi.homeDir }}/.local/share/Steam/steamapps/downloading" + - "{{ .chezmoi.homeDir }}/.local/share/Steam/steamapps/temp" + - "{{ .chezmoi.homeDir }}/.local/share/Steam/steamapps/shadercache" + - "{{ .chezmoi.homeDir }}/.local/share/Steam/appcache" + - "{{ .chezmoi.homeDir }}/.local/share/Steam/depotcache" + # --- app caches/indexes (state kept, rebuildables dropped) --- + - "{{ .chezmoi.homeDir }}/.local/share/voxtype/models" # whisper models; meetings/ kept + - "{{ .chezmoi.homeDir }}/.local/share/vicinae/favicon-data" + - "{{ .chezmoi.homeDir }}/.local/share/vicinae/file-indexer.db*" + # --- never-back-up disk images --- + - "**/*.qcow2" + - "**/*.iso" + schedule: "*-*-* 13:00" + # `user` (run while logged out) needs systemd lingering; without it resticprofile + # falls back to a system job and a non-root `chezmoi apply` can't create it. + # `user_logged_on` is a pure --user timer (Persistent=true catches up missed runs). + schedule-permission: user_logged_on + schedule-lock-wait: 30m + + # NOTE: no `retention` block — server is --append-only (clients can't forget/prune). + # Retention is enforced server-side against tank/backups/machines/ty/. + + check: + schedule: "*-*-01 04:00" + schedule-permission: user_logged_on # see backup note above + schedule-lock-wait: 1h + read-data-subset: "1/12" + +# NOTE: a root-owned `system` profile (backing up /etc, NetworkManager secrets, /boot, etc.) +# was intentionally dropped for now — only the user HOME backup runs. The `default` run-before +# above still stages a user-readable manifest of system state (explicit/AUR package lists, +# enabled systemd units) into ~/.local/state/restic-system, so a record of system config is +# captured without a root timer. Re-add a `system:` profile here if/when full /etc backup is wanted. +{{- else }} +# macOS ({{ $host }}): no resticprofile — this machine uses Time Machine to the NAS. +version: "1" +global: + default-command: version +{{- end }} diff --git a/dot_config/resticprofile/private_rest-pass.tmpl b/dot_config/resticprofile/private_rest-pass.tmpl new file mode 100644 index 0000000..921dae8 --- /dev/null +++ b/dot_config/resticprofile/private_rest-pass.tmpl @@ -0,0 +1,5 @@ +{{- /* restic transport password (REST basic auth) — fetched from 1Password once, then cached + here. profiles.yaml reads this file to inline it into the repo URL (offline after first apply). */ -}} +{{- includeTemplate "op-cached-secret" (list + ".config/resticprofile/rest-pass" + (printf "op://dev-keys/restic-%s/rest-key" .chezmoi.hostname)) | trim -}} diff --git a/run_before_01-decrypt.sh.tmpl b/run_before_01-decrypt.sh.tmpl index ca1de19..32a4feb 100644 --- a/run_before_01-decrypt.sh.tmpl +++ b/run_before_01-decrypt.sh.tmpl @@ -1,63 +1,45 @@ {{- if .decrypt -}} #!/usr/bin/env bash -# Ensure age decryption key exists for chezmoi encrypted files -# Key sources (in order): local file, DOTFILES_AGE_KEY env var, 1Password -# Fails if no key can be obtained — profile requires decryption. +# Ensure the age decryption key is on disk BEFORE chezmoi processes any encrypted (.age) files. +# Fetched from 1Password only when missing; once present it is reused (delete it to re-fetch). +# This must be a run_before (chezmoi needs the key to decrypt) — restic secrets do NOT live here; +# they are self-caching templates under dot_config/resticprofile/ (op-cached-secret partial). +# age key <- op://dev-keys/dotfiles-age-key/key (or $DOTFILES_AGE_KEY, e.g. install.sh --age-key) set -euo pipefail # shellcheck source=/dev/null source "${CHEZMOI_SOURCE_DIR:-$(chezmoi source-path)}/scripts/lib/common.sh" -ensure_pkg age +# ensure_secret