#!/usr/bin/env bash # Purpose: manage .sops.yaml based on gpg keys in the same dir _and_ verify correct configuration set -euo pipefail function fn_gpg_extract_fpr(){ # PURPOSE: get fingerprint from gpg keyfile gpgkeyfile=$1;shift; # fingerprint # caveat: restrict to netgo.de email, use-case: # uid ... <...@mehrwerk.net> # uid ... <...@netgo.de> # fancy gpg src: https://unix.stackexchange.com/a/731872 fpr="$(gpg --show-keys --list-options show-only-fpr-mbox "$(readlink -f "${gpgkeyfile}")" | grep '@netgo.de' | awk "{print \$1}")" echo "${fpr}" } function fn_gpg_extract_uid(){ # PURPOSE: get user-id from gpg keyfile gpgkeyfile=$1;shift; # user id # caveat: restrict to netgo.de email, use-case: # uid ... <...@mehrwerk.net> # uid ... <...@netgo.de> # fancy gpg src: https://unix.stackexchange.com/a/731872 uid="$(gpg --show-keys --with-colons "$(readlink -f "${gpgkeyfile}")" | awk -F':' '$1=="uid" {print $10}' | grep '@netgo.de')" echo "${uid}" } function fn_sops_locate_config_in_git_repo(){ # PURPOSE: locate sops config # Returns path sops config to be updated; defaults to returning "$(git rev-parse --show-toplevel)/.sops.yaml" # sops locates config by recursively walking _up_ the tree from the execeution dir context, # + _but_ does not have a mechanism to update the sops config # This function does the same in order to locate the correct sops config to update # starting dir, default: PWD. Note: 'realpath' to normalise the dir start_dir="$(realpath "${1:-"${PWD}"}")"; stop_dir="$(git rev-parse --show-toplevel)" >&2 echo "# ---" >&2 echo "# start_dir: "${start_dir}"" >&2 echo "# stop_dir: "${stop_dir}"" # BEGIN search_dir="${start_dir}" contender="${search_dir}/.sops.yaml" # base case - located the file OR stopping condition - at top of repo if [[ -e "${contender}" ]]; then >&2 echo "# BASE CASE: found ${contender}" echo "${contender}" elif [[ "${search_dir}" == "${stop_dir}" ]]; then >&2 echo "# STOPPING CONDITION: no sops config found, suggesting: ${contender}" echo "${contender}" else >&2 echo "# walk up one dir..." fn_sops_locate_config_in_git_repo "$(dirname "${search_dir}")" fi } function fn_sops_generate_config(){ # PURPOSE: generate sops config based on keyfiles # sops.yaml doc: https://github.com/getsops/sops?tab=readme-ov-file#using-sops-yaml-conf-to-select-kms-pgp-and-age-for-new-files # CAVEAT: dirty hacks, as DRY as feasible within bash >&2 echo "# RUN: generate SOPS config" # hack: 2D list workaround, i.e. difficult to have list-of-lists fpr_list=() uid_list=() type_list=() for gpgkeyfile in *automation*gpg.pub; do type_list+=( "autom" ) fpr_list+=( "$(fn_gpg_extract_fpr "${gpgkeyfile}")" ) uid_list+=( "$(fn_gpg_extract_uid "${gpgkeyfile}")" ) done for gpgkeyfile in $(ls *gpg.pub | grep -v automation); do type_list+=( "human" ) fpr_list+=( "$(fn_gpg_extract_fpr "${gpgkeyfile}")" ) uid_list+=( "$(fn_gpg_extract_uid "${gpgkeyfile}")" ) done # header echo "# Fingerprint | User Type | User ID" # entries/rows for ind in "${!fpr_list[@]}"; do printf "# %s | %s | %s\n" \ "${fpr_list[${ind}]}" \ "${type_list[${ind}]}" \ "${uid_list[${ind}]}" done echo "# keys in https://git.dev-at.de/smardigo-hetzner/communication-keys" cat <- EOM # all but last line get comma ind_2nd_last=$((${#fpr_list[@]} - 1)) for fpr in ${fpr_list[@]:0:${ind_2nd_last}}; do echo " ${fpr}," done # last line no comma # echo " ${fpr_list[-1]}," # requires bash v4.1 echo " ${fpr_list[${ind_2nd_last}]}" } fn_sops_updatekeys_and_verify(){ # PURPOSE: call 'sops updatekeys' and dump contents of file so end user can visually verify functionality sops_enc_file="${1}";shift; # update keys in secrets file test -e "${sops_enc_file}" || exit 1 # "update the keys of SOPS files using the config file" >&2 echo "# RUN: sops updatekeys ${sops_enc_file}" # HAAAACK: loop through all passed-in files, ignore any errors, always say "yes" -> rely on git diff to verify! sops updatekeys -y "${sops_enc_file}" || echo "SKIPPING" } function main(){ if [[ ! -n "${@}" ]]; then # if empty args, remove shift fi # "anchor" for actions relevant to this script repo_root="$(realpath $(dirname "${BASH_SOURCE[0]}")/..)" # OPTIONS: ARGPARSING and VALIDATION # assume location of script as running directly from repo with keys (instead of as a standalone packaged tool) keyfiles_dir="${repo_root}" # dir containing .sops.yaml sops_config_dir="" # path to group definitions groups_def_dir="${repo_root}/groups" opt_list_groups=0 groups_list=() opt_find_secrets=0 secrets_file_list=() while (( $# >= 1 ));do cur="${1}"; case $cur in # ARGS: print this help -h|--help) echo "# ARGUMENTS:"; grep -A 1 '# ARGS:' "${BASH_SOURCE[0]}"; exit 0 ;; # ARGS: [optional] dir containing gpg keyfiles. defaults to git repo root, var: ${repo_root} -k|--key|--keyfiles) keyfiles_dir="${2}"; shift ;; # ARGS: [optional] defines dir for sops config file (.sops.yaml), create if needed. defaults to git repo root, var: ${repo_root} -c|--config_dir) sops_config_dir="${2}"; shift ;; # ARGS: [optional] show list of groups and exit -lg|--list_groups) opt_list_groups=1 ;; # ARGS: [optional] [list] specify "groups" which correspond to e.g. job groups, projects, etc -g|--group) groups_list+=( "${2}" ); shift ;; # ARGS: [optional] update all "secrets.yaml" files found below .sops.yaml location -f|--find_secrets) opt_find_secrets=1;; # ARGS: [optional] [list] specify files containing sops-encrypted secrets -s|--secrets_file|-f|--file) secrets_file_list+=( "${2}" ); shift ;; # ARGS: [optional] [list] specify files containing sops-encrypted secrets *) secrets_file_list+=( "${cur}" ) esac shift; done # Resolve Parameters # ... i.e. combine,override,etc options which interact if [[ "${#groups_list[@]}" -eq 1 ]]; then # simply change keyfiles_dir to the "groups" dir keyfiles_dir="${groups_def_dir}/${groups_list[0]}" elif [[ "${#groups_list[@]}" -gt 1 ]]; then >&2 echo "# ERROR: only specify one group" exit 1 fi # VALIDATE INPUTS keyfiles_dir="$(realpath "${keyfiles_dir}")" test -d "${keyfiles_dir}" || (echo "E: specify dir containing keyfiles; invalid dir: '${keyfiles_dir}'" && exit 1) # define sops config location sops_config="" if [[ -n "${sops_config_dir:-}" ]]; then # user-specified sops_config_dir="$(realpath "${sops_config_dir}")" # vvv possibly redundant, since the 'realpath' will fail if dir not valid test -d "${sops_config_dir}" || (echo "E: specify dir containing .sops.yaml, invalid dir: '${sops_config_dir}'" && exit 1) sops_config="${sops_config_dir}/.sops.yaml" else # locate appropriate sops config if default assumption not found # dev note: '2> /dev/null' to disable debug output sops_config="$(fn_sops_locate_config_in_git_repo 2> /dev/null)" sops_config_dir="$(dirname "${sops_config}")" fi # Paths to Secrets Files if [[ "${#secrets_file_list[@]}" != "0" ]]; then for secrets_file in "${secrets_file_list[@]}"; do test -e "${secrets_file}" || (echo "E: could not locate file with secrets, tried: ${secrets_file}" && exit 1) done fi if [[ "${opt_find_secrets}" -eq 1 ]]; then secrets_file_list+=("$(find "${sops_config_dir}" -name secrets.yaml)") fi # /VALIDATE INPUTS # /OPTIONS: ARGPARSING and VALIDATION # BEGIN if [[ "${opt_list_groups}" -eq 1 ]]; then # list available groups and exit pushd "${groups_def_dir}" > /dev/null 2>&1 >&2 echo "# INFO: listing groups" ls -1d * exit 0 popd > /dev/null 2>&1 fi # UPDATE SOPS CONFIG # update sops config # TODO: remove the 'pushd;popd' workaround and make the functions aware of the dir being read pushd "${keyfiles_dir}" > /dev/null 2>&1 (fn_sops_generate_config) > "${sops_config}" popd > /dev/null 2>&1 # VERIFY if [[ "${#secrets_file_list[@]}" != "0" ]]; then # import keys pushd "${keyfiles_dir}" > /dev/null 2>&1 >&2 echo "# RUN: gpg --import *.gpg.pub" gpg_out="$(gpg --import *.gpg.pub 2>&1)" popd > /dev/null 2>&1 # update for secrets_file in "${secrets_file_list[@]}"; do fn_sops_updatekeys_and_verify "${secrets_file}" done echo "# SUCCESS: all users with keys in this dir should have functional keys" else echo "# WARN: no secrets file passed in, make sure to call 'sops updatekeys' on secrets files" fi } # pass-through, set default value main "${@-}" exit