Add SOPS age secret management bootstrap
This commit is contained in:
parent
e4b2057f15
commit
e3a1c05a2e
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
166
jeannie
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue