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 import socket
27
28 import Globals
29
30 from twisted.conch.ssh import transport, userauth, connection
31 from twisted.conch.ssh import common, channel
32 from twisted.conch.ssh.keys import Key
33 from twisted.internet import defer, reactor
34 from Products.ZenEvents import Event
35 from Products.ZenUtils.Utils import getExitMessage
36 from Products.ZenUtils.IpUtil import getHostByName
37
38 from Exceptions import *
39
40 import CollectorClient
47 """
48 Shortcut version of sendEvent()
49
50 @param message: message to send in Zenoss event
51 @type message: string
52 @param device: hostname of device to which this event is associated
53 @type device: string
54 @param severity: Zenoss severity from Products.ZenEvents
55 @type severity: integer
56 """
57
58
59 component= os.path.basename( sys.argv[0] ).replace( '.py', '' )
60
61 def hasattr_path( object_root, path ):
62 """
63 The regular hasattr() only works on one component,
64 not multiples.
65
66 @param object_root: object to start searching for path
67 @type object_root: object
68 @param path: path to func or variable (eg "conn.factory" )
69 @type path: string
70 @return: is object_root.path sane?
71 @rtype: boolean
72 """
73 obj = object_root
74 for chunk in path.split('.'):
75 obj= getattr( obj, chunk, None )
76 if obj is None:
77 return False
78 return True
79
80
81 if device == '':
82 if hasattr_path( self, "factory.hostname" ):
83 device= self.factory.hostname
84
85 elif hasattr_path( self, "conn.factory.hostname" ):
86 device= self.conn.factory.hostname
87
88 else:
89 log.debug( "Couldn't get the remote device's hostname" )
90
91 error_event= {
92 'agent': component,
93 'summary': message,
94 'device': device,
95 'eventClass': "/Cmd/Fail",
96 'component': component,
97 'severity': severity,
98 }
99
100
101 try:
102 if hasattr_path( self, "factory.datacollector.sendEvent" ):
103 self.factory.datacollector.sendEvent( error_event )
104
105 elif hasattr_path( self, "factory.sendEvent" ):
106 self.factory.sendEvent( error_event )
107
108 elif hasattr_path( self, "datacollector.sendEvent" ):
109 self.datacollector.sendEvent( error_event )
110
111 elif hasattr_path( self, "conn.factory.datacollector.sendEvent" ):
112 self.conn.factory.datacollector.sendEvent( error_event )
113
114 else:
115 log.debug( "Unable to send event for %s" % error_event )
116
117 except:
118 pass
119
123 """
124 Exception class
125 """
126
130 """
131 Base client class for constructing Twisted Conch services.
132 This class is *only* responsible for connecting to the SSH
133 service on the device, and ensuring that *host* keys are sane.
134 """
135
137 """
138 Module to verify the host's SSH key against the stored fingerprint we have
139 from the last time that we communicated with the host.
140
141 NB: currently does not verify this information but simply trusts every host key
142
143 @param hostKey: host's SSH key (unused)
144 @type hostKey: string
145 @param fingerprint: host fingerprint (unused)
146 @type fingerprint: string
147 @return: Twisted deferred object
148 @rtype: Twisted deferred object (defer.succeed(1)
149 @todo: verify the host key
150 """
151
152 from Products.ZenUtils.Utils import unused
153 unused(hostKey)
154 log.debug('%s host fingerprint: %s' % (self.factory.hostname, fingerprint))
155 return defer.succeed(1)
156
157
165
166
168 """
169 Called when a disconnect error message was received from the device.
170
171 @param reasonCode: error code from SSH connection failure
172 @type reasonCode: integer
173 @param description: human-readable version of the error code
174 @type description: string
175 """
176 message= 'SSH error from remote device (code %d): %s\n' % \
177 ( reasonCode, str( description ) )
178 log.warn( message )
179 sendEvent( self, message=message )
180 transport.SSHClientTransport.receiveError(self, reasonCode, description )
181
182
184 """
185 Called when an unimplemented packet message was received from the device.
186
187 @param seqnum: SSH message code
188 @type seqnum: integer
189 """
190 message= "Got 'unimplemented' SSH message, seqnum= %d" % seqnum
191 log.info( message )
192 sendEvent( self, message=message )
193 transport.SSHClientTransport.receiveUnimplemented(self, seqnum)
194
195
197 """
198 Called when a debug message was received from the device.
199
200 @param alwaysDisplay: boolean-type code to indicate if the message is to be displayed
201 @type alwaysDisplay: integer
202 @param message: debug message from remote device
203 @type message: string
204 @param lang: language code
205 @type lang: integer
206 """
207 message= "Debug message from remote device (%s): %s" % ( str(lang), str(message) )
208 log.info( message )
209 sendEvent( self, message=message, severity=Event.Debug )
210
211 transport.SSHClientTransport.receiveDebug(self, alwaysDisplay, message, lang )
212
213
215 """
216 This is called after the connection is set up and other services can be run.
217 This function starts the SshUserAuth client (ie the Connection client).
218 """
219 sshconn = SshConnection(self.factory)
220 sshauth = SshUserAuth(self.factory.username, sshconn, self.factory)
221 self.requestService(sshauth)
222
225
228 """
229 Class to gather credentials for use with our SSH connection,
230 and use them to authenticate against the remote device.
231 """
232
233 - def __init__(self, user, instance, factory):
234 """
235 If no username is supplied, defaults to the user running this code (eg zenoss)
236
237 @param user: username
238 @type user: string
239 @param instance: instance object
240 @type instance: object
241 @param factory: factory info
242 @type factory: Twisted factory object
243 """
244
245 user = str(user)
246 if user == '':
247 log.debug("Unable to determine username/password from " + \
248 "zCommandUser/zCommandPassword")
249
250
251
252
253 import pwd
254 try:
255 user = os.environ.get( 'LOGNAME', pwd.getpwuid(os.getuid())[0] )
256 except:
257 pass
258
259 if user == '':
260 message= "No zProperties defined and unable to determine current user."
261 log.error( message )
262 sendEvent( self, message=message )
263 raise SshClientError( message )
264
265 userauth.SSHUserAuthClient.__init__(self, user, instance)
266 self.user = user
267 self.factory = factory
268 self._key = self._getKey()
269
270
272 """
273 Called from conch.
274
275 Return a deferred object of success if there's a password or
276 return fail (ie no zCommandPassword specified)
277
278 @param unused: unused (unused)
279 @type unused: string
280 @return: Twisted deferred object (defer.succeed or defer.fail)
281 @rtype: Twisted deferred object
282 """
283 try:
284 password = self._getPassword()
285 d = defer.succeed(password)
286 except NoPasswordException, e:
287 d = self._handleFailure(str(e))
288 return d
289
291 """
292 Called from conch.
293
294 Returns a L{Deferred} with the responses to the prompts.
295
296 @param name: The name of the authentication currently in progress.
297 @param instruction: Describes what the authentication wants.
298 @param prompts: A list of (prompt, echo) pairs, where prompt is a
299 string to display and echo is a boolean indicating whether the
300 user's response should be echoed as they type it.
301 """
302 log.debug('getGenericAnswers name:"%s" instruction:"%s" prompts:%s',
303 name, instruction, pformat(prompts))
304 if prompts == []:
305
306
307
308 d = defer.succeed([])
309 else:
310 for prompt, echo in prompts:
311 if 'password' in prompt.lower():
312 try:
313 password = self._getPassword()
314 d = defer.succeed([password])
315 except NoPasswordException, e:
316 d = self._handleFailure(str(e))
317 break
318 else:
319 message = 'No known prompts: %s' % pformat(prompts)
320 d = self._handleFailure(message)
321 return d
322
324 """
325 Get the password. Raise an exception if it is not set.
326 """
327 if not self.factory.password:
328 message= "SshUserAuth: no password found -- " + \
329 "has zCommandPassword been set?"
330 raise NoPasswordException(message)
331 return self.factory.password
332
342
367
369 """
370 Return the SSH public key (using the zProperty zKeyPath) or None
371
372 @return: SSH public key
373 @rtype: string
374 """
375 if self._key is not None:
376 return self._key.blob()
377
379 """
380 Return a deferred with the SSH private key (using the zProperty zKeyPath)
381
382 @return: Twisted deferred object (defer.succeed)
383 @rtype: Twisted deferred object
384 """
385 if self._key is None:
386 keyObject = None
387 else:
388 keyObject = self._key.keyObject
389 return defer.succeed(keyObject)
390
392 """
393 Called when the SSH session can't authenticate.
394 NB: This function is also called as an initializer
395 to start the connections.
396
397 @param packet: returned packet from the host
398 @type packet: object
399 """
400 from twisted.conch.ssh.common import getNS
401 canContinue, partial = getNS(packet)
402 canContinue = canContinue.split(',')
403
404 from Products.ZenUtils.Utils import unused
405 unused(partial)
406
407 lastAuth= getattr( self, "lastAuth", '')
408 if lastAuth == '' or lastAuth == 'none':
409 pass
410
411 elif lastAuth == 'publickey':
412 self.authenticatedWith.append(self.lastAuth)
413 message= "SSH login to %s with SSH keys failed" % \
414 self.factory.hostname
415 log.error( message )
416 sendEvent( self, message=message )
417
418 elif lastAuth == 'password':
419 message= "SSH login to %s with username %s failed" % \
420 ( self.factory.hostname, self.user )
421 log.error( message )
422 sendEvent( self, message=message )
423
424 self.factory.loginTries -= 1
425 log.debug( "Decremented loginTries count to %d" % self.factory.loginTries )
426
427 if self.factory.loginTries <= 0:
428 message= "SSH connection aborted after maximum login attempts."
429 log.error( message )
430 sendEvent( self, message=message )
431
432 else:
433 return self.tryAuth('password')
434
435
436 self.authenticatedWith.append(self.lastAuth)
437
438
439 def _(x, y):
440 try:
441 i1 = self.preferredOrder.index(x)
442 except ValueError:
443 return 1
444 try:
445 i2 = self.preferredOrder.index(y)
446 except ValueError:
447 return -1
448 return cmp(i1, i2)
449
450 canContinue.sort(_)
451 log.debug( 'Sorted list of authentication methods: %s' % canContinue)
452 for method in canContinue:
453 if method not in self.authenticatedWith:
454 if self._key is None and method == 'publickey':
455
456
457 log.debug("Skipping %s method as the key was blank",
458 method )
459 self.authenticatedWith.append(method)
460 continue
461
462 log.debug( "Attempting method %s" % method )
463 if self.tryAuth(method):
464 return
465
466 log.debug( "All authentication methods attempted" )
467 self.factory.clientFinished()
468 self.transport.sendDisconnect(transport.DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, 'No more authentication methods available')
469
471 """
472 Wrapper class that starts channels on top of connections.
473 """
474
476 """
477 Initializer
478
479 @param factory: factory containing the connection info
480 @type factory: Twisted factory object
481 """
482 log.debug("Creating new SSH connection...")
483 connection.SSHConnection.__init__(self)
484 self.factory = factory
485
486
488 """
489 Called when the SSH session can't authenticate
490
491 @param packet: returned packet from the host
492 @type packet: object
493 """
494 message= "CHANNEL_FAILURE: Authentication failure"
495 log.error( message )
496 sendEvent( self, message=message )
497 connection.SSHConnection.ssh_CHANNEL_FAILURE(self, packet)
498
499
501 """
502 Called when the SSH session can't authenticate
503
504 @param packet: returned packet from the host
505 @type packet: object
506 """
507 message= "CHANNEL_OPEN_FAILURE: Try lowering zSshConcurrentSessions"
508 log.error( message )
509 sendEvent( self, message=message )
510 connection.SSHConnection.ssh_CHANNEL_OPEN_FAILURE( self, packet )
511
512
523
524
526 """
527 Called when the connection open() fails.
528 Usually this gets called after too many bad connection attempts,
529 and the remote device gets upset with us.
530
531 NB: reason.desc is the human-readable description of the failure
532 reason.code is the SSH error code
533 (see http://tools.ietf.org/html/rfc4250#section-4.2.2 for more details)
534
535 @param reason: reason object
536 @type reason: reason object
537 """
538 message= 'SSH connection to %s failed (error code %d): %s' % \
539 (self.command, reason.code, str(reason.desc) )
540 log.error( message )
541 sendEvent( self, message=message )
542 connection.SSHConnection.openFailed( self, reason )
543
544
546 """
547 Called when the service is active on the transport
548 """
549 self.factory.serviceStarted(self)
550
551
553 """
554 Open a new channel for each command in queue
555
556 @param cmd: command to run
557 @type cmd: string
558 """
559 ch = CommandChannel(cmd, conn=self)
560 self.openChannel(ch)
561 targetIp = self.transport.transport.addr[0]
562 log.debug("%s channel %s SshConnection added command %s",
563 targetIp, ch.id, cmd)
564
565
567 """
568 Called when a channel is closed.
569 REQUIRED function by Twisted.
570
571 @param channel: channel that closed
572 @type channel: Twisted channel object
573 """
574 targetIp = self.transport.transport.addr[0]
575 log.debug("%s channel %s SshConnection closing",
576 targetIp, channel.id)
577
578
579 self.localToRemoteChannel[channel.id] = None
580 self.channelsToRemoteChannel[channel] = None
581 connection.SSHConnection.channelClosed(self, channel)
582
586 """
587 The class that actually interfaces between Zenoss and the device.
588 """
589 name = 'session'
590 conn = None
591
592 - def __init__(self, command, conn=None):
593 """
594 Initializer
595
596 @param command: command to run
597 @type command: string
598 @param conn: connection to create the channel on
599 @type conn: Twisted connection object
600 """
601 channel.SSHChannel.__init__(self, conn=conn)
602 self.command = command
603 self.exitCode = None
604
605 @property
609
626
627
629 """
630 Called when we receive extended data (usually standard error)
631
632 @param dataType: data type code
633 @type dataType: integer
634 """
635 message= 'The command %s returned stderr data (%d) from the device: %s' \
636 % (self.command, dataType, data)
637 log.warn("%s channel %s %s", self.targetIp, self.conn.localChannelID,
638 message)
639 sendEvent(self, message=message)
640
641
643 """
644 Initialize the channel and send our command to the device.
645
646 @param unused: unused (unused)
647 @type unused: string
648 @return: Twisted channel
649 @rtype: Twisted channel
650 """
651
652 log.debug('%s channel %s Opening command channel for %s',
653 self.targetIp, self.conn.localChannelID, self.command)
654 self.data = ''
655
656
657
658
659
660 d = self.conn.sendRequest(self, 'exec', common.NS(self.command),
661 wantReply=1)
662 return d
663
664
666 """
667 Gathers the exit code from the device
668
669 @param data: returned value from device
670 @type data: packet
671 """
672 import struct
673 self.exitCode = struct.unpack('>L', data)[0]
674 log.debug("%s channel %s CommandChannel exit code for %s is %d: %s",
675 self.targetIp, getattr(self.conn, 'localChannelID', None),
676 self.command, self.exitCode, getExitMessage(self.exitCode))
677
678
680 """
681 Response stream from the device. Can be called multiple times.
682
683 @param data: returned value from device
684 @type data: string
685 """
686 self.data += data
687
688
700
701
702
703 -class SshClient(CollectorClient.CollectorClient):
704 """
705 SSH Collector class to connect to a particular device
706 """
707
708 - def __init__(self, hostname, ip, port=22, plugins=[], options=None,
709 device=None, datacollector=None, isLoseConnection=False):
710 """
711 Initializer
712
713 @param hostname: hostname of the device
714 @type hostname: string
715 @param ip: IP address of the device
716 @type ip: string
717 @param port: port number to use to connect to device
718 @type port: integer
719 @param plugins: plugins
720 @type plugins: list of plugins
721 @param options: options
722 @type options: list
723 @param device: name of device
724 @type device: string
725 @param datacollector: object
726 @type datacollector: object
727 """
728
729 CollectorClient.CollectorClient.__init__(self, hostname, ip, port,
730 plugins, options, device, datacollector)
731 self.hostname = hostname
732 self.protocol = SshClientTransport
733 self.connection = None
734 self.transport = None
735 self.openSessions = 0
736 self.workList = list(self.getCommands())
737 self.isLoseConnection = isLoseConnection
738
740 """
741 Start SSH collection.
742 """
743 log.debug("%s SshClient connecting to %s:%s with timeout %s seconds",
744 self.ip, self.hostname, self.port, self.loginTimeout)
745 reactor.connectTCP(self.ip, self.port, self, self.loginTimeout)
746
747
749 log.debug("%s SshClient has %d commands to assign to channels (max = %s, current = %s)",
750 self.ip, len(self.workList), self.concurrentSessions, self.openSessions)
751 availSessions = self.concurrentSessions - self.openSessions
752 for i in range(min(len(self.workList), availSessions)):
753 cmd = self.workList.pop(0)
754 self.openSessions += 1
755 self.connection.addCommand(cmd)
756
757
759 self.openSessions -= 1
760 log.debug("%s SshClient closing channel (openSessions = %s)",
761 self.ip, self.openSessions)
762 if self.commandsFinished():
763 if self.isLoseConnection:
764 self.transport.loseConnection()
765 self.clientFinished()
766 return
767
768 if self.workList:
769 cmd = self.workList.pop(0)
770 self.openSessions += 1
771 if self.connection:
772 self.connection.addCommand(cmd)
773
774
776 """
777 Run commands that are in the command queue
778
779 @param sshconn: connection to create channels on
780 @type sshconn: Twisted SSH connection
781 """
782 log.debug("SshClient connected to device %s (%s)", self.hostname, self.ip)
783 self.connection = sshconn
784 self.runCommands()
785
786
788 """
789 Add a command or commands to queue and open a command
790 channel for each command
791
792 @param commands: commands to run
793 @type commands: list
794 """
795 CollectorClient.CollectorClient.addCommand(self, commands)
796 if isinstance(commands, basestring):
797 commands = (commands,)
798 self.workList.extend(commands)
799
800
801 if self.connection:
802 self.runCommands()
803
804
820
821
823 """
824 Called when the connection gets closed.
825 """
826 log.debug("%s SshClient connection closed", self.ip)
827
828
829
830
831 -def main():
832 """
833 Test harness main()
834
835 Usage:
836
837 python SshClient.py hostname[:port] comand [command]
838
839 Each command must be enclosed in quotes (") to be interpreted
840 properly as a complete unit.
841 """
842 from itertools import chain
843 import pprint
844
845 logging.basicConfig()
846
847 parser = CollectorClient.buildOptions()
848 options = CollectorClient.parseOptions(parser,22)
849 log.setLevel(options.logseverity)
850
851 client = SshClient(options.hostname,
852 getHostByName(options.hostname),
853 options.port,
854 options=options)
855
856
857
858 client.workList= options.commands
859
860 client.run()
861
862 client.clientFinished= reactor.stop
863 client._commands.append( options.commands )
864 reactor.run()
865
866 pprint.pprint(client.getResults())
867
868
869 if __name__ == '__main__':
870 main()
871