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
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, "factory.sendEvent" ):
104 self.factory.sendEvent( error_event )
105
106 elif hasattr_path( self, "datacollector.sendEvent" ):
107 self.datacollector.sendEvent( error_event )
108
109 elif hasattr_path( self, "conn.factory.datacollector.sendEvent" ):
110 self.conn.factory.datacollector.sendEvent( error_event )
111
112 else:
113 log.debug( "Unable to send event for %s" % error_event )
114
115 except:
116 pass
117
121 """
122 Exception class
123 """
124
128 """
129 Base client class for constructing Twisted Conch services.
130 This class is *only* responsible for connecting to the SSH
131 service on the device, and ensuring that *host* keys are sane.
132 """
133
135 """
136 Module to verify the host's SSH key against the stored fingerprint we have
137 from the last time that we communicated with the host.
138
139 NB: currently does not verify this information but simply trusts every host key
140
141 @param hostKey: host's SSH key (unused)
142 @type hostKey: string
143 @param fingerprint: host fingerprint (unused)
144 @type fingerprint: string
145 @return: Twisted deferred object
146 @rtype: Twisted deferred object (defer.succeed(1)
147 @todo: verify the host key
148 """
149
150 from Products.ZenUtils.Utils import unused
151 unused(hostKey)
152 log.debug('%s host fingerprint: %s' % (self.factory.hostname, fingerprint))
153 return defer.succeed(1)
154
155
163
164
166 """
167 Called when a disconnect error message was received from the device.
168
169 @param reasonCode: error code from SSH connection failure
170 @type reasonCode: integer
171 @param description: human-readable version of the error code
172 @type description: string
173 """
174 message= 'SSH error from remote device (code %d): %s\n' % \
175 ( reasonCode, str( description ) )
176 log.warn( message )
177 sendEvent( self, message=message )
178 transport.SSHClientTransport.receiveError(self, reasonCode, description )
179
180
182 """
183 Called when an unimplemented packet message was received from the device.
184
185 @param seqnum: SSH message code
186 @type seqnum: integer
187 """
188 message= "Got 'unimplemented' SSH message, seqnum= %d" % seqnum
189 log.info( message )
190 sendEvent( self, message=message )
191 transport.SSHClientTransport.receiveUnimplemented(self, seqnum)
192
193
195 """
196 Called when a debug message was received from the device.
197
198 @param alwaysDisplay: boolean-type code to indicate if the message is to be displayed
199 @type alwaysDisplay: integer
200 @param message: debug message from remote device
201 @type message: string
202 @param lang: language code
203 @type lang: integer
204 """
205 message= "Debug message from remote device (%s): %s" % ( str(lang), str(message) )
206 log.info( message )
207 sendEvent( self, message=message, severity=Event.Debug )
208
209 transport.SSHClientTransport.receiveDebug(self, alwaysDisplay, message, lang )
210
211
213 """
214 This is called after the connection is set up and other services can be run.
215 This function starts the SshUserAuth client (ie the Connection client).
216 """
217 sshconn = SshConnection(self.factory)
218 sshauth = SshUserAuth(self.factory.username, sshconn, self.factory)
219 self.requestService(sshauth)
220
223
226 """
227 Class to gather credentials for use with our SSH connection,
228 and use them to authenticate against the remote device.
229 """
230
231 - def __init__(self, user, instance, factory):
232 """
233 If no username is supplied, defaults to the user running this code (eg zenoss)
234
235 @param user: username
236 @type user: string
237 @param instance: instance object
238 @type instance: object
239 @param factory: factory info
240 @type factory: Twisted factory object
241 """
242
243 user = str(user)
244 if user == '':
245 log.debug("Unable to determine username/password from " + \
246 "zCommandUser/zCommandPassword")
247
248
249
250
251 import pwd
252 try:
253 user = os.environ.get( 'LOGNAME', pwd.getpwuid(os.getuid())[0] )
254 except:
255 pass
256
257 if user == '':
258 message= "No zProperties defined and unable to determine current user."
259 log.error( message )
260 sendEvent( self, message=message )
261 raise SshClientError( message )
262
263 userauth.SSHUserAuthClient.__init__(self, user, instance)
264 self.user = user
265 self.factory = factory
266 self._key = self._getKey()
267
268
270 """
271 Called from conch.
272
273 Return a deferred object of success if there's a password or
274 return fail (ie no zCommandPassword specified)
275
276 @param unused: unused (unused)
277 @type unused: string
278 @return: Twisted deferred object (defer.succeed or defer.fail)
279 @rtype: Twisted deferred object
280 """
281 try:
282 password = self._getPassword()
283 d = defer.succeed(password)
284 except NoPasswordException, e:
285 d = self._handleFailure(str(e))
286 return d
287
289 """
290 Called from conch.
291
292 Returns a L{Deferred} with the responses to the prompts.
293
294 @param name: The name of the authentication currently in progress.
295 @param instruction: Describes what the authentication wants.
296 @param prompts: A list of (prompt, echo) pairs, where prompt is a
297 string to display and echo is a boolean indicating whether the
298 user's response should be echoed as they type it.
299 """
300 log.debug('getGenericAnswers name:"%s" instruction:"%s" prompts:%s',
301 name, instruction, pformat(prompts))
302 if prompts == []:
303
304
305
306 d = defer.succeed([])
307 else:
308 for prompt, echo in prompts:
309 if 'password' in prompt.lower():
310 try:
311 password = self._getPassword()
312 d = defer.succeed([password])
313 except NoPasswordException, e:
314 d = self._handleFailure(str(e))
315 break
316 else:
317 message = 'No known prompts: %s' % pformat(prompts)
318 d = self._handleFailure(message)
319 return d
320
322 """
323 Get the password. Raise an exception if it is not set.
324 """
325 if not self.factory.password:
326 message= "SshUserAuth: no password found -- " + \
327 "has zCommandPassword been set?"
328 raise NoPasswordException(message)
329 return self.factory.password
330
340
353
355 """
356 Return the SSH public key (using the zProperty zKeyPath) or None
357
358 @return: SSH public key
359 @rtype: string
360 """
361 if self._key is not None:
362 return self._key.blob()
363
365 """
366 Return a deferred with the SSH private key (using the zProperty zKeyPath)
367
368 @return: Twisted deferred object (defer.succeed)
369 @rtype: Twisted deferred object
370 """
371 if self._key is None:
372 keyObject = None
373 else:
374 keyObject = self._key.keyObject
375 return defer.succeed(keyObject)
376
378 """
379 Called when the SSH session can't authenticate.
380 NB: This function is also called as an initializer
381 to start the connections.
382
383 @param packet: returned packet from the host
384 @type packet: object
385 """
386 from twisted.conch.ssh.common import getNS
387 canContinue, partial = getNS(packet)
388 canContinue = canContinue.split(',')
389
390 from Products.ZenUtils.Utils import unused
391 unused(partial)
392
393 lastAuth= getattr( self, "lastAuth", '')
394 if lastAuth == '' or lastAuth == 'none':
395 pass
396
397 elif lastAuth == 'publickey':
398 self.authenticatedWith.append(self.lastAuth)
399 message= "SSH login to %s with SSH keys failed" % \
400 self.factory.hostname
401 log.error( message )
402 sendEvent( self, message=message )
403
404 elif lastAuth == 'password':
405 message= "SSH login to %s with username %s failed" % \
406 ( self.factory.hostname, self.user )
407 log.error( message )
408 sendEvent( self, message=message )
409
410 self.factory.loginTries -= 1
411 log.debug( "Decremented loginTries count to %d" % self.factory.loginTries )
412
413 if self.factory.loginTries <= 0:
414 message= "SSH connection aborted after maximum login attempts."
415 log.error( message )
416 sendEvent( self, message=message )
417
418 else:
419 return self.tryAuth('password')
420
421
422 self.authenticatedWith.append(self.lastAuth)
423
424
425 def _(x, y):
426 try:
427 i1 = self.preferredOrder.index(x)
428 except ValueError:
429 return 1
430 try:
431 i2 = self.preferredOrder.index(y)
432 except ValueError:
433 return -1
434 return cmp(i1, i2)
435
436 canContinue.sort(_)
437 log.debug( 'Sorted list of authentication methods: %s' % canContinue)
438 for method in canContinue:
439 if method not in self.authenticatedWith:
440 log.debug( "Attempting method %s" % method )
441 if self.tryAuth(method):
442 return
443
444 log.debug( "All authentication methods attempted" )
445 self.factory.clientFinished()
446 self.transport.sendDisconnect(transport.DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, 'No more authentication methods available')
447
449 """
450 Wrapper class that starts channels on top of connections.
451 """
452
454 """
455 Initializer
456
457 @param factory: factory containing the connection info
458 @type factory: Twisted factory object
459 """
460 log.debug("Creating new SSH connection...")
461 connection.SSHConnection.__init__(self)
462 self.factory = factory
463
464
466 """
467 Called when the SSH session can't authenticate
468
469 @param packet: returned packet from the host
470 @type packet: object
471 """
472 message= "CHANNEL_FAILURE: Authentication failure"
473 log.error( message )
474 sendEvent( self, message=message )
475 connection.SSHConnection.ssh_CHANNEL_FAILURE(self, packet)
476
477
479 """
480 Called when the SSH session can't authenticate
481
482 @param packet: returned packet from the host
483 @type packet: object
484 """
485 message= "CHANNEL_OPEN_FAILURE: Try lowering zSshConcurrentSessions"
486 log.error( message )
487 sendEvent( self, message=message )
488 connection.SSHConnection.ssh_CHANNEL_OPEN_FAILURE( self, packet )
489
490
492 """
493 Called when the SSH session can't authenticate
494
495 @param packet: returned packet from the host
496 """
497 message= "REQUEST_FAILURE: Authentication failure"
498 log.error( message )
499 sendEvent( self, message=message )
500 connection.SSHConnection.ssh_REQUEST_FAILURE( self, packet )
501
502
504 """
505 Called when the connection open() fails.
506 Usually this gets called after too many bad connection attempts,
507 and the remote device gets upset with us.
508
509 NB: reason.desc is the human-readable description of the failure
510 reason.code is the SSH error code
511 (see http://tools.ietf.org/html/rfc4250#section-4.2.2 for more details)
512
513 @param reason: reason object
514 @type reason: reason object
515 """
516 message= 'SSH connection to %s failed (error code %d): %s' % \
517 (self.command, reason.code, str(reason.desc) )
518 log.error( message )
519 sendEvent( self, message=message )
520 connection.SSHConnection.openFailed( self, reason )
521
522
524 """
525 Called when the service is active on the transport
526 """
527 self.factory.serviceStarted(self)
528
529
531 """
532 Open a new channel for each command in queue
533
534 @param cmd: command to run
535 @type cmd: string
536 """
537 ch = CommandChannel(cmd, conn=self)
538 self.openChannel(ch)
539 targetIp = self.transport.transport.addr[0]
540 log.debug("%s channel %s SshConnection added command %s",
541 targetIp, ch.id, cmd)
542
543
545 """
546 Called when a channel is closed.
547 REQUIRED function by Twisted.
548
549 @param channel: channel that closed
550 @type channel: Twisted channel object
551 """
552 targetIp = self.transport.transport.addr[0]
553 log.debug("%s channel %s SshConnection closing",
554 targetIp, channel.id)
555
556
557 self.localToRemoteChannel[channel.id] = None
558 self.channelsToRemoteChannel[channel] = None
559 connection.SSHConnection.channelClosed(self, channel)
560
564 """
565 The class that actually interfaces between Zenoss and the device.
566 """
567 name = 'session'
568 conn = None
569
570 - def __init__(self, command, conn=None):
571 """
572 Initializer
573
574 @param command: command to run
575 @type command: string
576 @param conn: connection to create the channel on
577 @type conn: Twisted connection object
578 """
579 channel.SSHChannel.__init__(self, conn=conn)
580 self.command = command
581 self.exitCode = None
582
583 @property
587
589 """
590 Called when the open fails.
591 """
592 from twisted.conch.error import ConchError
593 if isinstance(reason, ConchError):
594 args = (reason.data, reason.value)
595 else:
596 args = (reason.code, reason.desc)
597 message = 'CommandChannel Open of %s failed (error code %d): %s' % (
598 (self.command,) + args)
599 log.warn("%s %s", self.targetIp, message)
600 sendEvent(self, message=message)
601 channel.SSHChannel.openFailed(self, reason)
602 if self.conn is not None:
603 self.conn.factory.clientFinished()
604
605
607 """
608 Called when we receive extended data (usually standard error)
609
610 @param dataType: data type code
611 @type dataType: integer
612 """
613 message= 'The command %s returned stderr data (%d) from the device: %s' \
614 % (self.command, dataType, data)
615 log.warn("%s channel %s %s", self.targetIp, self.conn.localChannelID,
616 message)
617 sendEvent(self, message=message)
618
619
621 """
622 Initialize the channel and send our command to the device.
623
624 @param unused: unused (unused)
625 @type unused: string
626 @return: Twisted channel
627 @rtype: Twisted channel
628 """
629
630 log.debug('%s channel %s Opening command channel for %s',
631 self.targetIp, self.conn.localChannelID, self.command)
632 self.data = ''
633
634
635
636
637
638 d = self.conn.sendRequest(self, 'exec', common.NS(self.command),
639 wantReply=1)
640 return d
641
642
644 """
645 Gathers the exit code from the device
646
647 @param data: returned value from device
648 @type data: packet
649 """
650 import struct
651 self.exitCode = struct.unpack('>L', data)[0]
652 log.debug("%s channel %s CommandChannel exit code for %s is %d: %s",
653 self.targetIp, getattr(self.conn, 'localChannelID', None),
654 self.command, self.exitCode, getExitMessage(self.exitCode))
655
656
658 """
659 Response stream from the device. Can be called multiple times.
660
661 @param data: returned value from device
662 @type data: string
663 """
664 self.data += data
665
666
678
679
680
681 -class SshClient(CollectorClient.CollectorClient):
682 """
683 SSH Collector class to connect to a particular device
684 """
685
686 - def __init__(self, hostname, ip, port=22, plugins=[], options=None,
687 device=None, datacollector=None, isLoseConnection=False):
688 """
689 Initializer
690
691 @param hostname: hostname of the device
692 @type hostname: string
693 @param ip: IP address of the device
694 @type ip: string
695 @param port: port number to use to connect to device
696 @type port: integer
697 @param plugins: plugins
698 @type plugins: list of plugins
699 @param options: options
700 @type options: list
701 @param device: name of device
702 @type device: string
703 @param datacollector: object
704 @type datacollector: object
705 """
706
707 CollectorClient.CollectorClient.__init__(self, hostname, ip, port,
708 plugins, options, device, datacollector)
709 self.hostname = hostname
710 self.protocol = SshClientTransport
711 self.connection = None
712 self.transport = None
713 self.openSessions = 0
714 self.workList = list(self.getCommands())
715 self.isLoseConnection = isLoseConnection
716
718 """
719 Start SSH collection.
720 """
721 log.debug("%s SshClient connecting to %s:%s with timeout %s seconds",
722 self.ip, self.hostname, self.port, self.loginTimeout)
723 reactor.connectTCP(self.ip, self.port, self, self.loginTimeout)
724
725
727 log.debug("%s SshClient has %d commands to assign to channels (max = %s, current = %s)",
728 self.ip, len(self.workList), self.concurrentSessions, self.openSessions)
729 availSessions = self.concurrentSessions - self.openSessions
730 for i in range(min(len(self.workList), availSessions)):
731 cmd = self.workList.pop(0)
732 self.openSessions += 1
733 self.connection.addCommand(cmd)
734
735
737 self.openSessions -= 1
738 log.debug("%s SshClient closing channel (openSessions = %s)",
739 self.ip, self.openSessions)
740 if self.commandsFinished():
741 if self.isLoseConnection:
742 self.transport.loseConnection()
743 self.clientFinished()
744 return
745
746 if self.workList:
747 cmd = self.workList.pop(0)
748 self.openSessions += 1
749 self.connection.addCommand(cmd)
750
751
753 """
754 Run commands that are in the command queue
755
756 @param sshconn: connection to create channels on
757 @type sshconn: Twisted SSH connection
758 """
759
760 log.info("SshClient connected to device %s (%s)", self.hostname, self.ip)
761 self.connection = sshconn
762 self.runCommands()
763
764
766 """
767 Add a command or commands to queue and open a command
768 channel for each command
769
770 @param commands: commands to run
771 @type commands: list
772 """
773 CollectorClient.CollectorClient.addCommand(self, commands)
774 if type(commands) == type(''):
775 commands = (commands,)
776 self.workList.extend(commands)
777
778
779 if self.connection:
780 self.runCommands()
781
782
784 """
785 If we didn't connect let the modeler know
786
787 @param connector: connector associated with this failure
788 @type connector: object
789 @param reason: failure object
790 @type reason: object
791 """
792 from Products.ZenUtils.Utils import unused
793 unused(connector)
794 message= reason.getErrorMessage()
795 log.error("%s %s", self.ip, message)
796 sendEvent(self, device=self.hostname, message=message)
797 self.clientFinished()
798
799
801 """
802 Called when the connection gets closed.
803 """
804 log.debug("%s SshClient connection closed", self.ip)
805
806
807
808
809 -def main():
810 """
811 Test harness main()
812
813 Usage:
814
815 python SshClient.py hostname[:port] comand [command]
816
817 Each command must be enclosed in quotes (") to be interpreted
818 properly as a complete unit.
819 """
820 import socket
821 from itertools import chain
822 import pprint
823
824 logging.basicConfig()
825
826 parser = CollectorClient.buildOptions()
827 options = CollectorClient.parseOptions(parser,22)
828 log.setLevel(options.logseverity)
829
830 client = SshClient(options.hostname,
831 socket.gethostbyname(options.hostname),
832 options.port,
833 options=options)
834
835
836
837 client.workList= options.commands
838
839 client.run()
840
841 client.clientFinished= reactor.stop
842 client._commands.append( options.commands )
843 reactor.run()
844
845 pprint.pprint(client.getResults())
846
847
848 if __name__ == '__main__':
849 main()
850