feat(tmux): improve tmux ssh behavior

This commit is contained in:
2026-09-18 14:17:54 +02:00
parent b95c79e371
commit 2c1a37660b
3 changed files with 382 additions and 48 deletions

View File

@@ -1,53 +1,353 @@
#!/usr/bin/env bash #!/usr/bin/env bash
function _ssh_menu_preview { # pick a host from ~/.ssh/config and every file it includes
file=$1; shift # ssh-menu connect to the selection
host=$1; shift # ssh-menu -s print the selection: "<host>" or "<host>\tluks" (ctrl-u)
# show host block from ssh config # exported: the preview and the list reload run in child shells spawned by fzf
awk -v h="$host" ' export SSH_MENU_CONFIG="${SSH_MENU_CONFIG:-$HOME/.ssh/config}"
$0 == "Host " h { found=1 }
found && /^[^ \t]/ && $0 != "Host " h { exit }
found
' "$file"
echo "" # 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"
# resolve hostname for reachability check # config plus every file named on Include lines; globs expanded, relative
hostname=$(awk -v h="$host" ' # paths resolved against ~/.ssh, unreadable files skipped
$0 == "Host " h { found=1; next } function _ssh_menu_files {
found && /^[^ \t]/ { exit } local line paths path file
found && /HostName/ { print $2 } printf '%s\n' "$SSH_MENU_CONFIG"
' "$file") grep -iE '^[[:space:]]*Include[[:space:]]' "$SSH_MENU_CONFIG" 2>/dev/null \
| while read -r line; do
if command -v host &>/dev/null; then paths=${line#*[Ii]nclude}
host "${hostname:-$host}" for path in $paths; do
echo "" path=${path/#\~/$HOME}
fi [[ $path != /* ]] && path="$HOME/.ssh/$path"
for file in $path; do
ping -c 1 -W 1 "${hostname:-$host}" &>/dev/null \ [[ -r $file ]] && printf '%s\n' "$file"
&& echo "reachable" \ done
|| echo "not reachable" done
done
} }
export -f _ssh_menu_preview # 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 "<host>" or "<host>\tluks"
function _ssh_menu_select { function _ssh_menu_select {
rg -N --no-heading '^Host .*' ~/.ssh \ local key host marker reload_opts=()
| sed 's/Host \(.*\)/\1/' | sort \ mapfile -t reload_opts < <(_ssh_menu_reload_opts)
| SHELL=$(which bash) fzf \ {
--delimiter ':' \ read -r key
--with-nth 2 \ IFS=$'\t' read -r _ host _ _ marker
--preview "_ssh_menu_preview {1} {2}" \ } < <(_ssh_menu_hosts \
--preview-label "ssh config info" \ | SHELL=$(command -v bash) fzf \
--border-label "ssh-menu" \ --delimiter $'\t' \
| cut -d ":" -f2 --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 case "${1:-}" in
-s) _ssh_menu_select ;; -s) _ssh_menu_select ;;
*) *)
selected="$(_ssh_menu_select)" IFS=$'\t' read -r host profile < <(_ssh_menu_select)
[[ -n $selected ]] && ssh "$selected" [[ -z $host ]] && exit 0
if [[ $profile == luks ]]; then
exec ssh -P luks "$host"
else
exec ssh "$host"
fi
;; ;;
esac esac

View File

@@ -1,24 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ $# -eq 1 ]]; then # open (or switch to) a tmux session running ssh to a host
selected=$1 # tmux-ssher [host [luks]]
if [[ $# -ge 1 ]]; then
host=$1
profile=${2:-}
else else
selected="$(~/.config/bin/ssh-menu -s)" IFS=$'\t' read -r host profile < <(~/.config/bin/ssh-menu -s)
fi fi
if [[ -z $selected ]]; then if [[ -z $host ]]; then
exit 0 exit 0
fi fi
selected_name="ssh_$selected" # session names cannot contain . or :
name=${host//[.:]/_}
if [[ -z $TMUX ]] && ! tmux list-sessions 2>/dev/null; then # an unlock session is short-lived and must not collide with a live ssh session
tmux new-session -s "$selected_name" ssh "$selected" if [[ $profile == luks ]]; then
exit 0 session="unlock_$name"
args=(-P luks "$host")
else
session="ssh_$name"
args=("$host")
fi fi
if ! tmux has-session -t="$selected_name" 2>/dev/null; then # on failure keep the window until enter, so the error stays readable (e.g.
tmux new-session -ds "$selected_name" ssh "$selected" # publickey denied because the yubikey is not inserted); tmux.conf's
# detach-on-destroy then returns to the previous session. bash, not the tmux
# default shell, so read -p behaves
cmd="bash -c 'ssh \"\$@\" || { rc=\$?; echo; read -rp \"ssh exited \$rc, press enter to close \"; }' _$(printf ' %q' "${args[@]}")"
if ! tmux has-session -t="$session" 2>/dev/null; then
tmux new-session -ds "$session" -n "$host" "$cmd"
# tmux.conf's c / " / % bindings read this to open more panes on the same host
if [[ $profile != luks ]]; then
tmux set-option -t "=$session:" @ssh_host "$host"
fi
fi fi
tmux switch-client -t "$selected_name" if [[ -z $TMUX ]]; then
tmux attach-session -t "=$session"
else
tmux switch-client -t "=$session"
fi

View File

@@ -13,6 +13,10 @@ set -g prefix C-s
set -g base-index 1 set -g base-index 1
set -g focus-events on set -g focus-events on
set -g update-environment "SSH_AUTH_SOCK SSH_CONNECTION SSH_TTY DISPLAY" set -g update-environment "SSH_AUTH_SOCK SSH_CONNECTION SSH_TTY DISPLAY"
# when a session dies under the client (e.g. a failed ssh in tmux-ssher) go to
# the most recently used session instead of detaching. not "previous": that
# path crashes the whole server on 3.7b+ (tmux issue 5344, fixed after 3.7c)
set -g detach-on-destroy off
# status bar # status bar
set -g status-style 'bg=default fg=default' set -g status-style 'bg=default fg=default'
@@ -37,6 +41,13 @@ bind j select-pane -D
bind h select-pane -L bind h select-pane -L
bind l select-pane -R bind l select-pane -R
# windows and panes: inside an ssh session (@ssh_host set by tmux-ssher) new
# panes open on the same host over the multiplexed connection.
# new-window/split-window do not expand formats in their command, run-shell does
bind c if-shell -F "#{@ssh_host}" 'run-shell "tmux new-window -t \"#{session_name}\" -n \"#{@ssh_host}\" \"ssh #{@ssh_host}\""' "new-window"
bind '"' if-shell -F "#{@ssh_host}" 'run-shell "tmux split-window -v -t \"#{pane_id}\" \"ssh #{@ssh_host}\""' "split-window -v"
bind % if-shell -F "#{@ssh_host}" 'run-shell "tmux split-window -h -t \"#{pane_id}\" \"ssh #{@ssh_host}\""' "split-window -h"
# reload # reload
bind r source-file "$XDG_CONFIG_HOME/tmux/tmux.conf" bind r source-file "$XDG_CONFIG_HOME/tmux/tmux.conf"