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
.age-key.txt
sops-age.key
.sops.yaml.tmp
# Ignore older source iterations
*.old

View File

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

View File

@ -555,10 +555,18 @@ the Debian Docker host.
## Secrets
Use SOPS with age for secrets that need to live in Git. Start from
`.sops.yaml.example`, replace the age recipient with the public key generated on
the Debian host, and commit the resulting `.sops.yaml`. Keep the private age key
outside the repo. Operational notes are in `docs/secrets.md`.
Use SOPS with age for secrets that need to live in Git. The Debian host
bootstrap installs `age` and `sops`; then run:
```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

View File

@ -555,10 +555,18 @@ the Debian Docker host.
## Secrets
Use SOPS with age for secrets that need to live in Git. Start from
`.sops.yaml.example`, replace the age recipient with the public key generated on
the Debian host, and commit the resulting `.sops.yaml`. Keep the private age key
outside the repo. Operational notes are in `docs/secrets.md`.
Use SOPS with age for secrets that need to live in Git. The Debian host
bootstrap installs `age` and `sops`; then run:
```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

View File

@ -30,6 +30,7 @@ The playbook:
the current Debian release
- installs user-level pipx CLI tools such as `yt-dlp`, plus runtime helpers
such as `ffmpeg` and `nodejs`
- installs `age` and `sops` for encrypted repo-managed secrets
- installs Zoom from the upstream `.deb` when requested on amd64
- adds `jv` to expected local groups
- 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
SSH keys, kubeconfigs, SOPS keys, Docker auth, and browser session/password data
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
debian_pc_base_packages:
- age
- apt-transport-https
- bash-completion
- ca-certificates
@ -50,6 +51,7 @@ debian_pc_base_packages:
- python3
- python3-apt
- rsync
- sops
- sudo
- tar
- unzip

View File

@ -6,24 +6,29 @@ homelab server or in a deliberately scoped CI secret.
## First-Time Setup
Install the tools on the Debian host:
Restore/install the tools through the Debian host bootstrap:
```bash
sudo apt-get update
sudo apt-get install -y --no-install-recommends age sops
ansible-playbook -K -i bootstrap/host/inventory.ini bootstrap/host/playbook.yml
```
Generate the local age identity:
Then initialize the age identity and local SOPS config on the Debian host:
```bash
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
grep '^# public key:' ~/.config/sops/age/keys.txt
./jeannie secrets-init
```
Copy `.sops.yaml.example` to `.sops.yaml`, replace the placeholder recipient
with the printed public key, and commit `.sops.yaml`. The public recipient is
not sensitive; the private identity in `~/.config/sops/age/keys.txt` is.
`secrets-init` installs missing `age`/`sops` packages when needed, generates
`~/.config/sops/age/keys.txt` if it does not already exist, and writes
`.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
@ -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
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."
}
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
up)
up
@ -4390,6 +4548,12 @@ case "${1:-}" in
preflight)
homelab_preflight
;;
secrets-init)
secrets_init
;;
secrets-check)
secrets_check
;;
openwrt)
openwrt
;;
@ -4397,7 +4561,7 @@ case "${1:-}" in
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
;;
esac