Files
.dotfile/fish/functions/ff-ssh.fish
T

708 lines
25 KiB
Fish
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ff ssh: local init (keys + config.d), copy key + add host, copy config to remote
function __ff_ssh_has_key
for f in $HOME/.ssh/id_*
if test -f $f; and not string match -q '*.pub' -- $f
return 0
end
end
return 1
end
function __ff_ssh_ensure_keys
if __ff_ssh_has_key
echo "SSH key exists, skip generation"
return 0
end
set -l key_path "$HOME/.ssh/id_ed25519"
set -l comment "$USER@"(hostname -s 2>/dev/null; or echo localhost)
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
echo "No SSH key found, generating ed25519: $key_path"
if test -t 0
ssh-keygen -t ed25519 -f $key_path -C $comment; or return 1
else
ssh-keygen -t ed25519 -f $key_path -C $comment -N ""; or return 1
echo "Non-interactive mode: created with empty passphrase"
end
end
function __ff_ssh_has_toplevel_include --argument ssh_config
awk '
tolower($1) == "host" || tolower($1) == "match" { saw_block = 1 }
tolower($1) == "include" {
for (i = 2; i <= NF; i++) {
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
if (!saw_block) found_ok = 1
}
}
}
END { exit(found_ok ? 0 : 1) }
' "$ssh_config"
end
function __ff_ssh_prepend_include --argument ssh_config include_line
set -l tmp (mktemp)
awk -v include_line="$include_line" '
function is_config_d_include() {
if (tolower($1) != "include") {
return 0
}
for (i = 2; i <= NF; i++) {
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
return 1
}
}
return 0
}
BEGIN { print include_line }
is_config_d_include() { next }
{ print }
' "$ssh_config" > $tmp
cp $tmp "$ssh_config"
rm -f $tmp
chmod 600 "$ssh_config"
end
function __ff_ssh_ensure_config_d
set -l ssh_config "$HOME/.ssh/config"
set -l include_line "Include ~/.ssh/config.d/*"
mkdir -p "$HOME/.ssh/config.d"
chmod 700 "$HOME/.ssh"
if not test -f $ssh_config
printf '%s\n' "$include_line" > $ssh_config
chmod 600 $ssh_config
echo "Created $ssh_config with $include_line"
return
end
chmod 600 $ssh_config
if __ff_ssh_has_toplevel_include $ssh_config
echo "config.d Include already in $ssh_config"
return
end
set -l backup "$ssh_config.backup."(date +%Y%m%d%H%M%S)
cp -p $ssh_config $backup
__ff_ssh_prepend_include $ssh_config "$include_line"
echo "Prepended to $ssh_config: $include_line (backup $backup)"
end
function __ff_ssh_resolve_path --argument path
if test -L $path
set path (readlink $path)
if not string match -q '/*' -- $path
set path "$HOME/.ssh/$path"
end
end
echo $path
end
function __ff_ssh_remote_has_include --argument host
set -l script 'test -f ~/.ssh/config && awk "
tolower(\$1) == \"host\" || tolower(\$1) == \"match\" { saw_block = 1 }
tolower(\$1) == \"include\" {
for (i = 2; i <= NF; i++) {
if (tolower(\$i) ~ /(^|\/)config\.d\/\*/) {
if (!saw_block) found_ok = 1
}
}
}
END { exit(found_ok ? 0 : 1) }
" ~/.ssh/config'
printf '%s\n' "$script" | ssh -qT $host sh -s
end
function __ff_ssh_remote_ensure_include --argument host
set -l script 'mkdir -p ~/.ssh/config.d && chmod 700 ~/.ssh
include_line="Include ~/.ssh/config.d/*"
has_toplevel() {
awk "
tolower(\$1) == \"host\" || tolower(\$1) == \"match\" { saw_block = 1 }
tolower(\$1) == \"include\" {
for (i = 2; i <= NF; i++) {
if (tolower(\$i) ~ /(^|\/)config\.d\/\*/) {
if (!saw_block) found_ok = 1
}
}
}
END { exit(found_ok ? 0 : 1) }
" ~/.ssh/config
}
if [ ! -f ~/.ssh/config ]; then
printf "%s\n" "$include_line" > ~/.ssh/config
chmod 600 ~/.ssh/config
echo "Created remote config with config.d Include"
elif has_toplevel; then
echo "Remote config.d Include already exists"
else
tmp=$(mktemp)
awk -v include_line="$include_line" "
function is_config_d_include() {
if (tolower(\$1) != \"include\") return 0
for (i = 2; i <= NF; i++) {
if (tolower(\$i) ~ /(^|\/)config\.d\/\*/) return 1
}
return 0
}
BEGIN { print include_line }
is_config_d_include() { next }
{ print }
" ~/.ssh/config > "$tmp"
cp "$tmp" ~/.ssh/config
rm -f "$tmp"
chmod 600 ~/.ssh/config
echo "Prepended config.d Include on remote"
fi'
printf '%s\n' "$script" | ssh -T $host sh -s
end
function __ff_ssh_resolve_copy_src --argument file
set -l src $file
if test -f $src
echo $src
return 0
end
set -l in_config_d "$HOME/.ssh/config.d/$file"
if test -f $in_config_d
echo $in_config_d
return 0
end
set -l dotfile_src "$HOME/.dotfile/ssh/config.d/$file"
if test -f $dotfile_src
echo $dotfile_src
return 0
end
return 1
end
function __ff_ssh_copy_remote
set -l host $argv[1]
set -l files $argv[2..-1]
set -l ssh_dir "$HOME/.ssh"
set -l main_config "$ssh_dir/config"
if test -z "$host"
echo "Usage: ff ssh copy <host> <config-file>..."
echo " host: SSH alias or user@hostname"
echo " file: path, or name under ~/.ssh/config.d/"
return 1
end
if test (count $files) -eq 0
echo "Usage: ff ssh copy <host> <config-file>..."
echo ""
echo "Examples:"
echo " ff ssh copy mynas 30-hanxi.conf"
echo " ff ssh copy mynas ~/.dotfile/ssh/config.d/20-personal.conf"
if test -d "$ssh_dir/config.d"
echo ""
echo "Available in ~/.ssh/config.d/:"
ls -1 "$ssh_dir/config.d/"
end
return 1
end
echo "Checking remote Include on $host ..."
ssh -q $host "mkdir -p ~/.ssh/config.d && chmod 700 ~/.ssh"
if __ff_ssh_remote_has_include $host
echo "Remote config.d Include: OK"
else
echo "Remote missing config.d Include, adding ..."
__ff_ssh_remote_ensure_include $host
end
for f in $files
set -l src (__ff_ssh_resolve_copy_src $f 2>/dev/null)
if test $status -ne 0 -o -z "$src"
echo "Error: file not found: $f"
return 1
end
set -l resolved_main (__ff_ssh_resolve_path $main_config)
set -l name (basename $src)
if test "$src" = "$resolved_main"; or test "$name" = config
set -l ts (date +%Y%m%d%H%M%S)
ssh -q $host "test -f ~/.ssh/config && cp ~/.ssh/config ~/.ssh/config.bak.$ts || true"
scp -q $src "$host:.ssh/config"
ssh -q $host "chmod 600 ~/.ssh/config"
echo "Copied $src -> $host:~/.ssh/config"
else
scp -q $src "$host:.ssh/config.d/$name"
ssh -q $host "chmod 600 ~/.ssh/config.d/$name"
echo "Copied $src -> $host:~/.ssh/config.d/$name"
end
end
echo "Done."
end
# ---- ssh-style argument parsing -------------------------------------------
# ssh short options that consume a following value
function __ff_ssh_opt_takes_value --argument-names flag
contains -- $flag -b -c -D -E -e -F -I -i -J -L -l -m -O -o -p -Q -R -S -W -w
end
# First non-option argument of `ssh`-style args (the [user@]host)
function __ff_ssh_find_target
set -l n (count $argv)
set -l i 1
while test $i -le $n
set -l a $argv[$i]
if test "$a" = '--'
set i (math $i + 1)
if test $i -le $n
echo $argv[$i]
end
return 0
end
if string match -q -- '-*' $a
set -l flag (string sub -s 1 -l 2 -- $a)
if test (string length -- $a) -eq 2; and __ff_ssh_opt_takes_value $flag
set i (math $i + 2)
continue
end
set i (math $i + 1)
continue
end
echo $a
return 0
end
return 1
end
# Value of a short option, supporting both `-i file` and `-ifile`
function __ff_ssh_argv_value --argument-names flag
set -l args $argv[2..-1]
set -l n (count $args)
set -l flen (string length -- $flag)
set -l i 1
while test $i -le $n
set -l a $args[$i]
if test "$a" = "$flag"
set -l j (math $i + 1)
if test $j -le $n
echo $args[$j]
end
return 0
end
if test (string length -- $a) -gt $flen; and string match -q -- "$flag*" $a
string sub -s (math $flen + 1) -- $a
return 0
end
set i (math $i + 1)
end
return 1
end
# One value from `ssh -G` output (stdin)
function __ff_ssh_gval --argument-names key
while read -l line
set -l trimmed (string trim -- $line)
set -l k (string split -m1 ' ' -- $trimmed)[1]
if test (string lower -- $k) = (string lower -- $key)
string replace -r '^\S+\s+' '' -- $trimmed
return 0
end
end
end
# Files declaring an exact alias; repeated declarations need manual disambiguation.
function __ff_ssh_find_alias_file --argument-names alias_name
set -l files $HOME/.ssh/config
if test -d $HOME/.ssh/config.d
set files $files $HOME/.ssh/config.d/*
end
for f in $files
test -f $f; or continue
awk -v want=(string lower -- $alias_name) '
tolower($1) == "host" {
for (i = 2; i <= NF && $i !~ /^#/; i++) {
if (tolower($i) == want) { print FILENAME; break }
}
}
' $f
end
end
# Replace managed values in place; split shared Host declarations for other aliases.
function __ff_ssh_replace_alias --argument-names alias_name original replacement
awk -v want=(string lower -- $alias_name) -v replacement="$replacement" '
BEGIN {
while ((getline line < replacement) > 0) block = block line "\n"
close(replacement)
}
tolower($1) == "host" || tolower($1) == "match" {
updating = 0
if (tolower($1) == "host") {
hit = 0; others = ""; comment = ""
for (i = 2; i <= NF; i++) {
if ($i ~ /^#/) {
for (; i <= NF; i++) comment = comment " " $i
break
}
if (tolower($i) == want) hit = 1
else others = others " " $i
}
if (hit) {
printf "%s", block
if (others != "") print "Host" others " !" want comment
else {
updating = 1
if (comment != "") print " " comment
}
next
}
}
}
updating && tolower($1) ~ /^(hostname|user|port|identityfile|proxyjump|proxycommand)$/ { next }
{ print }
' "$original"
end
# Normalize ssh -G paths (including explicit .pub) to a private-key path.
function __ff_ssh_private_path --argument-names identity
set identity (string replace -r '^~/' "$HOME/" -- $identity)
set identity (string replace -a '%d' "$HOME" -- $identity)
set identity (string replace -r '\.pub$' '' -- $identity)
if not string match -q '/*' -- $identity
set identity "$PWD/$identity"
end
printf '%s\n' "$identity"
end
# Copy only the selected key; upstream can return 0 after an interrupted filter.
function __ff_ssh_copy_id
set -l target $argv[1]
set -l port $argv[2]
set -l identity $argv[3]
set -l proxyjump $argv[4]
set -l proxycommand $argv[5]
if not type -q ssh-copy-id
echo "ff ssh: 未找到 ssh-copy-id;未写入 Host(可用 --no-copy 只写配置)" >&2
return 1
end
set -l options -o ConnectTimeout=10 -o ConnectionAttempts=1
if test -n "$proxyjump"
set options $options -o "ProxyJump=$proxyjump"
end
if test -n "$proxycommand"
set options $options -o "ProxyCommand=$proxycommand"
end
set -l cmd ssh-copy-id -i "$identity.pub" -p "$port" $options "$target"
echo "复制公钥: $cmd"
# The wrapper records SIGINT even if ssh-copy-id's cleanup trap exits 0.
command sh -c 'trap "exit 130" INT; trap "exit 143" TERM; "$@"' ff-ssh $cmd
if test $status -ne 0
echo "ff ssh: 公钥复制失败或已取消,未写入 Host" >&2
return 1
end
# A skipped-key warning alone proves nothing. Exclude inherited identities,
# certificates and multiplexed sessions; the agent may unlock this key only.
command ssh -F /dev/null -i "$identity" -p "$port" $options \
-o BatchMode=yes -o PreferredAuthentications=publickey \
-o IdentitiesOnly=yes -o CertificateFile=none -o ControlPath=none \
"$target" exit
if test $status -ne 0
echo "ff ssh: 公钥可能已复制,但所选密钥登录验证失败,未写入 Host。加密私钥请先用 ssh-add 解锁,或用 --no-copy 只写配置" >&2
return 1
end
end
# `ff ssh a@b.com` / `ff ssh -p 2222 -i key root@host`
# 确保本地 key -> ssh-copy-id -> 写 ~/.ssh/config.d/<alias>.conf
function __ff_ssh_add_host
set -l args
set -l do_copy 1
for a in $argv
if test "$a" = --no-copy
set do_copy 0
else
set args $args $a
end
end
set -l target (__ff_ssh_find_target $args)
if test -z "$target"
echo "ff ssh: 未找到 [user@]hostname 参数" >&2
return 1
end
# ssh://user@host:port/path -> user / host / port
set -l plain (string replace -r '^ssh://' '' -- $target)
set plain (string replace -r '/.*$' '' -- $plain)
set -l user ""
if string match -q '*@*' -- $plain
set user (string replace -r '@[^@]*$' '' -- $plain)
end
set -l hostport (string replace -r '^.*@' '' -- $plain)
set -l port ""
if string match -q '*:*' -- $hostport
set port (string replace -r '^.*:' '' -- $hostport)
set hostport (string replace -r ':[^:]*$' '' -- $hostport)
end
set -l host $hostport
if test -z "$host"
echo "ff ssh: 无法解析主机名: $target" >&2
return 1
end
test -n "$port"; or set port (__ff_ssh_argv_value -p $args)
set -l alias_name $host
if test -t 0
read -P "SSH 别名 [$host]: " answer
if test $status -ne 0
echo "ff ssh: 已取消,未复制公钥或改写配置" >&2
return 1
end
test -n "$answer"; and set alias_name $answer
end
if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name
echo "ff ssh: 非法别名 '$alias_name'(只允许字母、数字、点、下划线、连字符)" >&2
return 1
end
set -l existing (__ff_ssh_find_alias_file $alias_name)
if test -n "$existing"
if test (count $existing) -ne 1
echo "ff ssh: 别名 '$alias_name' 有多个 Host 声明($existing),请先手动合并;未复制公钥或改写配置" >&2
return 1
end
echo "SSH 别名 '$alias_name' 已存在于 $existing"
if not test -t 0
echo "ff ssh: 非交互模式无法确认覆盖,未复制公钥或改写配置" >&2
return 1
end
read -l -P "覆盖更新 '$alias_name'?[y/N]: " overwrite
if test $status -ne 0
echo "ff ssh: 已取消覆盖,未复制公钥或改写配置" >&2
return 1
end
if not contains -- (string lower -- (string trim -- "$overwrite")) y yes
echo "ff ssh: 未确认覆盖,未复制公钥或改写配置" >&2
return 1
end
else if test -e "$HOME/.ssh/config.d/$alias_name.conf"
echo "ff ssh: 目标文件已存在但未声明 '$alias_name',请先手动整理;未复制公钥或改写配置" >&2
return 1
end
# 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy*
set -l resolved (ssh -G $args 2>/dev/null)
if test $status -ne 0
echo "ff ssh: 无法解析 SSH 配置,未写入 Host" >&2
return 1
end
set -l rhost (printf '%s\n' $resolved | __ff_ssh_gval hostname)
set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user)
set -l rport (printf '%s\n' $resolved | __ff_ssh_gval port)
set -l proxyjump (printf '%s\n' $resolved | __ff_ssh_gval proxyjump)
set -l proxycommand (printf '%s\n' $resolved | __ff_ssh_gval proxycommand)
test -n "$rhost"; or set rhost $host
test -n "$user"; or set user $ruser
test -n "$port"; or set port $rport
test -n "$port"; or set port 22
test "$proxyjump" = none; and set proxyjump ""
test "$proxycommand" = none; and set proxycommand ""
# Explicit -i / -o IdentityFile must never silently fall back to another key.
set -l explicit_identity ""
for i in (seq (count $args))
if test "$args[$i]" = -i; and test $i -lt (count $args)
set explicit_identity $args[(math $i + 1)]
break
else if string match -q -- '-i*' "$args[$i]"
set explicit_identity (string sub -s 3 -- "$args[$i]")
break
end
set -l option ""
if test "$args[$i]" = -o; and test $i -lt (count $args)
set option $args[(math $i + 1)]
else if string match -q -- '-o*' "$args[$i]"
set option (string sub -s 3 -- "$args[$i]")
end
if string match -qri '^identityfile[=[:space:]]' -- "$option"
set explicit_identity (string replace -ri '^identityfile[=[:space:]]+' '' -- "$option")
break
end
end
set -l identities (printf '%s\n' $resolved | string match -ri '^identityfile[[:space:]]+.*$' | string replace -r '^\S+\s+' '')
set -l copy_identity ""
set -l configured_identity 0
if test -n "$explicit_identity"
# ssh -G can omit an unreadable -i and print defaults instead.
set copy_identity (__ff_ssh_private_path "$explicit_identity")
set identities
end
for candidate in $identities
set -l private (__ff_ssh_private_path "$candidate")
set -l name (string replace -- "$HOME/.ssh/" '' "$private")
if not contains -- "$name" id_rsa id_dsa id_ecdsa id_ecdsa_sk id_ed25519 id_ed25519_sk id_xmss
set configured_identity 1
end
if test -r "$private"; and test -r "$private.pub"
set copy_identity "$private"
break
end
end
# ssh-copy-id also supports nonstandard id_*.pub names. Among these, keep
# its newest-public-key preference, but only select a complete readable pair.
if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
for pub in (command ls -t "$HOME"/.ssh/id_*.pub 2>/dev/null)
string match -q '*-cert.pub' -- "$pub"; and continue
set -l private (__ff_ssh_private_path "$pub")
if test -r "$private"; and test -r "$pub"
set copy_identity "$private"
break
end
end
end
if test "$do_copy" = 1
if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
__ff_ssh_ensure_keys; or return 1
set -l generated "$HOME/.ssh/id_ed25519"
if test -r "$generated"; and test -r "$generated.pub"
set copy_identity "$generated"
end
end
if test -z "$copy_identity"; or not test -r "$copy_identity"; or not test -r "$copy_identity.pub"
echo "ff ssh: 未找到可读的公私钥对,请用 -i 指定密钥;未写入 Host" >&2
return 1
end
echo "所选 SSH 密钥: $copy_identity"
set -l copy_target "$rhost"
test -n "$user"; and set copy_target "$user@$rhost"
__ff_ssh_copy_id "$copy_target" "$port" "$copy_identity" "$proxyjump" "$proxycommand"; or return 1
end
set -l identity "$copy_identity"
if string match -q -- "$HOME/*" "$identity"
set identity "~/"(string sub -s (math (string length -- "$HOME") + 2) -- "$identity")
end
set -l dir "$HOME/.ssh/config.d"
set -l conf_file "$dir/$alias_name.conf"
test -n "$existing"; and set conf_file "$existing"
set -l tmpdir /tmp
test -n "$TMPDIR"; and set tmpdir $TMPDIR
set -l tmp (mktemp "$tmpdir/ff-ssh.XXXXXX")
begin
printf 'Host %s\n' $alias_name
printf ' HostName %s\n' $rhost
if test -n "$user"
printf ' User %s\n' $user
end
printf ' Port %s\n' $port
if test -n "$identity"
printf ' IdentityFile "%s"\n' $identity
end
if test -n "$proxyjump"
printf ' ProxyJump %s\n' $proxyjump
end
if test -n "$proxycommand"
printf ' ProxyCommand %s\n' $proxycommand
end
end > $tmp
if test -n "$existing"
set -l updated (mktemp "$tmpdir/ff-ssh.XXXXXX"); or begin
rm -f $tmp
return 1
end
__ff_ssh_replace_alias "$alias_name" "$conf_file" "$tmp" > $updated; or begin
rm -f $tmp $updated
return 1
end
rm -f $tmp
set tmp $updated
# config.d/* would load backups too, so keep them outside that directory.
set -l backup_dir "$HOME/.ssh/config.backups"
mkdir -p "$backup_dir"; or return 1
chmod 700 "$backup_dir"
set -l backup (mktemp "$backup_dir/"(basename "$conf_file")".backup.XXXXXX"); or return 1
cp -p "$conf_file" "$backup"; or begin
rm -f $tmp $backup
return 1
end
echo "原配置备份: $backup"
end
mkdir -p $dir; or return 1
chmod 700 "$HOME/.ssh" $dir
chmod 600 $tmp
mv $tmp $conf_file; or return 1
__ff_ssh_ensure_config_d; or return 1
echo "已写入 SSH 配置: $conf_file"
echo "以后可直接运行: ssh $alias_name"
end
function __ff_ssh_usage
echo "用法: ff ssh [init | <ssh 参数> | add <ssh 参数> | run <ssh 参数> | copy <host> <file>... | help]"
echo ""
echo " (无参数) / init 确保本地 SSH key 存在,并启用 ~/.ssh/config.d Include"
echo " add <ssh 参数> 确保本地 key,ssh-copy-id 后写入 ~/.ssh/config.d/<别名>.conf"
echo " run <ssh 参数> 同 add,写完后立即连接(此时应已免密)"
echo " <ssh 参数> 裸参数等同 add(例: ff ssh -p 2222 root@1.2.3.4)"
echo " copy <host> <file> 检查远端 Include,然后 scp 指定配置文件到远端"
echo " help 显示此帮助"
echo ""
echo "选项:"
echo " --no-copy 只写配置,不执行 ssh-copy-id 或登录验证"
echo " 重复别名需交互确认覆盖(默认否),成功后备份并在原位置更新"
echo " 公钥复制后仅用所选密钥验证登录;连接超时 10 秒,失败或取消不写 Host"
echo " 验证使用 BatchMode;加密私钥请先用 ssh-add 解锁"
echo ""
echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)"
echo ""
echo "示例:"
echo " ff ssh # 初始化本地 key 与 config.d"
echo " ff ssh a@b.com # 复制公钥 + 添加 Host 配置(交互式确认别名)"
echo " ff ssh -p 2222 -i ~/.ssh/id_x root@1.2.3.4"
echo " ff ssh --no-copy a@b.com # 只写配置,不复制公钥"
echo " ff ssh run a@b.com # 复制公钥 + 写配置并立即连接"
echo " ff ssh copy mynas 30-hanxi.conf # 推送配置片段到远端"
end
function ff_ssh
set -l sub (test (count $argv) -ge 1; and echo $argv[1]; or echo init)
switch $sub
case init ''
__ff_ssh_ensure_keys
__ff_ssh_ensure_config_d
case add
__ff_ssh_add_host $argv[2..-1]
case run
set -l run_args
for a in $argv[2..-1]
test "$a" = --no-copy; and continue
set run_args $run_args $a
end
__ff_ssh_add_host $argv[2..-1]; or return 1
command ssh $run_args
case copy
__ff_ssh_copy_remote $argv[2..-1]
case help -h --help
__ff_ssh_usage
case '*'
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
if __ff_ssh_find_target $argv >/dev/null
__ff_ssh_add_host $argv
else
command ssh $argv
end
end
end