#!/usr/bin/env bash # ssh-open: open an authenticated SSH master connection per host, for later # `ssh HOST ...`, scp, rsync and git to reuse without a new key touch. # # Each host needs a ControlPath in ~/.ssh/config (ControlMaster auto, # ControlPath ~/.ssh/cm-%C); without one nothing could reuse the master, so # the host is refused. A host's ProxyJump host is opened first, so each # host costs one touch and the jumped host rides on the jump's master. # # Without a terminal (an agent, a script), the touch prompt goes to a desktop # notification (ssh-touch-notify) unless SSH_ASKPASS is already set. set -euo pipefail usage() { cat <<'EOF' usage: ssh-open [-t TTL] HOST... Open a master connection to each HOST (skipping those already open), then show their status. Touch the security key when asked. -t TTL keep each master this long after its last use (ssh ControlPersist: 10m, 1h, yes = until ssh-close); default: the host's ssh config EOF } ttl="" while getopts "t:h" opt; do case $opt in t) ttl=$OPTARG ;; h) usage; exit 0 ;; *) usage >&2; exit 2 ;; esac done shift $((OPTIND - 1)) [ $# -gt 0 ] || { usage >&2; exit 2; } state="${XDG_RUNTIME_DIR:-$HOME/.cache}/devkit-ssh/hosts" mkdir -p "$(dirname "$state")" touch "$state" # No terminal for ssh to prompt on: show the touch request on the desktop. if [ ! -t 0 ] && [ ! -t 2 ] && [ -z "${SSH_ASKPASS:-}" ]; then SSH_ASKPASS="$(command -v ssh-touch-notify || true)" if [ -n "$SSH_ASKPASS" ]; then export SSH_ASKPASS SSH_ASKPASS_REQUIRE=force else unset SSH_ASKPASS fi fi ssh_opt() { ssh -G "$1" 2>/dev/null | awk -v k="$2" '$1 == k { print $2; exit }'; } record() { grep -qxF "$1" "$state" || printf '%s\n' "$1" >>"$state"; } declare -A seen=() opened=() failed=0 open_host() { local host=$1 jump j jumps [ -z "${seen[$host]:-}" ] || return 0 seen[$host]=1 local cpath cpath="$(ssh_opt "$host" controlpath)" if [ -z "$cpath" ] || [ "$cpath" = none ]; then echo "ssh-open: $host: no ControlPath in ssh config, nothing could reuse a master; add:" >&2 echo " ControlMaster auto" >&2 echo " ControlPath ~/.ssh/cm-%C" >&2 failed=1 return 0 fi # Open bare-name jump hosts first (user@host:port forms are left to ssh). jump="$(ssh_opt "$host" proxyjump)" if [ -n "$jump" ] && [ "$jump" != none ]; then IFS=, read -ra jumps <<<"$jump" for j in "${jumps[@]}"; do case $j in *[@:/]*) ;; *) open_host "$j" ;; esac done fi if ssh -O check "$host" 2>/dev/null; then record "$host" opened+=("$host") return 0 fi echo "ssh-open: opening $host (touch your security key when it blinks)" >&2 local args=(-fNT -o ControlMaster=auto) [ -z "$ttl" ] || args+=(-o "ControlPersist=$ttl") # shellcheck disable=SC2029 # $host is the destination, not a remote command if DEVKIT_SSH_HOST=$host ssh "${args[@]}" "$host"; then record "$host" opened+=("$host") else echo "ssh-open: $host: could not connect" >&2 failed=1 fi } for host in "$@"; do open_host "$host"; done [ ${#opened[@]} -eq 0 ] || ssh-status "${opened[@]}" || failed=1 exit "$failed"