#!/usr/bin/env bash # pick a host from ~/.ssh/config and every file it includes # ssh-menu connect to the selection # ssh-menu -s print the selection: "" or "\tluks" (ctrl-u) # exported: the preview and the list reload run in child shells spawned by fzf export SSH_MENU_CONFIG="${SSH_MENU_CONFIG:-$HOME/.ssh/config}" # reachability results are cached per host and profile so browsing the list # does not re-probe (and re-log on) the same server; ctrl-r forgets one host export SSH_MENU_CACHE="${XDG_RUNTIME_DIR:-/tmp}/ssh-menu-${USER:-$(id -un)}" export SSH_MENU_CACHE_TTL="${SSH_MENU_CACHE_TTL:-3600}" # seconds to wait for an sshd banner: a plain tcp connection answers within one; # a ProxyCommand such as an iap tunnel needs a few to come up first export SSH_MENU_TIMEOUT="${SSH_MENU_TIMEOUT:-1}" export SSH_MENU_PROXY_TIMEOUT="${SSH_MENU_PROXY_TIMEOUT:-3}" [[ -d $SSH_MENU_CACHE ]] || mkdir -m 700 "$SSH_MENU_CACHE" # config plus every file named on Include lines; globs expanded, relative # paths resolved against ~/.ssh, unreadable files skipped function _ssh_menu_files { local line paths path file printf '%s\n' "$SSH_MENU_CONFIG" grep -iE '^[[:space:]]*Include[[:space:]]' "$SSH_MENU_CONFIG" 2>/dev/null \ | while read -r line; do paths=${line#*[Ii]nclude} for path in $paths; do path=${path/#\~/$HOME} [[ $path != /* ]] && path="$HOME/.ssh/$path" for file in $path; do [[ -r $file ]] && printf '%s\n' "$file" done done done } # one line per alias: file, alias, state, group, unlock marker # group is the fragment name; a "# unlock" comment inside a Host block, or on # the line directly above it, marks the host as having an initrd sshd function _ssh_menu_hosts { local file group while read -r file; do group=$(basename "$file") group=${group#ssh-fragment-} awk -v file="$file" -v group="$group" ' function flush() { for (i = 0; i < n; i++) printf "%s\t%s\t%s\t%s\n", file, hosts[i], group, (marks > 0 ? "unlock" : "") n = 0; marks = 0 } # unset would compare equal to 0, i.e. "the line before line 1" BEGIN { marker_line = -1 } /^[[:space:]]*[Hh]ost[[:space:]]/ { # a marker on the line directly above belongs to this block; the # previous block keeps any markers of its own carry = marker_line == NR - 1 if (carry) marks-- flush() marks = carry for (i = 2; i <= NF; i++) if ($i !~ /[*?!]/) hosts[n++] = $i next } /^[[:space:]]*[Mm]atch[[:space:]]/ { flush(); next } /^[[:space:]]*#[[:space:]]*unlock/ { marks++; marker_line = NR } END { flush() } ' "$file" done < <(_ssh_menu_files | awk '!seen[$0]++') \ | sort -t $'\t' -k3,3 -k2,2 \ | _ssh_menu_with_state } # $1 host, $2 now (epoch): one word summarising the cached default-profile # probe, "?" when nothing fresh is cached. reads the cache only, never probes function _ssh_menu_state { local host=$1 now=$2 file="$SSH_MENU_CACHE/$1__default" line [[ -f $file ]] || { echo "?"; return; } (( now - $(stat -c %Y "$file") < SSH_MENU_CACHE_TTL )) || { echo "?"; return; } line=$(<"$file") case $line in "jump "*" reachable"*) echo hop ;; "jump "*) echo down ;; reachable*) echo up ;; unreachable*) echo down ;; *) echo - ;; esac } # insert the cached state as the third field: file, alias, state, group, marker. # fzf keeps each field's trailing delimiter and the last field has none, so the # state must not be last or --with-nth would glue it to its neighbour function _ssh_menu_with_state { local now file host group marker now=$(date +%s) while IFS=$'\t' read -r file host group marker; do printf '%s\t%s\t%s\t%s\t%s\n' "$file" "$host" "$(_ssh_menu_state "$host" "$now")" "$group" "$marker" done } # the raw Host block for an alias, comments included function _ssh_menu_block { local file=$1 host=$2 awk -v h="$host" ' /^[[:space:]]*[Hh]ost[[:space:]]/ { found = 0 for (i = 2; i <= NF; i++) if ($i == h) found = 1 } /^[[:space:]]*[Mm]atch[[:space:]]/ { found = 0 } found ' "$file" } # an ssh server announces itself before the client sends anything, so a bare # tcp connection (or a running ProxyCommand) yields the banner without any # authentication and therefore without touching the agent or the yubikey # $1 host, $2 port: prints the sshd banner if the port answers with one function _ssh_menu_tcp_banner { # shellcheck disable=SC2016 # $1/$2/$3 are the inner bash's positional args timeout "$SSH_MENU_TIMEOUT" bash -c ' exec 3<>"/dev/tcp/$1/$2" || exit 1 IFS= read -t "$3" -r banner <&3 || exit 1 printf "%s\n" "$banner" | tr -d "\r" ' _ "$1" "$2" "$SSH_MENU_TIMEOUT" 2>/dev/null } # $1 expanded ProxyCommand: the remote banner arrives on its stdout. stdin is an # open, silent pipe (not /dev/null): a tunnel command may exit on stdin EOF # before the banner arrives. the sleep lives in a process substitution, not the # pipeline, so it is not waited for: the result returns as soon as the first # line arrives, or after the proxy timeout function _ssh_menu_proxy_banner { timeout "$SSH_MENU_PROXY_TIMEOUT" bash -c "$1" < <(sleep "$((SSH_MENU_PROXY_TIMEOUT + 1))") 2>/dev/null | head -n1 | tr -d '\r' } # ssh reading the same config ssh itself would; -F only when overridden, since # -F also drops /etc/ssh/ssh_config function _ssh_menu_sshflags { [[ $SSH_MENU_CONFIG != "$HOME/.ssh/config" ]] && printf '%s\n' -F "$SSH_MENU_CONFIG" return 0 } function _ssh_menu_ssh { local flags=() mapfile -t flags < <(_ssh_menu_sshflags) # shellcheck disable=SC2029 # plain argument passthrough, no remote command built here ssh "${flags[@]}" "$@" } function _ssh_menu_resolve { local host=$1 tag=$2 _ssh_menu_ssh -G ${tag:+-P "$tag"} -- "$host" 2>/dev/null } # effective config for one profile plus a (cached) reachability probe function _ssh_menu_probe { local host=$1 tag=$2 resolved if ! resolved=$(_ssh_menu_resolve "$host" "$tag") || [[ -z $resolved ]]; then echo "ssh -G failed for $host${tag:+ (-P $tag)}" return fi # identityfile only when the profile pins one; ssh -G lists all defaults otherwise awk ' $1 == "identitiesonly" && $2 == "yes" { pinned = 1 } $1 ~ /^(hostname|port|user|tag|proxyjump|proxycommand|userknownhostsfile)$/ { print } $1 == "identityfile" && pinned { print } ' <<<"$resolved" echo "" _ssh_menu_reach_cached "$host" "$tag" "$resolved" } function _ssh_menu_age { local s=$1 if (( s < 60 )); then echo "${s}s" elif (( s < 3600 )); then echo "$(( s / 60 ))m" else echo "$(( s / 3600 ))h" fi } # $1 host: drop cached results for every profile of that host function _ssh_menu_forget { rm -f -- "$SSH_MENU_CACHE/$1__"* } # $1 host, $2 unlock marker: forget and re-probe now, so the list can be # reloaded with the fresh state immediately instead of on the next timer tick function _ssh_menu_reprobe { _ssh_menu_forget "$1" _ssh_menu_probe "$1" "" >/dev/null [[ $2 == unlock ]] && _ssh_menu_probe "$1" luks >/dev/null return 0 } # $1 host, $2 tag, $3 ssh -G output: reachability line, served from cache while fresh function _ssh_menu_reach_cached { local host=$1 tag=$2 resolved=$3 file age result file="$SSH_MENU_CACHE/${host}__${tag:-default}" if [[ -f $file ]]; then age=$(( $(date +%s) - $(stat -c %Y "$file") )) if (( age < SSH_MENU_CACHE_TTL )); then printf '%s (cached %s ago, ctrl-r re-probes)\n' "$(<"$file")" "$(_ssh_menu_age "$age")" return fi fi result=$(_ssh_menu_reach "$host" "$tag" "$resolved") printf '%s\n' "$result" > "$file" printf '%s\n' "$result" } # $1 host, $2 tag, $3 ssh -G output: probe the endpoint, one line of result function _ssh_menu_reach { local host=$1 tag=$2 resolved=$3 hostname port user proxycommand proxyjump banner local hop hopuser hophost hopport hopresolved hopopts sshflags cmd hostname=$(awk '$1 == "hostname" { print $2 }' <<<"$resolved") port=$(awk '$1 == "port" { print $2 }' <<<"$resolved") user=$(awk '$1 == "user" { print $2 }' <<<"$resolved") proxycommand=$(awk '$1 == "proxycommand" { $1 = ""; sub(/^ /, ""); print }' <<<"$resolved") proxyjump=$(awk '$1 == "proxyjump" && $2 != "none" { print $2 }' <<<"$resolved") if [[ -n $proxyjump ]]; then # the target is only reachable through the hop, and opening a forwarded # channel needs an authenticated session there. if a ControlMaster to # the hop is already alive we ride it (no auth, no agent); otherwise # only the hop's own port is probed hop=${proxyjump%%,*} hopuser="" [[ $hop == *@* ]] && hopuser=${hop%%@*} hop=${hop##*@} hopport="" if [[ $hop == *:* ]]; then hopport=${hop##*:} hop=${hop%%:*} fi # the ControlPath hash covers user and port, so match the jump's exactly hopopts=(${hopuser:+-l "$hopuser"} ${hopport:+-p "$hopport"}) if _ssh_menu_ssh -O check "${hopopts[@]}" -- "$hop" >/dev/null 2>&1; then # timeout runs a binary, not a shell function, hence the explicit flags mapfile -t sshflags < <(_ssh_menu_sshflags) banner=$(timeout "$SSH_MENU_TIMEOUT" ssh "${sshflags[@]}" -o BatchMode=yes -o IdentityAgent=none -o ControlMaster=no \ "${hopopts[@]}" -W "$hostname:$port" -- "$hop" < <(sleep "$((SSH_MENU_TIMEOUT + 1))") 2>/dev/null | head -n1 | tr -d '\r') if [[ $banner == SSH-* ]]; then echo "reachable via $hop ($hostname:$port, $banner)" else echo "unreachable via $hop ($hostname:$port)" fi else hopresolved=$(_ssh_menu_resolve "$hop" "") hophost=$(awk '$1 == "hostname" { print $2 }' <<<"$hopresolved") hopport=${hopport:-$(awk '$1 == "port" { print $2 }' <<<"$hopresolved")} if banner=$(_ssh_menu_tcp_banner "$hophost" "$hopport"); then echo "jump $hop reachable ($hophost:$hopport, $banner); target not probed: no master (ssh -O check ${hopopts[*]} $hop)" else echo "jump $hop unreachable ($hophost:$hopport)" fi fi elif [[ -n $proxycommand ]]; then # running an ssh-based proxy would authenticate to the hop through the agent # matches "ssh", "/path/to/ssh", "sh -c 'ssh ..." but not "sshuttle" or "foo-ssh" if [[ $proxycommand =~ (^|[^[:alnum:]_.-])ssh([[:space:]]|$) ]]; then echo "proxycommand runs ssh; not probed (would need the agent)" else # ProxyCommand accepts exactly %%, %h, %n, %p and %r (ssh_config(5) TOKENS); # %% first so it cannot form a token cmd=${proxycommand//%%/$'\x01'} cmd=${cmd//%h/$hostname} cmd=${cmd//%p/$port} cmd=${cmd//%r/$user} cmd=${cmd//%n/$host} cmd=${cmd//$'\x01'/%} banner=$(_ssh_menu_proxy_banner "$cmd") if [[ $banner == SSH-* ]]; then echo "reachable via proxycommand ($banner)" else echo "unreachable via proxycommand" fi fi elif banner=$(_ssh_menu_tcp_banner "$hostname" "$port"); then echo "reachable ($hostname:$port, $banner)" else echo "unreachable ($hostname:$port)" fi } # $1 file, $2 alias, $3 unlock marker function _ssh_menu_preview { local file=$1 host=$2 marker=$3 _ssh_menu_block "$file" "$host" echo "" echo "--- ssh -G" _ssh_menu_probe "$host" "" if [[ $marker == unlock ]]; then echo "" echo "--- ssh -G -P luks" _ssh_menu_probe "$host" luks fi } export -f _ssh_menu_files _ssh_menu_hosts _ssh_menu_state _ssh_menu_with_state \ _ssh_menu_block _ssh_menu_tcp_banner _ssh_menu_proxy_banner _ssh_menu_sshflags _ssh_menu_ssh _ssh_menu_resolve \ _ssh_menu_age _ssh_menu_forget _ssh_menu_reprobe _ssh_menu_reach_cached _ssh_menu_reach _ssh_menu_probe _ssh_menu_preview # the state column is passive: it only reflects the cache, which the preview # fills as hosts are visited. a periodic reload keeps the column current; fzf # older than 0.73 lacks the every() event, so it is added only when supported function _ssh_menu_reload_opts { # exit 0 = option accepted and matched; an unknown event exits 2 if printf 'x\n' | fzf --bind 'every(2):ignore' --filter x >/dev/null 2>&1; then printf '%s\n' '--bind' 'every(2):reload-sync(_ssh_menu_hosts)' '--track' '--id-nth' '2' fi } # prints "" or "\tluks" function _ssh_menu_select { local key host marker reload_opts=() mapfile -t reload_opts < <(_ssh_menu_reload_opts) { read -r key IFS=$'\t' read -r _ host _ _ marker } < <(_ssh_menu_hosts \ | SHELL=$(command -v bash) fzf \ --delimiter $'\t' \ --with-nth 2..5 \ --nth 1,3 \ --tabstop 24 \ "${reload_opts[@]}" \ --expect ctrl-u \ --bind 'ctrl-r:execute-silent(_ssh_menu_reprobe {2} {5})+refresh-preview+reload-sync(_ssh_menu_hosts)' \ --header 'ENTER connect, CTRL-U unlock (luks), CTRL-R re-probe' \ --preview '_ssh_menu_preview {1} {2} {5}' \ --preview-label 'host' \ --border-label 'ssh-menu') [[ -z $host ]] && return 0 if [[ $key == ctrl-u ]]; then printf '%s\tluks\n' "$host" else printf '%s\n' "$host" fi } case "${1:-}" in -s) _ssh_menu_select ;; *) IFS=$'\t' read -r host profile < <(_ssh_menu_select) [[ -z $host ]] && exit 0 if [[ $profile == luks ]]; then exec ssh -P luks "$host" else exec ssh "$host" fi ;; esac