Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .chezmoi.toml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -63,6 +64,7 @@ encryption = "age"
tags = "{{ $tags }}"
work = {{ $work }}
decrypt = {{ $wantsDecrypt }}
backup = {{ $backup }}
de = "{{ $de }}"
{{ if $wantsDecrypt }}
[age]
Expand Down
9 changes: 9 additions & 0 deletions .chezmoidata/packages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ===========================================================================
Expand Down
2 changes: 2 additions & 0 deletions .chezmoidata/profiles.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions .chezmoiignore.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Comment on lines +30 to +31
{{ end }}

{{ if ne .profile "devpod" }}
# Devpod-only tooling
dot_local/bin/executable_devpod-linuxbrew-fetch
Expand Down
12 changes: 12 additions & 0 deletions .chezmoitemplates/op-cached-secret
Original file line number Diff line number Diff line change
@@ -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 -}}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions dot_config/resticprofile/private_password.tmpl
Original file line number Diff line number Diff line change
@@ -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 -}}
121 changes: 121 additions & 0 deletions dot_config/resticprofile/private_profiles.yaml.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{{- /* ============================================================================
resticprofile — fleet backup config (chezmoi-templated, renders per machine).

BACKEND: REST (rest-server on the TrueNAS storage LXC), --append-only.
Repo path is ty/<host> (username/host). "ty" is the rest-server htpasswd user;
--private-repos confines it to /ty/*, so every machine gets /ty/<host>.

MACHINES: $host namespaces the repo, so each machine has its own repo under
/ty/<host>. 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*"
# --- unreadable junk (read bits stripped → caused restic exit-3 warnings) ---
- "{{ .chezmoi.homeDir }}/.nv" # NVIDIA GPU shader/compute cache (rebuildable)
# --- 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/<host>.

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 }}
5 changes: 5 additions & 0 deletions dot_config/resticprofile/private_rest-pass.tmpl
Original file line number Diff line number Diff line change
@@ -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 -}}
78 changes: 30 additions & 48 deletions run_before_01-decrypt.sh.tmpl
Original file line number Diff line number Diff line change
@@ -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 <dest> <label> <op_ref> [env_var]
# Ensures <dest> exists (0600). Order: keep existing file -> $env_var (if set) -> 1Password -> fail.
ensure_secret() {
local dest="$1" label="$2" op_ref="$3" env_var="${4:-}"

AGE_KEY_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/age-key.txt"
mkdir -p "$(dirname "$dest")"
[[ -s "$dest" ]] && return 0

# Skip if key already exists on disk
if [[ -s "$AGE_KEY_FILE" ]]; then
exit 0
fi

# Create directory if needed
mkdir -p "$(dirname "$AGE_KEY_FILE")"

# Try env var (passed via install.sh --age-key)
if [[ -n "${DOTFILES_AGE_KEY:-}" ]]; then
printf '%s\n' "$DOTFILES_AGE_KEY" > "$AGE_KEY_FILE"
chmod 600 "$AGE_KEY_FILE"
log "Age key written from DOTFILES_AGE_KEY"
exit 0
fi

# Try 1Password if available
if command -v op &>/dev/null && op account list &>/dev/null; then
log "Fetching age key from 1Password..."
op read "op://Private/dotfiles-age-key/key" > "$AGE_KEY_FILE"

if [[ -s "$AGE_KEY_FILE" ]] && grep -q "AGE-SECRET-KEY" "$AGE_KEY_FILE"; then
chmod 600 "$AGE_KEY_FILE"
exit 0
if [[ -n "$env_var" && -n "${!env_var:-}" ]]; then
printf '%s\n' "${!env_var}" > "$dest"
chmod 600 "$dest"
log "$label written from \$$env_var"
return 0
fi

rm -f "$AGE_KEY_FILE"
fi

# Prompt interactively as last resort
if [[ -t 0 ]]; then
warn "Age key not found via env var or 1Password"
echo "Paste your age secret key (AGE-SECRET-KEY-...), then press Enter:"
read -r key_input
if [[ "$key_input" == AGE-SECRET-KEY-* ]]; then
printf '%s\n' "$key_input" > "$AGE_KEY_FILE"
chmod 600 "$AGE_KEY_FILE"
log "Age key written from interactive input"
exit 0
if command -v op &>/dev/null && op account list &>/dev/null; then
log "Fetching $label from 1Password..."
if op read --no-newline "$op_ref" > "$dest" 2>/dev/null && [[ -s "$dest" ]]; then
chmod 600 "$dest"
return 0
fi
rm -f "$dest"
fi
warn "Invalid key — must start with AGE-SECRET-KEY-"
fi

fail "Age key required but not found. Supply via:
--age-key 'AGE-SECRET-KEY-...'
Place manually at $AGE_KEY_FILE
Sign in to 1Password CLI: op signin"
fail "$label missing at $dest and could not be fetched.
Sign in to 1Password (op signin) and re-run 'chezmoi apply',
or place it manually at $dest (chmod 600). Source: $op_ref"
}

ensure_secret "${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/age-key.txt" "age key" \
"op://dev-keys/dotfiles-age-key/key" "DOTFILES_AGE_KEY"
{{ end -}}
17 changes: 17 additions & 0 deletions run_onchange_50-configure-hyprpm.sh.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,22 @@ done

# Updates plugins to match current hyprland — needs hyprland headers; skipped
# silently if hyprland isn't usable yet (fresh install before reboot, etc.).
# NB: never run hyprpm under sudo. Doing so builds plugins into
# /var/cache/hyprpm/$USER as root, after which the user session can't load
# them ("Invalid dispatcher: ...") and a later user-run `hyprpm update` can't
# overwrite the root-owned state ("Failed to write plugin state").
hyprpm update 2>/dev/null || echo "warn: hyprpm update failed (hyprland headers missing?)"

# hyprpm stages new plugin state at $XDG_RUNTIME_DIR/hyprpm/.temp-state, then
# renames it onto its cache dir (/var/cache/hyprpm/$USER). When those live on
# different filesystems — XDG_RUNTIME_DIR is tmpfs, /var/cache is often btrfs —
# the rename fails with EXDEV and hyprpm reports "Failed to write plugin state",
# leaving freshly built plugins uncommitted. Finish the commit with a copy so
# the next `hyprpm reload -n` (hypr autostart) loads the rebuilt plugins.
hyprpm_staged="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/hyprpm/.temp-state"
hyprpm_state="/var/cache/hyprpm/$(id -un)/state.toml"
if [[ -f "$hyprpm_staged" && -w "$(dirname "$hyprpm_state")" ]] && ! cmp -s "$hyprpm_staged" "$hyprpm_state"; then
echo "hyprpm: committing staged plugin state (cross-filesystem rename workaround)"
cp -f "$hyprpm_staged" "$hyprpm_state"
fi
{{ end -}}
26 changes: 26 additions & 0 deletions run_onchange_70-configure-restic.sh.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{{ if and (eq .chezmoi.os "linux") .backup -}}
#!/usr/bin/env bash
# Register the resticprofile backup/check schedules as systemd --user timers.
# Gated on the profile's `backup: true` flag — only personal machines run restic;
# servers/containers (debian-server, devpod, Proxmox/LXC) are deliberately excluded.
# Reruns automatically when the profile or this script changes (hashes below).
# profiles.yaml: {{ include "dot_config/resticprofile/private_profiles.yaml.tmpl" | sha256sum }}
# (restic secrets are self-caching templates in dot_config/resticprofile/; not hashed — they don't affect schedules)
set -euo pipefail

if ! command -v resticprofile >/dev/null 2>&1; then
echo "resticprofile not installed yet — skipping schedule (will run on next apply)"
exit 0
fi

mkdir -p "${HOME}/.cache/resticprofile" "${HOME}/.local/state/restic-system"

# Creates ~/.config/systemd/user/resticprofile-*.{service,timer}, reloads, and enables them.
# Only the `default` profile (user HOME backup + check) exists / is scheduled — all
# user_logged_on timers, so a non-root `chezmoi apply` registers them without sudo.
# (Scoped by name so re-adding a root `system` profile later won't break this apply.)
resticprofile --name default schedule

echo "Active resticprofile timers:"
systemctl --user list-timers 'resticprofile-*' --no-pager 2>/dev/null || true
{{ end -}}
2 changes: 1 addition & 1 deletion scripts/decrypt-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ elif [[ -s "$AGE_KEY_FILE" ]]; then
AGE_KEY=$(cat "$AGE_KEY_FILE")
elif command -v op &>/dev/null && op account list &>/dev/null; then
echo "Fetching age key from 1Password..."
AGE_KEY=$(op read "op://Private/dotfiles-age-key/key")
AGE_KEY=$(op read "op://dev-keys/dotfiles-age-key/key")
else
echo "Error: No age key found. Options:"
echo " 1. Set AGE_KEY environment variable"
Expand Down
Loading