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
|
||||
.age-key.txt
|
||||
sops-age.key
|
||||
.sops.yaml.tmp
|
||||
|
||||
# Ignore older source iterations
|
||||
*.old
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
166
jeannie
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue