交互式命令自动化:expect 与 pty

系统讲解交互式命令自动化的两大路径:expect 的 spawn/expect/send 三件套驱动 SSH 登录与菜单操作,以及 heredoc 与管道喂输入的轻量方案,并给出超时处理与场景封装的工程实践。

1. 为什么交互式命令难以自动化

一句话总结: 交互式命令读写的是伪终端 pty,脚本的标准输入输出管道无法模拟「按键回显、光标、逐行提示」,所以需要 expect 这类专用工具。

ssh user@host 会弹出 password: 提示等待输入,passwd、mysql、各种安装向导都是如此。用管道喂 stdin 往往不生效,因为程序检测到 stdin 不是终端时可能改变行为,甚至直接拒绝读取。

# 错误示范:echo 管道对 ssh 的密码提示无效
echo 'mypassword' | ssh user@host

# 错误示范:ssh 检测到非终端 stdin,直接挂起或报错
printf 'yes\n' | ssh -o StrictHostKeyChecking=no user@host 'uptime'

# 观察程序是否要求终端
ls -la /dev/tty   # 当前进程的终端设备

1.1 pty 与普通管道的差异

pty(pseudo-terminal)提供两端:程序侧看到的是「像终端一样的设备」,具备回显、行编辑、信号键等能力;脚本侧通过主端写入按键序列、读取全部输出。

# pty 的关键特征:echo on / line discipline
script -q -c 'read -p "输入名字: " name; echo "你好 $name"' /dev/null <<< 'Alice'

# script 命令能模拟终端:ssh 通过 script 包装后可读密码提示
script -q -c 'ssh -o StrictHostKeyChecking=no user@host' /dev/null

一句话总结: 判断是否需要 expect 的标准是「程序是否读取 /dev/tty 或检测 isatty」,检测到就上 pty 方案。

1.2 expect 的本质

expect 是 Tcl 语言的工具:spawn 启动带 pty 的子进程,expect 等待匹配的输出模式,send 写入按键序列,形成「读到提示 → 回应输入」的循环。

# 查看 expect 是否安装
command -v expect || sudo apt-get install -y expect

# 最小示例:expect 内嵌脚本执行
expect -c '
  spawn echo "hello"
  expect eof
'

2. expect 三件套:spawn / expect / send

一句话总结: spawn 启动进程、expect 等待模式、send 发送输入,三者循环配合即可驱动任意交互程序。

最基本的交互循环:等待 password: 提示,发送密码,再等待命令结束。

#!/usr/bin/expect -f
# 登录远程主机执行 uptime
set timeout 10
spawn ssh user@192.168.1.10
expect "password:"
send "secret123\r"
expect "$ "
send "uptime\r"
expect "$ "
send "exit\r"
expect eof

2.1 expect 的匹配模式

expect 支持字符串、正则与 glob,还有「匹配到任意一个就继续」的并列写法。

#!/usr/bin/expect -f
set timeout 5
spawn ssh user@host
# 多模式:密码提示或首次连接确认
expect {
  "password:" { send "secret\r" }
  "yes/no"    { send "yes\r"; exp_continue }
  timeout     { puts "连接超时"; exit 1 }
}
expect "$ "
send "hostname\r"
expect "$ "
send "exit\r"
expect eof

一句话总结: exp_continue 让匹配后继续等待下一模式,是处理「首次连接确认 + 密码」多步提示的标准写法。

2.2 send 的换行与转义

send 发送的字符串不会自动回车,必须显式加 \r;特殊字符用 send -- 防止被当成开关。

# \r 是回车,\n 在终端模式可能被当成回车+换行,推荐用 \r
send "ls -la\r"

# 发送含前导横杠的字符串
send -- "-h\r"

# 发送控制字符:Ctrl+C 中断
send "\003"

3. 自动化登录:SSH 与登录提示

一句话总结: SSH 自动化的正解是 SSH key 而非 expect,但 expect 仍适用于密钥托管、跳板机、旧设备等场景,且要处理好 host key 确认。

生产环境优先用 SSH key 免密;expect 用于「无法放置密钥」的场景,例如网络设备、堡垒机二次认证。

#!/usr/bin/expect -f
# 自动应答 host key 并完成登录
set timeout 15
set user [lindex $argv 0]
set host [lindex $argv 1]
set pass [lindex $argv 2]
spawn ssh -o StrictHostKeyChecking=accept-new $user@$host
expect {
  "password:" { send "$pass\r" }
  "yes/no"    { send "yes\r"; exp_continue }
}
expect "$ "
send "id\r"
expect "$ "
send "exit\r"
expect eof

3.1 带参数的 expect 脚本

expect 脚本用 [lindex $argv N] 读取命令行参数,$env(变量) 读环境变量,方便封装复用。

#!/usr/bin/expect -f
# 密码从环境变量取,避免写在脚本里
set timeout 10
set pass $env(SSH_PASS)
spawn ssh -o StrictHostKeyChecking=no $env(SSH_USER)@$env(SSH_HOST)
expect "password:"
send "$pass\r"
expect "$ "
interact

一句话总结: interact 把控制权交还给用户,适合「自动过认证、随后人机交互」的跳板机场景。

3.2 读取输出并断言结果

expect 匹配到的内容可以用 expect_out(buffer) 取出,做结果断言。

#!/usr/bin/expect -f
set timeout 10
spawn ssh user@host "df -h /"
expect {
  "文件系统"        { puts "找到中文系统标题" }
  -re {([0-9]+)%}   { puts "使用率出现" }
  timeout            { puts "命令超时"; exit 1 }
}
expect eof

4. heredoc 与管道的轻量交互

一句话总结: 不是所有交互都要 expect:heredoc 能喂「一次性输入」,ssh -t 配合远端命令可在无 pty 场景下完成多数自动化。

heredoc 把多行内容作为 stdin 喂给命令,适合「读 stdin 而非 /dev/tty」的程序。

# 给交互式命令喂多行输入
mysql -u root -p <<'SQL'
SELECT user, host FROM mysql.user;
SQL

# ssh 远端执行带输入的命令
ssh user@host 'read -r x; echo "远端收到 $x"' <<< 'hello'

# 需要远端有终端时加 -t
ssh -t user@host 'top -n 1 -b'

4.1 何时 heredoc 够用

判断标准是「程序是否检测终端」。sudo -S 从 stdin 读密码、grep 处理流、多数纯数据程序都能用 heredoc/管道。

# sudo 从 stdin 读密码(-S)
echo 'password' | sudo -S apt-get update

# 交互式向导的多数问题可用 yes 管道回答
yes '' | ./configure --prefix=/opt/app

# expect 只留给真正的终端程序
script -q -c 'passwd' /dev/null <<< '新密码
新密码'

一句话总结: 先试管道/heredoc,程序拒绝非终端输入时才升级到 expect,能用简单方案就不要引入 pty。

4.2 script 命令兜底

script 能强制分配 pty 并把输入重定向进去,某些「差一点就行」的场景用它兜底。

# 用 script 模拟终端执行并记录输出
script -q /tmp/session.log <<'EOF'
ssh user@host
EOF

# 记录会话用于审计
script -q -c 'ssh user@host "uptime"' /tmp/audit.log

5. 超时与失败处理

一句话总结: set timeout 控制每步等待上限,timeout 分支捕获无响应,脚本结尾用 exit 码对外暴露成败。

expect 默认永久等待,必须设置 timeout 并在失败分支退出非零码。

#!/usr/bin/expect -f
set timeout 8
spawn ssh user@host
expect {
  "password:" { send "wrongpass\r" }
  timeout { puts "连接超时"; exit 1 }
}
# 密码错误时提示会再次出现
expect {
  "password:" { puts "密码被拒"; exit 2 }
  "$ "        { send "exit\r"; expect eof; exit 0 }
  timeout     { exit 3 }
}

5.1 用返回码串联管道

expect 脚本退出码可被外层 Shell 捕获,从而纳入 set -e 或 CI 流程。

#!/usr/bin/env bash
set -euo pipefail
# 内联 expect,失败即中断
expect <<'EOF'
set timeout 10
spawn ssh -o StrictHostKeyChecking=no user@host "systemctl is-active nginx"
expect {
  "$ " { }
  timeout { puts "超时"; exit 1 }
}
expect eof
catch wait result
exit [lindex $result 3]
EOF
echo "远端命令执行成功"

一句话总结: catch wait result 能取到子进程退出码,把 expect 变成可组合的「远程命令执行器」。

5.2 日志与调试

exp_internal 打开调试输出,log_file 记录完整交互。

#!/usr/bin/expect -f
exp_internal 0       # 1 开启调试模式
log_file /tmp/expect.log
set timeout 10
spawn ssh user@host "date"
expect eof
close

6. 场景封装:通用交互函数

一句话总结: 把「登录 → 执行命令 → 退出」抽象成函数,用密码/主机参数化,业务脚本只关心要执行的命令。

封装一个 ssh_cmd 函数,接收主机、密码、命令三参数。

#!/usr/bin/env bash
set -euo pipefail

ssh_cmd() {
  local host="$1" pass="$2" cmd="$3"
  SSH_PASS="$pass" SSH_HOST="$host" expect <<EOF
set timeout 15
spawn ssh -o StrictHostKeyChecking=no \$env(SSH_HOST) "$cmd"
expect {
  "password:" { send "\$env(SSH_PASS)\r"; exp_continue }
  "yes/no"    { send "yes\r"; exp_continue }
  timeout     { puts "超时"; exit 1 }
}
expect eof
catch wait result
exit [lindex \$result 3]
EOF
}

ssh_cmd web1 'secret' 'uptime; df -h /'
ssh_cmd db1  'secret' 'systemctl status postgresql'

6.1 逐主机循环

配合数组循环,批量在多台主机执行同一命令并汇总。

#!/usr/bin/env bash
hosts=(web1 web2 db1)
pass='shared-secret'
for h in "${hosts[@]}"; do
  echo "== $h =="
  ssh_cmd "$h" "$pass" 'hostname; uptime' || echo "  $h 失败"
done

一句话总结: 封装的收益是业务脚本不再出现 expect 细节,失败主机也能单独标记继续执行。

6.2 配置生成与分发

expect 不传密码到命令行,避免进程列表泄露;配合配置文件读取更安全。

#!/usr/bin/env bash
# 从 .env 读密码,避免硬编码
# shellcheck disable=SC1091
source .env
ssh_cmd "$HOST" "$DB_PASSWORD" 'pg_isready -h localhost'

7. 实战:交互式安装与菜单自动化

一句话总结: 安装向导、配置菜单、首次登录改密是 expect 三类高频场景,核心套路都是「读到提示 → 发对应按键」。

7.1 自动应答安装向导

#!/usr/bin/expect -f
set timeout 60
spawn ./install.sh
expect {
  "Do you accept the license? [y/N]" { send "y\r"; exp_continue }
  "Install directory [/opt/app]"     { send "\r"; exp_continue }
  "Start service now? [y/N]"         { send "y\r"; exp_continue }
  eof {}
  timeout { puts "安装超时"; exit 1 }
}

7.2 自动化改密与初始化

#!/usr/bin/expect -f
set timeout 10
spawn passwd
expect "current password:"
send "oldpass\r"
expect "New password:"
send "newpass\r"
expect "Retype new password:"
send "newpass\r"
expect eof

一句话总结: 注意 passwd 等程序要求「旧密码 + 两次新密码」,每个提示都要有对应 expect/send,少一个都会中断。

8. 总结

环节要点
判断标准程序读 /dev/tty 或检测 isatty 才需要 expect
pty 原理script/expect 提供伪终端,模拟按键回显与行编辑
三件套spawn 启动、expect 等待模式、send 发送输入
多模式并列 expect + exp_continue 处理多步提示
轻量替代heredoc/管道/script 先试,能不用 expect 就不用
超时处理set timeout + timeout 分支,失败退出非零码
场景封装参数化函数复用,业务脚本只传主机/密码/命令
实战场景安装向导、改密、菜单操作三类高频应用

交互式自动化是脚本自动化的最后一块拼图:管道能处理数据流,expect 能处理「要键盘的程序」。判断标准记牢「是否读终端」,能用 heredoc 就不引 pty,必须上 expect 时把超时、退出码、参数化封装做到位。下一步是文件与 IO 进阶,把重定向、临时文件与文件锁这套底层能力补齐。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战