fix: confirm SSH alias updates and keep key handling consistent

This commit is contained in:
2026-10-19 16:57:44 +08:00
parent 59e2356f5c
commit 39cf5c6d19
2 changed files with 696 additions and 58 deletions
+208 -58
View File
@@ -22,9 +22,9 @@ function __ff_ssh_ensure_keys
echo "No SSH key found, generating ed25519: $key_path"
if test -t 0
ssh-keygen -t ed25519 -f $key_path -C $comment
ssh-keygen -t ed25519 -f $key_path -C $comment; or return 1
else
ssh-keygen -t ed25519 -f $key_path -C $comment -N ""
ssh-keygen -t ed25519 -f $key_path -C $comment -N ""; or return 1
echo "Non-interactive mode: created with empty passphrase"
end
end
@@ -313,7 +313,7 @@ function __ff_ssh_gval --argument-names key
end
end
# File that already declares `Host <alias>` (main config or config.d)
# 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
@@ -321,21 +321,63 @@ function __ff_ssh_find_alias_file --argument-names alias_name
end
for f in $files
test -f $f; or continue
if awk -v want=(string lower -- $alias_name) '
tolower($1) == "host" {
for (i = 2; i <= NF; i++)
if (tolower($i) == want) hit = 1
awk -v want=(string lower -- $alias_name) '
tolower($1) == "host" {
for (i = 2; i <= NF && $i !~ /^#/; i++) {
if (tolower($i) == want) { print FILENAME; break }
}
END { exit(hit ? 0 : 1) }
' $f
echo $f
return 0
end
}
' $f
end
return 1
end
# 复制公钥到远端(ssh-copy-id),失败不阻断配置写入
# 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]
@@ -344,27 +386,36 @@ function __ff_ssh_copy_id
set -l proxycommand $argv[5]
if not type -q ssh-copy-id
echo "ff ssh: 未找到 ssh-copy-id,跳过公钥复制(可手动执行 ssh-copy-id $target)" >&2
echo "ff ssh: 未找到 ssh-copy-id;未写入 Host(可用 --no-copy 只写配置)" >&2
return 1
end
set -l cmd ssh-copy-id
if test -n "$port"; and test "$port" != 22
set cmd $cmd -p $port
end
if test -n "$identity"
set cmd $cmd -i $identity
end
set -l options -o ConnectTimeout=10 -o ConnectionAttempts=1
if test -n "$proxyjump"
set cmd $cmd -o "ProxyJump=$proxyjump"
set options $options -o "ProxyJump=$proxyjump"
end
if test -n "$proxycommand"
set cmd $cmd -o "ProxyCommand=$proxycommand"
set options $options -o "ProxyCommand=$proxycommand"
end
set cmd $cmd $target
set -l cmd ssh-copy-id -i "$identity.pub" -p "$port" $options "$target"
echo "复制公钥: $cmd"
$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`
@@ -409,9 +460,11 @@ function __ff_ssh_add_host
set -l alias_name $host
if test -t 0
read -P "SSH 别名 [$host]: " answer
if test $status -eq 0; and test -n "$answer"
set alias_name $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
@@ -420,12 +473,35 @@ function __ff_ssh_add_host
set -l existing (__ff_ssh_find_alias_file $alias_name)
if test -n "$existing"
echo "SSH 别名 '$alias_name' 已存在于 $existing,跳过写入"
return 0
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)
@@ -438,38 +514,87 @@ function __ff_ssh_add_host
test "$proxyjump" = none; and set proxyjump ""
test "$proxycommand" = none; and set proxycommand ""
# IdentityFile: ssh -G 会列出所有默认密钥,仅在显式指定时记录
set -l identity ""
set -l explicit_identity (__ff_ssh_argv_value -i $args)
# 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"
set identity $explicit_identity
else if string match -q '*IdentityFile*' -- $args
set identity (printf '%s\n' $resolved | __ff_ssh_gval identityfile)
# ssh -G can omit an unreadable -i and print defaults instead.
set copy_identity (__ff_ssh_private_path "$explicit_identity")
set identities
end
if string match -q -- "$HOME/*" "$identity"
set identity "~/"(string replace -r "^$HOME/" '' -- $identity)
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 也要用同一把密钥(-i 或 -o IdentityFile=),否则会复制默认 key
set -l copy_identity $explicit_identity
if test -z "$copy_identity"; and test -n "$identity"
set copy_identity (string replace -r '^~/' "$HOME/" -- $identity)
# 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
# 先复制公钥,这样写完配置就能直接 ssh
if test "$do_copy" = 1
__ff_ssh_ensure_keys
__ff_ssh_copy_id "$target" "$port" "$copy_identity" "$proxyjump" "$proxycommand"
or echo "ff ssh: 公钥复制失败,已继续写入配置(可重试: ff ssh add $target)" >&2
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"
mkdir -p $dir
chmod 700 "$HOME/.ssh" $dir
set -l conf_file "$dir/$alias_name.conf"
if test -f $conf_file
cp -p $conf_file "$conf_file.backup."(date +%Y%m%d%H%M%S)
end
test -n "$existing"; and set conf_file "$existing"
set -l tmpdir /tmp
test -n "$TMPDIR"; and set tmpdir $TMPDIR
@@ -482,7 +607,7 @@ function __ff_ssh_add_host
end
printf ' Port %s\n' $port
if test -n "$identity"
printf ' IdentityFile %s\n' $identity
printf ' IdentityFile "%s"\n' $identity
end
if test -n "$proxyjump"
printf ' ProxyJump %s\n' $proxyjump
@@ -491,8 +616,33 @@ function __ff_ssh_add_host
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
mv $tmp $conf_file; or return 1
__ff_ssh_ensure_config_d; or return 1
echo "已写入 SSH 配置: $conf_file"
echo "以后可直接运行: ssh $alias_name"
@@ -509,7 +659,10 @@ function __ff_ssh_usage
echo " help 显示此帮助"
echo ""
echo "选项:"
echo " --no-copy 只写配置,不执行 ssh-copy-id"
echo " --no-copy 只写配置,不执行 ssh-copy-id 或登录验证"
echo " 重复别名需交互确认覆盖(默认否),成功后备份并在原位置更新"
echo " 公钥复制后仅用所选密钥验证登录;连接超时 10 秒,失败或取消不写 Host"
echo " 验证使用 BatchMode;加密私钥请先用 ssh-add 解锁"
echo ""
echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)"
echo ""
@@ -530,7 +683,6 @@ function ff_ssh
__ff_ssh_ensure_keys
__ff_ssh_ensure_config_d
case add
__ff_ssh_ensure_config_d
__ff_ssh_add_host $argv[2..-1]
case run
set -l run_args
@@ -538,7 +690,6 @@ function ff_ssh
test "$a" = --no-copy; and continue
set run_args $run_args $a
end
__ff_ssh_ensure_config_d
__ff_ssh_add_host $argv[2..-1]; or return 1
command ssh $run_args
case copy
@@ -548,7 +699,6 @@ function ff_ssh
case '*'
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
if __ff_ssh_find_target $argv >/dev/null
__ff_ssh_ensure_config_d
__ff_ssh_add_host $argv
else
command ssh $argv