中文编程
首发于中文编程

用python编写控制网络设备的自动化脚本7:跳板(远程登录)

项目地址:

https://github.com/cflw/network_device_scriptgithub.com

前言

考虑下面的网络拓扑:

假设路由器之间只在接口上配了个互联地址,以及简单的远程登录配置,没有其他东西,连静态路由也没有。那么这些路由器只能访问相邻的地址,跨设备跨网段就不通了。

如果要从R1远程登录R3,需要拿中间的R2作为跳板。先从R1登R2,再从R2登R3,这样就实现了R1到R3的间接远程登录。

在实际中,没有路由导致无法远程到目标设备的情况很少,更多的是各种隔离和访问限制的存在使得目标设备无法直接远程登录。拿一台设备充当跳板远程到其它设备的情况在多网络隔离时比较常见,跳板接入多个网络中,成为一个后门,在需要的时候可以很方便地从一个网络跳到另外一个网络,实施跨网络控制。

跳板过程

跳板过程其实很简单,敲个命令连接到目标设备。然后按照提示输入用户名密码,就登录了。发个动图演示一下手动远程登录的过程。

下面各种乱七八糟的内容其实都是为了模拟这张动图而写的代码。

惰性连接,重构的连接(编程)接口

《网络设备脚本》一直推崇惰性计算,只有当真正需要执行命令的时候才会切换到相应模式执行相应命令。但是连接却是实时的,一旦创建连接对象就马上连接到设备。一个连接对象只能连接一次,如果掉线了就不能重连。这种只能连一次的连接方式不仅影响了脚本的稳定性,也影响的新功能的开发(比如跳板、掉线重连)。所以有必要对连接(编程)接口进行重构。

说是重构,其实也没改多少,就是把连接过程单独拆分成一个函数。连接类的构造函数只是保存了连接信息,并不会实际连接到设备,具体连接过程放到了 f连接 中。

cflw代码库py/cflw网络连接.py

class I命令行连接:
    def f连接(self):
        raise NotImplementedError()
    #省略
        
class C网络终端(I命令行连接):
    def __init__(self, a主机, a端口号 = 23):
        self.m主机 = a主机
        self.m端口号 = a端口号
        self.m终端 = None
    def f连接(self):
        import telnetlib
        self.m终端 = telnetlib.Telnet(self.m主机, self.m端口号)
    #省略

既然现在的连接已经是惰性连接了,那么具体什么时候才会连接到设备?其实这个连接时机跟模式一样,等到真正需要执行命令的时候才会连接。从需要执行命令到真正执行命令所发生的过程如下:

  1. 调用f执行命令
  2. 判断是否当前连接,不是则切换到当前连接
  3. 判断是否当前模式,不是则切换到当前模式
  4. 真正执行命令

连接包装(编程)接口

当跳板设备作为跳板远程登录到目标设备时,跳板设备会创建一个连接包装对象,连接包装对象负责把跳板设备的状态包装起来,提供一个登录到目标设备的连接,这个连接可以用来创建设备。

连接包装(编程)接口的函数与原始的连接(编程)接口一致,其中的读写函数其实就是直接从原连接读写。

网络设备脚本/基础接口/连接.py

class I连接包装:
    def __init__(self, a模式):
        self.m模式 = a模式
        self.m设备 = a模式.m设备
        self.m连接 = a模式.m设备.m连接
    def f连接(self):
        raise NotImplementedError()
    def f关闭(self):
        passclass I命令行连接(I连接包装):
    def f读_最新(self):
        return self.m连接.f读_最新()
    def f读_最近(self, a数量):
        return self.m连接.f读_最近(a数量)
    def f读_直到(self, a文本 = "", a时间 = 5):
        return self.m连接.f读_直到(a文本, a时间)
    def f写(self, a文本):
        self.m连接.f写(a文本)
    def fs编码(self, a编码):
        self.m连接.fs编码(a编码)

这是一个(编程)接口,它本身不包含如何连接到设备的代码,还需要根据不同型号的设备编写具体的连接包装类。

用户模式的连接函数

远程登录到其它设备的命令一般在用户模式执行,常用的远程登录方式有3种:Telnet、SSH、集群,所以写3个连接函数:

网络设备脚本/基础接口/用户模式.py

#用户模式的连接函数↓
    def f连接_网络终端(self, a地址, **a参数):
        raise NotImplementedError()
    def f连接_安全外壳(self, a地址, **a参数):
        raise NotImplementedError()
    def f连接_集群(self, a名称):
        raise NotImplementedError()

连接函数返回一个连接,然后根据目标设备的品牌型号版本创建设备对象:

v连接2 = v用户1.f连接_网络终端("12.0.0.2")#设备1连接设备2
v设备2 = 思科.f创建设备(v连接2, 思科.E型号.c7200, 15.2)#创建设备2
v用户2 = v设备2.f模式_用户()
v用户2.f登录(a用户名 = "asdf", a密码 = "1234")

连接栈

连接栈参考了模式栈,主要是为了让脚本可以根据需要自动地切换连接。用户只需要知道谁连接谁,不需要知道脚本具体怎么连接、怎么退出。

为了确保连接栈有足够的信息实现自动切换,需要定义一个结构保存每一个栈元素所需的信息。S连接栈元素 就是这么一个结构,它保存了当前连接和当前设备。

网络设备脚本/命令行接口/设备.py

class S连接栈元素:   #连接栈的元素
    def __init__(self, a连接, a设备):
        self.m连接 = weakref.proxy(a连接)
        self.m设备 = weakref.proxy(a设备)

连接栈有2个,一个是从一个连接延伸出来的、所有设备共同管理维护的公共连接栈,另外一个是表示设备所在连接位置的当前连接栈。创建设备对象时总要传入一个连接,构造函数根据这个连接是不是连接包装来判断这是个跳板还是个新连接。如果是跳板,在原来的连接栈后面加上当前连接。如果是新连接,新建一个连接栈。

网络设备脚本/命令行接口/设备.py

class I设备(设备.I设备):
    def __init__(self, a连接):
        设备.I设备.__init__(self)
        #设备设置(省略代码)
        #连接状态
        self.m连接 = a连接  #当前连接
        v连接栈元素 = S连接栈元素(a连接, self)
        if isinstance(a连接, 连接.I连接包装):   #跳板
            self.m公共连接栈 = a连接.m设备.m公共连接栈    #同一个连接栈中的所有设备共同控制的栈
            self.m当前连接栈 = a连接.m设备.m当前连接栈 + (v连接栈元素,)    #当前连接在连接栈的位置
        else:   #新连接
            self.m公共连接栈 = []
            self.m当前连接栈 = (v连接栈元素,)
        #设备状态(省略代码)

切换连接的算法类似于切换模式,比较公共连接栈和当前连接栈的区别,逐级退出,逐级进入。

网络设备脚本/命令行接口/设备.py

#↓ I设备 的函数
    def f切换到当前连接(self):
        #切换连接的过程类似切换模式
        if self.fi当前连接():
            return
        v现连接长度 = len(self.m公共连接栈)
        v新连接长度 = len(self.m当前连接栈)
        v最大长度 = max(v现连接长度, v新连接长度)
        v进入位置 = 0
        for i in range(v最大长度):
            if i >= v现连接长度:
                break
            if i >= v新连接长度 or self.m公共连接栈[i] != self.m当前连接栈[i]:
                for j in range(v现连接长度 - i):
                    self.m公共连接栈[-1].m设备.f关闭()
                break
        v进入位置 = i
        for i in range(v进入位置, v新连接长度):
            v当前连接 = self.m当前连接栈[i]
            v当前连接.m连接.f连接()
            self.m公共连接栈.append(v当前连接)
            if v当前连接.m设备.mf自动登录:    #自动登录
                v当前连接.m设备.mf自动登录()
    def fi当前连接(self):
        if not self.m公共连接栈: #没有连接
            return False
        return self.m当前连接栈[-1] == self.m公共连接栈[-1]   #偷懒,直接比较最后一个

自动登录

自动登录是为了满足自动切换连接而做出来的。当连接栈反复横跳切换到后面的连接时,第一次登录可以手动登录,但是次数多了就不一定了,不可能每次都让用户手动登录,所以需要有个自动登录功能。

如何实现自动登录?很简单,把用户名密码记下来,等到下次要用的时候再输入。记住的时机在于第一次登录时,f登录 中有2个参数分别是用户名和密码,我们可以把这2个参数暂时保存到变量中,写个函数用来保存这些登录信息。

网络设备脚本/命令行接口/用户模式.py

class I用户模式:
    def f记住登录(self, a用户名 = "", a密码 = ""):
        if a用户名
            self.m登录用户名 = a用户名
        if a密码:
            self.m登录密码 = a密码
    def f记住提权(self, a密码 = "", a级别 = 0):
        if a密码
            self.m提权密码 = a密码
        if a级别:
            self.m提权级别 = a级别

网络设备脚本/思科命令行/用户模式.py

class C用户模式(用户模式.I用户模式):
    def f登录(self, a用户名 = "", a密码 = ""):
        #记住登录
        self.f记住登录(a用户名 = a用户名, a密码 = a密码)
        #登录(省略代码)
        #覆盖自动登录(省略代码)

记住登录信息后,就可以使用记住的用户名密码来登录

网络设备脚本/思科命令行/用户模式.py

class C用户模式(用户模式.I用户模式):
    def f登录(self, a用户名 = "", a密码 = ""):
        #记住登录(省略代码)
        #登录
        if "Username:" in v输出:
            v输出 = self.m设备.f执行命令(self.m登录用户名)
        if "Password:" in v输出:
            self.m设备.f执行命令(self.m登录密码)
        self.f切换到当前模式()
        #覆盖自动登录(省略代码)

记住了用户名密码还不够,还需要应用到登录中。如何让脚本自动调用用户模式中的 f登录 和 f提升权限?其实上面连接栈的代码已经给出答案了,就是在切换连接时自动登录。设备类有一个变量叫 mf自动登录,当连接的时候会调用 mf自动登录,从而实现自动登录。在调用 f登录 时把 self.f自动登录 赋给 self.m设备.mf自动登录,这样以后重新连接时会调用 self.f自动登录。

网络设备脚本/思科命令行/用户模式.py

class C用户模式(用户模式.I用户模式):
    def f自动登录(self):
        self.f登录()
        self.f提升权限()
    def f登录(self, a用户名 = "", a密码 = ""):
        #记住登录(省略代码)
        #登录(省略代码)
        #覆盖自动登录
        self.m设备.mf自动登录 = self.f自动登录

跳板细节

在远程连接到设备的过程中,会遇到各种正常情况和异常情况,这些都是必须处理的细节问题,我这里举几个常见例子。

连不上

连不上的原因有很多,比如网络不通,手滑输错ip,目标设备没开远程登录。连不上的话源设备都会报错。

对于连不上的情况,只要能识别到错误信息然后抛异常即可。报错信息是由源设备提供的,所以在具体品牌具体型号具体实现中加入错误信息判断。

网络设备脚本/思科命令行/连接.py

class C网络终端(连接.I命令行连接):
    def f连接(self):
        v命令 = self.fg进入命令()
        v输出 = self.m模式.f执行当前模式命令(v命令)
        # % Destination unreachable; gateway or host down
        if "Destination unreachable" in v输出:    #没路由
            raise 异常.X连接(v输出)
        # % Connection timed out; remote host not responding
        if "Connection timed out" in v输出:   #连接超时
            raise 异常.X连接(v输出)
    #省略其它函数 

连得上

连得上也是一个问题,因为这涉及到连接对象使用权的转移。连接栈中的每个连接对象看起来都不一样,实际上后面几个都是把前一个连接包装起来的连接包装,最终使用的都是同一个连接对象,为了避免多个设备共同使用同一个连接对象引起冲突才设计了连接栈这个东西。

连得上有什么问题呢?用Telnet连接的时候,目标设备会提示输入用户名密码,但是提示输入用户名的“Username:”会被源设备读到。

对于这个问题,最简单的方法就是让目标设备重新弹一次“Username:”。在思科设备中,用户名放空白按回车或者按ctrl+c都能重新弹一次。

网络设备脚本/思科命令行/用户模式.py

class C用户模式(用户模式.I用户模式):
    def f登录(self, a用户名 = "", a密码 = ""):
        #记住登录(省略代码)
        #登录
        self.m设备.f输入_结束符()  #登录中按ctrl+c可以刷出"Username:"
        if "Username:" in v输出:
            v输出 = self.m设备.f执行命令(self.m登录用户名)
        if "Password:" in v输出:
            self.m设备.f执行命令(self.m登录密码)
        self.f切换到当前模式()
        #覆盖自动登录(省略代码)

完整过程

应用示例:跳板

这个示例拿前面提到的一条线的拓扑作为例子,演示一下如何从R1登R3,然后修改设备名称。

import cflw代码库py.cflw网络连接 as 连接
import cflw代码库py.cflw网络连接_视窗 as 视窗连接
import 网络设备脚本 as 脚本
import 网络设备脚本.思科 as 思科
def f连接(a连接):
    v设备 = 思科.f创建设备(a连接, 思科.E型号.c7200, 15.0)
    v设备.fs回显(True)
    v用户 = v设备.f模式_用户()
    v用户.f登录(a用户名 = "asdf", a密码 = "1234")
    v用户.f提升权限(a密码 = "123456")
    return v设备, v用户
def main():
    v连接1 = 连接.C网络终端("gns3.localhost", 5000)
    v设备1, v用户模式1 = f连接(v连接1)
    v全局配置1 = v用户模式1.f模式_全局配置()
    
    v设备2, v用户模式2 = f连接(v用户模式1.f连接_网络终端("12.0.0.2"))
    v全局配置2 = v用户模式2.f模式_全局配置()
    
    v设备3, v用户模式3 = f连接(v用户模式2.f连接_网络终端("23.0.0.3"))
    v全局配置3 = v用户模式3.f模式_全局配置()
    
    v全局配置1.fs设备名("r1")
    v全局配置2.fs设备名("r2")
    v全局配置3.fs设备名("r3")
if __name__ == "__main__":
    main()

运行结果如动图(图片有加速)

发布于 2019-09-21

文章被以下专栏收录

    在所有编程语言和领域中尝试编写中文代码,开发相关工具,总结经验,一致代码风格。包括中文命名,汉化现有语言,创造中文语法的编程语言等等。作为最熟悉的母语,用来编写代码会让代码更容易被自己和母语相同的其他开发者理解。基于英文的编程语言和框架中,使用中文命名有时有技术问题。希望这里为后人趟雷,填坑。多数现有API是英文的,这里也会对其中一些常用的进行汉化。当然,这里也会对基于中文的编程语言进行探讨。包括汉化基于英文的编程语言,以及创造新的编程语言。