使用 Twisted Conch 编写客户端

简介

在计算的早期,rsh/rlogin 用于连接远程计算机并执行命令。这些命令存在一个问题,即密码和命令以明文形式发送。为了解决这个问题,SSH 协议被创建。Twisted Conch 实现了该协议的第二个版本。

使用 SSH 命令端点

如果您的目标是通过 SSH 连接在远程主机上执行命令,那么最简单的方法可能是使用 twisted.conch.endpoints.SSHCommandClientEndpoint 。如果您以前没有使用过端点,请先查看 端点操作指南 ,了解端点的一般工作原理。

Conch 提供了一个端点实现,它建立 SSH 连接,执行必要的身份验证,打开通道,并在该通道中启动命令。然后,它将该命令的输出与您提供的协议的输入相关联,并将该协议的输出与该命令的输入相关联。实际上,这使您可以忽略 SSH 的大部分复杂性,而只需像与其他面向流的连接(如 TCP 或 SSL)一样与远程进程进行交互。

Conch 还提供了一个端点,它使用已建立的 SSH 连接进行初始化。该端点只是在现有连接上打开一个新通道,并在其中启动一个命令。

使用 SSHCommandClientEndpoint 与使用任何其他面向流的客户端端点一样简单。只需创建定义要连接到的 SSH 服务器位置的端点,以及定义要用于与命令进行交互的协议类型的工厂,然后使用端点的 connect 方法让它们开始工作。

echoclient_ssh.py

#!/usr/bin/env python
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

if __name__ == "__main__":
    import sys

    import echoclient_ssh

    from twisted.internet.task import react

    react(echoclient_ssh.main, sys.argv[1:])

import getpass
import os

from twisted.conch.client.knownhosts import KnownHostsFile
from twisted.conch.endpoints import SSHCommandClientEndpoint
from twisted.conch.ssh.keys import EncryptedKeyError, Key
from twisted.internet.defer import Deferred
from twisted.internet.endpoints import UNIXClientEndpoint
from twisted.internet.protocol import Factory, Protocol
from twisted.python.filepath import FilePath
from twisted.python.usage import Options


class EchoOptions(Options):
    optParameters = [
        ("host", "h", "localhost", "hostname of the SSH server to which to connect"),
        ("port", "p", 22, "port number of SSH server to which to connect", int),
        (
            "username",
            "u",
            getpass.getuser(),
            "username with which to authenticate with the SSH server",
        ),
        (
            "identity",
            "i",
            None,
            "file from which to read a private key to use for authentication",
        ),
        ("password", None, None, "password to use for authentication"),
        (
            "knownhosts",
            "k",
            "~/.ssh/known_hosts",
            "file containing known ssh server public key data",
        ),
    ]

    optFlags = [
        ["no-agent", None, "Disable use of key agent"],
    ]


class NoiseProtocol(Protocol):
    def connectionMade(self):
        self.finished = Deferred()
        self.strings = ["bif", "pow", "zot"]
        self.sendNoise()

    def sendNoise(self):
        if self.strings:
            self.transport.write(self.strings.pop(0) + "\n")
        else:
            self.transport.loseConnection()

    def dataReceived(self, data):
        print("Server says:", data)
        self.sendNoise()

    def connectionLost(self, reason):
        self.finished.callback(None)


def readKey(path):
    try:
        return Key.fromFile(path)
    except EncryptedKeyError:
        passphrase = getpass.getpass(f"{path!r} keyphrase: ")
        return Key.fromFile(path, passphrase=passphrase)


class ConnectionParameters:
    def __init__(
        self, reactor, host, port, username, password, keys, knownHosts, agent
    ):
        self.reactor = reactor
        self.host = host
        self.port = port
        self.username = username
        self.password = password
        self.keys = keys
        self.knownHosts = knownHosts
        self.agent = agent

    @classmethod
    def fromCommandLine(cls, reactor, argv):
        config = EchoOptions()
        config.parseOptions(argv)

        keys = []
        if config["identity"]:
            keyPath = os.path.expanduser(config["identity"])
            if os.path.exists(keyPath):
                keys.append(readKey(keyPath))

        knownHostsPath = FilePath(os.path.expanduser(config["knownhosts"]))
        if knownHostsPath.exists():
            knownHosts = KnownHostsFile.fromPath(knownHostsPath)
        else:
            knownHosts = None

        if config["no-agent"] or "SSH_AUTH_SOCK" not in os.environ:
            agentEndpoint = None
        else:
            agentEndpoint = UNIXClientEndpoint(reactor, os.environ["SSH_AUTH_SOCK"])

        return cls(
            reactor,
            config["host"],
            config["port"],
            config["username"],
            config["password"],
            keys,
            knownHosts,
            agentEndpoint,
        )

    def endpointForCommand(self, command):
        return SSHCommandClientEndpoint.newConnection(
            self.reactor,
            command,
            self.username,
            self.host,
            port=self.port,
            keys=self.keys,
            password=self.password,
            agentEndpoint=self.agent,
            knownHosts=self.knownHosts,
        )


def main(reactor, *argv):
    parameters = ConnectionParameters.fromCommandLine(reactor, argv)
    endpoint = parameters.endpointForCommand(b"/bin/cat")

    factory = Factory()
    factory.protocol = NoiseProtocol

    d = endpoint.connect(factory)
    d.addCallback(lambda proto: proto.finished)
    return d

为了完整起见,此示例包含大量代码来支持不同的身份验证风格,读取(并可能更新)现有的 known_hosts 文件,以及解析命令行选项。请关注 main 函数的后半部分,以查看直接负责执行必要的 SSH 连接设置的代码。 SSHCommandClientEndpoint 接受相当多的选项,因为 SSH 具有很大的灵活性,并且可能存在许多不同的服务器配置,但是一旦创建了端点对象本身,它的使用就不比使用任何其他端点更复杂:将工厂传递给它的 connect 方法,并将回调附加到生成的 Deferred 上,以便对协议实例执行某些操作。如果您使用的是创建新连接的端点,则可以通过在该 Deferred 上调用 cancel() 来取消连接尝试。

在本例中,连接的协议实例仅用于使示例等待客户端完成与服务器的通信,这发生在将少量示例数据发送到服务器并由协议正在交互的 /bin/cat 进程反弹回来之后。

SSHCommandClientEndpoint.newConnection 接受的几个选项应该很容易理解。该端点接收一个反应器,它用于执行它需要执行的任何和所有 I/O 操作。它还接收一个命令,该命令在建立并验证 SSH 连接后在远程服务器上执行;该命令是一个单个字符串,可能包含空格或其他特殊 shell 符号,并由服务器上的 shell 解释。它接收一个用户名,用于在身份验证过程中向服务器标识自己。它接收一个可选的密码参数,该参数也将用于身份验证 - 如果服务器支持密码身份验证(如果可能,请优先使用密钥,见下文)。它接收一个主机(名称或 IP 地址)和一个端口号,定义要连接的位置。

其他一些选项可能需要进一步解释。

keys 参数提供任何可能对身份验证有用的 SSH Key 对象。这些密钥可供端点用于身份验证,但只有服务器指示有用的密钥才会实际使用。此参数是可选的。如果针对服务器的密钥身份验证是无必要的或不希望的,则可以完全省略它。

agentEndpoint 参数使 SSHCommandClientEndpoint 有机会连接到 SSH 身份验证代理。代理可能已经加载了密钥,或者可能具有其他身份验证连接的方法。使用代理意味着实际建立 SSH 连接的进程不需要自己加载任何身份验证材料(密码或密钥)(在密钥被加密且可能更安全的情况下通常很方便,因为只有代理进程实际持有秘密)。此参数的值是另一个 IStreamClientEndpoint 。在典型的 NIX 桌面环境中,*SSH_AUTH_SOCK* 环境变量通常会给出 AF_UNIX 套接字的位置。这解释了当未给出 –no-agentechoclient_ssh.py 为此参数分配的值。

knownHosts 参数接受一个 KnownHostsFile 实例,并控制如何检查和存储服务器密钥。如果服务器密钥与预期不同,此对象有机会拒绝它们。它还可以在首次观察到服务器密钥时保存它们。

最后,有一个选项在示例中没有演示 - ui 参数。此参数与上面描述的 knownHosts 参数密切相关。 KnownHostsFile 可能在某些情况下需要用户输入 - 例如,询问它是否应该在首次观察到服务器密钥时接受它。 ui 对象是获取此用户输入的方式。默认情况下,将使用与 /dev/tty 关联的 ConsoleUI 实例。这提供了与标准命令行 ssh 客户端中看到的行为大致相同的行为。有关如何处理此默认值的边缘情况的详细信息,请参见 SSHCommandClientEndpoint.newConnection 。对于旨在完全自主的 SSHCommandClientEndpoint 的使用,应用程序可能希望指定一个自定义的 ui 对象,该对象可以在没有用户输入的情况下做出必要的决策。

也可以通过已建立的连接运行命令(一个或多个)。这可以通过使用备用构造函数 SSHCommandClientEndpoint.existingConnection 来实现。该函数的 connection 参数可以通过访问已连接协议上的 transport.conn 来获取。

echoclient_shared_ssh.py

#!/usr/bin/env python
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

if __name__ == "__main__":
    import sys

    import echoclient_shared_ssh

    from twisted.internet.task import react

    react(echoclient_shared_ssh.main, sys.argv[1:])

from echoclient_ssh import ConnectionParameters

from twisted.conch.endpoints import SSHCommandClientEndpoint
from twisted.internet.defer import Deferred, gatherResults
from twisted.internet.protocol import Factory, Protocol
from twisted.internet.task import cooperate


class PrinterProtocol(Protocol):
    def dataReceived(self, data):
        print("Got some data:", data, end=" ")

    def connectionLost(self, reason):
        print("Lost my connection")
        self.factory.done.callback(None)


def main(reactor, *argv):
    parameters = ConnectionParameters.fromCommandLine(reactor, argv)
    endpoint = parameters.endpointForCommand(b"/bin/cat")

    done = []
    factory = Factory()
    factory.protocol = Protocol
    d = endpoint.connect(factory)

    def gotConnection(proto):
        conn = proto.transport.conn

        for i in range(50):
            factory = Factory()
            factory.protocol = PrinterProtocol
            factory.done = Deferred()
            done.append(factory.done)

            e = SSHCommandClientEndpoint.existingConnection(
                conn, b"/bin/echo %d" % (i,)
            )
            yield e.connect(factory)

    d.addCallback(gotConnection)
    d.addCallback(lambda work: cooperate(work).whenDone())
    d.addCallback(lambda ignored: gatherResults(done))

    return d

编写客户端

如果端点缺少一些必要的功能,或者您想与 SSH 服务器的不同部分进行交互 - 例如它的某个子系统(例如,SFTP),您可能需要使用更低级的 Conch 客户端接口。这将在下面描述。

使用 Conch 编写客户端涉及对 4 个类进行子类化:twisted.conch.ssh.transport.SSHClientTransporttwisted.conch.ssh.userauth.SSHUserAuthClienttwisted.conch.ssh.connection.SSHConnectiontwisted.conch.ssh.channel.SSHChannel。我们将从 SSHClientTransport 开始,因为它 是客户端的基础。

传输

from twisted.conch import error
from twisted.conch.ssh import transport
from twisted.internet import defer

class ClientTransport(transport.SSHClientTransport):

    def verifyHostKey(self, pubKey, fingerprint):
        if fingerprint != 'b1:94:6a:c9:24:92:d2:34:7c:62:35:b4:d2:61:11:84':
            return defer.fail(error.ConchError('bad key'))
        else:
            return defer.succeed(1)

    def connectionSecure(self):
        self.requestService(ClientUserAuth('user', ClientConnection()))

看看它有多容易?SSHClientTransport 为您处理加密协商和密钥验证。作为客户端编写者,您需要实现的唯一安全元素是 verifyHostKey()。此方法使用两个字符串调用:服务器发送的公钥及其指纹。您应该验证服务器发送的主机密钥,方法是针对硬编码值进行检查(如示例中所示),或询问用户。 verifyHostKey 返回一个 twisted.internet.defer.Deferred,如果主机密钥有效,则会调用回调,如果无效,则会调用错误回调。请注意,在上述内容中,将“user”替换为您尝试使用 ssh 的用户名,例如对 os.getlogin() 的调用以获取当前用户。

您需要实现的第二个方法是 connectionSecure()。当加密设置好并且可以运行其他服务时,会调用它。该示例请求启动 ClientUserAuth 服务。此服务将在下一节中讨论。

授权客户端

from twisted.conch.ssh import keys, userauth

# these are the public/private keys from test_conch

publicKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEArzJx8OYOnJmzf4tfBEvLi8DVPrJ3\
/c9k2I/Az64fxjHf9imyRJbixtQhlH9lfNjUIx+4LmrJH5QNRsFporcHDKOTwTTYLh5KmRpslkYHR\
ivcJSkbh/C+BR3utDS555mV'

privateKey = """-----BEGIN RSA PRIVATE KEY-----
MIIByAIBAAJhAK8ycfDmDpyZs3+LXwRLy4vA1T6yd/3PZNiPwM+uH8Yx3/YpskSW
4sbUIZR/ZXzY1CMfuC5qyR+UDUbBaaK3Bwyjk8E02C4eSpkabJZGB0Yr3CUpG4fw
vgUd7rQ0ueeZlQIBIwJgbh+1VZfr7WftK5lu7MHtqE1S1vPWZQYE3+VUn8yJADyb
Z4fsZaCrzW9lkIqXkE3GIY+ojdhZhkO1gbG0118sIgphwSWKRxK0mvh6ERxKqIt1
xJEJO74EykXZV4oNJ8sjAjEA3J9r2ZghVhGN6V8DnQrTk24Td0E8hU8AcP0FVP+8
PQm/g/aXf2QQkQT+omdHVEJrAjEAy0pL0EBH6EVS98evDCBtQw22OZT52qXlAwZ2
gyTriKFVoqjeEjt3SZKKqXHSApP/AjBLpF99zcJJZRq2abgYlf9lv1chkrWqDHUu
DZttmYJeEfiFBBavVYIF1dOlZT0G8jMCMBc7sOSZodFnAiryP+Qg9otSBjJ3bQML
pSTqy7c3a2AScC/YyOwkDaICHnnD3XyjMwIxALRzl0tQEKMXs6hH8ToUdlLROCrP
EhQ0wahUTCk1gKA4uPD6TMTChavbh4K63OvbKg==
-----END RSA PRIVATE KEY-----"""

class ClientUserAuth(userauth.SSHUserAuthClient):

    def getPassword(self, prompt = None):
        return
        # this says we won't do password authentication

    def getPublicKey(self):
        return keys.Key.fromString(data = publicKey).blob()

    def getPrivateKey(self):
        return defer.succeed(keys.Key.fromString(data = privateKey).keyObject)

同样,相当简单。 SSHUserAuthClient 处理了大部分工作,但需要提供实际的身份验证数据。 getPassword() 请求密码,getPublicKey()getPrivateKey() 分别获取公钥和私钥。 getPassword() 返回一个 Deferred,该 Deferred 使用要使用的密码进行回调。

getPublicKey() 返回要使用的公钥的 SSH 密钥数据。 Key.fromString() 将以 OpenSSH、LSH 或任何支持的格式(作为字符串)获取密钥,并生成一个新的 Key。或者,可以使用 keys.Key.fromFile(),它将获取支持格式的密钥的文件名,并生成一个新的 Key

getPrivateKey() 返回一个 Deferred,该 Deferred 使用私有 Key 进行回调。

getPassword()getPrivateKey() 返回 Deferreds,因为它们可能需要向用户请求输入。

身份验证完成后,SSHUserAuthClient 会负责启动传递给它的 SSHConnection 对象。接下来,我们将看看如何使用 SSHConnection

连接

from twisted.conch.ssh import connection

class ClientConnection(connection.SSHConnection):

    def serviceStarted(self):
        self.openChannel(CatChannel(conn = self))

SSHConnection 最简单,因为它只负责启动通道。它还有其他方法,这些方法将在我们查看 SSHChannel 时进行检查。

通道

from twisted.conch.ssh import channel, common

class CatChannel(channel.SSHChannel):

    name = 'session'

    def channelOpen(self, data):
        d = self.conn.sendRequest(self, 'exec', common.NS('cat'),
                                  wantReply = 1)
        d.addCallback(self._cbSendRequest)
        self.catData = ''

    def _cbSendRequest(self, ignored):
        self.write('This data will be echoed back to us by "cat."\r\n')
        self.conn.sendEOF(self)
        self.loseConnection()

    def dataReceived(self, data):
        self.catData += data

    def closed(self):
        print('We got this from "cat":', self.catData)

现在我们已经花了这么多时间来连接服务器和客户端,这里就是这些工作得到回报的地方。 SSHChannel 是您与另一方之间的接口。这个特定的通道打开一个会话并与“cat”程序进行交互,但您的通道可以实现任何东西,只要服务器支持它即可。

channelOpen() 方法是所有事情开始的地方。它传递了一块数据;但是,这块数据通常是空的,可以忽略。我们的 channelOpen() 初始化我们的通道,并使用 SSHConnection 对象的 sendRequest() 方法向另一方发送请求。请求用于向另一方发送事件。我们传递方法 self,以便它知道要为此通道发送请求。第二个参数“exec”告诉服务器我们想要执行一个命令。第三个参数是与请求一起提供的数据。 common.NS 将数据编码为长度前缀字符串,这是服务器期望数据的方式。我们还说我们想要一个回复,说明该进程已启动。 sendRequest() 然后返回一个 Deferred,我们为此添加一个回调。

回调触发后,我们发送数据。 SSHChannel 支持 twisted.internet.interfaces.ITransport 接口,因此可以将其传递给协议以在安全连接上运行它们。在我们的例子中,我们只是直接写入数据。 sendEOF() 不遵循接口,但 Conch 使用它来告诉另一方我们不会再写入数据。 loseConnection() 关闭我们这边的连接,但我们仍然会通过 dataReceived() 接收数据。 closed() 方法在连接的双方都关闭时被调用,我们使用它来显示我们接收到的数据(应该与我们发送的数据相同)。

最后,让我们实际调用我们设置的代码。

main() 函数

from twisted.internet import protocol, reactor

def main():
    factory = protocol.ClientFactory()
    factory.protocol = ClientTransport
    reactor.connectTCP('localhost', 22, factory)
    reactor.run()

if __name__ == "__main__":
    main()

我们调用 connectTCP() 连接到 localhost,端口 22(ssh 的标准端口),并将一个 twisted.internet.protocol.ClientFactory 实例传递给它。此实例具有属性 protocol,该属性设置为我们之前定义的 ClientTransport 类。请注意,协议属性设置为类 ClientTransport,而不是 ClientTransport 的实例!当 connectTCP 调用完成时,将调用协议以创建一个 ClientTransport() 对象 - 这将调用我们之前的所有工作。

值得注意的是,在示例 main() 例程中,reactor.run() 调用永远不会返回。如果您想让程序退出,请在之前的 closed() 方法中调用 reactor.stop()

如果您希望更详细地观察交互,请在 reactor.run() 调用之前添加对 log.startLogging(sys.stdout, setStdout=0) 的调用,这将把所有日志发送到 stdout。