尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Expect自动化交互脚本:从核心三要素到实战应用

Expect自动化交互脚本:从核心三要素到实战应用 1. 从“交互”到“自动化”为什么我们需要Expect在Linux运维和自动化脚本的世界里我们经常遇到一个看似简单却令人头疼的问题如何让一个脚本自动完成那些需要人工交互的任务比如你写了个脚本要自动登录一台服务器执行更新然后退出。你可能会想到用ssh userhost但紧接着系统就会提示你输入密码。脚本卡在这里自动化流程就此中断。你可能会说用密钥对认证不就行了没错这是最佳实践。但在很多现实场景中你无法控制目标环境可能是客户的老旧系统只支持密码认证可能是临时需要操作一批未配置密钥的设备或者像配置网络设备交换机、路由器的CLI它们天生就是交互式的。这时候传统的bash、sh就力不从心了因为它们的设计初衷是顺序执行命令而非“对话”。这就是expect登场的时刻。它不是一门新的脚本语言而是构建在Tcl语言之上的一个工具专门用于自动化交互式应用程序。它的核心思想很简单“期待”某个字符串出现然后“发送”对应的响应。这个“期待-发送”的循环模拟了人类在终端前的操作。因此当你的脚本需要处理ssh、ftp、telnet、passwd甚至是安装程序如mysql_secure_installation的提示时expect就成了连接自动化流水线断点的终极桥梁。最近在技术社区里关于shell的讨论热度不减从基础的“linux用shell重命名文件”到进阶的“shell脚本编程100例”再到安全领域的“ctfshow 反弹shell构造”都说明了shell脚本是系统管理的基石。而expect则是这块基石上用于处理“非标”任务的特殊工具它能将那些顽固的、需要人工介入的环节无缝地整合进你的自动化体系。2. Expect核心三要素spawn, expect, send要驾驭expect必须深刻理解它的三个核心命令。你可以把它们想象成一场自动化戏剧的导演指令。2.1 spawn启动你的“演员”spawn命令用于启动一个新的交互式进程。这是所有expect脚本的起点。你不能直接对当前shell环境使用expect必须通过spawn“孵化”出一个新的子进程来作为交互对象。#!/usr/bin/expect # 启动一个ssh连接进程 spawn ssh user192.168.1.100执行spawn后expect会接管这个新进程的标准输入、输出和错误流为后续的“对话”做好准备。这里有个关键细节spawn启动的进程其输出会被expect捕获用于模式匹配而send发送的内容则会作为该进程的输入。2.2 expect聆听“台词”并等待时机expect命令用于等待进程输出特定的字符串模式。它就像导演在侧幕仔细听着台上演员的台词一旦听到某句关键的台词就准备给出下一个指令。# 等待进程输出 “password:” 这个字符串 expect password:expect支持强大的模式匹配不仅仅是简单的字符串通配符expect password:*可以匹配 “password: ”、“password:” 后面跟任何内容。正则表达式使用-re选项来启用正则匹配例如expect -re \[Pp\]assword:*可以同时匹配 “Password:” 和 “password:”。多分支匹配一个expect可以等待多种可能的情况并对每种情况做出不同响应。这是处理复杂交互的关键。expect { # 情况1 匹配到 “yes/no” 提示例如首次SSH连接 yes/no { send yes\r exp_continue # 继续等待不结束这个expect块 } # 情况2 匹配到密码提示 password: { send MySecretPass\r } # 情况3 匹配到命令提示符例如 $, #, 表示登录成功 $ { # 登录成功可以执行后续命令了 } # 情况4 超时默认10秒 timeout { send_user 连接超时\n exit 1 } # 情况5 遇到文件结束符或进程意外结束 eof { send_user 连接意外关闭\n exit 1 } }这个结构是expect脚本的骨架。exp_continue命令尤其重要它让脚本在匹配并处理了某个模式如发送了“yes”后继续留在当前的expect块中等待其他模式而不是结束等待去执行后续脚本。timeout和eof是处理异常情况的保障务必养成使用的习惯。2.3 send下达行动指令send命令用于向spawn启动的进程发送字符串。这模拟了用户在键盘上的输入。# 发送密码并以回车键结束 send MySecretPass\r这里有两个至关重要的细节回车符\r 在交互式命令行中输入命令后需要按回车键执行。send命令本身只是输入字符不会附加回车。因此绝大多数情况下你都需要在发送的字符串末尾加上\r有时\n也可行但\r更通用。忘记加\r是新手最常见的错误会导致脚本“卡住”因为进程一直在等待你按回车。发送速度send_slow 有些老旧的程序或设备处理输入的速度较慢。如果send发送过快可能导致字符丢失。这时可以使用send_slow命令它可以在字符之间插入微小延迟。set send_slow {1 .1} # 每个字符间隔0.1秒每发送1个字符后延迟0.1秒 这里参数解释通常为{间隔时间 延迟时间}但常用写法是 {delay_between_chars delay_between_parts}。更常见的用法是直接指定一个延迟值。 # 更常见的用法是 set ::send_slow 50 # 每个字符间隔50毫秒实际上send_slow是一个过程procedure。标准用法是 # send_slow {delay_in_milliseconds} “string” # 例如 send_slow 100 “hello” # 发送hello每个字符间隔100毫秒实际上更可靠的方法是使用expect的send命令的-s选项或者简单地用after命令配合多个send来实现延迟。掌握了spawn、expect、send你就掌握了expect的绝大部分能力。剩下的就是如何用Tcl语言的变量、控制流等将它们有机组合起来解决实际问题。3. 实战案例一自动化SSH登录与巡检这是一个最经典的应用场景。假设我们需要每天凌晨自动登录一批服务器检查磁盘使用率并将结果收集回来。3.1 基础版本单次登录执行命令我们先写一个完成单台服务器检查的脚本ssh_check.exp。#!/usr/bin/expect -f # 定义变量提高脚本可维护性 set host “192.168.1.101” set user “ops” set password “ServerPass123” set timeout 30 # 设置全局等待超时时间为30秒 # 1. 启动SSH进程 spawn ssh $user$host # 2. 处理交互过程 expect { # 处理首次连接时的RSA密钥确认 “yes/no” { send “yes\r” exp_continue # 继续等待密码提示或提示符 } # 处理密码提示 “password:” { send “$password\r” } # 如果直接登录到了提示符可能配置了密钥但这里我们假设需要密码 } # 3. 等待登录成功出现命令提示符这里假设提示符包含 ‘$‘ 或 ‘#’ expect “[$#] ” # 匹配 ‘$ ‘ 或 ‘# ‘ # 4. 发送要执行的命令 send “df -h | grep -E ‘^(/dev/|Filesystem)’\r” # 查看磁盘使用率过滤出标题和/dev/开头的行 # 5. 等待命令执行输出并再次出现提示符 expect “[$#] ” # 6. 获取上一条命令的输出在expect匹配之前输出存在于expect_out(buffer)中 # 注意expect_out(buffer)包含从上次匹配结束到本次匹配开始的所有输出。 # 一个更清晰的做法是在执行命令前设置一个标记。 send “echo ‘—EOF—‘\r” # 输出一个唯一标记 expect “—EOF—“ # 此时从上次提示符到 ‘—EOF—‘ 之间的输出就包含了df命令的结果。 # 但更简单的方法是我们直接在上一步 expect “[$#] ” 后将 expect_out(buffer) 保存下来。 # 实际上我们需要重构一下思路。 # 更好的做法在发送df命令后expect一个独特的模式来捕获输出。 send “df -h | grep -E ‘^(/dev/|Filesystem)’ echo ‘COMMAND_FINISHED’\r” expect “COMMAND_FINISHED” # 此时expect_out(buffer) 中包含了从发送df命令后到 ‘COMMAND_FINISHED’ 的所有输出。 set cmd_output $expect_out(buffer) # 但这里面也包含了我们回显的 ‘COMMAND_FINISHED’ 和可能有的提示符。需要清洗。 # 我们可以用Tcl的字符串命令处理或者换一种更清晰的捕获方式。 # 7. 退出登录 send “exit\r” # 8. 等待spawn的进程结束 expect eof # 打印捕获到的输出简单处理仅作演示 puts “主机 $host 磁盘检查结果” # 这里需要从buffer中提取出df命令的输出。一个粗略的方法是按行分割然后过滤。 # 但为了示例清晰我们简化处理假设我们只关心输出内容。 puts “$cmd_output”这个基础版本有很多问题比如输出捕获不干净、没有错误处理。但它演示了核心流程。3.2 进阶版本函数化与错误处理让我们写一个更健壮、可复用的版本。我们将登录和执行命令封装成函数。#!/usr/bin/expect -f # 定义超时 set timeout 30 log_user 0 # 默认不显示expect的交互过程更干净。调试时可设为1。 # 定义一个执行远程命令的函数 proc execute_remote_command {host user password command} { # 内部变量避免污染外部 set timeout 30 # 启动ssh使用 -q 和 -o StrictHostKeyCheckingno 减少交互和确认 spawn ssh -q -o StrictHostKeyCheckingno $user$host expect { “password:” { send “$password\r” exp_continue # 继续等待提示符 } “Permission denied, please try again.” { puts stderr “错误登录 $host 权限被拒绝密码错误” catch {exp_close} # 尝试关闭进程 catch {exp_wait} # 等待进程结束 return -code error “AUTH_FAILED” } timeout { puts stderr “错误连接 $host 超时” catch {exp_close} catch {exp_wait} return -code error “CONN_TIMEOUT” } eof { puts stderr “错误连接 $host 意外关闭” return -code error “CONN_EOF” } # 等待登录成功后的提示符这里假设是 ‘$‘, ‘#‘ 或 ‘’ -re “\[$#\ ” { # 成功登录继续 } } # 发送目标命令并在命令后加上一个唯一标记 send “$command; echo ‘EXPECT_CMD_DELIMITER_$host‘\r” # 等待直到看到我们的唯一标记 expect { -re “EXPECT_CMD_DELIMITER_$host“ { # 成功执行。在标记出现前的所有输出是命令结果。 # expect_out(buffer) 包含从上次匹配到这次匹配的所有内容。 # 我们需要提取出命令的实际输出。 # 一种方法从buffer中删除最后一行即我们的标记行和可能跟随的提示符。 set full_buffer $expect_out(buffer) # 使用正则提取标记之前的内容可能包含多行 # 这是一个简化的处理实际情况可能更复杂。 regexp “(.*?)EXPECT_CMD_DELIMITER_$host“ $full_buffer match cmd_output set cmd_output [string trim $cmd_output] } timeout { puts stderr “错误在 $host 上执行命令超时” send “\003” # 发送Ctrl-C尝试中断 exp_continue } eof { puts stderr “错误在执行命令时连接 $host 断开” return -code error “CMD_EXEC_EOF” } } # 退出登录 send “exit\r” expect eof # 等待ssh进程完全退出 return $cmd_output } # 主程序 set hosts {192.168.1.101 192.168.1.102} set user “ops” set password “YourSecurePassword” # 注意密码硬编码在脚本中不安全应使用其他方式。 foreach host $hosts { puts “\n 正在检查主机$host ” set result [execute_remote_command $host $user $password “df -h | head -5”] # 只取前5行 if {[lindex $result 0] eq “AUTH_FAILED” || [lindex $result 0] eq “CONN_TIMEOUT”} { puts “检查失败$result” } else { puts “磁盘使用情况\n$result” } after 2000 # 每台服务器检查后等待2秒避免过快连接 }这个版本引入了procTcl的过程即函数提供了基本的错误处理认证失败、超时、连接断开并使用了更鲁棒的输出捕获策略通过唯一标记EXPECT_CMD_DELIMITER。log_user 0让输出更干净。当然密码硬编码是极不安全的这引出了下一个关键点。3.3 安全增强从文件或环境变量读取密码永远不要将密码明文写在脚本中。我们可以从受保护的文件或环境变量中读取。方法A从加密文件读取简单示例假设我们有一个文件.server_pass其内容格式为host:user:password并且该文件权限为600。#!/usr/bin/expect -f set timeout 30 log_user 0 # 读取凭证文件 set fh [open “~/.server_pass” r] while {[gets $fh line] ! -1} { # 忽略注释和空行 if {[string match “#*” $line] || [string trim $line] eq “”} { continue } set parts [split $line “:”] if {[llength $parts] 3} { lappend hostlist [lindex $parts 0] set cred([lindex $parts 0]) [list [lindex $parts 1] [lindex $parts 2]] } } close $fh foreach host $hostlist { set user [lindex $cred($host) 0] set password [lindex $cred($host) 1] puts “\n 检查 $host ($user) ” # … 调用之前的 execute_remote_command 函数 … }方法B从环境变量读取在运行脚本前先设置环境变量。export SERVER_PASS‘YourPassword‘在expect脚本中set password $env(SERVER_PASS)或者使用更安全的sshpass工具但expect本身也是一种替代sshpass的方案。注意任何自动化密码管理都存在风险。生产环境中优先使用SSH密钥认证。expect密码的方案应仅用于无法使用密钥的临时或遗留场景并且要严格控制脚本和凭证文件的访问权限chmod 600。4. 实战案例二自动化交互式命令行程序配置除了SSHexpect的另一个主战场是配置那些没有API、只有交互式CLI的设备或程序比如老式交换机、路由器或者某些数据库的初始化脚本。以自动化初始化MySQL为例mysql_secure_installation的简化模拟。这个脚本会交互式地询问多个问题。#!/usr/bin/expect -f # auto_init_mysql.exp set timeout 10 # 假设MySQL root用户的初始密码为空我们需要设置新密码并做一些安全设置。 spawn mysql_secure_installation expect “Enter current password for root (enter for none):” send “\r” # 当前密码为空直接回车 expect “Set root password? \[Y/n\]” send “Y\r” expect “New password:” send “NewMySQLRootPass123!\r” expect “Re-enter new password:” send “NewMySQLRootPass123!\r” expect “Remove anonymous users? \[Y/n\]” send “Y\r” expect “Disallow root login remotely? \[Y/n\]” send “Y\r” expect “Remove test database and access to it? \[Y/n\]” send “Y\r” expect “Reload privilege tables now? \[Y/n\]” send “Y\r” expect eof puts “MySQL安全初始化已完成。”这个脚本非常直观就是一系列严格的“问答”。但实际环境中问题顺序或提示语可能因版本而异。为了更健壮我们应该使用之前提到的多分支expect结构。#!/usr/bin/expect -f set timeout 15 spawn mysql_secure_installation expect { “Enter current password for root*” { send “\r” exp_continue } “Set root password?*” { send “Y\r” exp_continue } “New password:*” { send “NewMySQLRootPass123!\r” exp_continue } “Re-enter new password:*” { send “NewMySQLRootPass123!\r” exp_continue } “Remove anonymous users?*” { send “Y\r” exp_continue } “Disallow root login remotely?*” { send “Y\r” exp_continue } “Remove test database and access to it?*” { send “Y\r” exp_continue } “Reload privilege tables now?*” { send “Y\r” exp_continue } eof { puts “初始化完成。” } timeout { send_user “脚本执行超时可能遇到未预期的提示。\n” exit 1 } }这个版本使用了exp_continue无论问题以什么顺序出现只要提示文本匹配脚本都能处理。timeout处理了卡住的情况。这是一种更通用的编写交互式配置脚本的思路。5. 避坑指南与高级技巧即使掌握了基本语法在实际使用expect时你依然会踩到一些坑。以下是我从无数次调试中总结出的经验。5.1 坑一模糊匹配与预期不符问题你expect “$ “但实际提示符是[userhost ~]$导致匹配失败脚本超时。解决使用更宽松的匹配。对于提示符最好用正则表达式。expect -re “\[$#\ $” # 匹配以 ‘$‘, ‘#‘, ‘‘ 加空格结尾的行或者匹配包含特定字符的行expect “*$ “ # 通配符匹配任意字符后跟 ‘$ ‘心得在编写expect模式时先手动运行目标程序仔细观察其输出的精确格式包括空格和换行。使用*或-re进行模糊匹配但也要避免过于宽泛导致匹配到错误行。5.2 坑二输出缓冲导致匹配失败问题某些程序如一些基于Cisco IOS的设备的输出不是行缓冲的或者会一次性输出大量文本导致expect在收到完整提示符前就超时。解决调整timeout值适当增加全局或单个expect的超时时间。set timeout 60 # 全局设置为60秒 expect { -timeout 120 “some_pattern” { … } # 针对这个expect单独设置120秒 }使用expect的-ex完全匹配或-re正则有时换行符\n或回车符\r会影响匹配。匹配部分输出不一定要匹配完整的提示符匹配一个独特的、一定会出现的子串即可。expect “hostname” # 而不是 “hostname ”发送空操作触发输出在长时间等待后可以发送一个回车send “\r”来“唤醒”设备让它刷新提示符。expect { “some_prompt” { … } timeout { send “\r” # 发送回车 exp_continue # 继续等待 } }5.3 坑三特殊字符与转义问题密码或命令中包含$、[、*等特殊字符在Tcl或expect模式中具有特殊含义导致错误。解决在send中如果字符串包含]、[、$等Tcl特殊字符但你不希望它们被解释可以将其放在花括号{}中。send {MyP$ss[w]ord} # 花括号内的所有字符都被视为字面量 send “MyP\$ss\[w\]ord” # 使用反斜杠转义也可以但更繁琐在expect模式中对于正则表达式-re同样需要转义。对于普通字符串匹配如果模式中包含[、*等最好使用-ex选项进行精确字符串匹配或者用\转义。expect -ex “Password [required]:” # -ex 表示精确匹配方括号不会被解释为字符集5.4 高级技巧与Bash脚本协同工作expect脚本通常独立运行。但我们可以让Bash脚本调用expect脚本并传递参数实现更复杂的逻辑。Bash主脚本 (main.sh):#!/bin/bash HOST_LIST(“server1” “server2”) USER“admin” TASK“uptime” for HOST in “${HOST_LIST[]}”; do echo “Processing $HOST…” # 调用expect脚本并传递参数 /usr/bin/expect EOF set timeout 20 spawn ssh $USER$HOST expect “password:” send “\$ENV{SSH_PASS}\r” # 从环境变量读取密码 expect “*$ ” send “$TASK\r” expect “*$ ” send “exit\r” expect eof EOF # 注意这里的EOF必须顶格且后面不能有空格。 # 密码通过环境变量SSH_PASS传递避免了在脚本中明文出现。 done在这个例子中Bash脚本负责循环和任务编排而嵌入的expect代码段Here Document处理交互。密码存储在环境变量SSH_PASS中。这是一种灵活的混合编程模式。5.5 调试技巧当脚本不按预期运行时调试是必须的。启用详细输出在脚本开头或运行时加上-d参数。#!/usr/bin/expect -f exp_internal 1 # 在脚本内开启调试会显示内部匹配过程 # 或者运行脚本时 expect -d your_script.exp这会显示expect正在等待什么、收到了什么数据、以及何时匹配成功。这是最强大的调试手段。逐步执行在关键位置添加send_user或puts语句打印变量状态或执行步骤。send_user “正在等待密码提示…\n” expect “password:” send_user “已匹配到密码提示正在发送密码。\n” send “$password\r”检查超时如果总是超时首先检查expect的模式字符串是否完全正确包括大小写和空格。使用-ex进行精确匹配测试。expect是一个将“手动交互”转化为“自动流程”的利器。它的学习曲线在于对交互过程的精确观察和对边界情况的妥善处理。在DevOps、网络自动化、批量系统部署等领域掌握expect能让你摆脱大量重复的机械操作。记住它的最佳实践是密钥优先密码次之模式宽松异常严控日志详尽便于调试。当你下次面对一个喋喋不休的交互式命令行时你知道该请出哪位“对话专家”了。
返回列表