1
2
3
4
5
6
7
8
9
10
11 __doc__="""SshClient runs commands on a remote box using SSH and
12 returns their results.
13
14 See http://twistedmatrix.com/trac/wiki/Documentation for Twisted documentation,
15 specifically documentation on 'conch' (Twisted's SSH protocol support).
16 """
17
18 import os
19 import sys
20 from pprint import pformat
21 import logging
22 log = logging.getLogger("zen.SshClient")
23 import socket
24
25 import Globals
26
27 from twisted.conch.ssh import transport, userauth, connection
28 from twisted.conch.ssh import common, channel
29 from twisted.conch.ssh.keys import Key
30 from twisted.internet import defer, reactor
31 from Products.ZenEvents import Event
32 from Products.ZenUtils.Utils import getExitMessage
33 from Products.ZenUtils.IpUtil import getHostByName
34
35 from Exceptions import *
36
37 import CollectorClient
38
39
40
41
42
43 -def sendEvent( self, message="", device='', severity=Event.Error, event_key=None):
44 """
45 Shortcut version of sendEvent()
46
47 @param message: message to send in Zenoss event
48 @type message: string
49 @param device: hostname of device to which this event is associated
50 @type device: string
51 @param severity: Zenoss severity from Products.ZenEvents
52 @type severity: integer
53 @param event_key: The event key to use for event clearing.
54 @type event_key: string
55 """
56
57
58 component= os.path.basename( sys.argv[0] ).replace( '.py', '' )
59
60 def hasattr_path( object_root, path ):
61 """
62 The regular hasattr() only works on one component,
63 not multiples.
64
65 @param object_root: object to start searching for path
66 @type object_root: object
67 @param path: path to func or variable (eg "conn.factory" )
68 @type path: string
69 @return: is object_root.path sane?
70 @rtype: boolean
71 """
72 obj = object_root
73 for chunk in path.split('.'):
74 obj= getattr( obj, chunk, None )
75 if obj is None:
76 return False
77 return True
78
79
80 if device == '':
81 if hasattr_path( self, "factory.hostname" ):
82 device= self.factory.hostname
83
84 elif hasattr_path( self, "conn.factory.hostname" ):
85 device= self.conn.factory.hostname
86
87 else:
88 log.debug( "Couldn't get the remote device's hostname" )
89
90 error_event= {
91 'agent': component,
92 'summary': message,
93 'device': device,
94 'eventClass': "/Cmd/Fail",
95 'component': component,
96 'severity': severity,
97 }
98 if event_key:
99 error_event['eventKey'] = event_key
100
101
102 try:
103 if hasattr_path( self, "factory.datacollector.sendEvent" ):
104 self.factory.datacollector.sendEvent( error_event )
105
106 elif hasattr_path( self, "factory.sendEvent" ):
107 self.factory.sendEvent( error_event )
108
109 elif hasattr_path( self, "datacollector.sendEvent" ):
110 self.datacollector.sendEvent( error_event )
111
112 elif hasattr_path( self, "conn.factory.datacollector.sendEvent" ):
113 self.conn.factory.datacollector.sendEvent( error_event )
114
115 else:
116 log.debug( "Unable to send event for %s" % error_event )
117
118 except:
119 pass
120
124 """
125 Exception class
126 """
127
131 """
132 Base client class for constructing Twisted Conch services.
133 This class is *only* responsible for connecting to the SSH
134 service on the device, and ensuring that *host* keys are sane.
135 """
136
138 """
139 Module to verify the host's SSH key against the stored fingerprint we have
140 from the last time that we communicated with the host.
141
142 NB: currently does not verify this information but simply trusts every host key
143
144 @param hostKey: host's SSH key (unused)
145 @type hostKey: string
146 @param fingerprint: host fingerprint (unused)
147 @type fingerprint: string
148 @return: Twisted deferred object
149 @rtype: Twisted deferred object (defer.succeed(1)
150 @todo: verify the host key
151 """
152
153 from Products.ZenUtils.Utils import unused
154 unused(hostKey)
155 log.debug('%s host fingerprint: %s' % (self.factory.hostname, fingerprint))
156 return defer.succeed(1)
157
158
160 """
161 Called after the connection has been made.
162 Used to set up private instance variables.
163 """
164 self.factory.transport = self.transport
165 transport.SSHClientTransport.connectionMade(self)
166
167
169 """
170 Called when a disconnect error message was received from the device.
171
172 @param reasonCode: error code from SSH connection failure
173 @type reasonCode: integer
174 @param description: human-readable version of the error code
175 @type description: string
176 """
177 message= 'SSH error from remote device (code %d): %s\n' % \
178 ( reasonCode, str( description ) )
179 log.warn( message )
180 sendEvent( self, message=message )
181 transport.SSHClientTransport.receiveError(self, reasonCode, description )
182
183
185 """
186 Called when an unimplemented packet message was received from the device.
187
188 @param seqnum: SSH message code
189 @type seqnum: integer
190 """
191 message= "Got 'unimplemented' SSH message, seqnum= %d" % seqnum
192 log.info( message )
193 sendEvent( self, message=message )
194 transport.SSHClientTransport.receiveUnimplemented(self, seqnum)
195
196
198 """
199 Called when a debug message was received from the device.
200
201 @param alwaysDisplay: boolean-type code to indicate if the message is to be displayed
202 @type alwaysDisplay: integer
203 @param message: debug message from remote device
204 @type message: string
205 @param lang: language code
206 @type lang: integer
207 """
208 message= "Debug message from remote device (%s): %s" % ( str(lang), str(message) )
209 log.info( message )
210 sendEvent( self, message=message, severity=Event.Debug )
211
212 transport.SSHClientTransport.receiveDebug(self, alwaysDisplay, message, lang )
213
214
216 """
217 This is called after the connection is set up and other services can be run.
218 This function starts the SshUserAuth client (ie the Connection client).
219 """
220 sshconn = SshConnection(self.factory)
221 sshauth = SshUserAuth(self.factory.username, sshconn, self.factory)
222 self.requestService(sshauth)
223
226
229 """
230 Class to gather credentials for use with our SSH connection,
231 and use them to authenticate against the remote device.
232 """
233
234 - def __init__(self, user, instance, factory):
235 """
236 If no username is supplied, defaults to the user running this code (eg zenoss)
237
238 @param user: username
239 @type user: string
240 @param instance: instance object
241 @type instance: object
242 @param factory: factory info
243 @type factory: Twisted factory object
244 """
245
246 user = str(user)
247 if user == '':
248 log.debug("Unable to determine username/password from " + \
249 "zCommandUser/zCommandPassword")
250
251
252
253
254 import pwd
255 try:
256 user = os.environ.get( 'LOGNAME', pwd.getpwuid(os.getuid())[0] )
257 except:
258 pass
259
260 if user == '':
261 message= "No zProperties defined and unable to determine current user."
262 log.error( message )
263 sendEvent( self, message=message )
264 raise SshClientError( message )
265
266 userauth.SSHUserAuthClient.__init__(self, user, instance)
267 self._sent_password = False
268 self._sent_pk = False
269 self._sent_kbint = False
270 self._auth_failures = []
271 self._auth_succeeded = False
272 self.user = user
273 self.factory = factory
274 self._key = self._getKey()
275
276
278 """
279 Called from conch.
280
281 Return a deferred object of success if there's a password or
282 return fail (ie no zCommandPassword specified)
283
284 @param unused: unused (unused)
285 @type unused: string
286 @return: Twisted deferred object (defer.succeed or defer.fail)
287 @rtype: Twisted deferred object
288 """
289
290 if self._sent_password:
291 return None
292 try:
293 password = self._getPassword()
294 d = defer.succeed(password)
295 self._sent_password = True
296 except NoPasswordException, e:
297
298
299
300
301
302
303 d = None
304 return d
305
307 """
308 Called from conch.
309
310 Returns a L{Deferred} with the responses to the prompts.
311
312 @param name: The name of the authentication currently in progress.
313 @param instruction: Describes what the authentication wants.
314 @param prompts: A list of (prompt, echo) pairs, where prompt is a
315 string to display and echo is a boolean indicating whether the
316 user's response should be echoed as they type it.
317 """
318 log.debug('getGenericAnswers name:"%s" instruction:"%s" prompts:%s',
319 name, instruction, pformat(prompts))
320 if not prompts:
321
322
323
324 d = defer.succeed([])
325 else:
326 responses = []
327 found_prompt = False
328 for prompt, echo in prompts:
329 if 'password' in prompt.lower():
330 found_prompt = True
331 try:
332 responses.append(self._getPassword())
333 except NoPasswordException:
334
335
336 log.debug("getGenericAnswers called with empty password")
337 if not found_prompt:
338 log.warning('No known prompts: %s', pformat(prompts))
339 d = defer.succeed(responses)
340 return d
341
343 """
344 Get the password. Raise an exception if it is not set.
345 """
346 if not self.factory.password:
347 message= "SshUserAuth: no password found -- " + \
348 "has zCommandPassword been set?"
349 raise NoPasswordException(message)
350 return self.factory.password
351
358
383
385 """
386 Return the SSH public key (using the zProperty zKeyPath) or None
387
388 @return: SSH public key
389 @rtype: string
390 """
391
392
393 if self._key is not None and not self._sent_pk:
394 self._sent_pk = True
395 return self._key.blob()
396
398 """
399 Return a deferred with the SSH private key (using the zProperty zKeyPath)
400
401 @return: Twisted deferred object (defer.succeed)
402 @rtype: Twisted deferred object
403 """
404 if self._key is None:
405 keyObject = None
406 else:
407 keyObject = self._key.keyObject
408 return defer.succeed(keyObject)
409
422
424 if self.lastAuth != 'none' and self.lastAuth not in self._auth_failures:
425 self._auth_failures.append(self.lastAuth)
426 return userauth.SSHUserAuthClient.ssh_USERAUTH_FAILURE(self, *args, **kwargs)
427
429 self._auth_succeeded = True
430 return userauth.SSHUserAuthClient.ssh_USERAUTH_SUCCESS(self, *args, **kwargs)
431
433
434 if not self._auth_succeeded:
435
436 if self._auth_failures:
437 log.debug("Authentication failed for auth type(s): %s", ','.join(self._auth_failures))
438 msg = "SSH login to %s with username %s failed" % (self.factory.hostname, self.user)
439 else:
440 msg = "SSH authentication failed - no password or public key specified"
441 self._handleFailure(msg, event_key="sshClientAuth")
442 self.factory.clientFinished()
443 else:
444 sendEvent(self, "Authentication succeeded for username %s" % self.user, severity=Event.Clear,
445 event_key="sshClientAuth")
446 return userauth.SSHUserAuthClient.serviceStopped(self, *args, **kwargs)
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
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
585 if self.conn:
586 return self.conn.transport.transport.addr[0]
587
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
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 if self.connection:
750 self.connection.addCommand(cmd)
751
752
754 """
755 Run commands that are in the command queue
756
757 @param sshconn: connection to create channels on
758 @type sshconn: Twisted SSH connection
759 """
760 log.debug("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 isinstance(commands, basestring):
775 commands = (commands,)
776 self.workList.extend(commands)
777
778
779 if self.connection:
780 self.runCommands()
781
782
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 from itertools import chain
821 import pprint
822
823 logging.basicConfig()
824
825 parser = CollectorClient.buildOptions()
826 options = CollectorClient.parseOptions(parser,22)
827 log.setLevel(options.logseverity)
828
829 client = SshClient(options.hostname,
830 getHostByName(options.hostname),
831 options.port,
832 options=options)
833
834
835
836 client.workList= options.commands
837
838 client.run()
839
840 client.clientFinished= reactor.stop
841 client._commands.append( options.commands )
842 reactor.run()
843
844 pprint.pprint(client.getResults())
845
846
847 if __name__ == '__main__':
848 main()
849