SSH都普及这么多年了我上周整理机房资产清单时还是发现了一小批只开了 telnet 服务的旧网络设备。这批设备管理口没有 SSH 选项带外管理完全依赖串口终端服务器想批量抓配置、核对端口状态最省事的办法反而是把十几年前的telnetlib从 Python 标准库里翻出来用。很多人一听 telnet 就摇头觉得是明文协议、早该淘汰。但真实环境里大量嵌入式设备、老交换机、路由器、PDU、存储设备的带外管理口至今只提供 telnet。做网络自动化、运维脚本、批量操作设备的朋友迟早会碰到只能 telnet的尴尬场景。这篇就是把我自己用 Python 通过 telnet 做远程连接和管理设备的经验完整梳理一遍包含协议交互原理、完整可运行的脚本、以及我在真实项目里反复踩过的坑适合正在处理老旧设备自动化、想从零上手 telnetlib 的运维和开发同学参考。1. SSH都普及这么多年了为什么还在写telnet脚本1.1 哪些场景逼着你非用telnet不可我在刚开始接触网络自动化时也有同样的疑惑明明paramiko处理 SSH 那么顺手为什么还要专门研究 telnet后来经历了几台设备之后才明白telnet 在以下几个场景里是绕不开的。首先是老设备。很多网络设备固件停更很多年官方只内置了 telnet 服务端没有 SSH。这类设备分布在中小机房的角落、工厂产线、监控专网里数量不多但都很关键不可能全部靠人工台台登录去操作。其次是串口服务器和带外管理。不少机房用终端服务器把设备的 console 口映射成网络端口通过 telnet 协议访问这就是典型的带外管理通道。你想写脚本去抓 console 日志或做远程重启只能对着 telnet 说话。第三类情况是某些设备在 SSH 协商阶段容易出兼容性问题反而 telnet 稳定。比如一些老版本存储设备、UPS 管理卡SSH 客户端需要开一堆兼容参数才能连上telnet 则简单直接。所以别急着升级设备或硬啃 SSH先把 telnet 这条路走通是投入产出比很高的方案。1.2 telnet和SSH的核心差异以及选择边界很多人分不清两台设备的管理方式该选哪个。其实判断标准很简单telnet 不加密SSH 加密telnet 端口通常走 23SSH 走 22telnet 协议实现简单但也因此能跑在极其轻量的嵌入式设备上。对比项telnetSSH加密无明文传输加密传输默认端口2322认证方式用户名密码为主密码或密钥占用资源低相对高Python生态标准库 telnetlibparamiko等第三方库适用场景老设备、带外管理、内网调试常规生产环境、跨公网管理我的建议是能用 SSH 的地方绝不用 telnet这是安全底线但在老设备、带外管理口、内网调试这些场景里telnet 依然是必需品。做自动化脚本时完全可以在同一套框架里同时支持两种协议按设备类型分发而不是非此即彼。2. telnetlib的交互模型read_until在等什么write发出去的是什么2.1 三个最常用的方法先记熟Python 标准库里的telnetlib使用起来并不复杂核心就几个方法read_until、write、close。先看一段最基础的手感代码import telnetlib # 连接设备timeout 单位是秒 tn telnetlib.Telnet(192.0.2.10, port23, timeout10) # 等待出现Username:字符串返回从上次读取位置到这里的全部字节 tn.read_until(bUsername:, timeout10) tn.write(badmin\r\n) tn.read_until(bPassword:, timeout10) tn.write(bpassword123\r\n) # 等特权模式提示符随后执行命令 tn.read_until(b#, timeout10) tn.write(bshow version\r\n) # 读完整段输出直到再次出现提示符 output tn.read_until(b#, timeout10) print(output.decode()) tn.close()这段代码已经能完成一次最小闭环。但要真正写好 telnet 脚本必须理解read_until的行为否则后面全是坑。read_until(expected, timeout)的行为是持续从 socket 读取数据直到读到的内容里出现expected这个字节串然后返回从上次未读位置到匹配结束的所有字节。如果直到超时也没匹配到它不会抛异常而是把当前已经读到的内容原样返回可能是空字节串。这一点非常关键很多人以为超时会抛异常结果判断逻辑写错了。write(buffer)则是把字节串直接写到 socket 上。注意 telnetlib 不会自动帮你补回车换行所以执行命令时必须写成bshow version\r\n漏掉\r\n是最常见的新手错误。2.2 缓冲区与字节串Python 3下最容易忽略的形态变化telnetlib 在 Python 3 里处理的全部是bytes不是str。所有等待的目标、写入的命令都要用字节串。这个看起来很小的差别实际项目里会让新手栽无数跟头。举个例子设备提示符是switch你用read_until(, timeout5)Python 会直接报错因为read_until期望的是bytes类型。正确写法是read_until(b, timeout5)。同理写入用户名密码时也要写tn.write(badmin\r\n)而不是字符串。这里要再强调一下缓冲区特性。read_until返回的内容包含匹配串本身但匹配到的那一段会被消费掉下一次read_until不会再读到它。可以类比成从流水线上取货你盯着一箱货里出现红色标签一旦看到就把这些货全部拿走后面的货继续流过来。所以脚本里每个read_until都要对应一个明确的里程碑提示符顺序不能乱。2.3 用set_debuglevel抢救抓瞎现场写 telnet 脚本最痛苦的就是不知道设备到底发了什么、自己发了什么。这时候set_debuglevel(1)是救命的tn.set_debuglevel(1)开启后telnetlib 会把收发内容打印到标准输出。收发的是二进制协议加上可读文本你能看到类似这样的调试信息Telnet(192.0.2.10,23): recv b\xff\xfd\x01\xff\xfd\x03... Telnet(192.0.2.10,23): send bshow version\r\n前面的\xff\xfd是 telnet 协议的选项协商字节属于 IAC 命令正常情况不用管它telnetlib 自己会响应。发现问题时先打开 debuglevel 看数据流比瞎猜 prompt 是什么高效得多。我调试设备登录卡住时基本靠这一招定位是设备没发提示符还是提示符带了控制字符。3. 手写第一个完整连接脚本登录、进特权、抓配置、退出3.1 完整示例代码下面这段脚本是我在批量抓取网络设备配置文件时的精简版逻辑完整、适合直接改造import telnetlib import time HOST 192.0.2.10 USER admin PWD password123 ENA enable123 TIMEOUT 15 def main(): tn telnetlib.Telnet(HOST, port23, timeoutTIMEOUT) tn.set_debuglevel(0) # 1. 等待账号输入提示发送用户名 banner tn.read_until(bUsername:, TIMEOUT) tn.write(USER.encode(ascii) b\r\n) # 2. 等待密码提示发送密码 pwd_prompt tn.read_until(bPassword:, TIMEOUT) tn.write(PWD.encode(ascii) b\r\n) # 3. 等待普通用户提示符然后进入特权模式 result tn.read_until(b, TIMEOUT) if bsername in result and bPassword not in result: # 有些设备直接给特权模式 pass tn.write(benable\r\n) # 4. 进入特权模式后关闭分页避免输出卡在more ena_result tn.read_until(bPassword:, TIMEOUT) tn.write(ENA.encode(ascii) b\r\n) tn.read_until(b#, TIMEOUT) tn.write(bterminal length 0\r\n) tn.read_until(b#, TIMEOUT) # 5. 抓配置并输出 tn.write(bshow running-config\r\n) output tn.read_until(b#, TIMEOUT) print(output.decode(gbk, errorsreplace)) # 6. 退出设备 tn.write(bexit\r\n) tn.close() if __name__ __main__: main()3.2 逐行拆解和避坑说明这段代码每一步都有讲究不是随手拼出来的。连接之后第一步是read_until(bUsername:)。这里注意设备刚连上会先发一段 banner 横幅有的横幅很长但read_until会一直读到出现Username:才返回所以 banner 长不长无所谓。真正的隐患是有些设备改了提示语比如login as:、User:这就需要把提示符作为参数不同设备类型用不同匹配串。发送用户名和密码后等待的提示符是还是#取决于设备登录后默认进入的是普通用户模式还是特权模式。为了稳妥我习惯登录之后先看返回内容里有没有或#再决定是否执行enable。如果普通模式和特权模式的提示符判断错了后续命令基本全废。进入特权模式时有的设备还会再要一次密码。上面的脚本用固定流程处理了这种情况但如果设备不问密码read_until(bPassword:)会一直等到超时这时就得根据返回结果分支处理。真实设备五花八门建议每个厂商的登录流程单独封装一个函数。terminal length 0这条命令千万不能省。很多设备默认分页显示输出到一屏就停住等你按空格脚本会被活活卡死。有些华为设备叫screen-length 0 temporary思科是terminal length 0要在脚本里根据设备类型切换。最后退出之前建议等上一次命令的输出完全返回提示符出现后再发exit否则可能把后面的输出切断。timeout要设置得比命令执行时间长我抓配置时一般给 30 秒以上。3.3 没有真实设备时如何在本机模拟一个telnet服务端来验证脚本很多初学者卡在没有设备练手。其实完全可以自己写一个极简的 telnet 服务端模拟设备对话框。下面这段代码基于 Python 自带的socket实现了最核心的账号密码验证和几个命令处理import socket import threading def handle_client(conn): conn.send(bWelcome to test switch\r\nUsername: ) data b while b\r not in data: data conn.recv(1024) conn.send(bPassword: ) data b while b\r not in data: data conn.recv(1024) conn.send(bOK\r\n# ) while True: cmd b while not cmd.endswith(b\r): cmd conn.recv(1024) cmd cmd.strip().lower() if bexit in cmd: conn.send(blogout\r\n) break elif bshow running-config in cmd: conn.send(bhostname test-sw\r\ninterface Gi1/0/1\r\n# ) else: conn.send(bunknown command\r\n# ) conn.close() srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((127.0.0.1, 23023)) srv.listen(5) print(fake telnet server on 127.0.0.1:23023) while True: conn, _ srv.accept() threading.Thread(targethandle_client, args(conn,), daemonTrue).start()用这个服务端telnetlib.Telnet(127.0.0.1, port23023)就能练手。等脚本跑通了再把 HOST 换成真实设备风险小得多。不过模拟终归是模拟真实设备的提示符、编码、分页行为更多变本地验证只是第一步。4. 上线前必须排掉的四类暗坑乱码、超时、提示符、登录回环4.1 乱码问题设备输出GBK而你按UTF-8解码网络设备输出的编码并不统一这是项目里最磨人的问题之一。很多国产品牌设备默认输出 GBK 或 GB2312 编码你用output.decode(utf-8)去解轻则中文变乱码重则直接抛UnicodeDecodeError。我现在的处理方式是text output.decode(gbk, errorsreplace)errorsreplace是关键它保证即使输出里混了少量特殊字符解码也不会中断。如果你拿不准设备是什么编码可以先print看一段乱码结构再决定用gbk还是utf-8。另外要注意你发给设备的命令本身是 ASCII但设备回显的内容可能是双字节字符串。比如华为设备的display version输出就有中文描述如果脚本要解析其中的字段编码不对会直接导致后续正则匹配失败。4.2 read_until超时导致脚本卡死或误判read_until的 timeout 行为前面提过超时返回当前已读内容而不是抛异常。听起来挺温和但实际带来的问题是你以为它还在等其实它已经带着半截数据返回了。比如你等#提示符超时返回的内容是sho而你后面又拿这个output去解析解析结果自然是错的。我的做法是把所有read_until都显式传入 timeout并对返回值做检查output tn.read_until(b#, timeout15) if b# not in output: print(f告警设备响应超时当前返回内容长度 {len(output)}) # 根据业务决定重试还是退出还有一个相关坑如果你把 socket 全局超时设了很短read_until没传 timeout 时也会用这个短的全局值导致大配置输出没读完就被截断。所以连接设备时我习惯把全局 timeout 设大一点比如 30 秒再在每一步单独控制等待时间。4.3 设备提示符不一致固定bytes无法匹配不同厂商、不同型号的设备提示符五花八门switch、Router#、userhost:/$、[H3C]、\r\n甚至同一台设备在正常模式和配置视图下提示符都不一样。如果你每个步骤都写死b换个设备就废。这时应该用expect配合正则patterns [ re.compile(rb\s*$), # 普通用户模式 re.compile(rb#\s*$), # 特权模式 re.compile(rb\]\s*$), # 配置视图等 ] idx, match, text tn.expect(patterns, timeout10)expect返回三个东西匹配到的模式序号、匹配对象、本次读到的字节串。这样你就可以根据idx判断当前处在什么模式决定下一步发什么命令。正则末尾的\s*$是用来兼容提示符前后可能存在的空格和控制字符。注意提示符匹配不能只看结尾还要避免匹配到命令回显里的字符。比如命令里如果含有#会干扰判断所以等待提示符时要挑选一个相对稳定的标志性尾部。4.4 登录失败后的提示符循环最隐蔽的逻辑漏洞这是我踩过一次比较深的坑。设备登录密码输错后会打印一行错误信息然后重新出现Username:提示。此时如果脚本逻辑只是顺序执行等提示符、发命令、再等提示符第二次等待时读到的可能已经不是预期内容了。举一个场景脚本用read_until(b#, timeout5)等特权模式提示符。密码错误时设备返回的是% Authentication failed Username:这段数据里既没有#又包含了一个新的Username:。read_until会把这整段全部返回判断#不在里面触发超时分支。如果不小心直接忽略这个分支继续往下发命令那发布去的命令会变成下一次登录尝试的用户名整个会话彻底乱套。正确的做法是密码错误后主动识别Username:或Authentication failed关键字重新走登录流程并且限制重试次数。比如封装一个login_retry函数最多尝试三次每次把read_until到的那段数据打印出来用于排查。这种场景本地模拟很难复现因为模拟服务端逻辑太简单。建议真实设备上架后第一件事就是故意输错一次密码观察脚本的反应。4.5 回显字符会混进输出解析时必须去掉最后一个隐藏比较深的坑你发给设备的命令设备通常会在输出里回显一遍。比如发送show version返回的内容可能是show version Cisco IOS Software, ...如果脚本直接拿text去匹配关键词容易把命令行本身匹配进去。比如你要判断配置模式还是普通模式而命令字里刚好包含相关关键词就会误判。处理办法有两个一是解析时先按行处理跳过第一行或所有与命令完全相等的行二是尽量用输出尾部、提示符之前的内容做判断。坑典型症状主要对策编码错乱中文乱码或解码崩溃用gbkerrorsreplace超时截断输出少一截、字段缺失每次都传 timeout检查返回值尾部提示符漂移换设备就报错用expect 正则匹配多种提示符密码错误回环命令被当成用户名发送识别失败关键字限定重试次数回显干扰解析结果异常按行处理过滤命令回显行5. 从一个临时脚本到一个可维护的巡检小工具5.1 封装一个简洁可靠的DeviceTelnet类临时脚本跑通只是开始设备一多代码就会失控。我会先封装一个DeviceTelnet类把登录、执行命令、退出这些动作收敛到同一个地方。import telnetlib import re class DeviceTelnet: def __init__(self, host, user, password, enable_passwordNone, port23, timeout15, encodinggbk): self.host host self.user user self.password password self.enable_password enable_password self.port port self.timeout timeout self.encoding encoding self.tn None def login(self): self.tn telnetlib.Telnet(self.host, self.port, self.timeout) self.tn.read_until(bUsername:, self.timeout) self.tn.write(self.user.encode(ascii) b\r\n) self.tn.read_until(bPassword:, self.timeout) self.tn.write(self.password.encode(ascii) b\r\n) data self.tn.read_until(b, self.timeout) if bsername in data or bfailed in data: raise RuntimeError(f{self.host} 登录失败) if self.enable_password: self.tn.write(benable\r\n) self.tn.read_until(bPassword:, self.timeout) self.tn.write(self.enable_password.encode(ascii) b\r\n) self.tn.read_until(b#, self.timeout) self.tn.write(bterminal length 0\r\n) self.tn.read_until(b#, self.timeout) def cmd(self, command, promptb#, timeoutNone): self.tn.write(command b\r\n) output self.tn.read_until(prompt, timeout or self.timeout) return output.decode(self.encoding, errorsreplace) def close(self): if self.tn: self.tn.close()类的好处是后续要扩展cmd方法支持expect、支持错误重试都不用动调用方代码。实际使用时登录成功后我可以随手执行十几个命令而不用关心底层状态。5.2 批量执行命令并落盘日志有了类批量巡检就是循环的事。下面这段从设备清单文件里逐行读取设备信息对每台设备执行show running-config把结果按主机名落盘import os import time DEV_FILE devices.txt # 每行格式: 主机名,IP,用户名,密码,enable密码 for line in open(DEV_FILE): line line.strip() if not line or line.startswith(#): continue name, ip, user, pwd, ena line.split(,) dev DeviceTelnet(ip, user, pwd, ena, timeout20) try: dev.login() cfg dev.cmd(bshow running-config, timeout30) os.makedirs(backup, exist_okTrue) with open(fbackup/{name}.cfg, w, encodingutf-8) as f: f.write(cfg) print(f{name} 配置采集完成) except Exception as e: print(f{name} 采集失败: {e}) finally: dev.close() time.sleep(0.5)注意批量跑的时候每台设备之间加一点间隔否则老设备可能瞬间建立大量 telnet 会话直接拒绝新的连接。我遇到过同时并发 20 台就触发了设备自身的连接数限制后来改成顺序执行加小延迟稳了很多。5.3 重试、并发与密码管理的安全说明生产环境里跑自动化光有基础功能远远不够。首先是重试机制。网络设备偶尔会抽风一个命令返回超时不代表设备挂了。我会给login和cmd方法加一个retry参数失败后重新连接或重新执行最多三次。重试间隔用两秒不要一失败就立刻重试给设备留点反应时间。其次是并发控制。如果设备数量多完全串行效率低但并发太高又可能触发设备连接数限制。我的做法是先用ThreadPoolExecutor控制并发数在 5 以内跑完一批看结果再慢慢调大。重点是每个线程里单独创建DeviceTelnet实例绝不可共享同一个 socket 连接。最后也是最重要的telnet 是明文协议密码在网络里等于裸奔。即使在内网也不要掉以轻心。我处理设备登录密码时至少做到以下几点密码不硬编码在脚本里从环境变量或加密配置读取批量执行时控制日志内容避免把密码打印出来只把 telnet 连接限制在管理网段不要跨公网直接用 telnet 管理任何设备。能用 SSH 的设备坚决用 SSHtelnet 只留给实在没有其它选择的老设备。我自己用这套思路处理过一柜子的老设备从手动一台台登录变成脚本批量采集配置和巡检省下的时间不是一星半点。但每次写 telnet 脚本时都会提醒自己技术方案能用不代表可以滥用明文协议的管理通道必须在可控、受限的范围内使用。如果你手头也有一批只能 telnet 的老设备希望这些经验能帮你少走一点弯路也欢迎在踩到新的坑时回来分享你的解法。