OLD | NEW |
| (Empty) |
1 # -*- test-case-name: twisted.conch.test.test_conch -*- | |
2 # Copyright (c) 2001-2008 Twisted Matrix Laboratories. | |
3 # See LICENSE for details. | |
4 | |
5 import os, sys | |
6 | |
7 try: | |
8 import Crypto | |
9 except: | |
10 Crypto = None | |
11 | |
12 from twisted.cred import portal | |
13 from twisted.internet import reactor, defer, protocol | |
14 from twisted.internet.error import ProcessExitedAlready | |
15 from twisted.python import log, runtime | |
16 from twisted.python.filepath import FilePath | |
17 from twisted.trial import unittest | |
18 from twisted.conch.error import ConchError | |
19 from twisted.conch.test.test_ssh import ConchTestRealm | |
20 from twisted.python.procutils import which | |
21 | |
22 from twisted.conch.test.keydata import publicRSA_openssh, privateRSA_openssh | |
23 from twisted.conch.test.keydata import publicDSA_openssh, privateDSA_openssh | |
24 | |
25 | |
26 | |
27 class Echo(protocol.Protocol): | |
28 def connectionMade(self): | |
29 log.msg('ECHO CONNECTION MADE') | |
30 | |
31 | |
32 def connectionLost(self, reason): | |
33 log.msg('ECHO CONNECTION DONE') | |
34 | |
35 | |
36 def dataReceived(self, data): | |
37 self.transport.write(data) | |
38 if '\n' in data: | |
39 self.transport.loseConnection() | |
40 | |
41 | |
42 | |
43 class EchoFactory(protocol.Factory): | |
44 protocol = Echo | |
45 | |
46 | |
47 | |
48 class ConchTestOpenSSHProcess(protocol.ProcessProtocol): | |
49 """ | |
50 Test protocol for launching an OpenSSH client process. | |
51 | |
52 @ivar deferred: Set by whatever uses this object. Accessed using | |
53 L{_getDeferred}, which destroys the value so the Deferred is not | |
54 fired twice. Fires when the process is terminated. | |
55 """ | |
56 | |
57 deferred = None | |
58 buf = '' | |
59 | |
60 def _getDeferred(self): | |
61 d, self.deferred = self.deferred, None | |
62 return d | |
63 | |
64 | |
65 def outReceived(self, data): | |
66 self.buf += data | |
67 | |
68 | |
69 def processEnded(self, reason): | |
70 """ | |
71 Called when the process has ended. | |
72 | |
73 @param reason: a Failure giving the reason for the process' end. | |
74 """ | |
75 if reason.value.exitCode != 0: | |
76 self._getDeferred().errback( | |
77 ConchError("exit code was not 0: %s" % | |
78 reason.value.exitCode)) | |
79 else: | |
80 buf = self.buf.replace('\r\n', '\n') | |
81 self._getDeferred().callback(buf) | |
82 | |
83 | |
84 | |
85 class ConchTestForwardingProcess(protocol.ProcessProtocol): | |
86 """ | |
87 Manages a third-party process which launches a server. | |
88 | |
89 Uses L{ConchTestForwardingPort} to connect to the third-party server. | |
90 Once L{ConchTestForwardingPort} has disconnected, kill the process and fire | |
91 a Deferred with the data received by the L{ConchTestForwardingPort}. | |
92 | |
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. | |
96 """ | |
97 | |
98 deferred = None | |
99 | |
100 def __init__(self, port, data): | |
101 """ | |
102 @type port: C{int} | |
103 @param port: The port on which the third-party server is listening. | |
104 (it is assumed that the server is running on localhost). | |
105 | |
106 @type data: C{str} | |
107 @param data: This is sent to the third-party server. Must end with '\n' | |
108 in order to trigger a disconnect. | |
109 """ | |
110 self.port = port | |
111 self.buffer = None | |
112 self.data = data | |
113 | |
114 | |
115 def _getDeferred(self): | |
116 d, self.deferred = self.deferred, None | |
117 return d | |
118 | |
119 | |
120 def connectionMade(self): | |
121 self._connect() | |
122 | |
123 | |
124 def _connect(self): | |
125 """ | |
126 Connect to the server, which is often a third-party process. | |
127 Tries to reconnect if it fails because we have no way of determining | |
128 exactly when the port becomes available for listening -- we can only | |
129 know when the process starts. | |
130 """ | |
131 cc = protocol.ClientCreator(reactor, ConchTestForwardingPort, self, | |
132 self.data) | |
133 d = cc.connectTCP('127.0.0.1', self.port) | |
134 d.addErrback(self._ebConnect) | |
135 return d | |
136 | |
137 | |
138 def _ebConnect(self, f): | |
139 reactor.callLater(1, self._connect) | |
140 | |
141 | |
142 def forwardingPortDisconnected(self, buffer): | |
143 """ | |
144 The network connection has died; save the buffer of output | |
145 from the network and attempt to quit the process gracefully, | |
146 and then (after the reactor has spun) send it a KILL signal. | |
147 """ | |
148 self.buffer = buffer | |
149 self.transport.write('\x03') | |
150 self.transport.loseConnection() | |
151 reactor.callLater(0, self._reallyDie) | |
152 | |
153 | |
154 def _reallyDie(self): | |
155 try: | |
156 self.transport.signalProcess('KILL') | |
157 except ProcessExitedAlready: | |
158 pass | |
159 | |
160 | |
161 def processEnded(self, reason): | |
162 """ | |
163 Fire the Deferred at self.deferred with the data collected | |
164 from the L{ConchTestForwardingPort} connection, if any. | |
165 """ | |
166 self._getDeferred().callback(self.buffer) | |
167 | |
168 | |
169 | |
170 class ConchTestForwardingPort(protocol.Protocol): | |
171 """ | |
172 Connects to server launched by a third-party process (managed by | |
173 L{ConchTestForwardingProcess}) sends data, then reports whatever it | |
174 received back to the L{ConchTestForwardingProcess} once the connection | |
175 is ended. | |
176 """ | |
177 | |
178 | |
179 def __init__(self, protocol, data): | |
180 """ | |
181 @type protocol: L{ConchTestForwardingProcess} | |
182 @param protocol: The L{ProcessProtocol} which made this connection. | |
183 | |
184 @type data: str | |
185 @param data: The data to be sent to the third-party server. | |
186 """ | |
187 self.protocol = protocol | |
188 self.data = data | |
189 | |
190 | |
191 def connectionMade(self): | |
192 self.buffer = '' | |
193 self.transport.write(self.data) | |
194 | |
195 | |
196 def dataReceived(self, data): | |
197 self.buffer += data | |
198 | |
199 | |
200 def connectionLost(self, reason): | |
201 self.protocol.forwardingPortDisconnected(self.buffer) | |
202 | |
203 | |
204 | |
205 if Crypto: | |
206 from twisted.conch.client import options, default, connect | |
207 from twisted.conch.ssh import forwarding | |
208 from twisted.conch.ssh import connection | |
209 | |
210 from twisted.conch.test.test_ssh import ConchTestServerFactory | |
211 from twisted.conch.test.test_ssh import ConchTestPublicKeyChecker | |
212 | |
213 | |
214 class SSHTestConnectionForUnix(connection.SSHConnection): | |
215 """ | |
216 @ivar stopDeferred: Deferred that will be fired when C{serviceStopped} | |
217 is called. | |
218 @type stopDeferred: C{defer.Deferred} | |
219 """ | |
220 | |
221 def __init__(self, p, exe=None, cmds=None): | |
222 connection.SSHConnection.__init__(self) | |
223 if p: | |
224 self.spawn = (p, exe, cmds) | |
225 else: | |
226 self.spawn = None | |
227 self.connected = 0 | |
228 self.remoteForwards = {} | |
229 self.stopDeferred = defer.Deferred() | |
230 | |
231 def serviceStopped(self): | |
232 self.stopDeferred.callback(None) | |
233 | |
234 def serviceStarted(self): | |
235 if self.spawn: | |
236 env = os.environ.copy() | |
237 env['PYTHONPATH'] = os.pathsep.join(sys.path) | |
238 reactor.callLater(0,reactor.spawnProcess, env=env, *self.spawn) | |
239 self.connected = 1 | |
240 | |
241 def requestRemoteForwarding(self, remotePort, hostport): | |
242 data = forwarding.packGlobal_tcpip_forward(('0.0.0.0', remotePort)) | |
243 d = self.sendGlobalRequest('tcpip-forward', data, | |
244 wantReply=1) | |
245 log.msg('requesting remote forwarding %s:%s' %(remotePort, hostport)
) | |
246 d.addCallback(self._cbRemoteForwarding, remotePort, hostport) | |
247 d.addErrback(self._ebRemoteForwarding, remotePort, hostport) | |
248 | |
249 def _cbRemoteForwarding(self, result, remotePort, hostport): | |
250 log.msg('accepted remote forwarding %s:%s' % (remotePort, hostport)) | |
251 self.remoteForwards[remotePort] = hostport | |
252 log.msg(repr(self.remoteForwards)) | |
253 | |
254 def _ebRemoteForwarding(self, f, remotePort, hostport): | |
255 log.msg('remote forwarding %s:%s failed' % (remotePort, hostport)) | |
256 log.msg(f) | |
257 | |
258 def cancelRemoteForwarding(self, remotePort): | |
259 data = forwarding.packGlobal_tcpip_forward(('0.0.0.0', remotePort)) | |
260 self.sendGlobalRequest('cancel-tcpip-forward', data) | |
261 log.msg('cancelling remote forwarding %s' % remotePort) | |
262 try: | |
263 del self.remoteForwards[remotePort] | |
264 except: | |
265 pass | |
266 log.msg(repr(self.remoteForwards)) | |
267 | |
268 def channel_forwarded_tcpip(self, windowSize, maxPacket, data): | |
269 log.msg('%s %s' % ('FTCP', repr(data))) | |
270 remoteHP, origHP = forwarding.unpackOpen_forwarded_tcpip(data) | |
271 log.msg(self.remoteForwards) | |
272 log.msg(remoteHP) | |
273 if self.remoteForwards.has_key(remoteHP[1]): | |
274 connectHP = self.remoteForwards[remoteHP[1]] | |
275 log.msg('connect forwarding %s' % (connectHP,)) | |
276 return forwarding.SSHConnectForwardingChannel(connectHP, | |
277 remoteWindow = windowSize, | |
278 remoteMaxPacket = maxPacket, | |
279 conn = self) | |
280 else: | |
281 raise ConchError(connection.OPEN_CONNECT_FAILED, "don't know abo
ut that port") | |
282 | |
283 | |
284 | |
285 def _makeArgs(args, mod="conch"): | |
286 start = [sys.executable, '-c' | |
287 """ | |
288 ### Twisted Preamble | |
289 import sys, os | |
290 path = os.path.abspath(sys.argv[0]) | |
291 while os.path.dirname(path) != path: | |
292 if os.path.basename(path).startswith('Twisted'): | |
293 sys.path.insert(0, path) | |
294 break | |
295 path = os.path.dirname(path) | |
296 | |
297 from twisted.conch.scripts.%s import run | |
298 run()""" % mod] | |
299 return start + list(args) | |
300 | |
301 | |
302 | |
303 class ForwardingTestBase: | |
304 """ | |
305 Template class for tests of the Conch server's ability to forward arbitrary | |
306 protocols over SSH. | |
307 | |
308 These tests are integration tests, not unit tests. They launch a Conch | |
309 server, a custom TCP server (just an L{EchoProtocol}) and then call | |
310 L{execute}. | |
311 | |
312 L{execute} is implemented by subclasses of L{ForwardingTestBase}. It should | |
313 cause an SSH client to connect to the Conch server, asking it to forward | |
314 data to the custom TCP server. | |
315 """ | |
316 | |
317 if not Crypto: | |
318 skip = "can't run w/o PyCrypto" | |
319 | |
320 def _createFiles(self): | |
321 for f in ['rsa_test','rsa_test.pub','dsa_test','dsa_test.pub', | |
322 'kh_test']: | |
323 if os.path.exists(f): | |
324 os.remove(f) | |
325 open('rsa_test','w').write(privateRSA_openssh) | |
326 open('rsa_test.pub','w').write(publicRSA_openssh) | |
327 open('dsa_test.pub','w').write(publicDSA_openssh) | |
328 open('dsa_test','w').write(privateDSA_openssh) | |
329 os.chmod('dsa_test', 33152) | |
330 os.chmod('rsa_test', 33152) | |
331 open('kh_test','w').write('127.0.0.1 '+publicRSA_openssh) | |
332 | |
333 | |
334 def _getFreePort(self): | |
335 f = EchoFactory() | |
336 serv = reactor.listenTCP(0, f) | |
337 port = serv.getHost().port | |
338 serv.stopListening() | |
339 return port | |
340 | |
341 | |
342 def _makeConchFactory(self): | |
343 """ | |
344 Make a L{ConchTestServerFactory}, which allows us to start a | |
345 L{ConchTestServer} -- i.e. an actually listening conch. | |
346 """ | |
347 realm = ConchTestRealm() | |
348 p = portal.Portal(realm) | |
349 p.registerChecker(ConchTestPublicKeyChecker()) | |
350 factory = ConchTestServerFactory() | |
351 factory.portal = p | |
352 return factory | |
353 | |
354 | |
355 def setUp(self): | |
356 self._createFiles() | |
357 self.conchFactory = self._makeConchFactory() | |
358 self.conchFactory.expectedLoseConnection = 1 | |
359 self.conchServer = reactor.listenTCP(0, self.conchFactory, | |
360 interface="127.0.0.1") | |
361 self.echoServer = reactor.listenTCP(0, EchoFactory()) | |
362 self.echoPort = self.echoServer.getHost().port | |
363 | |
364 | |
365 def tearDown(self): | |
366 try: | |
367 self.conchFactory.proto.done = 1 | |
368 except AttributeError: | |
369 pass | |
370 else: | |
371 self.conchFactory.proto.transport.loseConnection() | |
372 return defer.gatherResults([ | |
373 defer.maybeDeferred(self.conchServer.stopListening), | |
374 defer.maybeDeferred(self.echoServer.stopListening)]) | |
375 | |
376 | |
377 def test_exec(self): | |
378 """ | |
379 Test that we can use whatever client to send the command "echo goodbye" | |
380 to the Conch server. Make sure we receive "goodbye" back from the | |
381 server. | |
382 """ | |
383 d = self.execute('echo goodbye', ConchTestOpenSSHProcess()) | |
384 return d.addCallback(self.assertEquals, 'goodbye\n') | |
385 | |
386 | |
387 def test_localToRemoteForwarding(self): | |
388 """ | |
389 Test that we can use whatever client to forward a local port to a | |
390 specified port on the server. | |
391 """ | |
392 lport = self._getFreePort() | |
393 process = ConchTestForwardingProcess(lport, 'test\n') | |
394 d = self.execute('', process, | |
395 sshArgs='-N -L%i:127.0.0.1:%i' | |
396 % (lport, self.echoPort)) | |
397 d.addCallback(self.assertEqual, 'test\n') | |
398 return d | |
399 | |
400 | |
401 def test_remoteToLocalForwarding(self): | |
402 """ | |
403 Test that we can use whatever client to forward a port from the server | |
404 to a port locally. | |
405 """ | |
406 localPort = self._getFreePort() | |
407 process = ConchTestForwardingProcess(localPort, 'test\n') | |
408 d = self.execute('', process, | |
409 sshArgs='-N -R %i:127.0.0.1:%i' | |
410 % (localPort, self.echoPort)) | |
411 d.addCallback(self.assertEqual, 'test\n') | |
412 return d | |
413 | |
414 | |
415 | |
416 class OpenSSHClientTestCase(ForwardingTestBase, unittest.TestCase): | |
417 | |
418 def execute(self, remoteCommand, process, sshArgs=''): | |
419 """ | |
420 Connects to the SSH server started in L{ForwardingTestBase.setUp} by | |
421 running the 'ssh' command line tool. | |
422 | |
423 @type remoteCommand: str | |
424 @param remoteCommand: The command (with arguments) to run on the | |
425 remote end. | |
426 | |
427 @type process: L{ConchTestOpenSSHProcess} | |
428 | |
429 @type sshArgs: str | |
430 @param sshArgs: Arguments to pass to the 'ssh' process. | |
431 | |
432 @return: L{defer.Deferred} | |
433 """ | |
434 process.deferred = defer.Deferred() | |
435 cmdline = ('ssh -2 -l testuser -p %i ' | |
436 '-oUserKnownHostsFile=kh_test ' | |
437 '-oPasswordAuthentication=no ' | |
438 # Always use the RSA key, since that's the one in kh_test. | |
439 '-oHostKeyAlgorithms=ssh-rsa ' | |
440 '-a ' | |
441 '-i dsa_test ') + sshArgs + \ | |
442 ' 127.0.0.1 ' + remoteCommand | |
443 port = self.conchServer.getHost().port | |
444 cmds = (cmdline % port).split() | |
445 reactor.spawnProcess(process, "ssh", cmds) | |
446 return process.deferred | |
447 | |
448 | |
449 | |
450 class CmdLineClientTestCase(ForwardingTestBase, unittest.TestCase): | |
451 def setUp(self): | |
452 if runtime.platformType == 'win32': | |
453 raise unittest.SkipTest("can't run cmdline client on win32") | |
454 ForwardingTestBase.setUp(self) | |
455 | |
456 | |
457 def execute(self, remoteCommand, process, sshArgs=''): | |
458 """ | |
459 As for L{OpenSSHClientTestCase.execute}, except it runs the 'conch' | |
460 command line tool, not 'ssh'. | |
461 """ | |
462 process.deferred = defer.Deferred() | |
463 port = self.conchServer.getHost().port | |
464 cmd = ('-p %i -l testuser ' | |
465 '--known-hosts kh_test ' | |
466 '--user-authentications publickey ' | |
467 '--host-key-algorithms ssh-rsa ' | |
468 '-a -I ' | |
469 '-K direct ' | |
470 '-i dsa_test ' | |
471 '-v ') % port + sshArgs + \ | |
472 ' 127.0.0.1 ' + remoteCommand | |
473 cmds = _makeArgs(cmd.split()) | |
474 log.msg(str(cmds)) | |
475 env = os.environ.copy() | |
476 env['PYTHONPATH'] = os.pathsep.join(sys.path) | |
477 reactor.spawnProcess(process, sys.executable, cmds, env=env) | |
478 return process.deferred | |
479 | |
480 | |
481 | |
482 class _UnixFixHome(object): | |
483 """ | |
484 Mixin class to fix the HOME environment variable to something usable. | |
485 | |
486 @ivar home: FilePath pointing at C{homePath}. | |
487 @type home: L{FilePath} | |
488 | |
489 @ivar homePath: relative path to the directory used as HOME during the | |
490 tests. | |
491 @type homePath: C{str} | |
492 """ | |
493 | |
494 def setUp(self): | |
495 path = self.mktemp() | |
496 self.home = FilePath(path) | |
497 self.homePath = os.path.join(*self.home.segmentsFrom(FilePath("."))) | |
498 if len(self.home.path) >= 70: | |
499 # UNIX_MAX_PATH is 108, and the socket file is generally of length | |
500 # 30, so we can't rely on mktemp... | |
501 self.homePath = "_tmp" | |
502 self.home = FilePath(self.homePath) | |
503 self.home.makedirs() | |
504 self.savedEnviron = os.environ.copy() | |
505 os.environ["HOME"] = self.homePath | |
506 | |
507 | |
508 def tearDown(self): | |
509 os.environ.clear() | |
510 os.environ.update(self.savedEnviron) | |
511 self.home.remove() | |
512 | |
513 | |
514 | |
515 class UnixClientTestCase(_UnixFixHome, ForwardingTestBase, unittest.TestCase): | |
516 def setUp(self): | |
517 if runtime.platformType == 'win32': | |
518 raise unittest.SkipTest("can't run cmdline client on win32") | |
519 ForwardingTestBase.setUp(self) | |
520 _UnixFixHome.setUp(self) | |
521 | |
522 | |
523 def tearDown(self): | |
524 d1 = ForwardingTestBase.tearDown(self) | |
525 d2 = defer.maybeDeferred(self.conn.transport.transport.loseConnection) | |
526 d3 = self.conn.stopDeferred | |
527 def clean(ign): | |
528 _UnixFixHome.tearDown(self) | |
529 return ign | |
530 return defer.gatherResults([d1, d2, d3]).addBoth(clean) | |
531 | |
532 | |
533 def makeOptions(self): | |
534 o = options.ConchOptions() | |
535 def parseArgs(host, *args): | |
536 o['host'] = host | |
537 o.parseArgs = parseArgs | |
538 return o | |
539 | |
540 | |
541 def makeAuthClient(self, port, options): | |
542 cmds = (('-p %i -l testuser ' | |
543 '--known-hosts kh_test ' | |
544 '--user-authentications publickey ' | |
545 '--host-key-algorithms ssh-rsa ' | |
546 '-a ' | |
547 '-K direct ' | |
548 '-i dsa_test ' | |
549 '127.0.0.1') % port).split() | |
550 options.parseOptions(cmds) | |
551 return default.SSHUserAuthClient(options['user'], options, self.conn) | |
552 | |
553 | |
554 def execute(self, remoteCommand, process, sshArgs=''): | |
555 """ | |
556 Connect to the forwarding process using the 'unix' client found in | |
557 L{twisted.conch.client.unix.connect}. See | |
558 L{OpenSSHClientTestCase.execute}. | |
559 """ | |
560 process.deferred = defer.Deferred() | |
561 port = self.conchServer.getHost().port | |
562 cmd = ('-p %i -l testuser ' | |
563 '-K unix ' | |
564 '-v ') % port + sshArgs + \ | |
565 ' 127.0.0.1 ' + remoteCommand | |
566 cmds = _makeArgs(cmd.split()) | |
567 options = self.makeOptions() | |
568 self.conn = SSHTestConnectionForUnix(process, sys.executable, cmds) | |
569 authClient = self.makeAuthClient(port, options) | |
570 d = connect.connect(options['host'], port, options, | |
571 default.verifyHostKey, authClient) | |
572 return d.addCallback(lambda x : process.deferred) | |
573 | |
574 | |
575 def test_noHome(self): | |
576 """ | |
577 When setting the HOME environment variable to a path that doesn't | |
578 exist, L{connect.connect} should forward the failure, and the created | |
579 process should fail with a L{ConchError}. | |
580 """ | |
581 path = self.mktemp() | |
582 # We override the HOME variable, and let tearDown restore the initial | |
583 # value | |
584 os.environ['HOME'] = path | |
585 process = ConchTestOpenSSHProcess() | |
586 d = self.execute('echo goodbye', process) | |
587 def cb(ign): | |
588 return self.assertFailure(process.deferred, ConchError) | |
589 return self.assertFailure(d, OSError).addCallback(cb) | |
590 | |
591 | |
592 | |
593 if not which('ssh'): | |
594 OpenSSHClientTestCase.skip = "no ssh command-line client available" | |
OLD | NEW |