刚接手一批网络设备管理的时候,最头疼的就是每天要对着十几个交换机和路由器反复敲相同的配置命令。telnet 登上去、输入账号密码、逐条执行命令、保存配置、退出,每一步都是重复劳动,稍微走神还可能把命令敲错。后来我用 Python 写了一套 telnet 自动化运维脚本,把整个远程登录管理流程收敛成了几段核心代码,从此批量巡检、批量下发配置都变成了一键操作。
这篇文章我打算把完整的实践过程重新捋一遍。从为什么选 telnet 而不是其他协议,到 telnetlib 库的底层交互逻辑,再到能直接拿去用的脚本模板,最后是实际调试过程中踩过的坑。内容围绕“Python 自动化运维实现 telnet 远程登录管理设备”展开,适合刚接触网络自动化的运维同学参考,也适合已经在做命令行自动化、想补充 telnet 这块拼图的同行用来查漏补缺。
1. 整体方案设计与技术选型
1.1 为什么在自动化场景里还需要关注 telnet
很多人一听 telnet 就觉得是老古董,觉得既然有 SSH 为什么还要折腾明文协议。但实际接触过生产网络环境的人都知道,telnet 在一些场景里不仅没被淘汰,反而还是主流。我这边管理的设备里,有一部分是多年前采购的三层交换机,出厂固件只支持 telnet 管理,不支持 SSH。另一部分是内网核心设备,由于安全策略要求管理链路完全隔离,运维网段和其他业务网段做了物理分离,在保证隔离的前提下,设备为了兼容旧的脚本工具链,仍然保留 telnet 管理口。
从自动化的角度来看,telnet 有两个非常实际的优点。第一是协议极简,连接建立之后就是纯文本交互,没有 SSH 那套密钥交换和加密握手流程,脚本逻辑更直白,调试也方便。第二是兼容性极好,无论是思科、华为还是某些国产网络设备,telnet 命令行接口的行为模式几乎一致,无非是“用户名 -> 密码 -> 用户视图 -> 系统视图”,这套流程编写起来非常顺手。
当然,我并不是鼓励大家无脑用 telnet。现代网络设备能开 SSH 还是优先开 SSH。但作为一个合格的运维脚本,你的工具箱里必须有 telnet 这个方法,因为你永远不知道下个季度采购的设备会不会又是只支持 telnet 的型号。
1.2 为什么选用 Python 的 telnetlib 库
Python 标准库里有一个 telnetlib 模块,专门用来处理 telnet 协议。选它而不是 paramiko 或 netmiko,核心原因是零依赖。在运维服务器的 Python 环境里,你不需要先 pip install 任何额外包,直接用标准库就能跑起来,这对生产环境来说非常重要。有些内网运维服务器是离线环境,装第三方包非常麻烦,标准库方案能省掉很多沟通成本。
另外,telnetlib 的抽象层级很低。它没有像 netmiko 那样封装好固定的交互模式,但这也意味着你可以完全控制"发送什么字符、等待什么回显"。比如某些设备的登录提示符是User name:而不是常见的Username:,如果你用 netmiko,可能需要通过自定义device_type才能适配,但用 telnetlib 只需要改一行read_until的匹配字符串即可。底层控制力强,在应对杂牌设备时优势尤其明显。
当然,telnetlib 的缺点也很明确:它不会帮你做命令结果的分页处理、不会自动处理---- More ----,也不会帮你维护一个健壮的命令通道。这些问题需要我们自己在脚本层解决,后面的章节我会专门讲分页符怎么处理。
1.3 自动化脚本整体结构拆解
我的脚本整体可以拆成四层:
设备管理清单层:维护设备的 IP、账号、密码、登录方式、端口号 连接控制层:负责建立 telnet 连接、登录认证、检测登录是否成功 命令交互层:发送命令、读取回显、处理分页、判断是否到末尾 业务编排层:批量巡检、批量备份、批量下发 ACL 等具体任务这个分层的思路很直接,把"连接设备"和"执行业务命令"解耦。以后你新增批量任务,不需要再重复写连接逻辑,只需要在业务编排层加一个函数就行。如果某一天网络改造导致登录方式变了,也只需要修改连接控制层,业务代码完全不用动。
2. 核心细节解析:登录认证与命令交互
2.1 telnetlib 连接设备的底层行为
telnetlib 本质上就是帮你处理了 TCP 连接的数据收发。它内部读取数据是流式的,你必须明确告诉它"等到哪个字符串出现再继续",否则read_until会一直阻塞到超时。这块是新手最容易卡住的地方,我多解释几句。
当你调用tn.read_until(b"Password: ", timeout=5)时,telnetlib 会不断读取套接字中的数据,每收到一段就检查缓冲区里是否包含目标字节串,如果包含,立即返回包含目标串的完整回显数据;如果超过 timeout 还没等到,就会返回当前已经收到的所有数据,但不抛出异常。这个特性非常关键,我们判断登录失败时,靠的就是它超时之后的返回值内容。
还有一个细节是tn.write()发送数据时,telnetlib 不会自动帮你加换行符。你写tn.write(b"system\n")和tn.write(b"system\r\n")在大多数设备上都能执行命令,但少数严格模式的设备只认\r\n。我在脚本里统一用\r\n,兼容性最好。
2.2 登录认证的可靠实现方式
telnet 登录一般有两种提示样式。旧款华为设备常见的是Username:,思科老设备常见的是Username:,但有不少国产设备中文提示和英文提示混杂。经验之谈是不要猜提示词,而是用read_until等待一个比较宽泛的字节串,然后紧接着判断当前处于什么状态。
我常用的写法是:
import telnetlib import time def telnet_login(host, port, username, password, timeout=10): tn = telnetlib.Telnet(host, port, timeout=timeout) # 等待出现登录提示,常见的有 Username/Login/User name data = tn.read_until(b"Username:") if b"Username:" not in data and b"name:" not in data: raise RuntimeError(f"未检测到登录提示, 回显: {data[:200]}") tn.write(username.encode("ascii") + b"\r\n") data = tn.read_until(b"Password:") if b"Password:" not in data: raise RuntimeError("未检测到密码提示") tn.write(password.encode("ascii") + b"\r\n") # 等待登录成功的标志,常见的是 > ] 或设备名称 data = tn.read_until(b">", timeout=5) if b">" not in data and b"]" not in data: raise RuntimeError(f"登录可能失败, 回显: {data[:200]}") return tn这里有个细节:read_until(b"Username:")如果设备的提示是User name:,因为里面包含name:,所以也能匹配到,这就是我等宽泛字节串的原因。但要注意,如果设备登录提示是纯中文的,字节匹配会失效,这种情况需要先登录一次拿到中文提示的编码,再在脚本里用对应的中文 encode。
2.3 命令下发与回显读取的机制
登录成功之后,命令交互就简单了。发送命令、读取回显、判断结果,三个动作一个循环。但真正麻烦的是设备输出过长时出现的分页。大多数网络设备默认开启分页功能,输出超过一屏就会暂停在---- More ----等待你按键继续。
处理分页的策略有三种:
第一种是发送命令前先禁用分页。比如思科执行terminal length 0,华为执行screen-length 0 temporary。这是最推荐的做法,一劳永逸。但要注意,部分老设备不支持关闭分页,这时就用到第二种策略。
第二种是遇到More就发送空格键。实现方式是循环调用read_until(b"---- More ----"),匹配到了就write(b" "),直到命令回显完全结束。这个方法比较暴力,但只要设备输出里出现了这个提示符就能用。
第三种是干脆不用 read_until,改用定时 sleep 加固定次数读取。比如:
tn.write(command.encode("ascii") + b"\r\n") time.sleep(2) output = b"" while True: try: chunk = tn.read_very_eager() output += chunk if not chunk: break except Exception: break这种方式适合那些既不支持关闭分页、又对空格响应迟钝的老设备。缺点是不够优雅,但因为time.sleep给足了时间,实际效果最稳。
2.4 为什么需要等待标志字符串而不是固定 sleep
很多初学写的 telnet 脚本是"发送命令后 sleep 3 秒再读数据",这种方法简单但隐患很大。网络设备执行命令的速度差异非常大,display current-configuration在大配置量的设备上可能要执行半分钟,而display clock可能一秒不到就完成。如果统一 sleep 3 秒,慢设备的结果还没出来,快设备的输出也读不全。
所以原则是"等待标志字符串优先,sleep 兜底"。比如查询命令,我们可以在发送命令之后等待界面提示符再次出现,比如]或>,这代表设备已经执行完命令并且回到了可以输入下一行命令的状态。时间上完全自适应,设备执行多久我们就等多久。只有当等待提示符超时了,才降级到额外 sleep。
3. 实操过程与完整脚本实现
3.1 批量设备登录的脚本骨架
我这边实际使用的脚本会先从文本文件读取设备信息,然后逐台连接执行命令。文本文件的格式非常简单:
192.168.10.1|admin|pass123|huawei 192.168.10.2|admin|pass456|cisco 192.168.10.3|admin|pass789|generic每行字段用竖线分隔,分别是 IP、用户名、密码、设备类型。之所以在文件里带设备类型,是因为不同品牌的命令不同。同样是查看设备名称,华为是display current-configuration | include sysname,思科是show running-config | include hostname。脚本里根据设备类型选择不同的命令集。
批量处理的核心逻辑如下:
import telnetlib import datetime def process_device(ip, username, password, device_type): tn = telnet_login(ip, 23, username, password) commands = get_commands_by_type(device_type) results = {} for cmd in commands: output = send_command(tn, cmd) results[cmd] = output save_to_file(ip, results) tn.close()这段代码看起来简单,但生产环境里有一个非常耗时的环节:每台设备连接一次,登录认证要 2 到 5 秒,执行多条命令再算上网络往返,一台设备跑完大约需要 20 到 40 秒。如果你有 50 台设备,串行处理就要 20 分钟以上。这时候就需要引入并发,但并发又会带来一个新的问题,后面的常见问题章节我会专门讲多线程并发登录的结果错乱是怎么解决的。
3.2 发送命令的封装与结果解析
send_command函数不要直接裸写tn.write()加read_until(),因为你需要同时处理"命令回显里包含命令本身"和"结果末尾出现提示符"这两个特征。注意,把命令本身回显剔除是一个容易踩坑的细节。设备默认开启 echo,你在终端里输入什么,设备就会回显什么。所以如果你执行display version,收到的第一个字符块就是display version\r\n,然后才是命令结果。
这个回显对我们判断有没有发送成功有帮助,但如果要归档日志,就得把它去掉。我的处理方式是发送命令后,先read_until(b"display version"),把命令回显消耗掉,再继续读取后续输出直到提示符。这样最终存放在字典里的结果就不包含命令回显,干净很多。
当然,这里有个反例。有些设备在注销后会重新显示登录界面,或者命令执行过程中设备主动重启,都会导致等待提示符超时。这属于设备异常,我会单独打标,不塞进正常结果里。
def send_command(tn, cmd, prompt=b"]", timeout=15): tn.write(cmd.encode("ascii") + b"\r\n") data = tn.read_until(cmd.encode("ascii"), timeout=5) # 此时已经读到了命令回显 result = b"" while True: try: chunk = tn.read_until(prompt, timeout) result += chunk if prompt in chunk: break except Exception: # 超时兜底,读取当前所有数据 result += tn.read_very_eager() break # 剔除最后那个提示符自身 result = result.replace(prompt, b"") return result3.3 检测设备连通性与登录失败的快速跳过
批量处理时首先要做的是连通性预检。脚本开局会对所有设备发送一个 TCP 连接测试。如果端口不通,直接标记为离线,不进入登录逻辑。这一步可以避免登录阶段的大量超时等待。
TCP 预检可以用 socket 实现,也可以用 telnetlib 的Telnet()构造直接 try except。我的实现是 socket 连接测试:
import socket def check_port(ip, port, timeout=3): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) try: s.connect((ip, port)) return True except Exception: return False finally: s.close()如果预检通过但登录失败,脚本不会硬等。登录函数里检测到没有出现标志字符串,就直接抛异常,外层捕获后记录日志,继续处理下一台设备。
3.4 配置备份场景的实操脚本
下面我给一个可以直接改改就用的完整备份脚本。功能是登录一批华为交换机,依次执行关闭分页、进入系统视图、执行display current-configuration,把输出保存到以 IP 和日期命名的文件里。
import telnetlib import datetime import os HOSTS = [ {"ip": "192.168.10.1", "user": "admin", "pwd": "pass123"}, {"ip": "192.168.10.2", "user": "admin", "pwd": "pass456"}, ] LOG_DIR = "config_backup" def login(ip, user, pwd): tn = telnetlib.Telnet(ip, 23, timeout=10) tn.read_until(b"Username:") tn.write(user.encode() + b"\r\n") tn.read_until(b"Password:") tn.write(pwd.encode() + b"\r\n") # 登录成功标志通常是 ] tn.read_until(b"]", timeout=5) return tn def backup_one(host): ip = host["ip"] tn = login(ip, host["user"], host["pwd"]) # 关闭分页 tn.write(b"screen-length 0 temporary\r\n") tn.read_until(b"]", timeout=5) # 进入系统视图再执行 display current-configuration tn.write(b"system\r\n") tn.read_until(b"]", timeout=5) tn.write(b"display current-configuration\r\n") config = b"" while True: try: chunk = tn.read_until(b"]", timeout=10) config += chunk if b"]" in chunk: break except Exception: config += tn.read_very_eager() break # 去掉末尾的提示符 config = config.replace(b"]", b"", 1) # 保存文件 if not os.path.exists(LOG_DIR): os.makedirs(LOG_DIR) fname = f"{ip}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.cfg" with open(os.path.join(LOG_DIR, fname), "wb") as f: f.write(config) tn.close() print(f"[OK] {ip} 备份完成") for h in HOSTS: try: backup_one(h) except Exception as e: print(f"[FAIL] {h['ip']} 失败: {e}")这个脚本我实际跑过很多次,有一个要点值得单独讲:screen-length 0 temporary后面必须等待]出现再发下一条命令。因为设备处理命令是串行的,前一条没执行完,后一条命令可能会被吞掉,或者挤在同一个输入缓冲区里导致前一条命令被截断。脚本里的每个read_until都是在强制保证时序。
3.5 结果日志与报告生成
批量运维脚本跑完,如果没有清晰的报告,那等于白做。我的脚本会把每台设备的执行结果汇总成一个纯文本报告,标记出三档状态:
OK:登录成功、命令执行成功、回显完整 WARN:登录成功但某条命令回显缺失 FAIL:登录失败或连接超时报告生成逻辑不复杂,在 process_device 返回结果后,统一写进一个本地横线分隔的日志文件。文件内容大概长这样:
======================================== 设备: 192.168.10.1 时间: 2024-06-01 10:30:22 状态: OK 命令: display version 回显前100字节: VRP (R) software, Version 5.130 ========================================有了这个报告,就算脚本交给别的同事跑,也能直接看懂整体巡检情况。
4. 常见问题与排查技巧实录
4.1 登录提示符不匹配导致一直超时
这是最常见的故障。网络设备厂商非常多,登录界面的提示词五花八门。有的显示Username:,有的显示login:,有的甚至直接显示User name:,如果你写的只有一个固定匹配串,很容易卡死在等待阶段。
我的排查思路是:先用普通 telnet 客户端手动连一次设备,把真实的登录提示字符串原样拿下来,再回到脚本里调试。如果实在拿不准,可以用read_until(b"name:")这个折中方案,能同时匹配Username:、User name:、login:,覆盖度高很多。不过要小心某些设备在输入用户名错误之后退回到login:提示,这也可以用来辅助判断账号是否错误。
4.2 禁用分页命令不生效
华为设备上screen-length 0 temporary一般是临时生效的,但有些设备对这条命令的支持有差异,比如需要指定屏幕行数加零,有些设备实际参数是screen-length 0不带 temporary。思科设备则是terminal length 0,两者不同。
如果在测试时发现发送禁用分页命令后,回显依然出现---- More ----,先不要急着改脚本,打开调试模式手动执行一次。telnetlib 有个tn.set_debug_level(1)方法,开启后所有收发内容会打印到终端。用这个方法去确认设备到底有没有收到命令、有没有返回错误提示,通常问题立刻就能暴露出来。
4.3 命令回显中包含异常字符或乱码
telnet 协议本身还会传输一些控制字符,比如\xff\xfb\x01这种 IAC 序列。telnetlib 内部已经做了处理,一般不会出现在read_until返回的数据里。但某些国产设备在登录后会输出一些 ANSI 颜色转义序列或者制表符,看起来像乱码,但对后续判断影响不大。归档日志时如果想清理,可以用正则去掉 ANSI 转义。
如果设备使用中文语言包,回显里还有可能包含 UTF-8 中文。这时读取数据时要特别注意:在 Python 3 里这些都是 bytes,你用decode("utf-8")转成字符串时,如果碰到半个字节残留就会报错。我的解决办法是先用bytes.decode("utf-8", errors="replace")做兜底解码,保证日志不会因为单个字符异常而中断。
4.4 多线程并发时结果错乱的问题
曾经我把脚本改成多线程并发执行,每台设备一个线程,结果发现日志文件里不同设备的内容互相穿插。原因很简单:多个线程同时往同一个文件里写,没有加锁。这个问题排查看起来复杂,其实解决办法很粗暴,每个线程生成的报告先写在内存里,最后统一由主线程汇总写入文件。或者用threading.Lock包住写入操作。我在生产脚本里用的是前者,因为日志采集量不大,内存完全能承受。
另外一个并发隐患是设备侧本身不允许同一账号同时登录多个会话。部分老设备检测到重复登录会强制踢掉之前那个会话,导致后面的任务全部乱掉。所以并发数量我一般控制在 5 个以内,并且同一个设备文件里的账号尽量错开。更稳妥的方案是每台设备使用独立运维账号。
4.5 密码配置与敏感信息保护
telnet 本身就是明文协议,所以脚本里的明文密码就更加敏感。我在生产环境中不会把真实密码直接写在脚本或者设备清单文件里,而是用一个简单的环境变量注入方式。脚本运行时从环境变量读取密码,比如os.environ.get("NET_DEVICE_PASSWORD")。这样即使脚本被传到其他地方,拿不到环境变量也跑不了核心逻辑。
另外,设备清单文件建议设置严格的文件权限,只允许运维账号读取。如果公司有密钥管理系统,也可以先把密码加密存放到系统里,脚本启动时临时解密,用完即焚。这部分属于安全工程范畴,根据公司安全策略实施即可,但底线是脚本里绝对不能出现真实生产密码。
4.6 命令执行超时但设备其实还在处理
有时候你等待提示符],等了 20 秒没等到,但实际上命令还没执行完。比如设备正在保存配置或者正在执行某些耗时操作,回显停顿是正常的。这种情况下直接判失败会误报。
我的改进策略是增加重试机制。第一次read_until超时后,不要立即抛异常,先读取一次read_very_eager()看看是否有部分输出;如果有,说明命令还在执行中,继续等待下一轮。只有连续多轮都读不到任何新数据,才判定命令卡死。
5. 个人实操建议与扩展方向
这套 telnet 自动化方案我已经在真实环境里稳定跑了一年多,定期批量备份、批量巡检、批量修改设备密码都靠它。用下来最大的体会是:telnetlib 虽然功能简单,但配合"等待标志字符串、处理分页、统一分层"这三个核心思路,能解决绝大多数网络设备自动化问题。而且标准库没有额外依赖,扔到任何一台内置 Python 的运维机上就能运行,这是它最大的优势。
如果后续想在这个基础上更进一步,建议留意两个方向。第一个方向是引入 Netmiko 这样的高阶库,它其实就是对底层 telnet/ssh 通道的进一步封装,内置了大量设备类型的交互模板,适合需要兼容上百种品牌设备的大型环境。第二个方向是把自己的脚本改造成服务化接口,通过 API 方式接收设备清单和任务指令,让其他同事也能自助触发远程备份和巡检,不必再直接操作脚本。
最后分享一个我实践出来的小技巧:telnet 脚本调试前期,建议在关键步骤之间多插入print(repr(tn.read_very_eager())),直接打印原始字节数据。很多诡异的问题都是回显格式和我们想象的不一样导致的,看一眼原始数据,立刻就知道怎么改匹配串了。保持这个调试习惯,telnet 自动化这块会顺畅很多。