// LATEST

【技术分享】一个字母管住所有 SSH:我写了个叫 s 的连接管理器

一个字母管住所有 SSH — s 连接管理器

当服务器从 3 台变成 30 台,ssh 这件事就开始变得难受了。IP 记不住,密钥和密码混着来,有的机器登进去还得手动 sudo -i。我一直想要一个足够简单的命令:记住一个短别名,敲一下就进去

于是有了 s——一个不到 600 行的 bash 脚本,把日常 SSH 运维压缩成一个字母开头的命令。这篇文章记录它的设计取舍、完整功能,以及几个写它时踩到的坑。

一分钟感受

$ s
别名                 目标                     端口   登录   描述
hk-01               root@1.2.3.4             22     密钥   香港 主力节点
db-1                deploy@5.6.7.8           2222   密钥   业务库
win-box             admin@9.9.9.9            22     密码   ⇒root  跳板机

$ s hk-01          # 直接连,密钥/密码自动判断
$ s ping           # 全部主机一眼看在线/离线
$ s run 'db-*' 'df -h /'   # 一批机器上跑同一条命令

一个核心决定:不发明新格式,就用 ~/.ssh/config

市面上的 SSH 管理器大多有自己的一套配置文件/数据库。我不想要——那意味着 scprsync、VS Code Remote 这些工具都得再教一遍。

s 的所有主机就是标准的 ~/.ssh/config 条目

Host hk-01
  HostName 1.2.3.4
  User root
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes

好处是白拿的:

  • s hk-01 能连,原生 ssh hk-01 也能连;
  • scp file hk-01:/tmp/rsync ... hk-01:... 全部自动认识这个别名;
  • 别的工具、别的机器只要有这份 config 就通用,s 只是个更顺手的入口,不是一道锁。

那些 s 自己需要、而 SSH 又不认识的元数据(描述、登录方式、是否自动切 root),我用注释行存在同一个 Host 块里:

Host win-box
  #s-desc 跳板机
  #s-auth password
  #s-root yes
  HostName 9.9.9.9
  User admin

ssh 会忽略所有 # 开头的行,所以这些标记对原生 ssh 完全透明,而 sawk 把它们读出来。一份配置,两套解读,互不打架:

                    ~/.ssh/config
              ┌───────────────────────┐
              │ Host win-box          │
    #s-* 注释 │   #s-desc   跳板机     │ ← ssh 视而不见
    只有 s 读 │   #s-auth   password  │   (# 开头一律忽略)
              │   #s-root   yes       │
              │   HostName  9.9.9.9   │ ← ssh 和 s 都读
    标准字段  │   User      admin     │
              └───────────┬───────────┘
                          │
              ┌───────────┴───────────┐
              ▼                       ▼
        原生 ssh / scp            s(多读几行注释,
        rsync / VS Code           知道要走密码、切 root)
        (只认标准字段)

密钥与密码:密码不落明文

加机器就一条命令,密钥、密码都支持:

s add hk-01 root@1.2.3.4 -i ~/.ssh/id_ed25519 -d "香港 主力节点"
s add win-box admin@9.9.9.9 --pass -d "跳板机"     # --pass 会提示输入密码

密钥登录没什么好说的,交给 ssh 就行。密码登录麻烦在两点:一是明文存哪,二是怎么喂给 ssh

密码存进 macOS Keychain,不落进任何明文文件。 ~/.ssh/config 里只留一行 #s-auth password 做标记,真正的密码用 security add-generic-password 写入钥匙串;连接时再 security find-generic-password 取出,通过 sshpass 喂给 ssh。配置文件即使被人翻到,也拿不到密码。

连接时 s 看到 #s-auth password 就自动走 sshpass,你完全无感:

s win-box       # 自动取出密码登录,你什么都不用输

改密码、把密码机升级成免密,也各有一条命令:

s passwd win-box     # 只更新 Keychain 里的密码
s cp win-box         # 把本机公钥拷过去(ssh-copy-id),之后就能免密了

登录后自动切 root

有些机器登进去是普通用户,第一件事永远是 sudo -i。把它交给 s

s root win-box on     # 开启:以后 s win-box 连上就自动 sudo -i

对密码机还有个细节:sudo 通常要再输一次密码,而这个密码一般和登录密码是同一个。所以 s 会把 Keychain 里那把密码顺手喂给 sudo——真正做到一条命令直达 root shell,中间一次都不用输密码。喂失败(比如两个密码确实不一样)就自动回退成手动输入,不会卡住。

s win-box
# → 自动填 SSH 密码 → 自动用同一密码过 sudo → 直接落在 root@win-box

列表、传输、连通性、改配置

日常琐事都收敛成子命令:

s ls                              # 列出所有主机(裸 s 也一样)
s ping                            # TCP 探测,标注每台在线/离线
s ping 'db-*' win-box             # 只查一部分,支持通配符
s put ./app.tar.gz db-1:/root/    # 上传(密码机自动走 sshpass)
s get db-1:/var/log/app.log ./    # 下载
s set hk-01 host 1.2.3.9          # 改 IP(云主机重启后公网 IP 变了很常见)
s desc hk-01 "香港 主力节点"       # 改描述
s edit                            # 直接 $EDITOR 打开 config

put/get 会自动从参数里认出 别名:路径 那部分,判断该走原生 scp 还是 sshpass scp,你不用关心目标机是密钥还是密码登录。

三个让 fleet 变顺手的命令

机器一多,光能连还不够。

fzf 模糊选机——别名记不全时,模糊搜索:

s pick     # 弹出 fzf 列表,输关键字回车直接连

端口转发——把远端只监听在本机的服务(数据库、内部 API 等)拉到本地直连:

s fwd db-1 8123                       # 简写:本地:8123 → 远端 localhost:8123
s fwd jump 5432:10.0.0.100:5432       # 借 jump 当跳板,连它内网里的库
端口转发里最容易绕晕的一点:5432:10.0.0.100:5432 中间那个地址是从服务器角度解析的,不是你本机。写 localhost 指的是「SSH 连上的那台服务器自己」,写内网 IP 则是让那台服务器当跳板去连它内网里的第三台机器——后者能帮你够到公网根本访问不到的机器。

批量执行——一条命令扫一批机器,天然适合集群:

s run 'k8s-*' 'kubectl get nodes'     # 通配符选一组
s run 'db-1,win-box' 'df -h /'        # 逗号分隔多个选择器

逐台执行、带标题分隔、末尾汇总成功/失败数,某台挂了不影响其它。

Tab 补全

作为一个每天要敲很多次的命令,补全是刚需。给 zsh 写了个补全函数丢进 fpath

  • s <Tab> 列出所有别名 + 子命令;
  • s hk<Tab> 补成 hk-01
  • s rm <Tab> / s ping <Tab> / s fwd <Tab> 补别名。

候选是实时从 ~/.ssh/config 读的——所以 s add 新加的机器会自动出现在补全里,不用维护第二份清单。

几个踩到的坑

写这种「看起来很简单」的小工具,坑往往在细节里。挑几个有代表性的。

1. 中文变量名后面跟全角括号,会报 unbound variable。 开了 set -u 后,"未找到别名:$alias(...)" 这一句直接崩:

line 147: alias�: unbound variable

原因是 bash 把 $alias 后面紧跟的全角括号 的字节吞进了变量名,去找一个不存在的变量。解法是老老实实用花括号界定:${alias}

2. 中文对齐不能用 printf %-Ns printf 按字符个数补空格,但中文在终端里占两格,于是带中文的列全歪了。得按显示宽度补齐。有个不用起子进程的小技巧——对「纯 ASCII + 三字节中文」的字符串,显示宽度正好等于 (字符数 + 字节数) / 2

dwidth() { local s="$1" c b; c=${#s}; local LC_ALL=C; b=${#s}; echo $(( (c + b) / 2 )); }

${#s} 在 UTF-8 locale 下数的是字符数,临时切到 LC_ALL=C 再数一次就是字节数,两者一平均就得到显示宽度。

3. 密码对了却 Permission denied 强制 PreferredAuthentications=password 时,某些带 PAM 的服务器其实走的是 keyboard-interactive,于是密码正确也被拒。把两种都允许即可:password,keyboard-interactive

4. set -e 会让批量执行第一台失败就整体退出。 因为 set -e 遇到非零退出就中止。解法是把 ssh 放进 if 条件里——条件语境下的命令失败不触发 set -e

if ssh ... "$alias" "$cmd"; then rc=0; else rc=$?; fi

5. 密码机交互登录,连上却「秒退、无输出、退出码 0」。 这个最迷惑。原因是 sshpass 介入后,ssh 的 stdin 不再被识别成终端,于是它不向远端申请伪终端;远端 shell 一起来就读到 EOF,立刻正常退出——所以退出码是 0,什么都没留下。解法是交互登录时显式 -t 强制分配伪终端:

sshpass -e ssh -t "$alias"    # 少了这个 -t,交互 shell 会瞬间关掉

6. 密码机第一次连,sshpass 直接以退出码 6 失败。 因为这台的主机公钥还不在 known_hosts 里,ssh 想弹「是否信任该主机」的确认,而 sshpass 没法回答这个交互提示,只好放弃。解法是加 StrictHostKeyChecking=accept-new:首次自动接受新主机密钥,但主机密钥若变更仍然拒绝——安全性上是个合理折中。

小结

s 没做什么高深的事,它只是把「一堆 SSH 服务器」这件事里所有重复动作——记 IP、切密钥密码、切 root、传文件、查状态、批量跑命令——各压成一条短命令,同时坚持一个原则:底层永远是标准 ~/.ssh/config,绝不发明新格式,绝不把自己变成一道锁。

如果你也管着一堆机器,不妨拿去改。代码已经放到 GitHub,clone 下来 ./install.sh 就能用;完整源码也附在文末。

GitHub · almightyYantao/ssh-quick-connect
完整源码 s(放进 PATH,chmod +x 即用;密码功能需要 sshpass,fzf 选机需要 fzf)
#!/usr/bin/env bash
# s — 极简 SSH 连接管理器(基于标准 ~/.ssh/config)
#
#   s                         列出所有主机
#   s <别名>                  连接(自动判断密钥/密码登录)
#   s add <别名> <user@host> [-p PORT] [-i KEYFILE] [--pass] [-d "描述"] [--root]
#   s rm <别名>               删除一台主机(含 Keychain 里的密码)
#   s set <别名> host|user|port <值>   快速改某个字段
#   s desc <别名> [描述…]     设置/修改描述
#   s root <别名> [on|off]    开/关"登录后自动 sudo -i 切 root"
#   s passwd <别名>           修改密码登录主机在 Keychain 里的密码
#   s put/get [-r] …          文件传输(密码机自动走 sshpass)
#   s ping [别名/通配符…]     TCP 连通性检查
#   s pick                    fzf 模糊选机并连接
#   s fwd <别名> <spec…>      端口转发
#   s run <选择器> <命令…>    批量执行
#   s edit / s cp <别名>      编辑 config / 拷公钥升级免密

set -euo pipefail

CONFIG="$HOME/.ssh/config"
KC_SERVICE="ssh-s-tool"

# ---- 颜色 ----
if [ -t 1 ]; then
  B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; C=$'\033[36m'; R=$'\033[31m'; D=$'\033[2m'; N=$'\033[0m'
else
  B=""; G=""; Y=""; C=""; R=""; D=""; N=""
fi
err()  { printf "%s✗%s %s\n" "$R" "$N" "$*" >&2; }
info() { printf "%s•%s %s\n" "$C" "$N" "$*"; }

# 终端显示宽度(ASCII=1,中文=2):纯 ASCII+3字节中文时 宽度=(字符数+字节数)/2
dwidth() { local s="$1" c b; c=${#s}; local LC_ALL=C; b=${#s}; echo $(( (c + b) / 2 )); }
pad() { local s="$1" w="$2" n; n=$(( w - $(dwidth "$s") )); (( n < 0 )) && n=0; printf "%s%*s" "$s" "$n" ""; }

ensure_config() {
  mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh" 2>/dev/null || true
  [ -f "$CONFIG" ] || { : >"$CONFIG"; }
  chmod 600 "$CONFIG" 2>/dev/null || true
}

host_exists() { # $1=alias
  awk -v a="$1" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { for (i=2;i<=NF;i++) if ($i==a) { found=1; exit } }
    END { exit(found?0:1) }
  ' "$CONFIG" 2>/dev/null
}

host_field() { # $1=alias $2=field
  awk -v a="$1" -v f="$2" '
    BEGIN { fl=tolower(f) }
    /^[[:space:]]*[Hh]ost[[:space:]]/ { inblk=0; for (i=2;i<=NF;i++) if ($i==a) inblk=1; next }
    inblk && tolower($1)==fl { print $2; exit }
  ' "$CONFIG" 2>/dev/null
}

is_password_auth() { # $1=alias
  awk -v a="$1" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { inblk=0; for (i=2;i<=NF;i++) if ($i==a) inblk=1; next }
    inblk && $1=="#s-auth" && $2=="password" { found=1; exit }
    END { exit(found?0:1) }
  ' "$CONFIG" 2>/dev/null
}

is_auto_root() { # $1=alias
  awk -v a="$1" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { inblk=0; for (i=2;i<=NF;i++) if ($i==a) inblk=1; next }
    inblk && $1=="#s-root" && $2=="yes" { found=1; exit }
    END { exit(found?0:1) }
  ' "$CONFIG" 2>/dev/null
}

host_desc() { # $1=alias
  awk -v a="$1" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { inblk=0; for (i=2;i<=NF;i++) if ($i==a) inblk=1; next }
    inblk && $1=="#s-desc" { line=$0; sub(/^[[:space:]]*#s-desc[[:space:]]*/,"",line); print line; exit }
  ' "$CONFIG" 2>/dev/null
}

# ---- Keychain ----
kc_set() { security add-generic-password -U -s "$KC_SERVICE" -a "$1" -w "$2" >/dev/null 2>&1; }
kc_get() { security find-generic-password -s "$KC_SERVICE" -a "$1" -w 2>/dev/null; }
kc_del() { security delete-generic-password -s "$KC_SERVICE" -a "$1" >/dev/null 2>&1 || true; }

cmd_list() {
  ensure_config
  if ! grep -qE '^[[:space:]]*[Hh]ost[[:space:]]' "$CONFIG" 2>/dev/null; then
    info "还没有任何主机,用 ${B}s add${N} 添加一台。"; return 0
  fi
  printf "%s%s %s %s %s %s%s\n" "$B" \
    "$(pad 别名 20)" "$(pad 目标 24)" "$(pad 端口 6)" "$(pad 登录 6)" "描述" "$N"
  awk '/^[[:space:]]*[Hh]ost[[:space:]]/ { for (i=2;i<=NF;i++) print $i }' "$CONFIG" \
    | grep -vE '[*?]' | while read -r alias; do
      [ -z "$alias" ] && continue
      local_host="$(host_field "$alias" HostName)"; [ -z "$local_host" ] && local_host="$alias"
      user="$(host_field "$alias" User)"; [ -z "$user" ] && user="(默认)"
      port="$(host_field "$alias" Port)"; [ -z "$port" ] && port="22"
      desc="$(host_desc "$alias")"
      if is_password_auth "$alias"; then acolor="$Y"; aname="密码"; else acolor="$G"; aname="密钥"; fi
      local roottag=""; is_auto_root "$alias" && roottag="${G}⇒root${N} "
      printf "%s%s%s %s %s %s%s%s %b%s%s%s\n" \
        "$C" "$(pad "$alias" 20)" "$N" \
        "$(pad "${user}@${local_host}" 24)" "$(pad "$port" 6)" \
        "$acolor" "$(pad "$aname" 6)" "$N" "$roottag" "$D" "$desc" "$N"
  done
}

cmd_connect() { # $1=alias, 其余透传给 ssh
  ensure_config
  local alias="$1"; shift || true
  if ! host_exists "$alias"; then
    err "未找到别名:${alias}(用 ${B}s${N} 查看全部,或 ${B}s add${N} 添加)"; exit 1
  fi
  local auto_root=0
  is_auto_root "$alias" && [ $# -eq 0 ] && auto_root=1
  if is_password_auth "$alias"; then
    local pw; pw="$(kc_get "$alias")"
    [ -z "$pw" ] && { err "别名 $alias 标记为密码登录,但 Keychain 里没有密码。"; exit 1; }
    # accept-new:首次自动接受新主机密钥(sshpass 无法回答交互提示,否则退出码 6 秒退)
    local sshopts=(-o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no -o StrictHostKeyChecking=accept-new)
    if [ "$auto_root" = 1 ]; then
      # 用同一密码喂 sudo(base64 传输避开特殊字符);喂失败则回退手动输入
      local b64; b64="$(printf '%s' "$pw" | base64 | tr -d '\n')"
      exec env SSHPASS="$pw" sshpass -e ssh "${sshopts[@]}" -t "$alias" \
        "echo $b64 | base64 -d 2>/dev/null | sudo -S -v 2>/dev/null; exec sudo -i"
    elif [ $# -eq 0 ]; then
      # 交互登录:sshpass 下必须显式 -t,否则远端不分配伪终端、shell 秒退
      exec env SSHPASS="$pw" sshpass -e ssh "${sshopts[@]}" -t "$alias"
    else
      exec env SSHPASS="$pw" sshpass -e ssh "${sshopts[@]}" "$alias" "$@"
    fi
  else
    if [ "$auto_root" = 1 ]; then exec ssh -t "$alias" sudo -i; else exec ssh "$alias" "$@"; fi
  fi
}

append_host() { # alias host user port keyfile is_pass desc auto_root
  local alias="$1" host="$2" user="$3" port="$4" keyfile="$5" is_pass="$6" desc="${7:-}" auto_root="${8:-0}"
  {
    printf "\nHost %s\n" "$alias"
    [ -n "$desc" ] && printf "  #s-desc %s\n" "$desc"
    [ "$auto_root" = "1" ] && printf "  #s-root yes\n"
    printf "  HostName %s\n" "$host"
    [ -n "$user" ] && printf "  User %s\n" "$user"
    [ -n "$port" ] && [ "$port" != "22" ] && printf "  Port %s\n" "$port"
    if [ "$is_pass" = "1" ]; then printf "  #s-auth password\n"
    elif [ -n "$keyfile" ]; then printf "  IdentityFile %s\n  IdentitiesOnly yes\n" "$keyfile"; fi
  } >>"$CONFIG"
  chmod 600 "$CONFIG" 2>/dev/null || true
}

cmd_add() {
  ensure_config
  local alias="" target="" user="" host="" port="22" keyfile="" is_pass=0 desc="" auto_root=0
  if [ $# -ge 2 ]; then
    alias="$1"; target="$2"; shift 2
    if [[ "$target" == *@* ]]; then user="${target%@*}"; host="${target#*@}"; else host="$target"; fi
    while [ $# -gt 0 ]; do
      case "$1" in
        -p|--port) port="$2"; shift 2 ;;
        -i|--identity) keyfile="$2"; shift 2 ;;
        --pass|--password) is_pass=1; shift ;;
        -d|--desc|--description) desc="$2"; shift 2 ;;
        --root) auto_root=1; shift ;;
        *) err "未知参数: $1"; exit 1 ;;
      esac
    done
  else
    printf "%s别名%s (连接时敲的短名): " "$B" "$N"; read -r alias
    [ -z "$alias" ] && { err "别名不能为空"; exit 1; }
    printf "%s主机%s (IP 或域名): " "$B" "$N"; read -r host
    [ -z "$host" ] && { err "主机不能为空"; exit 1; }
    printf "%s用户%s [root]: " "$B" "$N"; read -r user; [ -z "$user" ] && user="root"
    printf "%s端口%s [22]: " "$B" "$N"; read -r port; [ -z "$port" ] && port="22"
    printf "%s描述%s (可留空): " "$B" "$N"; read -r desc
    printf "%s登录方式%s  1) 密钥(默认)  2) 密码 : " "$B" "$N"; read -r m
    if [ "$m" = "2" ]; then is_pass=1
    else printf "%s指定私钥%s (留空=默认 ~/.ssh/id_*): " "$B" "$N"; read -r keyfile; fi
    printf "%s登录后自动 sudo -i 切 root?%s [y/N]: " "$B" "$N"; read -r r
    [[ "$r" =~ ^[Yy] ]] && auto_root=1
  fi
  if host_exists "$alias"; then err "别名 $alias 已存在。"; exit 1; fi
  local pw=""
  if [ "$is_pass" = "1" ]; then
    printf "%s密码%s (输入时不显示): " "$B" "$N"; read -rs pw; echo
    [ -z "$pw" ] && { err "密码不能为空"; exit 1; }
  fi
  append_host "$alias" "$host" "$user" "$port" "$keyfile" "$is_pass" "$desc" "$auto_root"
  [ "$is_pass" = "1" ] && kc_set "$alias" "$pw"
  echo
  if [ "$is_pass" = "1" ]; then printf "%s✓%s 已添加 %s%s%s(密码登录,密码存于 Keychain)\n" "$G" "$N" "$C" "$alias" "$N"
  else printf "%s✓%s 已添加 %s%s%s(密钥登录)\n" "$G" "$N" "$C" "$alias" "$N"; fi
  printf "  现在可以:%ss %s%s\n" "$B" "$alias" "$N"
}

cmd_rm() {
  ensure_config
  local alias="${1:-}"; [ -z "$alias" ] && { err "用法:s rm <别名>"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  local tmp; tmp="$(mktemp)"
  awk -v a="$alias" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { drop=0; if (NF==2 && $2==a) drop=1 }
    !drop { print }
  ' "$CONFIG" >"$tmp"
  awk 'NF||p{print} {p=NF}' "$tmp" >"$CONFIG"
  rm -f "$tmp"; chmod 600 "$CONFIG" 2>/dev/null || true
  kc_del "$alias"
  printf "%s✓%s 已删除 %s%s%s\n" "$G" "$N" "$C" "$alias" "$N"
}

cmd_cp() {
  ensure_config
  local alias="${1:-}"; [ -z "$alias" ] && { err "用法:s cp <别名>"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  if is_password_auth "$alias"; then
    local pw; pw="$(kc_get "$alias")"
    info "用密码把公钥拷到 $alias …"
    env SSHPASS="$pw" sshpass -e ssh-copy-id \
      -o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no "$alias"
  else
    ssh-copy-id "$alias"
  fi
}

cmd_passwd() {
  ensure_config
  local alias="${1:-}"; [ -z "$alias" ] && { err "用法:s passwd <别名>"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  is_password_auth "$alias" || { err "$alias 不是密码登录的主机。"; exit 1; }
  local pw; printf "%s新密码%s (输入时不显示): " "$B" "$N"; read -rs pw; echo
  [ -z "$pw" ] && { err "密码不能为空"; exit 1; }
  kc_set "$alias" "$pw"
  printf "%s✓%s 已更新 %s%s%s 的密码\n" "$G" "$N" "$C" "$alias" "$N"
}

cmd_desc() {
  ensure_config
  local alias="${1:-}"; shift || true
  [ -z "$alias" ] && { err "用法:s desc <别名> <描述>(留空则清除)"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  local desc="$*" tmp; tmp="$(mktemp)"
  awk -v a="$alias" -v d="$desc" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { intgt=(NF==2 && $2==a)?1:0; print; if (intgt && d!="") print "  #s-desc " d; next }
    intgt && $1=="#s-desc" { next }
    { print }
  ' "$CONFIG" >"$tmp"
  mv "$tmp" "$CONFIG"; chmod 600 "$CONFIG" 2>/dev/null || true
  [ -n "$desc" ] && printf "%s✓%s %s%s%s 的描述:%s\n" "$G" "$N" "$C" "$alias" "$N" "$desc" \
                 || printf "%s✓%s 已清除 %s%s%s 的描述\n" "$G" "$N" "$C" "$alias" "$N"
}

cmd_root() {
  ensure_config
  local alias="${1:-}" want="${2:-}"
  [ -z "$alias" ] && { err "用法:s root <别名> [on|off]"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  local on=0
  case "$want" in
    on|yes|1) on=1 ;; off|no|0) on=0 ;;
    "") if is_auto_root "$alias"; then on=0; else on=1; fi ;;
    *) err "参数只能是 on / off"; exit 1 ;;
  esac
  local tmp; tmp="$(mktemp)"
  awk -v a="$alias" -v on="$on" '
    /^[[:space:]]*[Hh]ost[[:space:]]/ { intgt=(NF==2 && $2==a)?1:0; print; if (intgt && on=="1") print "  #s-root yes"; next }
    intgt && $1=="#s-root" { next }
    { print }
  ' "$CONFIG" >"$tmp"
  mv "$tmp" "$CONFIG"; chmod 600 "$CONFIG" 2>/dev/null || true
  [ "$on" = "1" ] && printf "%s✓%s %s%s%s 登录后将自动 sudo -i\n" "$G" "$N" "$C" "$alias" "$N" \
                  || printf "%s✓%s %s%s%s 已关闭自动 sudo -i\n" "$G" "$N" "$C" "$alias" "$N"
}

cmd_scp() {
  ensure_config
  [ $# -lt 2 ] && { err "用法:s put <本地…> <别名:路径> 或 s get <别名:路径> <本地>"; exit 1; }
  local a cand host_alias=""
  for a in "$@"; do
    if [[ "$a" == *:* ]]; then cand="${a%%:*}"; if host_exists "$cand"; then host_alias="$cand"; break; fi; fi
  done
  [ -z "$host_alias" ] && { err "参数里没找到已知别名(格式 别名:远程路径)"; exit 1; }
  if is_password_auth "$host_alias"; then
    local pw; pw="$(kc_get "$host_alias")"
    exec env SSHPASS="$pw" sshpass -e scp \
      -o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no "$@"
  else
    exec scp "$@"
  fi
}

cmd_set() {
  ensure_config
  local alias="${1:-}" field="${2:-}" value="${3:-}"
  [ -z "$alias" ] || [ -z "$field" ] || [ -z "$value" ] && { err "用法:s set <别名> <host|user|port> <值>"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  local directive
  case "$field" in
    host|hostname|ip|HostName) directive="HostName" ;;
    user|User) directive="User" ;;
    port|Port) directive="Port" ;;
    *) err "字段只能是 host / user / port"; exit 1 ;;
  esac
  local tmp; tmp="$(mktemp)"
  awk -v a="$alias" -v d="$directive" -v v="$value" '
    BEGIN { dl=tolower(d) }
    /^[[:space:]]*[Hh]ost[[:space:]]/ { intgt=(NF==2 && $2==a)?1:0; print; if (intgt) print "  " d " " v; next }
    intgt && tolower($1)==dl { next }
    { print }
  ' "$CONFIG" >"$tmp"
  mv "$tmp" "$CONFIG"; chmod 600 "$CONFIG" 2>/dev/null || true
  printf "%s✓%s %s%s%s 的 %s 已设为 %s\n" "$G" "$N" "$C" "$alias" "$N" "$directive" "$value"
}

all_aliases() {
  awk '/^[[:space:]]*[Hh]ost[[:space:]]/ { for (i=2;i<=NF;i++) print $i }' "$CONFIG" 2>/dev/null | grep -vE '[*?]'
}

expand_aliases() {
  local -a out=(); local pat alias matched
  if [ $# -eq 0 ]; then all_aliases; return 0; fi
  for pat in "$@"; do
    matched=0
    while IFS= read -r alias; do
      [ -z "$alias" ] && continue
      case "$alias" in $pat) out+=("$alias"); matched=1 ;; esac
    done < <(all_aliases)
    [ "$matched" = 0 ] && err "没有匹配的别名:$pat"
  done
  [ ${#out[@]} -eq 0 ] && return 0
  printf "%s\n" "${out[@]}" | awk '!seen[$0]++'
}

cmd_ping() {
  ensure_config
  local -a targets=()
  while IFS= read -r a; do [ -n "$a" ] && targets+=("$a"); done < <(expand_aliases "$@")
  [ ${#targets[@]} -eq 0 ] && { err "没有可检查的主机"; exit 1; }
  printf "%s%s %s %s%s\n" "$B" "$(pad 别名 20)" "$(pad 目标 26)" "状态" "$N"
  local alias local_host port st
  for alias in "${targets[@]}"; do
    local_host="$(host_field "$alias" HostName)"; [ -z "$local_host" ] && local_host="$alias"
    port="$(host_field "$alias" Port)"; [ -z "$port" ] && port="22"
    if nc -z -G 3 "$local_host" "$port" >/dev/null 2>&1; then st="${G}● 在线${N}"; else st="${R}○ 离线${N}"; fi
    printf "%s%s%s %s %b\n" "$C" "$(pad "$alias" 20)" "$N" "$(pad "${local_host}:${port}" 26)" "$st"
  done
}

cmd_pick() {
  ensure_config
  command -v fzf >/dev/null 2>&1 || { err "未安装 fzf"; exit 1; }
  local line alias
  line="$(all_aliases | while IFS= read -r a; do
      [ -z "$a" ] && continue
      printf "%s\t%s@%s\t%s\n" "$a" "$(host_field "$a" User)" "$(host_field "$a" HostName)" "$(host_desc "$a")"
    done | column -t -s $'\t' | fzf --height=40% --reverse --prompt='ssh> ' --header='选机器回车连接')"
  [ -z "$line" ] && exit 0
  alias="${line%% *}"
  cmd_connect "$alias"
}

cmd_fwd() {
  ensure_config
  local alias="${1:-}"; shift || true
  [ -z "$alias" ] && { err "用法:s fwd <别名> <本地端口|本地:远端主机:远端口> …"; exit 1; }
  if ! host_exists "$alias"; then err "未找到别名:$alias"; exit 1; fi
  [ $# -eq 0 ] && { err "至少给一个转发规则,如 8123 或 15432:10.0.0.5:5432"; exit 1; }
  local spec; local -a lopts=()
  for spec in "$@"; do
    case "$spec" in
      *:*:*) lopts+=(-L "$spec") ;;
      *:*)   lopts+=(-L "$spec") ;;
      *)     lopts+=(-L "$spec:localhost:$spec") ;;
    esac
  done
  info "隧道建立中(${alias}):$* —— 保持此窗口,Ctrl-C 断开"
  if is_password_auth "$alias"; then
    local pw; pw="$(kc_get "$alias")"
    exec env SSHPASS="$pw" sshpass -e ssh -N \
      -o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no "${lopts[@]}" "$alias"
  else
    exec ssh -N "${lopts[@]}" "$alias"
  fi
}

cmd_run() {
  ensure_config
  local sel="${1:-}"; shift || true
  [ -z "$sel" ] || [ $# -eq 0 ] && { err "用法:s run <别名/通配符[,…]> <命令…>"; exit 1; }
  local cmd="$*"
  local -a pats=(); local IFS_OLD="$IFS"; IFS=','; read -ra pats <<< "$sel"; IFS="$IFS_OLD"
  local -a targets=()
  while IFS= read -r a; do [ -n "$a" ] && targets+=("$a"); done < <(expand_aliases "${pats[@]}")
  [ ${#targets[@]} -eq 0 ] && { err "没有可执行的主机"; exit 1; }
  local alias ok=0 fail=0 rc
  for alias in "${targets[@]}"; do
    printf "\n%s%s─── %s ───%s\n" "$B" "$C" "$alias" "$N"
    rc=0
    if is_password_auth "$alias"; then
      local pw; pw="$(kc_get "$alias")"
      if env SSHPASS="$pw" sshpass -e ssh \
        -o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no \
        -o ConnectTimeout=10 "$alias" "$cmd"; then rc=0; else rc=$?; fi
    else
      if ssh -o BatchMode=yes -o ConnectTimeout=10 "$alias" "$cmd"; then rc=0; else rc=$?; fi
    fi
    if [ "$rc" -eq 0 ]; then ok=$((ok+1)); else fail=$((fail+1)); printf "%s✗ %s 失败(退出码 %s)%s\n" "$R" "$alias" "$rc" "$N"; fi
  done
  printf "\n%s完成%s:%s成功 %d%s,%s失败 %d%s\n" "$B" "$N" "$G" "$ok" "$N" "$R" "$fail" "$N"
}

cmd_edit() { ensure_config; "${EDITOR:-vi}" "$CONFIG"; }
usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; }

main() {
  local cmd="${1:-ls}"
  case "$cmd" in
    ls|list) cmd_list ;;
    add) shift; cmd_add "$@" ;;
    rm|del|remove) shift; cmd_rm "$@" ;;
    cp|copy-id) shift; cmd_cp "$@" ;;
    passwd|pw) shift; cmd_passwd "$@" ;;
    desc) shift; cmd_desc "$@" ;;
    root) shift; cmd_root "$@" ;;
    put|get|scp) shift; cmd_scp "$@" ;;
    set) shift; cmd_set "$@" ;;
    ping) shift; cmd_ping "$@" ;;
    pick|p) cmd_pick ;;
    fwd|tunnel|forward) shift; cmd_fwd "$@" ;;
    run|exec) shift; cmd_run "$@" ;;
    edit) cmd_edit ;;
    -h|--help|help) usage ;;
    "") cmd_list ;;
    *) cmd_connect "$@" ;;
  esac
}
main "$@"

还没有评论

欢迎留下你的观点,保持交流的清晰和友好。

写下评论