Skip to content

Instantly share code, notes, and snippets.

@bogorad
Created November 27, 2025 10:32
Show Gist options
  • Select an option

  • Save bogorad/3cbf21a69942d0913431dd8a419e2deb to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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