Add SOPS age secret management bootstrap

This commit is contained in:
juvdiaz 2026-06-27 17:27:05 -06:00
parent e4b2057f15
commit e3a1c05a2e
8 changed files with 228 additions and 21 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ infra/youtube-backup/logs/
*.secret.local.yaml *.secret.local.yaml
.age-key.txt .age-key.txt
sops-age.key sops-age.key
.sops.yaml.tmp
# Ignore older source iterations # Ignore older source iterations
*.old *.old

View File

@ -1,6 +1,6 @@
# Copy this file to .sops.yaml after replacing the age recipient with the # Copy this file to .sops.yaml after replacing the age recipient with the
# public key generated on the Debian homelab server. # public key generated on the Debian homelab server.
creation_rules: creation_rules:
- path_regex: '(^|/).*\.(secret|enc)\.ya?ml$' - path_regex: '(^|/).*\.(secret|enc)\.(ya?ml|json)$'
encrypted_regex: '^(data|stringData|values)$' encrypted_regex: '^(data|stringData|values|password|token|secret|key|credentials)$'
age: age1replacewithyourpublicrecipient age: age1replacewithyourpublicrecipient

View File

@ -555,10 +555,18 @@ the Debian Docker host.
## Secrets ## Secrets
Use SOPS with age for secrets that need to live in Git. Start from Use SOPS with age for secrets that need to live in Git. The Debian host
`.sops.yaml.example`, replace the age recipient with the public key generated on bootstrap installs `age` and `sops`; then run:
the Debian host, and commit the resulting `.sops.yaml`. Keep the private age key
outside the repo. Operational notes are in `docs/secrets.md`. ```bash
./jeannie secrets-init
./jeannie secrets-check
```
`secrets-init` creates `~/.config/sops/age/keys.txt` if needed and renders
`.sops.yaml` from `.sops.yaml.example` with the host's public age recipient.
Commit `.sops.yaml`, but keep the private age key outside the repo. Operational
notes are in `docs/secrets.md`.
## Edge Services ## Edge Services

View File

@ -555,10 +555,18 @@ the Debian Docker host.
## Secrets ## Secrets
Use SOPS with age for secrets that need to live in Git. Start from Use SOPS with age for secrets that need to live in Git. The Debian host
`.sops.yaml.example`, replace the age recipient with the public key generated on bootstrap installs `age` and `sops`; then run:
the Debian host, and commit the resulting `.sops.yaml`. Keep the private age key
outside the repo. Operational notes are in `docs/secrets.md`. ```bash
./{{ main_script }} secrets-init
./{{ main_script }} secrets-check
```
`secrets-init` creates `~/.config/sops/age/keys.txt` if needed and renders
`.sops.yaml` from `.sops.yaml.example` with the host's public age recipient.
Commit `.sops.yaml`, but keep the private age key outside the repo. Operational
notes are in `docs/secrets.md`.
## Edge Services ## Edge Services

View File

@ -30,6 +30,7 @@ The playbook:
the current Debian release the current Debian release
- installs user-level pipx CLI tools such as `yt-dlp`, plus runtime helpers - installs user-level pipx CLI tools such as `yt-dlp`, plus runtime helpers
such as `ffmpeg` and `nodejs` such as `ffmpeg` and `nodejs`
- installs `age` and `sops` for encrypted repo-managed secrets
- installs Zoom from the upstream `.deb` when requested on amd64 - installs Zoom from the upstream `.deb` when requested on amd64
- adds `jv` to expected local groups - adds `jv` to expected local groups
- enables core services such as Docker, containerd, kubelet, ssh, cron, and - enables core services such as Docker, containerd, kubelet, ssh, cron, and
@ -40,3 +41,10 @@ The playbook:
Secrets and active sessions are intentionally not stored in this repo. Keep Secrets and active sessions are intentionally not stored in this repo. Keep
SSH keys, kubeconfigs, SOPS keys, Docker auth, and browser session/password data SSH keys, kubeconfigs, SOPS keys, Docker auth, and browser session/password data
in a separate encrypted backup if you want them restored. in a separate encrypted backup if you want them restored.
After the playbook finishes, initialize the Debian host's SOPS identity with:
```bash
./jeannie secrets-init
./jeannie secrets-check
```

View File

@ -38,6 +38,7 @@ debian_pc_hold_packages:
- kubelet - kubelet
debian_pc_base_packages: debian_pc_base_packages:
- age
- apt-transport-https - apt-transport-https
- bash-completion - bash-completion
- ca-certificates - ca-certificates
@ -50,6 +51,7 @@ debian_pc_base_packages:
- python3 - python3
- python3-apt - python3-apt
- rsync - rsync
- sops
- sudo - sudo
- tar - tar
- unzip - unzip

View File

@ -6,24 +6,29 @@ homelab server or in a deliberately scoped CI secret.
## First-Time Setup ## First-Time Setup
Install the tools on the Debian host: Restore/install the tools through the Debian host bootstrap:
```bash ```bash
sudo apt-get update ansible-playbook -K -i bootstrap/host/inventory.ini bootstrap/host/playbook.yml
sudo apt-get install -y --no-install-recommends age sops
``` ```
Generate the local age identity: Then initialize the age identity and local SOPS config on the Debian host:
```bash ```bash
mkdir -p ~/.config/sops/age ./jeannie secrets-init
age-keygen -o ~/.config/sops/age/keys.txt
grep '^# public key:' ~/.config/sops/age/keys.txt
``` ```
Copy `.sops.yaml.example` to `.sops.yaml`, replace the placeholder recipient `secrets-init` installs missing `age`/`sops` packages when needed, generates
with the printed public key, and commit `.sops.yaml`. The public recipient is `~/.config/sops/age/keys.txt` if it does not already exist, and writes
not sensitive; the private identity in `~/.config/sops/age/keys.txt` is. `.sops.yaml` from `.sops.yaml.example` with the public recipient from that key.
The public recipient in `.sops.yaml` is not sensitive and should be committed.
The private identity in `~/.config/sops/age/keys.txt` must stay outside Git.
Validate the current repo secret state:
```bash
./jeannie secrets-check
```
## File Naming ## File Naming
@ -54,3 +59,14 @@ SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops -d apps/example/app.secret.ya
Decrypted scratch files are intentionally ignored by `.gitignore`; encrypted Decrypted scratch files are intentionally ignored by `.gitignore`; encrypted
files are not. files are not.
## Git Rules
- Commit `.sops.yaml` after `./jeannie secrets-init` creates it.
- Commit only encrypted files matching `*.secret.yaml`, `*.enc.yaml`,
`*.secret.json`, or `*.enc.json`.
- Do not commit `~/.config/sops/age/keys.txt`, `.age-key.txt`, `sops-age.key`,
or decrypted scratch files such as `*.dec.yaml`, `*.decrypted.yaml`, and
`*.plain.yaml`.
- Run `./jeannie secrets-check` before pushing a change that adds or edits
encrypted secrets.

166
jeannie
View File

@ -4326,6 +4326,164 @@ EOF
echo "Destruction complete. Retained data under /data/openebs/local was left intact." echo "Destruction complete. Retained data under /data/openebs/local was left intact."
} }
ensure_sops_age_tools() {
local missing_packages=()
if ! command -v age-keygen >/dev/null 2>&1; then
missing_packages+=(age)
fi
if ! command -v sops >/dev/null 2>&1; then
missing_packages+=(sops)
fi
if ((${#missing_packages[@]} > 0)); then
echo "Installing missing secret-management tools: ${missing_packages[*]}"
sudo apt-get update
sudo apt-get install -y --no-install-recommends "${missing_packages[@]}"
fi
if ! command -v age-keygen >/dev/null 2>&1; then
echo "age-keygen is still unavailable after package installation." >&2
exit 1
fi
if ! command -v sops >/dev/null 2>&1; then
echo "sops is still unavailable after package installation." >&2
exit 1
fi
}
sops_age_key_file() {
printf '%s\n' "${SOPS_AGE_KEY_FILE:-${HOME}/.config/sops/age/keys.txt}"
}
sops_age_recipient() {
local key_file="$1"
awk -F': ' '/^# public key:/ { print $2; exit }' "${key_file}"
}
secrets_init() {
local key_file
local key_dir
local recipient
local config_file="${SOPS_CONFIG:-${REPO_ROOT}/.sops.yaml}"
local example_file="${REPO_ROOT}/.sops.yaml.example"
require_debian_server "secrets-init"
ensure_sops_age_tools
key_file="$(sops_age_key_file)"
key_dir="$(dirname "${key_file}")"
mkdir -p "${key_dir}"
chmod 700 "${key_dir}"
if [[ ! -s "${key_file}" ]]; then
echo "Generating age identity at ${key_file}..."
age-keygen -o "${key_file}"
chmod 600 "${key_file}"
else
echo "Using existing age identity at ${key_file}."
chmod 600 "${key_file}"
fi
recipient="$(sops_age_recipient "${key_file}")"
if [[ -z "${recipient}" ]]; then
echo "Could not read the public recipient from ${key_file}." >&2
exit 1
fi
if [[ -e "${config_file}" ]]; then
if grep -q 'age1replacewithyourpublicrecipient' "${config_file}"; then
echo "${config_file} still contains the placeholder age recipient." >&2
exit 1
fi
echo "SOPS config already exists: ${config_file}"
else
if [[ ! -s "${example_file}" ]]; then
echo "Missing ${example_file}" >&2
exit 1
fi
sed "s/age1replacewithyourpublicrecipient/${recipient}/g" "${example_file}" >"${config_file}"
echo "Wrote ${config_file} with Debian host age recipient ${recipient}."
fi
echo "Private age key: ${key_file}"
echo "Public age recipient: ${recipient}"
echo "Review and commit ${config_file}; never commit ${key_file}."
}
secrets_check_file() {
local file="$1"
local key_file="$2"
case "${file}" in
*.json)
grep -q '"sops"' "${file}" || {
echo "${file} does not look SOPS-encrypted." >&2
return 1
}
;;
*)
grep -q '^sops:' "${file}" || {
echo "${file} does not look SOPS-encrypted." >&2
return 1
}
;;
esac
if [[ -s "${key_file}" ]]; then
SOPS_AGE_KEY_FILE="${key_file}" sops -d "${file}" >/dev/null
fi
}
secrets_check() {
local config_file="${SOPS_CONFIG:-${REPO_ROOT}/.sops.yaml}"
local key_file
local recipient=""
local failures=0
local found_files=0
local file
key_file="$(sops_age_key_file)"
if [[ ! -s "${config_file}" ]]; then
echo "Missing ${config_file}. Run ./jeannie secrets-init on the Debian host." >&2
failures=$((failures + 1))
elif grep -q 'age1replacewithyourpublicrecipient' "${config_file}"; then
echo "${config_file} still contains the placeholder age recipient." >&2
failures=$((failures + 1))
fi
if [[ -s "${key_file}" ]]; then
recipient="$(sops_age_recipient "${key_file}")"
if [[ -n "${recipient}" && -s "${config_file}" ]] && ! grep -q "${recipient}" "${config_file}"; then
echo "${config_file} does not include this host's age recipient ${recipient}." >&2
failures=$((failures + 1))
fi
else
echo "No local age key found at ${key_file}; encrypted file structure will be checked without decrypting."
fi
if command -v git >/dev/null 2>&1; then
while IFS= read -r file; do
[[ -n "${file}" ]] || continue
found_files=$((found_files + 1))
secrets_check_file "${REPO_ROOT}/${file}" "${key_file}" || failures=$((failures + 1))
done < <(git -C "${REPO_ROOT}" ls-files '*.secret.yaml' '*.secret.yml' '*.secret.json' '*.enc.yaml' '*.enc.yml' '*.enc.json')
fi
if ((failures > 0)); then
echo "Secret checks failed with ${failures} issue(s)." >&2
exit 1
fi
if ((found_files == 0)); then
echo "SOPS config checks passed. No encrypted secret files are committed yet."
else
echo "SOPS config checks passed for ${found_files} encrypted secret file(s)."
fi
}
case "${1:-}" in case "${1:-}" in
up) up)
up up
@ -4390,6 +4548,12 @@ case "${1:-}" in
preflight) preflight)
homelab_preflight homelab_preflight
;; ;;
secrets-init)
secrets_init
;;
secrets-check)
secrets_check
;;
openwrt) openwrt)
openwrt openwrt
;; ;;
@ -4397,7 +4561,7 @@ case "${1:-}" in
nuke nuke
;; ;;
*) *)
echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|openwrt|nuke}" echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|secrets-init|secrets-check|openwrt|nuke}"
exit 1 exit 1
;; ;;
esac esac