fix: confirm SSH alias updates and keep key handling consistent
This commit is contained in:
+206
-56
@@ -22,9 +22,9 @@ function __ff_ssh_ensure_keys
|
|||||||
|
|
||||||
echo "No SSH key found, generating ed25519: $key_path"
|
echo "No SSH key found, generating ed25519: $key_path"
|
||||||
if test -t 0
|
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
|
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"
|
echo "Non-interactive mode: created with empty passphrase"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -313,7 +313,7 @@ function __ff_ssh_gval --argument-names key
|
|||||||
end
|
end
|
||||||
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
|
function __ff_ssh_find_alias_file --argument-names alias_name
|
||||||
set -l files $HOME/.ssh/config
|
set -l files $HOME/.ssh/config
|
||||||
if test -d $HOME/.ssh/config.d
|
if test -d $HOME/.ssh/config.d
|
||||||
@@ -321,21 +321,63 @@ function __ff_ssh_find_alias_file --argument-names alias_name
|
|||||||
end
|
end
|
||||||
for f in $files
|
for f in $files
|
||||||
test -f $f; or continue
|
test -f $f; or continue
|
||||||
if awk -v want=(string lower -- $alias_name) '
|
awk -v want=(string lower -- $alias_name) '
|
||||||
tolower($1) == "host" {
|
tolower($1) == "host" {
|
||||||
for (i = 2; i <= NF; i++)
|
for (i = 2; i <= NF && $i !~ /^#/; i++) {
|
||||||
if (tolower($i) == want) hit = 1
|
if (tolower($i) == want) { print FILENAME; break }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
END { exit(hit ? 0 : 1) }
|
|
||||||
' $f
|
' $f
|
||||||
echo $f
|
|
||||||
return 0
|
|
||||||
end
|
end
|
||||||
end
|
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
|
function __ff_ssh_copy_id
|
||||||
set -l target $argv[1]
|
set -l target $argv[1]
|
||||||
set -l port $argv[2]
|
set -l port $argv[2]
|
||||||
@@ -344,27 +386,36 @@ function __ff_ssh_copy_id
|
|||||||
set -l proxycommand $argv[5]
|
set -l proxycommand $argv[5]
|
||||||
|
|
||||||
if not type -q ssh-copy-id
|
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
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
set -l cmd ssh-copy-id
|
set -l options -o ConnectTimeout=10 -o ConnectionAttempts=1
|
||||||
if test -n "$port"; and test "$port" != 22
|
|
||||||
set cmd $cmd -p $port
|
|
||||||
end
|
|
||||||
if test -n "$identity"
|
|
||||||
set cmd $cmd -i $identity
|
|
||||||
end
|
|
||||||
if test -n "$proxyjump"
|
if test -n "$proxyjump"
|
||||||
set cmd $cmd -o "ProxyJump=$proxyjump"
|
set options $options -o "ProxyJump=$proxyjump"
|
||||||
end
|
end
|
||||||
if test -n "$proxycommand"
|
if test -n "$proxycommand"
|
||||||
set cmd $cmd -o "ProxyCommand=$proxycommand"
|
set options $options -o "ProxyCommand=$proxycommand"
|
||||||
end
|
end
|
||||||
set cmd $cmd $target
|
set -l cmd ssh-copy-id -i "$identity.pub" -p "$port" $options "$target"
|
||||||
|
|
||||||
echo "复制公钥: $cmd"
|
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
|
end
|
||||||
|
|
||||||
# `ff ssh a@b.com` / `ff ssh -p 2222 -i key root@host`
|
# `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
|
set -l alias_name $host
|
||||||
if test -t 0
|
if test -t 0
|
||||||
read -P "SSH 别名 [$host]: " answer
|
read -P "SSH 别名 [$host]: " answer
|
||||||
if test $status -eq 0; and test -n "$answer"
|
if test $status -ne 0
|
||||||
set alias_name $answer
|
echo "ff ssh: 已取消,未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
end
|
end
|
||||||
|
test -n "$answer"; and set alias_name $answer
|
||||||
end
|
end
|
||||||
if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name
|
if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name
|
||||||
echo "ff ssh: 非法别名 '$alias_name'(只允许字母、数字、点、下划线、连字符)" >&2
|
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)
|
set -l existing (__ff_ssh_find_alias_file $alias_name)
|
||||||
if test -n "$existing"
|
if test -n "$existing"
|
||||||
echo "SSH 别名 '$alias_name' 已存在于 $existing,跳过写入"
|
if test (count $existing) -ne 1
|
||||||
return 0
|
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
|
end
|
||||||
|
|
||||||
# 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy*
|
# 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy*
|
||||||
set -l resolved (ssh -G $args 2>/dev/null)
|
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 rhost (printf '%s\n' $resolved | __ff_ssh_gval hostname)
|
||||||
set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user)
|
set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user)
|
||||||
set -l rport (printf '%s\n' $resolved | __ff_ssh_gval port)
|
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 "$proxyjump" = none; and set proxyjump ""
|
||||||
test "$proxycommand" = none; and set proxycommand ""
|
test "$proxycommand" = none; and set proxycommand ""
|
||||||
|
|
||||||
# IdentityFile: ssh -G 会列出所有默认密钥,仅在显式指定时记录
|
# Explicit -i / -o IdentityFile must never silently fall back to another key.
|
||||||
set -l identity ""
|
set -l explicit_identity ""
|
||||||
set -l explicit_identity (__ff_ssh_argv_value -i $args)
|
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"
|
if test -n "$explicit_identity"
|
||||||
set identity $explicit_identity
|
# ssh -G can omit an unreadable -i and print defaults instead.
|
||||||
else if string match -q '*IdentityFile*' -- $args
|
set copy_identity (__ff_ssh_private_path "$explicit_identity")
|
||||||
set identity (printf '%s\n' $resolved | __ff_ssh_gval identityfile)
|
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
|
||||||
if string match -q -- "$HOME/*" "$identity"
|
|
||||||
set identity "~/"(string replace -r "^$HOME/" '' -- $identity)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# ssh-copy-id 也要用同一把密钥(-i 或 -o IdentityFile=),否则会复制默认 key
|
# ssh-copy-id also supports nonstandard id_*.pub names. Among these, keep
|
||||||
set -l copy_identity $explicit_identity
|
# its newest-public-key preference, but only select a complete readable pair.
|
||||||
if test -z "$copy_identity"; and test -n "$identity"
|
if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
|
||||||
set copy_identity (string replace -r '^~/' "$HOME/" -- $identity)
|
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
|
end
|
||||||
|
|
||||||
# 先复制公钥,这样写完配置就能直接 ssh
|
|
||||||
if test "$do_copy" = 1
|
if test "$do_copy" = 1
|
||||||
__ff_ssh_ensure_keys
|
if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
|
||||||
__ff_ssh_copy_id "$target" "$port" "$copy_identity" "$proxyjump" "$proxycommand"
|
__ff_ssh_ensure_keys; or return 1
|
||||||
or echo "ff ssh: 公钥复制失败,已继续写入配置(可重试: ff ssh add $target)" >&2
|
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
|
end
|
||||||
|
|
||||||
set -l dir "$HOME/.ssh/config.d"
|
set -l dir "$HOME/.ssh/config.d"
|
||||||
mkdir -p $dir
|
|
||||||
chmod 700 "$HOME/.ssh" $dir
|
|
||||||
set -l conf_file "$dir/$alias_name.conf"
|
set -l conf_file "$dir/$alias_name.conf"
|
||||||
if test -f $conf_file
|
test -n "$existing"; and set conf_file "$existing"
|
||||||
cp -p $conf_file "$conf_file.backup."(date +%Y%m%d%H%M%S)
|
|
||||||
end
|
|
||||||
|
|
||||||
set -l tmpdir /tmp
|
set -l tmpdir /tmp
|
||||||
test -n "$TMPDIR"; and set tmpdir $TMPDIR
|
test -n "$TMPDIR"; and set tmpdir $TMPDIR
|
||||||
@@ -482,7 +607,7 @@ function __ff_ssh_add_host
|
|||||||
end
|
end
|
||||||
printf ' Port %s\n' $port
|
printf ' Port %s\n' $port
|
||||||
if test -n "$identity"
|
if test -n "$identity"
|
||||||
printf ' IdentityFile %s\n' $identity
|
printf ' IdentityFile "%s"\n' $identity
|
||||||
end
|
end
|
||||||
if test -n "$proxyjump"
|
if test -n "$proxyjump"
|
||||||
printf ' ProxyJump %s\n' $proxyjump
|
printf ' ProxyJump %s\n' $proxyjump
|
||||||
@@ -491,8 +616,33 @@ function __ff_ssh_add_host
|
|||||||
printf ' ProxyCommand %s\n' $proxycommand
|
printf ' ProxyCommand %s\n' $proxycommand
|
||||||
end
|
end
|
||||||
end > $tmp
|
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
|
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 配置: $conf_file"
|
||||||
echo "以后可直接运行: ssh $alias_name"
|
echo "以后可直接运行: ssh $alias_name"
|
||||||
@@ -509,7 +659,10 @@ function __ff_ssh_usage
|
|||||||
echo " help 显示此帮助"
|
echo " help 显示此帮助"
|
||||||
echo ""
|
echo ""
|
||||||
echo "选项:"
|
echo "选项:"
|
||||||
echo " --no-copy 只写配置,不执行 ssh-copy-id"
|
echo " --no-copy 只写配置,不执行 ssh-copy-id 或登录验证"
|
||||||
|
echo " 重复别名需交互确认覆盖(默认否),成功后备份并在原位置更新"
|
||||||
|
echo " 公钥复制后仅用所选密钥验证登录;连接超时 10 秒,失败或取消不写 Host"
|
||||||
|
echo " 验证使用 BatchMode;加密私钥请先用 ssh-add 解锁"
|
||||||
echo ""
|
echo ""
|
||||||
echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)"
|
echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -530,7 +683,6 @@ function ff_ssh
|
|||||||
__ff_ssh_ensure_keys
|
__ff_ssh_ensure_keys
|
||||||
__ff_ssh_ensure_config_d
|
__ff_ssh_ensure_config_d
|
||||||
case add
|
case add
|
||||||
__ff_ssh_ensure_config_d
|
|
||||||
__ff_ssh_add_host $argv[2..-1]
|
__ff_ssh_add_host $argv[2..-1]
|
||||||
case run
|
case run
|
||||||
set -l run_args
|
set -l run_args
|
||||||
@@ -538,7 +690,6 @@ function ff_ssh
|
|||||||
test "$a" = --no-copy; and continue
|
test "$a" = --no-copy; and continue
|
||||||
set run_args $run_args $a
|
set run_args $run_args $a
|
||||||
end
|
end
|
||||||
__ff_ssh_ensure_config_d
|
|
||||||
__ff_ssh_add_host $argv[2..-1]; or return 1
|
__ff_ssh_add_host $argv[2..-1]; or return 1
|
||||||
command ssh $run_args
|
command ssh $run_args
|
||||||
case copy
|
case copy
|
||||||
@@ -548,7 +699,6 @@ function ff_ssh
|
|||||||
case '*'
|
case '*'
|
||||||
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
|
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
|
||||||
if __ff_ssh_find_target $argv >/dev/null
|
if __ff_ssh_find_target $argv >/dev/null
|
||||||
__ff_ssh_ensure_config_d
|
|
||||||
__ff_ssh_add_host $argv
|
__ff_ssh_add_host $argv
|
||||||
else
|
else
|
||||||
command ssh $argv
|
command ssh $argv
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Isolated ff ssh regressions: dummy keys and mock commands, no network access."""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import selectors
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REAL_SSH = shutil.which("ssh")
|
||||||
|
SOURCE = Path(os.environ.get("FF_SSH_TEST_SOURCE", ROOT / "fish/functions/ff-ssh.fish"))
|
||||||
|
MOCK = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, pathlib, signal, sys, time
|
||||||
|
home = pathlib.Path(os.environ["HOME"])
|
||||||
|
name = pathlib.Path(sys.argv[0]).name
|
||||||
|
with (home / "calls.jsonl").open("a") as f:
|
||||||
|
f.write(json.dumps([name, sys.argv[1:]]) + "\n")
|
||||||
|
if name == "ssh":
|
||||||
|
if "-G" in sys.argv:
|
||||||
|
print((home / "resolved").read_text(), end="")
|
||||||
|
sys.exit(int(os.environ.get("RESOLVE_STATUS", "0")))
|
||||||
|
sys.exit(int(os.environ.get("VERIFY_STATUS", "0")))
|
||||||
|
if name == "ssh-keygen":
|
||||||
|
if os.environ.get("KEYGEN_FAIL"):
|
||||||
|
sys.exit(1)
|
||||||
|
key = pathlib.Path(sys.argv[sys.argv.index("-f") + 1])
|
||||||
|
key.write_text("dummy private\n")
|
||||||
|
key.with_name(key.name + ".pub").write_text("dummy public\n")
|
||||||
|
sys.exit(0)
|
||||||
|
mode = os.environ.get("COPY_MODE", "success")
|
||||||
|
if mode == "cancel-zero":
|
||||||
|
def cancel(*_):
|
||||||
|
print("ERROR: Assertion failure: in filter_ids(): scratch_dir was not correctly set up", file=sys.stderr)
|
||||||
|
print("WARNING: All keys were skipped because they already exist", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGINT, cancel)
|
||||||
|
(home / "ready").touch()
|
||||||
|
time.sleep(15)
|
||||||
|
if mode == "false-zero":
|
||||||
|
print("ERROR: Assertion failure: in filter_ids(): scratch_dir was not correctly set up", file=sys.stderr)
|
||||||
|
print("WARNING: All keys were skipped because they already exist", file=sys.stderr)
|
||||||
|
if mode in ("failure", "timeout"):
|
||||||
|
print("Connection timed out" if mode == "timeout" else "Permission denied", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class SshTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(prefix="ff-ssh-test-")
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.home = Path(self.temp.name)
|
||||||
|
self.ssh = self.home / ".ssh"
|
||||||
|
self.ssh.mkdir()
|
||||||
|
bindir = self.home / "bin"
|
||||||
|
bindir.mkdir()
|
||||||
|
for name in ("ssh", "ssh-copy-id", "ssh-keygen"):
|
||||||
|
path = bindir / name
|
||||||
|
path.write_text(MOCK)
|
||||||
|
path.chmod(0o755)
|
||||||
|
self.env = dict(os.environ, HOME=str(self.home),
|
||||||
|
XDG_CONFIG_HOME=str(self.home / ".config"),
|
||||||
|
PATH=f"{bindir}:{os.environ['PATH']}", USER="test")
|
||||||
|
self.resolve("~/.ssh/id_rsa", "~/.ssh/id_ed25519")
|
||||||
|
|
||||||
|
def resolve(self, *identities):
|
||||||
|
(self.home / "resolved").write_text(
|
||||||
|
"hostname machine.invalid\nuser testuser\nport 2222\nproxyjump none\nproxycommand none\n" +
|
||||||
|
"".join(f"identityfile {key}\n" for key in identities))
|
||||||
|
|
||||||
|
def key(self, name):
|
||||||
|
private = self.ssh / name
|
||||||
|
private.write_text("dummy private\n")
|
||||||
|
private.with_name(name + ".pub").write_text("dummy public\n")
|
||||||
|
return private
|
||||||
|
|
||||||
|
def launch(self, args):
|
||||||
|
return subprocess.Popen(
|
||||||
|
["fish", "--no-config", "-c", 'source "$argv[1]"; ff_ssh $argv[2..-1]',
|
||||||
|
str(SOURCE), *args], env=self.env, stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||||
|
|
||||||
|
def run_ff(self, *args):
|
||||||
|
proc = self.launch(args)
|
||||||
|
try:
|
||||||
|
out, err = proc.communicate(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
proc.communicate()
|
||||||
|
raise
|
||||||
|
return proc.returncode, out + err
|
||||||
|
|
||||||
|
def calls(self, name):
|
||||||
|
path = self.home / "calls.jsonl"
|
||||||
|
return [args for cmd, args in map(json.loads, path.read_text().splitlines()) if cmd == name] if path.exists() else []
|
||||||
|
|
||||||
|
def assert_selected(self, private):
|
||||||
|
copy = self.calls("ssh-copy-id")
|
||||||
|
self.assertEqual(len(copy), 1)
|
||||||
|
chosen = copy[0][copy[0].index("-i") + 1]
|
||||||
|
self.assertEqual(str(private), chosen.removesuffix(".pub"))
|
||||||
|
config = (self.ssh / "config.d/machine.invalid.conf").read_text()
|
||||||
|
identities = [line.strip().split(maxsplit=1)[1].strip('"') for line in config.splitlines() if line.strip().startswith("IdentityFile ")]
|
||||||
|
self.assertEqual(identities, [str(private).replace(str(self.home), "~", 1)])
|
||||||
|
|
||||||
|
def assert_no_host(self):
|
||||||
|
self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
|
||||||
|
def test_default_matches_resolved_order_with_multiple_keys(self):
|
||||||
|
selected = self.key("id_rsa")
|
||||||
|
self.key("id_ed25519")
|
||||||
|
status, output = self.run_ff("testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_nonstandard_default_key(self):
|
||||||
|
selected = self.key("id_hx_new")
|
||||||
|
status, output = self.run_ff("testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_explicit_private_and_public_paths(self):
|
||||||
|
for suffix in ("", ".pub"):
|
||||||
|
with self.subTest(suffix=suffix):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.resolve(str(selected) + suffix, "~/.ssh/id_rsa")
|
||||||
|
status, output = self.run_ff("-i", str(selected) + suffix, "testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_identity_option_case_and_forms(self):
|
||||||
|
for option in ("IdentityFile=", "identityfile "):
|
||||||
|
with self.subTest(option=option):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.resolve(str(selected), "~/.ssh/id_rsa")
|
||||||
|
status, output = self.run_ff("-o", option + str(selected), "testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_existing_config_identity(self):
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.resolve(str(selected))
|
||||||
|
status, output = self.run_ff("testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_invalid_explicit_identity_never_falls_back(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
# OpenSSH omits an unreadable -i from ssh -G, leaving defaults.
|
||||||
|
self.resolve("~/.ssh/id_rsa")
|
||||||
|
status, output = self.run_ff("-i", "/missing/key", "testuser@machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
def test_unavailable_config_identity_never_falls_back(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.resolve("~/.ssh/custom_missing")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
def test_unpaired_default_key_is_not_overwritten(self):
|
||||||
|
path = self.ssh / "id_ed25519"
|
||||||
|
path.write_text("existing private")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertEqual(path.read_text(), "existing private")
|
||||||
|
self.assert_no_host()
|
||||||
|
|
||||||
|
def test_generation_then_copy_same_key(self):
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(self.ssh / "id_ed25519")
|
||||||
|
|
||||||
|
def test_generation_failure_aborts(self):
|
||||||
|
self.env["KEYGEN_FAIL"] = "1"
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
def test_copy_failure_and_timeout_abort(self):
|
||||||
|
for mode in ("failure", "timeout"):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env["COPY_MODE"] = mode
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
|
||||||
|
def test_false_success_requires_selected_key_verification(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env.update(COPY_MODE="false-zero", VERIFY_STATUS="255")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
|
||||||
|
def test_ctrl_c_with_upstream_exit_zero_aborts(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env["COPY_MODE"] = "cancel-zero"
|
||||||
|
proc = self.launch(["machine.invalid"])
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while not (self.home / "ready").exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(.02)
|
||||||
|
self.assertTrue((self.home / "ready").exists(), "mock copy must reach cancellable stage")
|
||||||
|
os.killpg(proc.pid, signal.SIGINT)
|
||||||
|
out, err = proc.communicate(timeout=5)
|
||||||
|
self.assertNotEqual(proc.returncode, 0, out + err)
|
||||||
|
self.assert_no_host()
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
proc.communicate()
|
||||||
|
|
||||||
|
def test_no_copy_never_generates_or_connects(self):
|
||||||
|
status, output = self.run_ff("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertTrue((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
self.assertFalse(self.calls("ssh-keygen"))
|
||||||
|
self.assertTrue(all("-G" in call for call in self.calls("ssh")))
|
||||||
|
|
||||||
|
def test_no_copy_public_identity_records_private(self):
|
||||||
|
private = self.key("custom")
|
||||||
|
self.resolve(str(private) + ".pub")
|
||||||
|
status, output = self.run_ff("--no-copy", "-i", str(private) + ".pub", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
config = (self.ssh / "config.d/machine.invalid.conf").read_text()
|
||||||
|
self.assertIn("~/.ssh/custom", config)
|
||||||
|
self.assertNotIn("custom.pub", config)
|
||||||
|
|
||||||
|
def existing(self, location="config.d/machine.invalid.conf", shared=False):
|
||||||
|
path = self.ssh / location
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = ("# original settings\nHost " + ("machine.invalid neighbor" if shared else "machine.invalid") +
|
||||||
|
"\n HostName old.invalid\n User olduser\n Port 22\n"
|
||||||
|
" IdentityFile ~/.ssh/old\n ForwardAgent no\n"
|
||||||
|
"Host untouched\n HostName untouched.invalid\n")
|
||||||
|
path.write_text(content)
|
||||||
|
if location != "config":
|
||||||
|
(self.ssh / "config").write_text(f"Include {self.ssh}/config.d/*\n")
|
||||||
|
return path, content
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
return {str(p.relative_to(self.ssh)): p.read_bytes()
|
||||||
|
for p in self.ssh.rglob("*") if p.is_file()}
|
||||||
|
|
||||||
|
def interactive(self, *args, answer="y", cancel_at=None):
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["fish", "--no-config", "-c", 'source "$argv[1]"; ff_ssh $argv[2..-1]',
|
||||||
|
str(SOURCE), *args], env=self.env, stdin=slave,
|
||||||
|
stdout=slave, stderr=slave, start_new_session=True,
|
||||||
|
preexec_fn=lambda: fcntl.ioctl(slave, termios.TIOCSCTTY, 0))
|
||||||
|
os.close(slave)
|
||||||
|
output = b""
|
||||||
|
selector = selectors.DefaultSelector()
|
||||||
|
selector.register(master, selectors.EVENT_READ)
|
||||||
|
|
||||||
|
def until(prompt):
|
||||||
|
nonlocal output
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while prompt.encode() not in output and time.monotonic() < deadline:
|
||||||
|
for key, _ in selector.select(.1):
|
||||||
|
chunk = os.read(key.fileobj, 65536)
|
||||||
|
if not chunk:
|
||||||
|
self.fail("process ended before prompt: " + output.decode(errors="replace"))
|
||||||
|
output += chunk
|
||||||
|
self.assertIn(prompt, output.decode(errors="replace"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
until("SSH 别名")
|
||||||
|
time.sleep(.1) # let fish finish entering terminal input mode
|
||||||
|
if cancel_at == "alias":
|
||||||
|
os.write(master, b"\x04")
|
||||||
|
else:
|
||||||
|
os.write(master, b"\r")
|
||||||
|
until("[y/N]")
|
||||||
|
time.sleep(.1)
|
||||||
|
if cancel_at == "interrupt":
|
||||||
|
os.killpg(proc.pid, signal.SIGINT)
|
||||||
|
elif cancel_at == "eof":
|
||||||
|
os.write(master, b"\x04")
|
||||||
|
else:
|
||||||
|
os.write(master, (answer + "\r").encode())
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
ready = selector.select(.1)
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
chunk = os.read(master, 65536)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output += chunk
|
||||||
|
elif proc.poll() is not None:
|
||||||
|
break
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
return proc.returncode, output.decode(errors="replace")
|
||||||
|
finally:
|
||||||
|
selector.close()
|
||||||
|
os.close(master)
|
||||||
|
if proc.poll() is None:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
proc.communicate()
|
||||||
|
|
||||||
|
def assert_unchanged(self, before):
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
self.assertFalse(self.calls("ssh-keygen"))
|
||||||
|
|
||||||
|
def assert_backup(self, content):
|
||||||
|
backups = list((self.ssh / "config.backups").glob("*.backup.*"))
|
||||||
|
self.assertEqual(len(backups), 1)
|
||||||
|
self.assertEqual(backups[0].read_text(), content)
|
||||||
|
self.assertEqual((self.ssh / "config.backups").stat().st_mode & 0o777, 0o700)
|
||||||
|
self.assertFalse(list((self.ssh / "config.d").glob("*.backup.*")))
|
||||||
|
|
||||||
|
def effective(self, host):
|
||||||
|
# -G is local only; absolute Include plus -F avoid the real SSH config.
|
||||||
|
result = subprocess.run([REAL_SSH, "-G", "-F", str(self.ssh / "config"), host],
|
||||||
|
env=self.env, capture_output=True, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
return dict(line.split(" ", 1) for line in result.stdout.splitlines())
|
||||||
|
|
||||||
|
def test_existing_alias_is_actionable_failure_and_preserved(self):
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("非交互", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
self.assertFalse(self.calls("ssh"))
|
||||||
|
|
||||||
|
def test_overwrite_accepts_only_explicit_yes(self):
|
||||||
|
for answer in ("n", "", "sure", "yes please"):
|
||||||
|
with self.subTest(answer=answer):
|
||||||
|
self.setUp()
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.interactive("machine.invalid", answer=answer)
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("未确认覆盖", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
self.assertFalse(self.calls("ssh"))
|
||||||
|
|
||||||
|
def test_overwrite_eof_and_ctrl_c_preserve_everything(self):
|
||||||
|
for cancel in ("eof", "interrupt", "alias"):
|
||||||
|
with self.subTest(cancel=cancel):
|
||||||
|
self.setUp()
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.interactive("machine.invalid", cancel_at=cancel)
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("取消", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
|
||||||
|
def test_overwrite_yes_copies_then_backs_up_and_updates(self):
|
||||||
|
for answer in ("y", "YES"):
|
||||||
|
with self.subTest(answer=answer):
|
||||||
|
self.setUp()
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.resolve(str(selected))
|
||||||
|
path, original = self.existing()
|
||||||
|
status, output = self.interactive("newuser@machine.invalid", "-p", "2200", answer=answer)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn(str(path), output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
self.assert_backup(original)
|
||||||
|
updated = path.read_text()
|
||||||
|
for value in ("HostName machine.invalid", "User newuser", "Port 2200", "ForwardAgent no",
|
||||||
|
"Host untouched\n HostName untouched.invalid"):
|
||||||
|
self.assertIn(value, updated)
|
||||||
|
self.assertNotIn("old.invalid", updated)
|
||||||
|
self.assertNotIn("~/.ssh/old", updated)
|
||||||
|
|
||||||
|
def test_overwrite_no_copy_in_shared_nonmatching_filename(self):
|
||||||
|
path, original = self.existing("config.d/20-shared.conf", shared=True)
|
||||||
|
status, output = self.interactive("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_backup(original)
|
||||||
|
self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
self.assertFalse(self.calls("ssh-keygen"))
|
||||||
|
self.assertTrue(all("-G" in call for call in self.calls("ssh")))
|
||||||
|
self.assertEqual(self.effective("machine.invalid")["hostname"], "machine.invalid")
|
||||||
|
self.assertEqual(self.effective("neighbor")["hostname"], "old.invalid")
|
||||||
|
self.assertEqual(self.effective("neighbor")["identityfile"], "~/.ssh/old")
|
||||||
|
self.assertEqual(self.effective("untouched")["hostname"], "untouched.invalid")
|
||||||
|
self.assertIn("Host neighbor !machine.invalid", path.read_text())
|
||||||
|
|
||||||
|
def test_overwrite_shared_wildcard_excludes_target(self):
|
||||||
|
path, original = self.existing("config.d/20-shared.conf", shared=True)
|
||||||
|
original = original.replace("machine.invalid neighbor", "machine.invalid neighbor *")
|
||||||
|
path.write_text(original)
|
||||||
|
status, output = self.interactive("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_backup(original)
|
||||||
|
self.assertEqual(self.effective("machine.invalid")["hostname"], "machine.invalid")
|
||||||
|
self.assertNotIn("~/.ssh/old", self.effective("machine.invalid")["identityfile"])
|
||||||
|
self.assertEqual(self.effective("neighbor")["hostname"], "old.invalid")
|
||||||
|
|
||||||
|
def test_overwrite_main_config_preserves_neighbors(self):
|
||||||
|
path, original = self.existing("config")
|
||||||
|
status, output = self.interactive("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_backup(original)
|
||||||
|
self.assertIn("HostName machine.invalid", path.read_text())
|
||||||
|
self.assertIn("Host untouched\n HostName untouched.invalid", path.read_text())
|
||||||
|
self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
|
||||||
|
def test_overwrite_copy_or_verification_failure_preserves_original(self):
|
||||||
|
for mode in ("failure", "false-zero"):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
self.env.update(COPY_MODE=mode, VERIFY_STATUS="255")
|
||||||
|
status, output = self.interactive("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(len(self.calls("ssh-copy-id")), 1)
|
||||||
|
|
||||||
|
def test_ambiguous_declarations_are_not_partially_updated(self):
|
||||||
|
self.existing()
|
||||||
|
(self.ssh / "config.d/20-second.conf").write_text("Host machine.invalid\n HostName second.invalid\n")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("多个 Host", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
|
||||||
|
def test_filename_collision_does_not_replace_other_alias(self):
|
||||||
|
path = self.ssh / "config.d/machine.invalid.conf"
|
||||||
|
path.parent.mkdir()
|
||||||
|
path.write_text("Host other\n HostName other.invalid\n")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("目标文件已存在", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
|
||||||
|
def test_connect_timeout_and_verification_identity_isolation(self):
|
||||||
|
selected = self.key("id_rsa")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
copy = self.calls("ssh-copy-id")[0]
|
||||||
|
self.assertIn("ConnectTimeout=10", copy)
|
||||||
|
self.assertIn("ConnectionAttempts=1", copy)
|
||||||
|
verify = [call for call in self.calls("ssh") if "-G" not in call]
|
||||||
|
self.assertEqual(len(verify), 1)
|
||||||
|
for option in ("ConnectTimeout=10", "ConnectionAttempts=1", "BatchMode=yes", "IdentitiesOnly=yes", "CertificateFile=none", "ControlPath=none", "PreferredAuthentications=publickey"):
|
||||||
|
self.assertIn(option, verify[0])
|
||||||
|
self.assertEqual(verify[0][verify[0].index("-F") + 1], "/dev/null")
|
||||||
|
self.assertEqual(verify[0][verify[0].index("-i") + 1], str(selected))
|
||||||
|
self.assertNotIn("StrictHostKeyChecking=no", verify[0])
|
||||||
|
|
||||||
|
def test_resolve_failure_aborts(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env["RESOLVE_STATUS"] = "255"
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Reference in New Issue
Block a user