Created
November 27, 2025 10:32
-
-
Save bogorad/3cbf21a69942d0913431dd8a419e2deb to your computer and use it in GitHub Desktop.
Auto-generate ssh+age keys for a new host, store in BitWarden, update SOPS config.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| die() { | |
| echo "$0: ERROR: $1" >&2 | |
| exit 1 | |
| } | |
| log() { | |
| echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" | |
| } | |
| usage() { | |
| cat << EOF | |
| Usage: $0 [OPTIONS] HOSTNAME [HOSTNAME...] | |
| Options: | |
| -f, --force Regenerate SSH key even if it exists in Bitwarden | |
| -d, --delete Delete host key from Bitwarden and .sops.yaml | |
| -h, --help Show this help message | |
| Arguments: | |
| HOSTNAME One or more hostnames to process | |
| EOF | |
| exit 1 | |
| } | |
| check_rbw_status() { | |
| rbw unlocked || die "Unlock Bitwarden" | |
| } | |
| cleanup() { | |
| if [[ -n "${TEMP_DIR:-}" ]] && [[ -d "$TEMP_DIR" ]]; then | |
| rm -rf "$TEMP_DIR" | |
| log "Removed temporary directory: $TEMP_DIR" | |
| fi | |
| } | |
| backup_config() { | |
| local file="$1" | |
| if [[ -f "$file" ]]; then | |
| local backup | |
| backup="${file}.backup-$(date +'%Y%m%d-%H%M%S')" | |
| cp "$file" "$backup" | |
| log "Backup created: $backup" | |
| fi | |
| } | |
| get_rbw_entry_id() { | |
| local key_name="$1" | |
| local raw_output | |
| if raw_output=$(rbw get --raw "$key_name" 2>/dev/null); then | |
| echo "$raw_output" | jq -r '.id // empty' | |
| else | |
| echo "" | |
| fi | |
| } | |
| delete_host() { | |
| local host="$1" | |
| local key_name="${KEY_PREFIX}${host}" | |
| local found_in_rbw=false | |
| local found_in_sops=false | |
| log "Deleting host: $host" | |
| [[ $host =~ ^[a-zA-Z0-9-]+$ ]] || | |
| die "Invalid hostname $host. Use only letters, numbers, and hyphens." | |
| local entry_id | |
| entry_id=$(get_rbw_entry_id "$key_name") | |
| if [[ -n "$entry_id" ]]; then | |
| log "Found in Bitwarden (id: $entry_id), removing..." | |
| rbw remove "$key_name" || die "Failed to remove $key_name from Bitwarden" | |
| found_in_rbw=true | |
| log "Removed from Bitwarden" | |
| fi | |
| if [[ -f "$SOPS_YAML" ]] && grep -q "^\s*- &$host " "$SOPS_YAML" 2>/dev/null; then | |
| log "Found in $SOPS_YAML, removing..." | |
| sed -i "/^ - &$host /d" "$SOPS_YAML" | |
| sed -i "/- \*$host$/d" "$SOPS_YAML" | |
| found_in_sops=true | |
| log "Removed from $SOPS_YAML" | |
| fi | |
| if [[ "$found_in_rbw" == "false" ]] && [[ "$found_in_sops" == "false" ]]; then | |
| die "Host '$host' not found in Bitwarden or $SOPS_YAML" | |
| fi | |
| log "Completed deletion for host: $host" | |
| } | |
| process_host() { | |
| local host="$1" | |
| local key_dir="$TEMP_DIR/$host" | |
| local key_name="${KEY_PREFIX}${host}" | |
| local entry_id="" | |
| local is_new_entry=false | |
| log "Processing host: $host" | |
| [[ $host =~ ^[a-zA-Z0-9-]+$ ]] || | |
| die "Invalid hostname $host. Use only letters, numbers, and hyphens." | |
| entry_id=$(get_rbw_entry_id "$key_name") | |
| if [[ -n "$entry_id" ]]; then | |
| if [[ "$FORCE" != "true" ]]; then | |
| die "Key '$key_name' already exists in Bitwarden. Use --force to regenerate." | |
| fi | |
| log "Key exists (id: $entry_id), will regenerate with --force" | |
| else | |
| is_new_entry=true | |
| log "Key does not exist, will create new entry" | |
| fi | |
| mkdir -p "$key_dir" || die "Failed to create directory $key_dir" | |
| log "Generating ED25519 SSH key..." | |
| ssh-keygen -t ed25519 -N "" -f "$key_dir/ssh_host_ed25519_key" -q || | |
| die "Failed to generate SSH key" | |
| log "Private key generated:" | |
| cat "$key_dir/ssh_host_ed25519_key" | |
| if [[ "$is_new_entry" == "true" ]]; then | |
| log "Creating new Bitwarden entry..." | |
| VISUAL=true EDITOR=true rbw add --folder=ssh "$key_name" || | |
| die "Failed to create Bitwarden entry" | |
| rbw sync || die "Failed to sync rbw" | |
| entry_id=$(get_rbw_entry_id "$key_name") | |
| [[ -n "$entry_id" ]] || die "Failed to get entry id after creation" | |
| log "Created entry with id: $entry_id" | |
| fi | |
| log "Updating Bitwarden entry notes with private key..." | |
| { | |
| printf '\n' | |
| cat "$key_dir/ssh_host_ed25519_key" | |
| } | rbw edit "$entry_id" || die "Failed to update Bitwarden entry" | |
| log "Converting SSH key to age format..." | |
| local age_key | |
| age_key=$(ssh-to-age < "$key_dir/ssh_host_ed25519_key.pub") || | |
| die "Failed to convert SSH key to age format" | |
| log "Age key: $age_key" | |
| update_sops_yaml "$host" "$age_key" "$is_new_entry" | |
| log "Completed processing for host: $host" | |
| } | |
| update_sops_yaml() { | |
| local host="$1" | |
| local age_key="$2" | |
| local is_new_entry="$3" | |
| if [[ ! -f "$SOPS_YAML" ]]; then | |
| log "Creating new $SOPS_YAML file..." | |
| cat > "$SOPS_YAML" << EOF | |
| keys: | |
| - &$host $age_key # Added $(date +'%Y-%m-%d') | |
| creation_rules: | |
| - path_regex: secrets/[^/]+\.yaml$ | |
| key_groups: | |
| - age: | |
| - *$host | |
| EOF | |
| return | |
| fi | |
| if [[ "$is_new_entry" == "true" ]]; then | |
| log "Adding new host to $SOPS_YAML..." | |
| sed -i "/^keys:/a\\ - \&$host $age_key # Added $(date +'%Y-%m-%d')" "$SOPS_YAML" | |
| sed -i "/age:/a\\ - *$host" "$SOPS_YAML" | |
| else | |
| log "Updating existing host in $SOPS_YAML..." | |
| sed -i "s|^\( - &$host \).*|\1$age_key # Updated $(date +'%Y-%m-%d')|" "$SOPS_YAML" | |
| fi | |
| } | |
| # Parse arguments | |
| HOSTS=() | |
| FORCE="false" | |
| DELETE="false" | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -f|--force) | |
| FORCE="true" | |
| shift | |
| ;; | |
| -d|--delete) | |
| DELETE="true" | |
| shift | |
| ;; | |
| -h|--help) | |
| usage | |
| ;; | |
| -*) | |
| die "Unknown option: $1" | |
| ;; | |
| *) | |
| HOSTS+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| [[ ${#HOSTS[@]} -eq 0 ]] && usage | |
| [[ "$FORCE" == "true" ]] && [[ "$DELETE" == "true" ]] && | |
| die "--force and --delete are mutually exclusive" | |
| KEY_PREFIX="ssh-host-key-" | |
| SOPS_YAML="../.sops.yaml" | |
| for cmd in rbw jq; do | |
| command -v "$cmd" &> /dev/null || | |
| die "$cmd not found. Please install it first." | |
| done | |
| if [[ "$DELETE" != "true" ]]; then | |
| for cmd in ssh-to-age ssh-keygen; do | |
| command -v "$cmd" &> /dev/null || | |
| die "$cmd not found. Please install it first." | |
| done | |
| TEMP_DIR=$(mktemp -d) || die "Failed to create temporary directory" | |
| chmod 700 "$TEMP_DIR" | |
| trap cleanup EXIT | |
| fi | |
| check_rbw_status | |
| backup_config "$SOPS_YAML" | |
| for host in "${HOSTS[@]}"; do | |
| if [[ "$DELETE" == "true" ]]; then | |
| delete_host "$host" | |
| else | |
| process_host "$host" | |
| fi | |
| done | |
| rbw sync | |
| [[ -f "../secrets/secrets.yaml" ]] && sops updatekeys ../secrets/secrets.yaml | |
| log "Successfully completed all operations"generate-host-key.sh |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment