|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
IFS=$'\n\t' |
|
umask 077 |
|
|
|
SSH_DIR="$HOME/.ssh" |
|
SSH_CONFIG="$SSH_DIR/config" |
|
KNOWN_HOSTS="$SSH_DIR/known_hosts" |
|
DRY_RUN=false |
|
CONTACT_TIMEOUT_SECONDS="${SSH_COPY_CONTACT_TIMEOUT_SECONDS:-10}" |
|
IS_NEW_HOST=false |
|
HOST_KEY_STATE="unknown" |
|
SSH_ALIAS="" |
|
SSH_HOST="" |
|
SSH_PORT="22" |
|
SSH_USER="" |
|
PRIVATE_KEY="" |
|
PUBLIC_KEY="" |
|
declare -a TEMP_FILES=() |
|
|
|
cleanup() { |
|
(( ${#TEMP_FILES[@]} == 0 )) || rm -f -- "${TEMP_FILES[@]}" |
|
} |
|
trap cleanup EXIT |
|
|
|
die() { printf 'Error: %s\n' "$*" >&2; exit 1; } |
|
warn() { printf 'Warning: %s\n' "$*" >&2; } |
|
|
|
run_with_deadline() { |
|
local timeout_seconds="$1" |
|
shift |
|
local pid deadline status |
|
|
|
"$@" & |
|
pid=$! |
|
deadline=$((SECONDS + timeout_seconds)) |
|
while kill -0 "$pid" 2>/dev/null; do |
|
if (( SECONDS >= deadline )); then |
|
kill -TERM "$pid" 2>/dev/null || true |
|
sleep 0.2 |
|
kill -KILL "$pid" 2>/dev/null || true |
|
wait "$pid" 2>/dev/null || true |
|
return 124 |
|
fi |
|
sleep 0.1 |
|
done |
|
wait "$pid" |
|
status=$? |
|
return "$status" |
|
} |
|
|
|
usage() { |
|
cat <<'EOF' |
|
Usage: ssh_copy.sh [--dry-run] [--help] |
|
ssh_copy.sh --install |
|
|
|
Interactive workflow: |
|
1. Select an SSH alias or enter a new host. |
|
2. Confirm user, port, and alias. |
|
3. Select or generate a client key pair. |
|
4. Inspect saved and currently offered server host-key fingerprints. |
|
5. Optionally replace a verified old host key. |
|
6. Install the public key and verify key-only login. |
|
7. Save a new SSH alias after successful verification. |
|
EOF |
|
} |
|
|
|
expand_path() { |
|
local path="$1" prefix=$'\x7e/' |
|
if [[ "$path" == "~" ]]; then |
|
printf '%s\n' "$HOME" |
|
elif [[ "${path:0:2}" == "$prefix" ]]; then |
|
printf '%s/%s\n' "$HOME" "${path:2}" |
|
else |
|
printf '%s\n' "$path" |
|
fi |
|
} |
|
|
|
validate_alias() { |
|
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || |
|
die "Alias contains unsafe characters." |
|
} |
|
|
|
validate_host() { |
|
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || |
|
die "HostName must be a DNS name or IPv4 address without shell metacharacters." |
|
} |
|
|
|
validate_user() { |
|
[[ "$1" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]*$ ]] || |
|
die "User contains unsafe characters." |
|
} |
|
|
|
validate_port() { |
|
if [[ ! "$1" =~ ^[0-9]+$ ]] || (( 10#$1 < 1 || 10#$1 > 65535 )); then |
|
die "Port must be an integer from 1 through 65535." |
|
fi |
|
} |
|
|
|
prompt_value() { |
|
local prompt="$1" default="$2" value |
|
printf '%s [%s]: ' "$prompt" "$default" >&2 |
|
read -r value < /dev/tty || die "Could not read terminal input." |
|
printf '%s\n' "${value:-$default}" |
|
} |
|
|
|
check_tools() { |
|
local command |
|
for command in ssh ssh-keygen ssh-keyscan ssh-copy-id awk sort cmp mktemp; do |
|
command -v "$command" >/dev/null 2>&1 || |
|
die "Required command is missing: $command" |
|
done |
|
if [[ "$DRY_RUN" == false ]]; then |
|
mkdir -p "$SSH_DIR" |
|
chmod 700 "$SSH_DIR" |
|
elif [[ ! -d "$SSH_DIR" ]]; then |
|
warn "SSH directory does not exist; dry run will not create it." |
|
fi |
|
if [[ ! -e "$SSH_CONFIG" ]]; then |
|
if [[ "$DRY_RUN" == true ]]; then |
|
warn "SSH config does not exist; dry run will not create it." |
|
else |
|
: > "$SSH_CONFIG" |
|
chmod 600 "$SSH_CONFIG" |
|
fi |
|
fi |
|
[[ ! -e "$SSH_CONFIG" || ( -f "$SSH_CONFIG" && ! -L "$SSH_CONFIG" ) ]] || |
|
die "SSH config must be a regular, non-symlink file." |
|
} |
|
|
|
config_aliases() { |
|
[[ -r "$SSH_CONFIG" ]] || return 0 |
|
awk ' |
|
tolower($1) == "host" { |
|
for (i=2; i<=NF; i++) |
|
if ($i !~ /[*?!]/) print $i |
|
} |
|
' "$SSH_CONFIG" | sort -u |
|
} |
|
|
|
resolved_value() { |
|
local alias="$1" key="$2" |
|
ssh -G "$alias" 2>/dev/null | |
|
awk -v wanted="$key" '$1 == wanted { print $2; exit }' |
|
} |
|
|
|
select_host() { |
|
local short_name existing configured_user="" found=false |
|
printf 'Raspberry Pi hostname (without .local): ' |
|
read -r short_name < /dev/tty || die "Could not read hostname." |
|
[[ "$short_name" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || |
|
die "Enter only the short hostname, without .local." |
|
|
|
SSH_ALIAS="$short_name" |
|
SSH_HOST="$short_name.local" |
|
SSH_PORT="22" |
|
|
|
while IFS= read -r existing; do |
|
[[ "$existing" != "$SSH_ALIAS" ]] || { found=true; break; } |
|
done < <(config_aliases) |
|
|
|
if [[ "$found" == true ]]; then |
|
local resolved_host resolved_port |
|
resolved_host=$(resolved_value "$SSH_ALIAS" hostname) |
|
resolved_port=$(resolved_value "$SSH_ALIAS" port) |
|
configured_user=$(resolved_value "$SSH_ALIAS" user) |
|
[[ -z "$resolved_host" ]] || SSH_HOST="$resolved_host" |
|
[[ -z "$resolved_port" ]] || SSH_PORT="$resolved_port" |
|
else |
|
IS_NEW_HOST=true |
|
fi |
|
|
|
validate_alias "$SSH_ALIAS" |
|
validate_host "$SSH_HOST" |
|
validate_port "$SSH_PORT" |
|
select_username "$configured_user" |
|
} |
|
|
|
|
|
config_users() { |
|
[[ -r "$SSH_CONFIG" ]] || return 0 |
|
awk 'tolower($1) == "user" && NF >= 2 { print $2 }' "$SSH_CONFIG" | sort -u |
|
} |
|
|
|
select_username() { |
|
local configured_user="${1:-}" candidate choice index=1 |
|
local -a users=("pi") |
|
|
|
while IFS= read -r candidate; do |
|
[[ -n "$candidate" ]] || continue |
|
validate_user "$candidate" || continue |
|
local seen=false existing |
|
for existing in "${users[@]}"; do |
|
[[ "$existing" != "$candidate" ]] || { seen=true; break; } |
|
done |
|
[[ "$seen" == true ]] || users+=("$candidate") |
|
done < <({ |
|
[[ -z "$configured_user" ]] || printf '%s\n' "$configured_user" |
|
config_users |
|
printf '%s\n' "${USER:-}" |
|
} | awk 'NF' | sort -u) |
|
|
|
printf '\nAvailable SSH usernames (press Enter for pi):\n' |
|
for candidate in "${users[@]}"; do |
|
if [[ "$candidate" == "pi" ]]; then |
|
printf ' %d) %s [default]\n' "$index" "$candidate" |
|
else |
|
printf ' %d) %s\n' "$index" "$candidate" |
|
fi |
|
((index += 1)) |
|
done |
|
printf ' %d) Other\n' "$index" |
|
printf 'Selection: ' |
|
read -r choice < /dev/tty || die "Could not read username selection." |
|
|
|
if [[ -z "$choice" ]]; then |
|
SSH_USER="pi" |
|
return |
|
fi |
|
[[ "$choice" =~ ^[0-9]+$ ]] || die "Invalid username selection." |
|
if (( choice == index )); then |
|
printf 'SSH username: ' |
|
read -r SSH_USER < /dev/tty || die "Could not read SSH username." |
|
validate_user "$SSH_USER" |
|
elif (( choice >= 1 && choice < index )); then |
|
SSH_USER="${users[choice-1]}" |
|
else |
|
die "Username selection is out of range." |
|
fi |
|
} |
|
|
|
generate_key() { |
|
local default_path path |
|
default_path="$SSH_DIR/id_ed25519" |
|
path=$(prompt_value "New private-key path" "$default_path") |
|
path=$(expand_path "$path") |
|
[[ "$path" != *.pub ]] || die "Enter a private-key path, not .pub." |
|
[[ ! -e "$path" && ! -L "$path" && ! -e "$path.pub" && ! -L "$path.pub" ]] || |
|
die "Refusing to overwrite an existing key pair." |
|
[[ "$DRY_RUN" == false ]] || die "Dry run cannot generate a key." |
|
mkdir -p "$(dirname "$path")" |
|
printf 'Generating Ed25519 key; a passphrase is recommended.\n' |
|
ssh-keygen -t ed25519 -a 64 -f "$path" |
|
PRIVATE_KEY="$path" |
|
} |
|
|
|
key_rank() { |
|
local public_key="$1" details type |
|
details=$(ssh-keygen -lf "$public_key" 2>/dev/null) || { printf '99\n'; return; } |
|
type="${details##* (}" |
|
type="${type%)}" |
|
case "$type" in |
|
ED25519-SK) printf '10\n' ;; |
|
ED25519) printf '20\n' ;; |
|
ECDSA-SK) printf '30\n' ;; |
|
ECDSA) printf '40\n' ;; |
|
RSA) printf '50\n' ;; |
|
*) printf '90\n' ;; |
|
esac |
|
} |
|
|
|
select_client_key() { |
|
local -a keys=() ordered=() |
|
local key choice index=1 default_index=0 default_rank=999 rank |
|
for key in "$SSH_DIR"/id_*; do |
|
[[ -f "$key" && ! -L "$key" && "$key" != *.pub && |
|
-f "$key.pub" && ! -L "$key.pub" ]] || continue |
|
ssh-keygen -lf "$key.pub" >/dev/null 2>&1 || continue |
|
keys+=("$key") |
|
rank=$(key_rank "$key.pub") |
|
if (( rank < default_rank )); then |
|
default_rank=$rank |
|
default_index=${#keys[@]} |
|
elif (( rank == default_rank )) && [[ "${key##*/}" == "id_ed25519" ]]; then |
|
default_index=${#keys[@]} |
|
fi |
|
done |
|
|
|
if (( default_index > 0 )); then |
|
ordered+=("${keys[default_index-1]}") |
|
for key in "${keys[@]}"; do |
|
[[ "$key" == "${keys[default_index-1]}" ]] || ordered+=("$key") |
|
done |
|
fi |
|
|
|
printf '\nAvailable public keys:\n' |
|
for key in "${ordered[@]}"; do |
|
if (( index == 1 )); then |
|
printf ' %d) %s.pub [default]\n' "$index" "$key" |
|
else |
|
printf ' %d) %s.pub\n' "$index" "$key" |
|
fi |
|
((index += 1)) |
|
done |
|
printf ' %d) Generate a new Ed25519 key\n' "$index" |
|
if (( ${#ordered[@]} > 0 )); then |
|
printf 'Selection (press Enter for 1): ' |
|
else |
|
printf 'Selection (press Enter to generate): ' |
|
fi |
|
read -r choice < /dev/tty || die "Could not read key selection." |
|
|
|
if [[ -z "$choice" ]]; then |
|
if (( ${#ordered[@]} > 0 )); then |
|
PRIVATE_KEY="${ordered[0]}" |
|
else |
|
generate_key |
|
fi |
|
elif [[ "$choice" =~ ^[0-9]+$ ]] && (( choice == index )); then |
|
generate_key |
|
elif [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice < index )); then |
|
PRIVATE_KEY="${ordered[choice-1]}" |
|
else |
|
die "Invalid key selection." |
|
fi |
|
|
|
PUBLIC_KEY="$PRIVATE_KEY.pub" |
|
ssh-keygen -lf "$PUBLIC_KEY" >/dev/null || die "Invalid public key: $PUBLIC_KEY" |
|
} |
|
|
|
|
|
host_token() { |
|
if [[ "$SSH_PORT" == 22 ]]; then |
|
printf '%s\n' "$SSH_HOST" |
|
else |
|
printf '[%s]:%s\n' "$SSH_HOST" "$SSH_PORT" |
|
fi |
|
} |
|
|
|
fingerprints_from_file() { |
|
local file="$1" |
|
[[ -s "$file" ]] || { printf ' none\n'; return; } |
|
ssh-keygen -lf "$file" 2>/dev/null | sed 's/^/ /' || |
|
printf ' unable to parse fingerprints\n' |
|
} |
|
|
|
inspect_host_key() { |
|
local token known_file scan_file known_keys scan_keys backup |
|
token=$(host_token) |
|
known_file=$(mktemp) |
|
scan_file=$(mktemp) |
|
TEMP_FILES+=("$known_file" "$scan_file") |
|
|
|
ssh-keygen -F "$token" -f "$KNOWN_HOSTS" 2>/dev/null | |
|
awk '!/^#/ && NF >= 3 { print $2, $3 }' > "$known_file" || true |
|
|
|
if [[ "$DRY_RUN" == true ]]; then |
|
printf '\nSaved host-key fingerprints for %s:\n' "$token" |
|
fingerprints_from_file "$known_file" |
|
printf 'Dry run: current host keys were not scanned.\n' |
|
return |
|
fi |
|
|
|
if ! run_with_deadline "$CONTACT_TIMEOUT_SECONDS" \ |
|
ssh-keyscan -T 5 -p "$SSH_PORT" "$SSH_HOST" > "$scan_file" 2>/dev/null; then |
|
die "Could not contact $SSH_HOST:$SSH_PORT within $CONTACT_TIMEOUT_SECONDS seconds. Check power, name resolution, and SSH." |
|
fi |
|
[[ -s "$scan_file" ]] || die "The host offered no SSH host keys." |
|
|
|
printf '\nSaved host-key fingerprints:\n' |
|
fingerprints_from_file "$known_file" |
|
printf 'Currently offered fingerprints (not yet trusted):\n' |
|
fingerprints_from_file "$scan_file" |
|
|
|
if [[ ! -s "$known_file" ]]; then |
|
HOST_KEY_STATE="new" |
|
return |
|
fi |
|
|
|
known_keys=$(awk '{print $1, $2}' "$known_file" | sort -u) |
|
scan_keys=$(awk 'NF >= 3 {print $2, $3}' "$scan_file" | sort -u) |
|
if [[ "$known_keys" == "$scan_keys" ]]; then |
|
HOST_KEY_STATE="known" |
|
return |
|
fi |
|
|
|
backup="$KNOWN_HOSTS.backup.$(date +%Y%m%d%H%M%S)" |
|
cp -p "$KNOWN_HOSTS" "$backup" || |
|
die "Could not back up known_hosts; the old entry was not removed." |
|
ssh-keygen -R "$token" -f "$KNOWN_HOSTS" >/dev/null || |
|
die "Could not remove the old host-key entry. Backup: $backup" |
|
HOST_KEY_STATE="replaced" |
|
printf '\nHost key changed for %s.\n' "$token" |
|
printf 'Expected after re-imaging; otherwise this can indicate interception.\n' |
|
printf 'Backed up known_hosts: %s\n' "$backup" |
|
printf 'Removed the old entry; OpenSSH will ask you to verify the new key.\n' |
|
} |
|
|
|
|
|
show_plan() { |
|
printf '\nSSH setup plan:\n' |
|
printf ' Alias: %s%s\n' "$SSH_ALIAS" "$([[ "$IS_NEW_HOST" == true ]] && printf ' (new)' || true)" |
|
printf ' Destination: %s@%s\n' "$SSH_USER" "$SSH_HOST" |
|
printf ' Port: %s\n' "$SSH_PORT" |
|
printf ' Public key: %s\n' "$PUBLIC_KEY" |
|
printf ' Fingerprint: %s\n' "$(ssh-keygen -lf "$PUBLIC_KEY")" |
|
} |
|
|
|
normalize_ssh_copy_output() { |
|
local info_color="" warning_color="" error_color="" reset_color="" |
|
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then |
|
info_color=$'\033[36m' |
|
warning_color=$'\033[33m' |
|
error_color=$'\033[31m' |
|
reset_color=$'\033[0m' |
|
fi |
|
|
|
awk -v info_color="$info_color" -v warning_color="$warning_color" \ |
|
-v error_color="$error_color" -v reset_color="$reset_color" ' |
|
{ |
|
sub(/^([^:]*\/)?ssh-copy-id: /, "") |
|
if ($0 ~ /^[[:space:]]+\(/) { |
|
sub(/^[[:space:]]+/, "") |
|
} |
|
sub(/^INFO:/, info_color "INFO:" reset_color) |
|
sub(/^WARNING:/, warning_color "WARNING:" reset_color) |
|
sub(/^ERROR:/, error_color "ERROR:" reset_color) |
|
} |
|
/^[[:space:]]*$/ { next } |
|
{ print } |
|
' |
|
} |
|
|
|
install_client_key() { |
|
local destination="$SSH_USER@$SSH_HOST" copy_status |
|
show_plan |
|
inspect_host_key |
|
[[ "$DRY_RUN" == false ]] || { |
|
printf 'Dry run: no connection or change occurred.\n' |
|
return 0 |
|
} |
|
|
|
if [[ "$HOST_KEY_STATE" == "known" ]] && |
|
ssh -p "$SSH_PORT" -o BatchMode=yes -o IdentitiesOnly=yes \ |
|
-o ConnectTimeout="$CONTACT_TIMEOUT_SECONDS" -o ConnectionAttempts=1 \ |
|
-o StrictHostKeyChecking=yes -i "$PRIVATE_KEY" "$destination" true \ |
|
>/dev/null 2>&1; then |
|
printf 'Selected public key already works; nothing to copy.\n' |
|
return 0 |
|
fi |
|
|
|
set +e |
|
ssh-copy-id -i "$PUBLIC_KEY" -p "$SSH_PORT" \ |
|
-o ConnectTimeout="$CONTACT_TIMEOUT_SECONDS" -o ConnectionAttempts=1 \ |
|
-o ServerAliveInterval=5 -o ServerAliveCountMax=1 \ |
|
-o StrictHostKeyChecking=ask "$destination" 2>&1 | |
|
normalize_ssh_copy_output |
|
copy_status=${PIPESTATUS[0]} |
|
set -e |
|
(( copy_status == 0 )) || die "ssh-copy-id failed with status $copy_status." |
|
|
|
ssh -p "$SSH_PORT" -o BatchMode=yes -o IdentitiesOnly=yes \ |
|
-o ConnectTimeout="$CONTACT_TIMEOUT_SECONDS" -o ConnectionAttempts=1 \ |
|
-o ServerAliveInterval=5 -o ServerAliveCountMax=1 \ |
|
-o StrictHostKeyChecking=yes -i "$PRIVATE_KEY" "$destination" true || |
|
die "Key installation returned, but selected-key login verification failed." |
|
printf 'Public key installed and verified.\n' |
|
} |
|
|
|
|
|
save_new_alias() { |
|
[[ "$IS_NEW_HOST" == true && "$DRY_RUN" == false ]] || return 0 |
|
local temp backup |
|
temp=$(mktemp "$SSH_DIR/config.tmp.XXXXXX") |
|
if [[ -e "$SSH_CONFIG" ]]; then |
|
cp -p "$SSH_CONFIG" "$temp" || die "Could not prepare SSH config update." |
|
backup="$SSH_CONFIG.backup.$(date +%Y%m%d%H%M%S)" |
|
cp -p "$SSH_CONFIG" "$backup" || die "Could not back up SSH config." |
|
fi |
|
{ |
|
printf '\nHost %s\n' "$SSH_ALIAS" |
|
printf ' HostName %s\n' "$SSH_HOST" |
|
printf ' User %s\n' "$SSH_USER" |
|
printf ' Port %s\n' "$SSH_PORT" |
|
printf ' IdentityFile %s\n' "$PRIVATE_KEY" |
|
printf ' StrictHostKeyChecking ask\n' |
|
} >> "$temp" |
|
chmod 600 "$temp" |
|
mv -f "$temp" "$SSH_CONFIG" |
|
printf 'Saved SSH alias %s in %s.\n' "$SSH_ALIAS" "$SSH_CONFIG" |
|
} |
|
|
|
install_self() { |
|
local source target="/usr/local/bin/ssh-copy" |
|
source="${BASH_SOURCE[0]}" |
|
[[ "$source" != "-" && -f "$source" && ! -L "$source" ]] || |
|
die "--install requires an inspected local, non-symlink script file." |
|
source="$(cd "$(dirname "$source")" && pwd -P)/$(basename "$source")" |
|
command -v install >/dev/null 2>&1 || die "The install command is required." |
|
if (( EUID == 0 )); then |
|
install -d -m 0755 /usr/local/bin |
|
install -m 0755 "$source" "$target" |
|
else |
|
command -v sudo >/dev/null 2>&1 || die "sudo is required to install in /usr/local/bin." |
|
sudo install -d -m 0755 /usr/local/bin |
|
sudo install -m 0755 "$source" "$target" |
|
fi |
|
printf 'Installed %s. Run it with: ssh-copy\n' "$target" |
|
} |
|
|
|
main() { |
|
while (( $# )); do |
|
case "$1" in |
|
--install) install_self; exit 0 ;; |
|
--dry-run) DRY_RUN=true ;; |
|
-h|--help) usage; exit 0 ;; |
|
*) usage >&2; die "Unknown argument: $1" ;; |
|
esac |
|
shift |
|
done |
|
if [[ ! "$CONTACT_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || |
|
(( CONTACT_TIMEOUT_SECONDS < 1 || CONTACT_TIMEOUT_SECONDS > 60 )); then |
|
die "SSH_COPY_CONTACT_TIMEOUT_SECONDS must be from 1 through 60." |
|
fi |
|
check_tools |
|
select_host |
|
select_client_key |
|
install_client_key |
|
save_new_alias |
|
[[ "$DRY_RUN" == true ]] || |
|
printf 'Ready: ssh %s\n' "$SSH_ALIAS" |
|
} |
|
main "$@" |