# 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 ..." 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 ..." 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/.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 | | add | run | copy ... | help]" echo "" echo " (无参数) / init 确保本地 SSH key 存在,并启用 ~/.ssh/config.d Include" echo " add 确保本地 key,ssh-copy-id 后写入 ~/.ssh/config.d/<别名>.conf" echo " run 同 add,写完后立即连接(此时应已免密)" echo " 裸参数等同 add(例: ff ssh -p 2222 root@1.2.3.4)" echo " copy 检查远端 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