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 进阶,把重定向、临时文件与文件锁这套底层能力补齐。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。