diff --git a/.gitignore b/.gitignore index 0abdf20..1f71413 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.sops.yaml.example b/.sops.yaml.example index c3c6510..8696df3 100644 --- a/.sops.yaml.example +++ b/.sops.yaml.example @@ -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 diff --git a/README.md b/README.md index 840e77e..70a4d20 100644 --- a/README.md +++ b/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 diff --git a/README.md.tmpl b/README.md.tmpl index 4afc533..bb959c0 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -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 diff --git a/bootstrap/host/README.md b/bootstrap/host/README.md index cf96e2d..e749eba 100644 --- a/bootstrap/host/README.md +++ b/bootstrap/host/README.md @@ -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 +``` diff --git a/bootstrap/host/group_vars/debian_pc.yml b/bootstrap/host/group_vars/debian_pc.yml index 142b660..4c1b3a8 100644 --- a/bootstrap/host/group_vars/debian_pc.yml +++ b/bootstrap/host/group_vars/debian_pc.yml @@ -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 diff --git a/docs/secrets.md b/docs/secrets.md index a817821..331b186 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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. diff --git a/jeannie b/jeannie index e3823ee..452fde8 100755 --- a/jeannie +++ b/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