1
2
3
4
5
6
7
8
9
10
11
12
13
14 __doc__="""SshClient runs commands on a remote box using SSH and
15 returns their results.
16
17 See http://twistedmatrix.com/trac/wiki/Documentation for Twisted documentation,
18 specifically documentation on 'conch' (Twisted's SSH protocol support).
19 """
20
21 import os
22 import sys
23 from pprint import pformat
24 import logging
25 log = logging.getLogger("zen.SshClient")
26
27 import Globals
28
29 from twisted.conch.ssh import transport, userauth, connection
30 from twisted.conch.ssh import common, channel
31 from twisted.conch.ssh.keys import Key
32 from twisted.internet import defer, reactor
33 from Products.ZenEvents import Event
34 from Products.ZenUtils.Utils import getExitMessage
35
36 from Exceptions import *
37
38 import CollectorClient
39
40
41
42
43
45 """
46 Shortcut version of sendEvent()
47
48 @param message: message to send in Zenoss event
49 @type message: string
50 @param device: hostname of device to which this event is associated
51 @type device: string
52 @param severity: Zenoss severity from Products.ZenEvents
53 @type severity: integer
54 """
55
56
57 component= os.path.basename( sys.argv[0] ).replace( '.py', '' )
58
59 def hasattr_path( object_root, path ):
60 """
61 The regular hasattr() only works on one component,
62 not multiples.
63
64 @param object_root: object to start searching for path
65 @type object_root: object
66 @param path: path to func or variable (eg "conn.factory" )
67 @type path: string
68 @return: is object_root.path sane?
69 @rtype: boolean
70 """
71 obj = object_root
72 for chunk in path.split('.'):
73 obj= getattr( obj, chunk, None )
74 if obj is None:
75 return False
76 return True
77
78
79 if device == '':
80 if hasattr_path( self, "factory.hostname" ):
81 device= self.factory.hostname
82
83 elif hasattr_path( self, "conn.factory.hostname" ):
84 device= self.conn.factory.hostname
85
86 else:
87 log.debug( "Couldn't get the remote device's hostname" )
88
89 error_event= {
90 'agent': component,
91 'summary': message,
92 'device': device,
93 'eventClass': "/Cmd/Fail",
94 'component': component,
95 'severity': severity,
96 }
97
98
99 try:
100 if hasattr_path( self, "factory.datacollector.sendEvent" ):
101 self.factory.datacollector.sendEvent( error_event )
102
103 elif hasattr_path( self, "datacollector.sendEvent" ):
104 self.datacollector.sendEvent( error_event )
105
106 elif hasattr_path( self, "conn.factory.datacollector.sendEvent" ):
107 self.conn.factory.datacollector.sendEvent( error_event )
108
109 else:
110 log.debug( "Unable to send event for %s" % error_event )
111
112 except:
113 pass
114
115
116
118 """
119 Exception class
120 """
121
122
123
125 """
126 Base client class for constructing Twisted Conch services.
127 This class is *only* responsible for connecting to the SSH
128 service on the device, and ensuring that *host* keys are sane.
129 """
130
132 """
133 Module to verify the host's SSH key against the stored fingerprint we have
134 from the last time that we communicated with the host.
135
136 NB: currently does not verify this information but simply trusts every host key
137
138 @param hostKey: host's SSH key (unused)
139 @type hostKey: string
140 @param fingerprint: host fingerprint (unused)
141 @type fingerprint: string
142 @return: Twisted deferred object
143 @rtype: Twisted deferred object (defer.succeed(1)
144 @todo: verify the host key
145 """
146
147 from Products.ZenUtils.Utils import unused
148 unused(hostKey)
149 log.debug('%s host fingerprint: %s' % (self.factory.hostname, fingerprint))
150 return defer.succeed(1)
151
152
160
161
163 """
164 Called when a disconnect error message was received from the device.
165
166 @param reasonCode: error code from SSH connection failure
167 @type reasonCode: integer
168 @param description: human-readable version of the error code
169 @type description: string
170 """
171 message= 'SSH error from remote device (code %d): %s\n' % \
172 ( reasonCode, str( description ) )
173 log.warn( message )
174 sendEvent( self, message=message )
175 transport.SSHClientTransport.receiveError(self, reasonCode, description )
176
177
179 """
180 Called when an unimplemented packet message was received from the device.
181
182 @param seqnum: SSH message code
183 @type seqnum: integer
184 """
185 message= "Got 'unimplemented' SSH message, seqnum= %d" % seqnum
186 log.info( message )
187 sendEvent( self, message=message )
188 transport.SSHClientTransport.receiveUnimplemented(self, seqnum)
189
190
192 """
193 Called when a debug message was received from the device.
194
195 @param alwaysDisplay: boolean-type code to indicate if the message is to be displayed
196 @type alwaysDisplay: integer
197 @param message: debug message from remote device
198 @type message: string
199 @param lang: language code
200 @type lang: integer
201 """
202 message= "Debug message from remote device (%s): %s" % ( str(lang), str(message) )
203 log.info( message )
204 sendEvent( self, message=message, severity=Event.Debug )
205
206 transport.SSHClientTransport.receiveDebug(self, alwaysDisplay, message, lang )
207
208
210 """
211 This is called after the connection is set up and other services can be run.
212 This function starts the SshUserAuth client (ie the Connection client).
213 """
214 sshconn = SshConnection(self.factory)
215 sshauth = SshUserAuth(self.factory.username, sshconn, self.factory)
216 self.requestService(sshauth)
217
220
221
223 """
224 Class to gather credentials for use with our SSH connection,
225 and use them to authenticate against the remote device.
226 """
227
228 - def __init__(self, user, instance, factory):
229 """
230 If no username is supplied, defaults to the user running this code (eg zenoss)
231
232 @param user: username
233 @type user: string
234 @param instance: instance object
235 @type instance: object
236 @param factory: factory info
237 @type factory: Twisted factory object
238 """
239
240 user = str(user)
241 if user == '':
242 log.debug( "Unable to determine username/password from " + \
243 "zCommandUser/zCommandPassword" )
244
245
246
247
248 import pwd
249 try:
250 user = os.environ.get( 'LOGNAME', pwd.getpwuid(os.getuid())[0] )
251 except:
252 pass
253
254 if user == '':
255 message= "No zProperties defined and unable to determine current user."
256 log.error( message )
257 sendEvent( self, message=message )
258 raise SshClientError( message )
259
260 userauth.SSHUserAuthClient.__init__(self, user, instance)
261 self.user = user
262 self.factory = factory
263 self._key = self._getKey()
264
265
267 """
268 Called from conch.
269
270 Return a deferred object of success if there's a password or
271 return fail (ie no zCommandPassword specified)
272
273 @param unused: unused (unused)
274 @type unused: string
275 @return: Twisted deferred object (defer.succeed or defer.fail)
276 @rtype: Twisted deferred object
277 """
278 try:
279 password = self._getPassword()
280 d = defer.succeed(password)
281 except NoPasswordException, e:
282 d = self._handleFailure(str(e))
283 return d
284
286 """
287 Called from conch.
288
289 Returns a L{Deferred} with the responses to the prompts.
290
291 @param name: The name of the authentication currently in progress.
292 @param instruction: Describes what the authentication wants.
293 @param prompts: A list of (prompt, echo) pairs, where prompt is a
294 string to display and echo is a boolean indicating whether the
295 user's response should be echoed as they type it.
296 """
297 log.debug('getGenericAnswers name:"%s" instruction:"%s" prompts:%s',
298 name, instruction, pformat(prompts))
299 if prompts == []:
300
301
302
303 d = defer.succeed([])
304 else:
305 for prompt, echo in prompts:
306 if 'password' in prompt.lower():
307 try:
308 password = self._getPassword()
309 d = defer.succeed([password])
310 except NoPasswordException, e:
311 d = self._handleFailure(str(e))
312 break
313 else:
314 message = 'No known prompts: %s' % pformat(prompts)
315 d = self._handleFailure(message)
316 return d
317
319 """
320 Get the password. Raise an exception if it is not set.
321 """
322 if not self.factory.password:
323 message= "SshUserAuth: no password found -- " + \
324 "has zCommandPassword been set?"
325 raise NoPasswordException(message)
326 return self.factory.password
327
329 """
330 Handle a failure by logging a message, sending an event, calling
331 clientFinished, and returning a failure defered.
332 """
333 log.error( message )
334 sendEvent( self, message=message )
335 self.factory.clientFinished()
336 return defer.fail( SshClientError( message ) )
337
350
352 """
353 Return the SSH public key (using the zProperty zKeyPath) or None
354
355 @return: SSH public key
356 @rtype: string
357 """
358 if self._key is not None:
359 return self._key.blob()
360
362 """
363 Return a deferred with the SSH private key (using the zProperty zKeyPath)
364
365 @return: Twisted deferred object (defer.succeed)
366 @rtype: Twisted deferred object
367 """
368 if self._key is None:
369 keyObject = None
370 else:
371 keyObject = self._key.keyObject
372 return defer.succeed(keyObject)
373
375 """
376 Called when the SSH session can't authenticate.
377 NB: This function is also called as an initializer
378 to start the connections.
379
380 @param packet: returned packet from the host
381 @type packet: object
382 """
383 from twisted.conch.ssh.common import getNS
384 canContinue, partial = getNS(packet)
385 canContinue = canContinue.split(',')
386
387 from Products.ZenUtils.Utils import unused
388 unused(partial)
389
390 lastAuth= getattr( self, "lastAuth", '')
391 if lastAuth == '' or lastAuth == 'none':
392 pass
393
394 elif lastAuth == 'publickey':
395 self.authenticatedWith.append(self.lastAuth)
396 message= "SSH login to %s with SSH keys failed" % \
397 self.factory.hostname
398 log.error( message )
399 sendEvent( self, message=message )
400
401 elif lastAuth == 'password':
402 message= "SSH login to %s with username %s failed" % \
403 ( self.factory.hostname, self.user )
404 log.error( message )
405 sendEvent( self, message=message )
406
407 self.factory.loginTries -= 1
408 log.debug( "Decremented loginTries count to %d" % self.factory.loginTries )
409
410 if self.factory.loginTries <= 0:
411 message= "SSH connection aborted after maximum login attempts."
412 log.error( message )
413 sendEvent( self, message=message )
414
415 else:
416 return self.tryAuth('password')
417
418
419 self.authenticatedWith.append(self.lastAuth)
420
421
422 def _(x, y):
423 try:
424 i1 = self.preferredOrder.index(x)
425 except ValueError:
426 return 1
427 try:
428 i2 = self.preferredOrder.index(y)
429 except ValueError:
430 return -1
431 return cmp(i1, i2)
432
433 canContinue.sort(_)
434 log.debug( 'Sorted list of authentication methods: %s' % canContinue)
435 for method in canContinue:
436 if method not in self.authenticatedWith:
437 log.debug( "Attempting method %s" % method )
438 if self.tryAuth(method):
439 return
440
441 log.debug( "All authentication methods attempted" )
442 self.factory.clientFinished()
443 self.transport.sendDisconnect(transport.DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, 'No more authentication methods available')
444
446 """
447 Wrapper class that starts channels on top of connections.
448 """
449
451 """
452 Initializer
453
454 @param factory: factory containing the connection info
455 @type factory: Twisted factory object
456 """
457 log.debug( "Creating new SSH connection..." )
458 connection.SSHConnection.__init__(self)
459 self.factory = factory
460
461
463 """
464 Called when the SSH session can't authenticate
465
466 @param packet: returned packet from the host
467 @type packet: object
468 """
469 message= "CHANNEL_FAILURE: Authentication failure"
470 log.error( message )
471 sendEvent( self, message=message )
472 connection.SSHConnection.ssh_CHANNEL_FAILURE( self, packet )
473
474
476 """
477 Called when the SSH session can't authenticate
478
479 @param packet: returned packet from the host
480 @type packet: object
481 """
482 message= "CHANNEL_OPEN_FAILURE: Try lowering zSshConcurrentSessions"
483 log.error( message )
484 sendEvent( self, message=message )
485 connection.SSHConnection.ssh_CHANNEL_OPEN_FAILURE( self, packet )
486
487
489 """
490 Called when the SSH session can't authenticate
491
492 @param packet: returned packet from the host
493 """
494 message= "REQUEST_FAILURE: Authentication failure"
495 log.error( message )
496 sendEvent( self, message=message )
497 connection.SSHConnection.ssh_REQUEST_FAILURE( self, packet )
498
499
501 """
502 Called when the connection open() fails.
503 Usually this gets called after too many bad connection attempts,
504 and the remote device gets upset with us.
505
506 NB: reason.desc is the human-readable description of the failure
507 reason.code is the SSH error code
508 (see http://tools.ietf.org/html/rfc4250#section-4.2.2 for more details)
509
510 @param reason: reason object
511 @type reason: reason object
512 """
513
514 message= 'SSH connection to %s failed (error code %d): %s' % \
515 (self.command, reason.code, str(reason.desc) )
516 log.error( message )
517 sendEvent( self, message=message )
518 connection.SSHConnection.openFailed( self, reason )
519
520
522 """
523 Called when the service is active on the transport
524 """
525 self.factory.serviceStarted(self)
526
527
529 """
530 Open a new channel for each command in queue
531
532 @param cmd: command to run
533 @type cmd: string
534 """
535 ch = CommandChannel(cmd, conn=self)
536 self.openChannel(ch)
537
538
540 """
541 Called when a channel is closed.
542 REQUIRED function by Twisted.
543
544 @param channel: channel that closed
545 @type channel: Twisted channel object
546 """
547
548
549 self.localToRemoteChannel[channel.id] = None
550 self.channelsToRemoteChannel[channel] = None
551 connection.SSHConnection.channelClosed(self, channel)
552
553
554
556 """
557 The class that actually interfaces between Zenoss and the device.
558 """
559
560 name = 'session'
561 conn = None
562
563 - def __init__(self, command, conn=None):
564 """
565 Initializer
566
567 @param command: command to run
568 @type command: string
569 @param conn: connection to create the channel on
570 @type conn: Twisted connection object
571 """
572 channel.SSHChannel.__init__(self, conn=conn)
573 self.command = command
574 self.exitCode = None
575 log.debug( "Started the channel for command: %s" % command )
576
577
579 """
580 Called when the open fails.
581 """
582 from twisted.conch.error import ConchError
583 if isinstance(reason, ConchError):
584 args = (reason.data, reason.value)
585 else:
586 args = (reason.code, reason.desc)
587 message = 'Open of %s failed (error code %d): %s' % (
588 (self.command,) + args)
589 log.warn(message)
590 sendEvent(self, message=message)
591 channel.SSHChannel.openFailed(self, reason)
592 if self.conn is not None:
593 self.conn.factory.clientFinished()
594
595
596
598 """
599 Called when we receive extended data (usually standard error)
600
601 @param dataType: data type code
602 @type dataType: integer
603 """
604 message= 'The command %s returned stderr data (%d) from the device: %s' \
605 % (self.command, dataType, data)
606 log.warn( message )
607 sendEvent( self, message=message )
608
609
611 """
612 Initialize the channel and send our command to the device.
613
614 @param unused: unused (unused)
615 @type unused: string
616 @return: Twisted channel
617 @rtype: Twisted channel
618 """
619
620 log.debug('Opening command channel for %s' % self.command)
621 self.data = ''
622
623
624
625
626
627 d = self.conn.sendRequest(self, 'exec', common.NS(self.command),
628 wantReply=1)
629 return d
630
631
633 """
634 Gathers the exit code from the device
635
636 @param data: returned value from device
637 @type data: packet
638 """
639 import struct
640 self.exitCode = struct.unpack('>L', data)[0]
641 log.debug("Exit code for %s is %d: %s",
642 self.command,
643 self.exitCode,
644 getExitMessage(self.exitCode))
645
646
648 """
649 Response stream from the device. Can be called multiple times.
650
651 @param data: returned value from device
652 @type data: string
653 """
654 self.data += data
655
656
667
668
669
670 -class SshClient(CollectorClient.CollectorClient):
671 """
672 SSH Collector class to connect to a particular device
673 """
674
675 - def __init__(self, hostname, ip, port=22, plugins=[], options=None,
676 device=None, datacollector=None, isLoseConnection=False):
677 """
678 Initializer
679
680 @param hostname: hostname of the device
681 @type hostname: string
682 @param ip: IP address of the device
683 @type ip: string
684 @param port: port number to use to connect to device
685 @type port: integer
686 @param plugins: plugins
687 @type plugins: list of plugins
688 @param options: options
689 @type options: list
690 @param device: name of device
691 @type device: string
692 @param datacollector: object
693 @type datacollector: object
694 """
695
696 CollectorClient.CollectorClient.__init__(self, hostname, ip, port,
697 plugins, options, device, datacollector)
698 self.hostname = hostname
699 self.protocol = SshClientTransport
700 self.connection = None
701 self.transport = None
702 self.openSessions = 0
703 self.workList = list(self.getCommands())
704 self.isLoseConnection = isLoseConnection
705
707 """
708 Start SSH collection.
709 """
710 reactor.connectTCP(self.ip, self.port, self, self.loginTimeout)
711
712
714 availSessions = (self.concurrentSessions - 1) - self.openSessions
715 for i in range(min(len(self.workList), availSessions)):
716 cmd = self.workList.pop(0)
717 self.openSessions += 1
718 self.connection.addCommand(cmd)
719
720
733
734
736 """
737 Run commands that are in the command queue
738
739 @param sshconn: connection to create channels on
740 @type sshconn: Twisted SSH connection
741 """
742
743 log.info("Connected to device %s" % self.hostname)
744 self.connection = sshconn
745 self.runCommands()
746
747
749 """
750 Add a command or commands to queue and open a command
751 channel for each command
752
753 @param commands: commands to run
754 @type commands: list
755 """
756
757 CollectorClient.CollectorClient.addCommand(self, commands)
758 if type(commands) == type(''):
759 commands = (commands,)
760 self.workList.extend(commands)
761
762
763 if self.connection:
764 self.runCommands()
765
766
768 """
769 If we didn't connect let the modeler know
770
771 @param connector: connector associated with this failure
772 @type connector: object
773 @param reason: failure object
774 @type reason: object
775 """
776 from Products.ZenUtils.Utils import unused
777 unused(connector)
778 message= reason.getErrorMessage()
779 log.error( message )
780 sendEvent( self, device=self.hostname, message=message )
781 self.clientFinished()
782
783
785 """
786 Called when the connection gets closed.
787 """
788 log.debug( "Connection closed" )
789
790
791
792
794 """
795 Test harness main()
796
797 Usage:
798
799 python SshClient.py hostname[:port] comand [command]
800
801 Each command must be enclosed in quotes (") to be interpreted
802 properly as a complete unit.
803 """
804 import socket
805 from itertools import chain
806 import pprint
807
808 logging.basicConfig()
809
810 parser = CollectorClient.buildOptions()
811 options = CollectorClient.parseOptions(parser,22)
812 log.setLevel(options.logseverity)
813
814 client = SshClient(options.hostname,
815 socket.gethostbyname(options.hostname),
816 options.port,
817 options=options)
818
819
820
821 client.getCommands= lambda: chain( options.commands )
822
823 client.run()
824
825 client.clientFinished= reactor.stop
826 client._commands.append( options.commands )
827 reactor.run()
828
829 pprint.pprint(client.getResults())
830
831
832 if __name__ == '__main__':
833 main()
834