1 # -*- test-case-name: twisted.conch.test.test_conch -*-
2 # Copyright (c) Twisted Matrix Laboratories.
3 # See LICENSE for details.
6 from itertools import count
8 from zope.interface import implements
10 from twisted.cred import portal
11 from twisted.internet import reactor, defer, protocol
12 from twisted.internet.error import ProcessExitedAlready
13 from twisted.internet.task import LoopingCall
14 from twisted.python import log, runtime
15 from twisted.trial import unittest
16 from twisted.conch.error import ConchError
17 from twisted.conch.avatar import ConchUser
18 from twisted.conch.ssh.session import ISession, SSHSession, wrapProtocol
21 from twisted.conch.scripts.conch import SSHSession as StdioInteractingSession
22 except ImportError, e:
23 StdioInteractingSession = None
27 from twisted.conch.test.test_ssh import ConchTestRealm
28 from twisted.python.procutils import which
30 from twisted.conch.test.keydata import publicRSA_openssh, privateRSA_openssh
31 from twisted.conch.test.keydata import publicDSA_openssh, privateDSA_openssh
33 from twisted.conch.test.test_ssh import Crypto, pyasn1
35 from twisted.conch.test.test_ssh import ConchTestServerFactory, \
36 ConchTestPublicKeyChecker
42 class StdioInteractingSessionTests(unittest.TestCase):
44 Tests for L{twisted.conch.scripts.conch.SSHSession}.
46 if StdioInteractingSession is None:
49 def test_eofReceived(self):
51 L{twisted.conch.scripts.conch.SSHSession.eofReceived} loses the
52 write half of its stdio connection.
57 def loseWriteConnection(self):
58 self.writeConnLost = True
61 channel = StdioInteractingSession()
64 self.assertTrue(stdio.writeConnLost)
68 class Echo(protocol.Protocol):
69 def connectionMade(self):
70 log.msg('ECHO CONNECTION MADE')
73 def connectionLost(self, reason):
74 log.msg('ECHO CONNECTION DONE')
77 def dataReceived(self, data):
78 self.transport.write(data)
80 self.transport.loseConnection()
84 class EchoFactory(protocol.Factory):
89 class ConchTestOpenSSHProcess(protocol.ProcessProtocol):
91 Test protocol for launching an OpenSSH client process.
93 @ivar deferred: Set by whatever uses this object. Accessed using
94 L{_getDeferred}, which destroys the value so the Deferred is not
95 fired twice. Fires when the process is terminated.
101 def _getDeferred(self):
102 d, self.deferred = self.deferred, None
106 def outReceived(self, data):
110 def processEnded(self, reason):
112 Called when the process has ended.
114 @param reason: a Failure giving the reason for the process' end.
116 if reason.value.exitCode != 0:
117 self._getDeferred().errback(
118 ConchError("exit code was not 0: %s" %
119 reason.value.exitCode))
121 buf = self.buf.replace('\r\n', '\n')
122 self._getDeferred().callback(buf)
126 class ConchTestForwardingProcess(protocol.ProcessProtocol):
128 Manages a third-party process which launches a server.
130 Uses L{ConchTestForwardingPort} to connect to the third-party server.
131 Once L{ConchTestForwardingPort} has disconnected, kill the process and fire
132 a Deferred with the data received by the L{ConchTestForwardingPort}.
134 @ivar deferred: Set by whatever uses this object. Accessed using
135 L{_getDeferred}, which destroys the value so the Deferred is not
136 fired twice. Fires when the process is terminated.
141 def __init__(self, port, data):
144 @param port: The port on which the third-party server is listening.
145 (it is assumed that the server is running on localhost).
148 @param data: This is sent to the third-party server. Must end with '\n'
149 in order to trigger a disconnect.
156 def _getDeferred(self):
157 d, self.deferred = self.deferred, None
161 def connectionMade(self):
167 Connect to the server, which is often a third-party process.
168 Tries to reconnect if it fails because we have no way of determining
169 exactly when the port becomes available for listening -- we can only
170 know when the process starts.
172 cc = protocol.ClientCreator(reactor, ConchTestForwardingPort, self,
174 d = cc.connectTCP('127.0.0.1', self.port)
175 d.addErrback(self._ebConnect)
179 def _ebConnect(self, f):
180 reactor.callLater(.1, self._connect)
183 def forwardingPortDisconnected(self, buffer):
185 The network connection has died; save the buffer of output
186 from the network and attempt to quit the process gracefully,
187 and then (after the reactor has spun) send it a KILL signal.
190 self.transport.write('\x03')
191 self.transport.loseConnection()
192 reactor.callLater(0, self._reallyDie)
195 def _reallyDie(self):
197 self.transport.signalProcess('KILL')
198 except ProcessExitedAlready:
202 def processEnded(self, reason):
204 Fire the Deferred at self.deferred with the data collected
205 from the L{ConchTestForwardingPort} connection, if any.
207 self._getDeferred().callback(self.buffer)
211 class ConchTestForwardingPort(protocol.Protocol):
213 Connects to server launched by a third-party process (managed by
214 L{ConchTestForwardingProcess}) sends data, then reports whatever it
215 received back to the L{ConchTestForwardingProcess} once the connection
220 def __init__(self, protocol, data):
222 @type protocol: L{ConchTestForwardingProcess}
223 @param protocol: The L{ProcessProtocol} which made this connection.
226 @param data: The data to be sent to the third-party server.
228 self.protocol = protocol
232 def connectionMade(self):
234 self.transport.write(self.data)
237 def dataReceived(self, data):
241 def connectionLost(self, reason):
242 self.protocol.forwardingPortDisconnected(self.buffer)
246 def _makeArgs(args, mod="conch"):
247 start = [sys.executable, '-c'
251 path = os.path.abspath(sys.argv[0])
252 while os.path.dirname(path) != path:
253 if os.path.basename(path).startswith('Twisted'):
254 sys.path.insert(0, path)
256 path = os.path.dirname(path)
258 from twisted.conch.scripts.%s import run
260 return start + list(args)
264 class ConchServerSetupMixin:
266 skip = "can't run w/o PyCrypto"
269 skip = "Cannot run without PyASN1"
271 realmFactory = staticmethod(lambda: ConchTestRealm('testuser'))
273 def _createFiles(self):
274 for f in ['rsa_test','rsa_test.pub','dsa_test','dsa_test.pub',
276 if os.path.exists(f):
278 open('rsa_test','w').write(privateRSA_openssh)
279 open('rsa_test.pub','w').write(publicRSA_openssh)
280 open('dsa_test.pub','w').write(publicDSA_openssh)
281 open('dsa_test','w').write(privateDSA_openssh)
282 os.chmod('dsa_test', 33152)
283 os.chmod('rsa_test', 33152)
284 open('kh_test','w').write('127.0.0.1 '+publicRSA_openssh)
287 def _getFreePort(self):
290 port = s.getsockname()[1]
295 def _makeConchFactory(self):
297 Make a L{ConchTestServerFactory}, which allows us to start a
298 L{ConchTestServer} -- i.e. an actually listening conch.
300 realm = self.realmFactory()
301 p = portal.Portal(realm)
302 p.registerChecker(ConchTestPublicKeyChecker())
303 factory = ConchTestServerFactory()
310 self.conchFactory = self._makeConchFactory()
311 self.conchFactory.expectedLoseConnection = 1
312 self.conchServer = reactor.listenTCP(0, self.conchFactory,
313 interface="127.0.0.1")
314 self.echoServer = reactor.listenTCP(0, EchoFactory())
315 self.echoPort = self.echoServer.getHost().port
320 self.conchFactory.proto.done = 1
321 except AttributeError:
324 self.conchFactory.proto.transport.loseConnection()
325 return defer.gatherResults([
326 defer.maybeDeferred(self.conchServer.stopListening),
327 defer.maybeDeferred(self.echoServer.stopListening)])
331 class ForwardingMixin(ConchServerSetupMixin):
333 Template class for tests of the Conch server's ability to forward arbitrary
336 These tests are integration tests, not unit tests. They launch a Conch
337 server, a custom TCP server (just an L{EchoProtocol}) and then call
340 L{execute} is implemented by subclasses of L{ForwardingMixin}. It should
341 cause an SSH client to connect to the Conch server, asking it to forward
342 data to the custom TCP server.
347 Test that we can use whatever client to send the command "echo goodbye"
348 to the Conch server. Make sure we receive "goodbye" back from the
351 d = self.execute('echo goodbye', ConchTestOpenSSHProcess())
352 return d.addCallback(self.assertEqual, 'goodbye\n')
355 def test_localToRemoteForwarding(self):
357 Test that we can use whatever client to forward a local port to a
358 specified port on the server.
360 localPort = self._getFreePort()
361 process = ConchTestForwardingProcess(localPort, 'test\n')
362 d = self.execute('', process,
363 sshArgs='-N -L%i:127.0.0.1:%i'
364 % (localPort, self.echoPort))
365 d.addCallback(self.assertEqual, 'test\n')
369 def test_remoteToLocalForwarding(self):
371 Test that we can use whatever client to forward a port from the server
374 localPort = self._getFreePort()
375 process = ConchTestForwardingProcess(localPort, 'test\n')
376 d = self.execute('', process,
377 sshArgs='-N -R %i:127.0.0.1:%i'
378 % (localPort, self.echoPort))
379 d.addCallback(self.assertEqual, 'test\n')
384 class RekeyAvatar(ConchUser):
386 This avatar implements a shell which sends 60 numbered lines to whatever
387 connects to it, then closes the session with a 0 exit status.
389 60 lines is selected as being enough to send more than 2kB of traffic, the
390 amount the client is configured to initiate a rekey after.
392 # Conventionally there is a separate adapter object which provides ISession
393 # for the user, but making the user provide ISession directly works too.
394 # This isn't a full implementation of ISession though, just enough to make
399 ConchUser.__init__(self)
400 self.channelLookup['session'] = SSHSession
403 def openShell(self, transport):
405 Write 60 lines of data to the transport, then exit.
407 proto = protocol.Protocol()
408 proto.makeConnection(transport)
409 transport.makeConnection(wrapProtocol(proto))
411 # Send enough bytes to the connection so that a rekey is triggered in
417 transport.session.conn.sendRequest(
418 transport.session, 'exit-status', '\x00\x00\x00\x00')
419 transport.loseConnection()
421 transport.write("line #%02d\n" % (i,))
423 # The timing for this loop is an educated guess (and/or the result of
424 # experimentation) to exercise the case where a packet is generated
425 # mid-rekey. Since the other side of the connection is (so far) the
426 # OpenSSH command line client, there's no easy way to determine when the
427 # rekey has been initiated. If there were, then generating a packet
428 # immediately at that time would be a better way to test the
429 # functionality being tested here.
430 call = LoopingCall(write, count().next)
436 Ignore the close of the session.
443 This realm gives out new L{RekeyAvatar} instances for any avatar request.
445 def requestAvatar(self, avatarID, mind, *interfaces):
446 return interfaces[0], RekeyAvatar(), lambda: None
450 class RekeyTestsMixin(ConchServerSetupMixin):
452 TestCase mixin which defines tests exercising L{SSHTransportBase}'s handling
453 of rekeying messages.
455 realmFactory = RekeyRealm
457 def test_clientRekey(self):
459 After a client-initiated rekey is completed, application data continues
460 to be passed over the SSH connection.
462 process = ConchTestOpenSSHProcess()
463 d = self.execute("", process, '-o RekeyLimit=2K')
464 def finished(result):
467 '\n'.join(['line #%02d' % (i,) for i in range(60)]) + '\n')
468 d.addCallback(finished)
473 class OpenSSHClientMixin:
475 skip = "no ssh command-line client available"
477 def execute(self, remoteCommand, process, sshArgs=''):
479 Connects to the SSH server started in L{ConchServerSetupMixin.setUp} by
480 running the 'ssh' command line tool.
482 @type remoteCommand: str
483 @param remoteCommand: The command (with arguments) to run on the
486 @type process: L{ConchTestOpenSSHProcess}
489 @param sshArgs: Arguments to pass to the 'ssh' process.
491 @return: L{defer.Deferred}
493 process.deferred = defer.Deferred()
494 cmdline = ('ssh -2 -l testuser -p %i '
495 '-oUserKnownHostsFile=kh_test '
496 '-oPasswordAuthentication=no '
497 # Always use the RSA key, since that's the one in kh_test.
498 '-oHostKeyAlgorithms=ssh-rsa '
500 '-i dsa_test ') + sshArgs + \
501 ' 127.0.0.1 ' + remoteCommand
502 port = self.conchServer.getHost().port
503 cmds = (cmdline % port).split()
504 reactor.spawnProcess(process, "ssh", cmds)
505 return process.deferred
509 class OpenSSHClientForwardingTestCase(ForwardingMixin, OpenSSHClientMixin,
512 Connection forwarding tests run against the OpenSSL command line client.
517 class OpenSSHClientRekeyTestCase(RekeyTestsMixin, OpenSSHClientMixin,
520 Rekeying tests run against the OpenSSL command line client.
525 class CmdLineClientTestCase(ForwardingMixin, unittest.TestCase):
527 Connection forwarding tests run against the Conch command line client.
529 if runtime.platformType == 'win32':
530 skip = "can't run cmdline client on win32"
532 def execute(self, remoteCommand, process, sshArgs=''):
534 As for L{OpenSSHClientTestCase.execute}, except it runs the 'conch'
535 command line tool, not 'ssh'.
537 process.deferred = defer.Deferred()
538 port = self.conchServer.getHost().port
539 cmd = ('-p %i -l testuser '
540 '--known-hosts kh_test '
541 '--user-authentications publickey '
542 '--host-key-algorithms ssh-rsa '
545 '-v ') % port + sshArgs + \
546 ' 127.0.0.1 ' + remoteCommand
547 cmds = _makeArgs(cmd.split())
549 env = os.environ.copy()
550 env['PYTHONPATH'] = os.pathsep.join(sys.path)
551 reactor.spawnProcess(process, sys.executable, cmds, env=env)
552 return process.deferred