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 端口通常走 23,SSH 走 22;telnet 协议实现简单,但也因此能跑在极其轻量的嵌入式设备上。
| 对比项 | telnet | SSH |
|---|---|---|
| 加密 | 无,明文传输 | 加密传输 |
| 默认端口 | 23 | 22 |
| 认证方式 | 用户名密码为主 | 密码或密钥 |
| 占用资源 | 低 | 相对高 |
| Python生态 | 标准库 telnetlib | paramiko等第三方库 |
| 适用场景 | 老设备、带外管理、内网调试 | 常规生产环境、跨公网管理 |
我的建议是:能用 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', port=23, timeout=10) # 等待出现"Username:"字符串,返回从上次读取位置到这里的全部字节 tn.read_until(b"Username:", timeout=10) tn.write(b"admin\r\n") tn.read_until(b"Password:", timeout=10) tn.write(b"password123\r\n") # 等特权模式提示符,随后执行命令 tn.read_until(b"#", timeout=10) tn.write(b"show version\r\n") # 读完整段输出,直到再次出现提示符 output = tn.read_until(b"#", timeout=10) print(output.decode()) tn.close()这段代码已经能完成一次最小闭环。但要真正写好 telnet 脚本,必须理解read_until的行为,否则后面全是坑。
read_until(expected, timeout)的行为是:持续从 socket 读取数据,直到读到的内容里出现expected这个字节串,然后返回从上次未读位置到匹配结束的所有字节。如果直到超时也没匹配到,它不会抛异常,而是把当前已经读到的内容原样返回,可能是空字节串。这一点非常关键,很多人以为超时会抛异常,结果判断逻辑写错了。
write(buffer)则是把字节串直接写到 socket 上。注意 telnetlib 不会自动帮你补回车换行,所以执行命令时必须写成b"show version\r\n",漏掉\r\n是最常见的新手错误。
2.2 缓冲区与字节串:Python 3下最容易忽略的形态变化
telnetlib 在 Python 3 里处理的全部是bytes,不是str。所有等待的目标、写入的命令,都要用字节串。这个看起来很小的差别,实际项目里会让新手栽无数跟头。
举个例子,设备提示符是switch>,你用read_until(">", timeout=5),Python 会直接报错,因为read_until期望的是bytes类型。正确写法是read_until(b">", timeout=5)。同理,写入用户名密码时也要写tn.write(b"admin\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 b'show 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, port=23, timeout=TIMEOUT) tn.set_debuglevel(0) # 1. 等待账号输入提示,发送用户名 banner = tn.read_until(b"Username:", TIMEOUT) tn.write(USER.encode("ascii") + b"\r\n") # 2. 等待密码提示,发送密码 pwd_prompt = tn.read_until(b"Password:", TIMEOUT) tn.write(PWD.encode("ascii") + b"\r\n") # 3. 等待普通用户提示符,然后进入特权模式 result = tn.read_until(b">", TIMEOUT) if b"sername" in result and b"Password" not in result: # 有些设备直接给特权模式 pass tn.write(b"enable\r\n") # 4. 进入特权模式后关闭分页,避免输出卡在more ena_result = tn.read_until(b"Password:", TIMEOUT) tn.write(ENA.encode("ascii") + b"\r\n") tn.read_until(b"#", TIMEOUT) tn.write(b"terminal length 0\r\n") tn.read_until(b"#", TIMEOUT) # 5. 抓配置并输出 tn.write(b"show running-config\r\n") output = tn.read_until(b"#", TIMEOUT) print(output.decode("gbk", errors="replace")) # 6. 退出设备 tn.write(b"exit\r\n") tn.close() if __name__ == "__main__": main()3.2 逐行拆解和避坑说明
这段代码每一步都有讲究,不是随手拼出来的。
连接之后第一步是read_until(b"Username:")。这里注意,设备刚连上会先发一段 banner 横幅,有的横幅很长,但read_until会一直读到出现Username:才返回,所以 banner 长不长无所谓。真正的隐患是有些设备改了提示语,比如login as:、User:,这就需要把提示符作为参数,不同设备类型用不同匹配串。
发送用户名和密码后,等待的提示符是>还是#,取决于设备登录后默认进入的是普通用户模式还是特权模式。为了稳妥,我习惯登录之后先看返回内容里有没有>或#,再决定是否执行enable。如果普通模式和特权模式的提示符判断错了,后续命令基本全废。
进入特权模式时,有的设备还会再要一次密码。上面的脚本用固定流程处理了这种情况,但如果设备不问密码,read_until(b"Password:")会一直等到超时,这时就得根据返回结果分支处理。真实设备五花八门,建议每个厂商的登录流程单独封装一个函数。
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(b"Welcome to test switch\r\nUsername: ") data = b"" while b"\r" not in data: data += conn.recv(1024) conn.send(b"Password: ") data = b"" while b"\r" not in data: data += conn.recv(1024) conn.send(b"OK\r\n# ") while True: cmd = b"" while not cmd.endswith(b"\r"): cmd += conn.recv(1024) cmd = cmd.strip().lower() if b"exit" in cmd: conn.send(b"logout\r\n") break elif b"show running-config" in cmd: conn.send(b"hostname test-sw\r\ninterface Gi1/0/1\r\n# ") else: conn.send(b"unknown 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(target=handle_client, args=(conn,), daemon=True).start()用这个服务端,telnetlib.Telnet("127.0.0.1", port=23023)就能练手。等脚本跑通了,再把 HOST 换成真实设备,风险小得多。不过模拟终归是模拟,真实设备的提示符、编码、分页行为更多变,本地验证只是第一步。
4. 上线前必须排掉的四类暗坑:乱码、超时、提示符、登录回环
4.1 乱码问题:设备输出GBK而你按UTF-8解码
网络设备输出的编码并不统一,这是项目里最磨人的问题之一。很多国产品牌设备默认输出 GBK 或 GB2312 编码,你用output.decode("utf-8")去解,轻则中文变乱码,重则直接抛UnicodeDecodeError。
我现在的处理方式是:
text = output.decode("gbk", errors="replace")errors="replace"是关键,它保证即使输出里混了少量特殊字符,解码也不会中断。如果你拿不准设备是什么编码,可以先print看一段乱码结构,再决定用gbk还是utf-8。
另外要注意,你发给设备的命令本身是 ASCII,但设备回显的内容可能是双字节字符串。比如华为设备的display version输出就有中文描述,如果脚本要解析其中的字段,编码不对会直接导致后续正则匹配失败。
4.2 read_until超时导致脚本卡死或误判
read_until的 timeout 行为前面提过:超时返回当前已读内容而不是抛异常。听起来挺温和,但实际带来的问题是"你以为它还在等,其实它已经带着半截数据返回了"。比如你等#提示符,超时返回的内容是sho,而你后面又拿这个output去解析,解析结果自然是错的。
我的做法是把所有read_until都显式传入 timeout,并对返回值做检查:
output = tn.read_until(b"#", timeout=15) if b"#" not in output: print(f"告警:设备响应超时,当前返回内容长度 {len(output)}") # 根据业务决定重试还是退出还有一个相关坑:如果你把 socket 全局超时设了很短,read_until没传 timeout 时也会用这个短的全局值,导致大配置输出没读完就被截断。所以连接设备时,我习惯把全局 timeout 设大一点,比如 30 秒,再在每一步单独控制等待时间。
4.3 设备提示符不一致,固定bytes无法匹配
不同厂商、不同型号的设备,提示符五花八门:switch>、Router#、user@host:/$、[H3C]、\r\n>,甚至同一台设备在正常模式和配置视图下提示符都不一样。如果你每个步骤都写死b">",换个设备就废。
这时应该用expect配合正则:
patterns = [ re.compile(rb">\s*$"), # 普通用户模式 re.compile(rb"#\s*$"), # 特权模式 re.compile(rb"\]\s*$"), # 配置视图等 ] idx, match, text = tn.expect(patterns, timeout=10)expect返回三个东西:匹配到的模式序号、匹配对象、本次读到的字节串。这样你就可以根据idx判断当前处在什么模式,决定下一步发什么命令。正则末尾的\s*$是用来兼容提示符前后可能存在的空格和控制字符。
注意,提示符匹配不能只看结尾,还要避免匹配到命令回显里的字符。比如命令里如果含有#,会干扰判断,所以等待提示符时要挑选一个相对稳定的"标志性尾部"。
4.4 登录失败后的提示符循环:最隐蔽的逻辑漏洞
这是我踩过一次比较深的坑。设备登录密码输错后,会打印一行错误信息,然后重新出现Username:提示。此时如果脚本逻辑只是顺序执行"等提示符、发命令、再等提示符",第二次等待时读到的可能已经不是预期内容了。
举一个场景:脚本用read_until(b"#", timeout=5)等特权模式提示符。密码错误时,设备返回的是:
% Authentication failed Username:这段数据里既没有#,又包含了一个新的Username:。read_until会把这整段全部返回,判断#不在里面,触发超时分支。如果不小心直接忽略这个分支,继续往下发命令,那发布去的命令会变成下一次登录尝试的用户名,整个会话彻底乱套。
正确的做法是:密码错误后主动识别Username:或Authentication failed关键字,重新走登录流程,并且限制重试次数。比如封装一个login_retry函数,最多尝试三次,每次把read_until到的那段数据打印出来用于排查。
这种场景本地模拟很难复现,因为模拟服务端逻辑太简单。建议真实设备上架后,第一件事就是故意输错一次密码,观察脚本的反应。
4.5 回显字符会混进输出,解析时必须去掉
最后一个隐藏比较深的坑:你发给设备的命令,设备通常会在输出里回显一遍。比如发送show version,返回的内容可能是:
show version Cisco IOS Software, ...如果脚本直接拿text去匹配关键词,容易把命令行本身匹配进去。比如你要判断"配置模式"还是"普通模式",而命令字里刚好包含相关关键词,就会误判。处理办法有两个:一是解析时先按行处理,跳过第一行或所有与命令完全相等的行;二是尽量用输出尾部、提示符之前的内容做判断。
| 坑 | 典型症状 | 主要对策 |
|---|---|---|
| 编码错乱 | 中文乱码或解码崩溃 | 用gbk+errors="replace" |
| 超时截断 | 输出少一截、字段缺失 | 每次都传 timeout,检查返回值尾部 |
| 提示符漂移 | 换设备就报错 | 用expect+ 正则匹配多种提示符 |
| 密码错误回环 | 命令被当成用户名发送 | 识别失败关键字,限定重试次数 |
| 回显干扰 | 解析结果异常 | 按行处理,过滤命令回显行 |
5. 从一个临时脚本到一个可维护的巡检小工具
5.1 封装一个简洁可靠的DeviceTelnet类
临时脚本跑通只是开始,设备一多,代码就会失控。我会先封装一个DeviceTelnet类,把登录、执行命令、退出这些动作收敛到同一个地方。
import telnetlib import re class DeviceTelnet: def __init__(self, host, user, password, enable_password=None, port=23, timeout=15, encoding="gbk"): 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(b"Username:", self.timeout) self.tn.write(self.user.encode("ascii") + b"\r\n") self.tn.read_until(b"Password:", self.timeout) self.tn.write(self.password.encode("ascii") + b"\r\n") data = self.tn.read_until(b">", self.timeout) if b"sername" in data or b"failed" in data: raise RuntimeError(f"{self.host} 登录失败") if self.enable_password: self.tn.write(b"enable\r\n") self.tn.read_until(b"Password:", self.timeout) self.tn.write(self.enable_password.encode("ascii") + b"\r\n") self.tn.read_until(b"#", self.timeout) self.tn.write(b"terminal length 0\r\n") self.tn.read_until(b"#", self.timeout) def cmd(self, command, prompt=b"#", timeout=None): self.tn.write(command + b"\r\n") output = self.tn.read_until(prompt, timeout or self.timeout) return output.decode(self.encoding, errors="replace") 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, timeout=20) try: dev.login() cfg = dev.cmd(b"show running-config", timeout=30) os.makedirs("backup", exist_ok=True) with open(f"backup/{name}.cfg", "w", encoding="utf-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 的设备坚决用 SSH,telnet 只留给实在没有其它选择的老设备。
我自己用这套思路处理过一柜子的老设备,从手动一台台登录变成脚本批量采集配置和巡检,省下的时间不是一星半点。但每次写 telnet 脚本时都会提醒自己:技术方案能用,不代表可以滥用,明文协议的管理通道必须在可控、受限的范围内使用。如果你手头也有一批只能 telnet 的老设备,希望这些经验能帮你少走一点弯路,也欢迎在踩到新的坑时回来分享你的解法。