python ssh实时交互_[NetDevOps] 使用SSH进行网络自动化的例子及问题(1)

先上结论

如果不是设备太老,不支持任何现代API接口,我不推荐任何人尝试使用SSH来自动化管理网络设备。

NETCONF和XML再丑,RESTCONF再不好用,那也仍然是API,只要这个API能毫秒级响应你的请求并给你返回数据,那它总是比SSH好用的。所以,在做任何自动化尝试之前,第一件事就是找找看你要操作的设备是否有API可供使用,如果没有,再去考虑用SSH。

使用SSH做网络自动化的本质,是对人类行为的模拟,说白了,你是在写代码模拟你日常的CLI操作。对于绝大部分网工(包括我)来说,Python是首选的做网络自动化的编程语言,而Python实现SSHv2的模块Paramiko,也几乎是你首选的SSH轮子。

P.S.1. 还有个轮子叫Netmiko,但我个人不是很喜欢这类轮子,尽管它进行了更高层面的封装,但这也意味着,如果他的轮子测试不完善或者有Bug,排障会更加痛苦。我个人认为,你都已经开始学Python了,那么思考一下怎么处理SSH返回的字符串,应该是个“入门级”的任务了……否则如果学了半天仍然只是在用别人封装好的高级轮子,那可能还是学一下怎么写更漂亮的PPT唬人比较实在(笑……

由于不同的厂家,甚至同一厂家的不同硬件,甚至同一硬件不同代的软件版本,CLI都可能发生变化,这就意味着,上次针对A设备的版本1写的代码,在A设备的版本2上执行可能就是出错的,如果希望代码能够重用,就需要尽可能细的做模块的拆分,以及大量的测试。说白了,使用CLI,等于你自己在为设备开发一套API接口。

所以,如无必要,尽量不要用SSH&CLI去做自动化,厂商做不好的事,作为用户,只能通过无尽的投诉和Case去逼他们做好,而不要为难自己——除非,做这件事给你带来实际的价值,比如你就是上千台不太可能支持API,也不太可能马上淘汰的设备需要维护,又或者,做这件事能在Boss面前展现你的Value,又又或者,只是为了解决燃眉之急(比如我)。

浅刨一下Paramiko

P.S.2. 我不打算讲如何安装以及如何import这样的问题,我假定你在看这篇文章的时候,已经掌握了Python的基础知识, 而Python的教程满天飞,自己百度就好。

解释下Paramiko的几个核心部分的东西——

SSHClient

SSHClient是Paramiko最高层的抽象,大部分时候,我们直接通过实例化一个SSHClient来访问所有的函数/方法。

Transport

在你实例化SSHClient的时候,Transport对象就已经创建过了,如无特殊需求,一般不需要单独使用。Transport包含的是SSH建立通道所需要的参数,如密钥交互算法。

ChannelA secure tunnel across an SSH

顾名思义,Channel就是那条真正在工作的SSH通道。Paramiko实际上可以创建两条不同的通道,一条是建立SSH连接后就一定会存在的通道,另一条是你调用exec_command方法的时候,它会创建另一条通道出来。但是,并非所有的SSH服务端都支持,按官方的说法就是,基于标准的OpenSSH实现都是可以的……不过嘛,大厂的网络设备,总会有非标的情况。在下面的例子中你会看到,Cisco IOS-XE软件很可能就是个非标实现,至少,它不能通过exec_command来发送多条命令,而ASA软件则可以。

invoke_shell

Paramiko提供了一个invoke_shell()的方法,让你直接调用SSH建立通道时默认存在的那条Channel,不过通过这条Channel返回的数据是字节流,但它仍然是一个"Channel"对象,这意味着你可以通过调用所有Channel支持的方法来达到和exec_command同样的效果。

关于invoke_shell和exec_command的差别,可以参考这个链接——

交互式命令行

我们重新来理解一下"CLI",本质上,它是一种“交互式”的操作界面,你输入command,它给你返回正确或者错误的结果。

CLI本质上是为人设计的,用程序去调CLI,其实就是写代码来模拟人的行为,难度取决于CLI本身设计是否合理。好的CLI的回显数据,实际上也是"格式化"的,有一定的套路,只不过这个套路没有一个明确的规范,你必须为不同的command去适配不同的规则。

简而言之,假如一个程序用到了三条配置命令和三条show命令,为保不出错,最好是有一个贴近生产环境的实验环境让你实际把这些命令都刷一遍,每条命令如何解析搞清楚,再去写程序。

此外,程序和人有一点不同的是,人可以连上去一次性把事情做完,而写程序,出于重用代码的目标,你不太可能把一堆命令写在一起。

人脑是可以判断敲完第一条命令后第二条我要敲什么的,程序不行,如果你把一堆命令写在一起做成一个脚本,这个脚本就很难重用了,因为下次你的需求,用到的命令或者只是执行命令的顺序可能是不同的。

但是这也就意味着要把抽象的粒度缩小到尽可能小的程度,比如配置接口IP地址的任务做成一个模块,把配置BGP邻居做成另一个模块,那么实际上两个模块执行的时候是两个不同的连接,执行的命令分别是——

配置IP地址

config terminal

interface GigabitEthernet0/1

ip address 1.1.1.1 255.255.255.252

no shutdown

配置BGP邻居

config terminal

router bgp 100

neighbor 1.1.1.2 remote-as 200

address-family ipv4 unicast

反之,如果你在一个脚本里同时执行这两个任务,换言之你只建立了一个SSH连接,你把两件事的逻辑写在一起,没有做进一步的拆分,万一下次你不要配置IP,你只要配置BGP,那等于这次写的脚本白写了。

[补] 其实换个角度想,每次的RESTAPI调用,也是一个单独的HTTP连接,并没什么不同。所以多建几次拆几次SSH连接也没什么,充其量是如果你用了AAA,你大概会看到某个user经常登陆吧(笑……

使用Paramiko操作设备——基本代码

无论如何,你总要实例化一个SSHClient的,所以

import paramiko

hostname = "YOUR DEVICE HOSTNAME"

username = "YOUR ACCOUNT USERNAME"

password = "YOUR PASSWORD"

ssh_client = paramiko.SSHClient()

ssh_client.set_missing_host_key_policy(paramiko.client.AutoAddPolicy())

ssh_client.connect(hostname=hostname, port=22, username=username, password=password)

P.S.4. 笔者所连接的设备是Cisco ASA的虚拟机版本

通过exec_command发送单条命令

stdin, stdout, stderr = ssh_client.exec_command("show inventory\n")

stdout.channel.close()

res = stdout.readlines()

print(res)

\n其实就是在模拟敲回车。命令执行完之后需要关闭Channle,否则你会发现你根本不能读取到stdout中的数据,你的程序会Hanging……很多人都遇到了这个问题。

这个问题,官方的文档里倒也给出了描述——Similarly, if the server isn’t reading data you send, calls to

不过,对于很多网络设备的CLI来说,实际上你在执行完一条命令后,CLI在等你执行下一条命令,如果没有关闭Channel,res = stdout.readlines()这行其实一直都没有执行,除非你设置timeout,或者主动关闭Channel

stdout实际上是在调用exec_command方法时自动调用了makefile(),返回了一个类文件的对象,你可以直接对这个对象使用Python的file()函数和方法,比如readlines,逐行读取后返回一个列表

But,到这里为止,上面的代码还是有问题,如果执行它,会发现返回了一个空列表,或者返回的内容不全,都有可能……

如下所示,返回的列表中只有短短的一点点东西

['User admin logged in to asa-1\r\n']

而实际上,当你登录ASA时,从登录到输出show inventory这条命令,全部的回显应该是这样子的

User yuxs logged in to asa-1

Logins over the last 91 days: 13. Last login: 05:57:09 UTC Feb 23 2020 from XXX.XXX.XXX.XXX

Failed logins since the last login: 0.

Type help or '?' for a list of available commands.

asa-1> show inventory

Name: "Chassis", DESCR: "ASAv Adaptive Security Virtual Appliance"

PID: ASAv , VID: V01 , SN: 9AGQ80L7F88

asa-1>

这个问题让人非常哭笑不得的地方是,它出现可能是因为你的程序跑的太快了,回显压根没来得及写入stdout,我参考了 @弈心 的代码,以及一些Google出来的结果,解决的办法就是,加个延时,让程序在读stdout之前中断一下。

time.sleep(0.1)

stdout.channel.close()

res = stdout.readlines()

print(res)

P.S.5. 这里用到了time模块,是需要在首行导入的。

小小的sleep一下,数据就全了——

['User admin logged in to asa-1\r\n', 'Logins over the last 91 days: 227. Last login: 10:11:54 UTC Feb 23 2020 from XXX.XXX.XXX.XXX\r\n', 'Failed logins since the last login: 0. Last failed login: 01:10:04 UTC Feb 21 2020 from XXX.XXX.XXX.XXX\r\n', "Type help or '?' for a list of available commands.\r\n", '\rasa-1> show inventory\r\n', 'Name: "Chassis", DESCR: "ASAv Adaptive

Security Virtual Appliance"\r\n', 'PID: ASAv , VID: V01 , SN: 9AGQ80L7F88\r\n', '\r\n', '\rasa-1> ']

P.S.6. 敏感考虑,IP地址作了替换隐藏

由于调用了readlines方法,所以你会看到返回的是个列表,数据的每一行就是列表的一个元素。

不过,这里只是单条命令的效果。而大部分时候,我们需要在设备上操作多条命令,比如,配置的时候你需要config terminal后才能进行配置操作,所以……

配置多条命令的不靠谱方法——

P.S.7.如果你不想知道这个不靠谱的方法可以跳过这段

exec_command是可以配置多条命令的,只是,在不同的SSH服务器上,表现有些不同。

笔者就遇到了Cisco的ASA和IOS软件完全不同的效果——ASA可以识别你输入的任何一个字符并且起作用,而IOS因为一个神奇的feature,让你的努力变为无效……

以下面这个例子来说,show interface ip brief必须要特权模式,而我使用的账户默认没有在特权模式中,所以需要先输入enable和密码进入特权模式

stdin, stdout, stderr = ssh_client.exec_command("enable\ncisco\nshow interface ip brief\n", get_pty=True)

这样执行是没问题的,返回的数据——

['User admin logged in to asa-1\r\n', 'Logins over the last 91 days: 228. Last login: 10:20:11 UTC Feb 23 2020 from XXX.XXX.XXX.XXX\r\n', 'Failed logins since the last login: 0. Last failed login: 01:10:04 UTC Feb 21 2020 from XXX.XXX.XXX.XXX\r\n', "Type help or '?' for a list of available commands.\r\n", '\rasa-1> enable\r\n', 'Password: *****\r\n', '\rasa-1# show interface

ip brief\r\n', 'Interface IP-Address OK? Method Status Protocol\r\n', 'GigabitEthernet0/0 10.0.0.2 YES manual up up

\r\n', 'GigabitEthernet0/1 10.0.1.1 YES manual up up \r\n', 'GigabitEthernet0/2 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/3 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/4 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/5 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/6 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/7 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/8 unassigned YES unset administratively down down\r\n', 'Management0/0 YYY.YYY.YYY.YYY

YES CONFIG up up \r\n', '\rasa-1# ']

ASA会认到所有的命令,所以如果你多在两个命令之间加一个空格,回显就会报错

stdin, stdout, stderr = ssh_client.exec_command("enable\ncisco\nshow interface ip brief\n", get_pty=True)

返回的结果就成了

['User admin logged in to asa-1\r\n', 'Logins over the last 91 days: 229. Last login: 10:29:47 UTC Feb 23 2020 from XXX.XXX.XXX.XXX\r\n', 'Failed logins since the last login: 0. Last failed login: 01:10:04 UTC Feb 21 2020 from XXX.XXX.XXX.XXX\r\n', "Type help or '?' for a list of available commands.\r\n", '\rasa-1> enable\r\n', 'Password: ******\r\n', 'Invalid password\r\n',

'Password: ************************\r\n', 'Invalid password\r\n', 'Password: ']

实际上cisco\n show interface ip brief这一整串被当成密码输入了进去……

很蛋疼吧?IOS更蛋疼。

在一台CSR1000v的路由器上,下面的代码执行是不成功的——

stdin, stdout, stderr = ssh_client.exec_command("show ip interface brief\nshow ip route\n", get_pty=True)

执行结果——

['\r\n', '\r\n', '\r\n', 'Line has invalid autocommand "show ip interface brief\r\n', 'show ip route\r\n', '"']

IOS报了一个Line has invalid autocommand 的错误,它确实认出来了两条命令,但是它觉得这样不行。

autocommand是IOS的一个feature,我们用exec_command发送两条命令时触发了这个功能,而且这个功能还无法关闭,更骚的是,如果你去Google,会发现很多人在不同的自动化软件上都遇到过这个问题……

这个feature的本意是在用户登录的时候自动执行一条命令,不知为何,程序发送命令下去触发了这个功能,怎么看都像是个Bug……而且似乎没打算修。

吐槽.1 这也是我为什么不推荐SSH,如非万不得已,不要尝试挑战这些封闭的黑盒子

配置多条命令的可能靠谱方法——

我不敢说绝对靠谱,万一哪天又触发了神奇的feature呢?

在上面的IOS的问题中,很明显是autocommand这个feature在作妖,因为我并没有真的要启用这个功能。可以救命的方式是Paramiko的invoke_shell,调用这个方法,就真的是在进行“人类迷惑行为模拟”了……

不再使用exec_command,实例化SSHClient的代码不变,然后——

# 以ASA为例

command = ssh_client.invoke_shell()

command.send("enable\n")

command.send("cisco\n")

command.send("show interface ip brief\n")

command.send("show route\n")

time.sleep(0.1)

# RETURN A BYTES OBJECT THAT DECODED BY THE UNICODE

print(command.recv(65535).decode("utf-8"))

请注意,这里返回输出的,不再是可以调用file()方法的对象了,而是一个Bytes对象,顾名思义,是字节流 ,通过utf-8解码可以得到和CLI一样的回显。

P.S.8. 这里time.sleep()的时间设的比较短,这可能会有问题,比如,数据可能会不全,可以适当延长,比如延长到1s,1000ms足够大部分文本类数据的传输了,如果网络状况不好,可能还要更久一点,所以实际的业务逻辑中,可以把这里设置为一个可调的变量。

User admin logged in to asa-1

Logins over the last 91 days: 232. Last login: 12:18:27 UTC Feb 23 2020 from XXX.XXX.XXX.XXX

Failed logins since the last login: 0. Last failed login: 01:10:04 UTC Feb 21 2020 from XXX.XXX.XXX

Type help or '?' for a list of available commands.

asa-1> enable

Password: *****

asa-1# show interface ip brief

Interface IP-Address OK? Method Status Protocol

GigabitEthernet0/0 10.0.0.2 YES manual up up

GigabitEthernet0/1 10.0.1.1 YES manual up up

GigabitEthernet0/2 unassigned YES unset administratively down down

GigabitEthernet0/3 unassigned YES unset administratively down down

GigabitEthernet0/4 unassigned YES unset administratively down down

GigabitEthernet0/5 unassigned YES unset administratively down down

GigabitEthernet0/6 unassigned YES unset administratively down down

GigabitEthernet0/7 unassigned YES unset administratively down down

GigabitEthernet0/8 unassigned YES unset administratively down down

asa-1# show route

Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP

D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area

N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2

E1 - OSPF external type 1, E2 - OSPF external type 2, V - VPN

i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2

ia - IS-IS inter area, * - candidate default, U - per-user static route

o - ODR, P - periodic downloaded static route, + - replicated route

C 10.0.0.0 255.255.255.252 is directly connected, inside

L 10.0.0.2 255.255.255.255 is directly connected, inside

C 10.0.1.0 255.255.255.248 is directly connected, outside

L 10.0.1.1 255.255.255.255 is directly connected, outside

S 10.0.100.2 255.255.255.255 [1/0] via 10.0.1.2, outside

S 10.0.100.3 255.255.255.255 [1/0] via 10.0.1.3, outside

S 10.1.0.0 255.255.255.0 [1/0] via 10.0.0.1, inside

asa-1#

P.S.9. 如前,敏感IP信息删除&替换

不过,字节流不好处理,数据提取会比较痛苦,所幸invoke_shell仍然是一个Channel,所以可以调用makefile()方法来达到和使用exec_command一样的效果。makefile(*params)

Return a file-like object associated with this channel. The optional mode and bufsize arguments are interpreted the same way as by the built-in file() function in Python.

Returns:

代码如下——

stdout = command.makefile()

command.close()

print(stdout.readlines())

同样,也要在读取之前close()掉Channel,返回的数据将会是按行分割的列表。

['User admin logged in to asa-1\r\n', 'Logins over the last 91 days: 236. Last login: 12:25:19 UTC Feb 23 2020 from XXX.XXX.XXX.XXX\r\n', 'Failed logins since the last login: 0. Last failed login: 01:10:04 UTC Feb 21 2020 from XXX.XXX.XXX.XXX\r\n', "Type help or '?' for a list of available commands.\r\n", '\rasa-1> enable\r\n', 'Password: *****\r\n', '\rasa-1# show interface

ip brief\r\n', 'Interface IP-Address OK? Method Status Protocol\r\n', 'GigabitEthernet0/0 10.0.0.2 YES manual up up

\r\n', 'GigabitEthernet0/1 10.0.1.1 YES manual up up \r\n', 'GigabitEthernet0/2 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/3 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/4 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/5 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/6 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/7 unassigned YES unset administratively down down\r\n', 'GigabitEthernet0/8 unassigned YES unset administratively down down\r\n',

'\rasa-1# show route\r\n', '\r\n', 'Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP\r\n', ' D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area\r\n', ' N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2\r\n', ' E1 - OSPF external type 1, E2 - OSPF external type 2, V - VPN\r\n', ' i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2\r\n', ' ia - IS-IS inter area, * - candidate default, U - per-user static route\r\n',

' o - ODR, P - periodic downloaded static route, + - replicated route\r\n', '\r\n', 'C 10.0.0.0 255.255.255.252 is directly connected, inside\r\n', 'L 10.0.0.2 255.255.255.255 is directly connected, inside\r\n', 'C 10.0.1.0 255.255.255.248 is directly connected, outside\r\n', 'L 10.0.1.1 255.255.255.255 is directly connected, outside\r\n', 'S 10.0.100.2 255.255.255.255 [1/0] via 10.0.1.2, outside\r\n', 'S 10.0.100.3 255.255.255.255 [1/0] via 10.0.1.3, outside\r\n', 'S 10.1.0.0 255.255.255.0 [1/0] via 10.0.0.1, inside\r\n', '\r\n', '\rasa-1# ']

列表有索引,这也就意味着,只要事先测试过,设计好命令的顺序,返回的数据在第几行是可以提前确定下来的,可以确定也就可以比较轻松的写逻辑。

这个方法对于IOS也有用——

字节流回显——

R01>show ip interface brief

Interface IP-Address OK? Method Status Protocol

GigabitEthernet1 unassigned YES NVRAM down down

GigabitEthernet2 unassigned YES NVRAM down down

GigabitEthernet3 unassigned YES NVRAM down down

GigabitEthernet3.101 unassigned YES unset down down

GigabitEthernet3.102 unassigned YES unset down down

GigabitEthernet4 unassigned YES NVRAM up up

Loopback0 10.40.0.1 YES NVRAM up up

R01>show ip route

Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP

D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area

N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2

E1 - OSPF external type 1, E2 - OSPF external type 2

i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2

ia - IS-IS inter area, * - candidate default, U - per-user static route

o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP

a - application route

+ - replicated route, % - next hop override, p - overrides from PfR

Gateway of last resort is not set

10.0.0.0/32 is subnetted, 1 subnets

C 10.40.0.1 is directly connected, Loopback0

R01>

列表回显

['\r\n', '\r\n', '\r\n', 'R01>show ip interface brief\r\n', 'Interface IP-Address OK? Method Status Protocol\r\n', 'GigabitEthernet1 unassigned

YES NVRAM down down \r\n', 'GigabitEthernet2 unassigned YES NVRAM down down \r\n', 'GigabitEthernet3 unassigned YES NVRAM down down \r\n', 'GigabitEthernet3.101 unassigned YES unset down down \r\n', 'GigabitEthernet3.102 unassigned YES unset down

down \r\n', 'GigabitEthernet4 unassigned YES NVRAM up up \r\n',

'Loopback0 10.40.0.1 YES NVRAM up up\r\n', 'R01>show ip route\r\n', 'Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP\r\n', ' D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area\r\n', ' N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2\r\n', ' E1 - OSPF external type 1, E2 - OSPF external type 2\r\n', ' i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2\r\n', ' ia - IS-IS inter area, * - candidate default, U - per-user static route\r\n', ' o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP\r\n', ' a - application route\r\n', ' + - replicated route, % - next hop override, p - overrides from PfR\r\n', '\r\n', 'Gateway of last resort is not set\r\n', '\r\n', ' 10.0.0.0/32 is subnetted, 1 subnets\r\n', 'C 10.40.0.1 is directly connected, Loopback0\r\n', 'R01>']

小结

这篇我没有给出具体场景的代码逻辑,只是刨了一下用Paramiko & SSH去连接设备,如何推命令,以及获取到的数据是什么样子,有一定基础的人,看到列表类数据就会会心一笑,这是个可以进一步处理的东西。

附本篇用到的代码

import paramiko

hostname = "YOUR DEVICE HOSTNAME"

username = "YOUR ACCOUNT USERNAME"

password = "YOUR PASSWORD"

# 实例化一个SSHClient

ssh_client = paramiko.SSHClient()

ssh_client.set_missing_host_key_policy(paramiko.client.AutoAddPolicy())

ssh_client.connect(hostname=hostname, port=22, username=username, password=password)

# 调用invoke_shell()

# time.sleep更久一些,因为0.1缺数据了……

command = ssh_client.invoke_shell()

command.send("show ip interface brief\n")

command.send("show ip route\n")

time.sleep(1)

# 调用makefile,并暂存数据到一个本地变量

command.close()

stdout = command.makefile()

echolist = stdout.readlines()

# 输出

print(echolist)

for e in echolist:

print(e.strip("\n"))

第二篇出炉了,传送门

你可能感兴趣的:(python,ssh实时交互)