Package Products :: Package DataCollector :: Module SshClient
[hide private]
[frames] | no frames]

Source Code for Module Products.DataCollector.SshClient

  1  ########################################################################### 
  2  # 
  3  # This program is part of Zenoss Core, an open source monitoring platform. 
  4  # Copyright (C) 2007, Zenoss Inc. 
  5  # 
  6  # This program is free software; you can redistribute it and/or modify it 
  7  # under the terms of the GNU General Public License version 2 as published by 
  8  # the Free Software Foundation. 
  9  # 
 10  # For complete information please visit: http://www.zenoss.com/oss/ 
 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  # NB: Most messages returned back from Twisted are Unicode. 
 41  #     Expect to use str() to convert to ASCII before dumping out. :) 
 42   
 43   
44 -def sendEvent( self, message="", device='', severity=Event.Error ):
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 # Parse out the daemon's name 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 # ... and the device's name (as known by Zenoss) 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 # At this point, we don't know what we have 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 # Don't cause other issues 114 115 116
117 -class SshClientError( Exception ):
118 """ 119 Exception class 120 """
121 122 123
124 -class SshClientTransport(transport.SSHClientTransport):
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
131 - def verifyHostKey(self, hostKey, fingerprint):
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 #blowing off host key right now, should store and check 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
153 - def connectionMade(self):
154 """ 155 Called after the connection has been made. 156 Used to set up private instance variables. 157 """ 158 self.factory.transport = self.transport 159 transport.SSHClientTransport.connectionMade(self)
160 161
162 - def receiveError( self, reasonCode, description ):
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
178 - def receiveUnimplemented( self, seqnum ):
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
191 - def receiveDebug( self, alwaysDisplay, message, lang ):
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
209 - def connectionSecure(self):
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
218 -class NoPasswordException(Exception):
219 pass
220 221
222 -class SshUserAuth(userauth.SSHUserAuthClient):
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) # damn unicode 241 if user == '': 242 log.debug( "Unable to determine username/password from " + \ 243 "zCommandUser/zCommandPassword" ) 244 245 # From the Python docs about the preferred method of 246 # obtaining user name in preference to os.getlogin() 247 # (http://docs.python.org/library/os.html) 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
266 - def getPassword(self, unused=None):
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
285 - def getGenericAnswers(self, name, instruction, prompts):
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 # RFC 4256 - In the case that the server sends a `0' num-prompts 301 # field in the request message, the client MUST send a response 302 # message with a `0' num-responses field to complete the exchange. 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
318 - def _getPassword(self):
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
328 - def _handleFailure(self, message):
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
338 - def _getKey(self):
339 keyPath = os.path.expanduser(self.factory.keyPath) 340 log.debug('Expanded SSH key path from zKeyPath %s to %s' % ( 341 self.factory.keyPath, keyPath)) 342 if os.path.exists(keyPath): 343 data = ''.join(open(keyPath).readlines()).strip() 344 key = Key.fromString(data, 345 passphrase=self.factory.password) 346 else: 347 key = None 348 log.debug( "SSH key path %s doesn't exist" % keyPath ) 349 return key
350
351 - def getPublicKey(self):
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
361 - def getPrivateKey(self):
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
374 - def ssh_USERAUTH_FAILURE( self, packet):
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 # Start our connection 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 # Straight from the nasty Twisted code 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
445 -class SshConnection(connection.SSHConnection):
446 """ 447 Wrapper class that starts channels on top of connections. 448 """ 449
450 - def __init__(self, factory):
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
462 - def ssh_CHANNEL_FAILURE( self, packet):
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
475 - def ssh_CHANNEL_OPEN_FAILURE( self, packet):
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
488 - def ssh_REQUEST_FAILURE( self, packet):
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
500 - def openFailed(self, reason):
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
521 - def serviceStarted(self):
522 """ 523 Called when the service is active on the transport 524 """ 525 self.factory.serviceStarted(self)
526 527
528 - def addCommand(self, cmd):
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
539 - def channelClosed(self, channel):
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 # grr.. patch SSH inherited method to deal with partially 548 # configured channels 549 self.localToRemoteChannel[channel.id] = None 550 self.channelsToRemoteChannel[channel] = None 551 connection.SSHConnection.channelClosed(self, channel)
552 553 554
555 -class CommandChannel(channel.SSHChannel):
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
578 - def openFailed(self, reason):
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
597 - def extReceived(self, dataType, data ):
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
610 - def channelOpen(self, unused):
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 # Notes for sendRequest: 624 # 'exec' - execute the following command and exit 625 # common.NS() - encodes the command as a length-prefixed string 626 # wantReply - reply to let us know the process has been started 627 d = self.conn.sendRequest(self, 'exec', common.NS(self.command), 628 wantReply=1) 629 return d
630 631
632 - def request_exit_status(self, data):
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
647 - def dataReceived(self, data):
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
657 - def closed(self):
658 """ 659 Cleanup for the channel, as both ends have closed the channel. 660 """ 661 log.debug('Closed command channel for command %s with data: %s' % \ 662 (self.command, repr(self.data))) 663 self.conn.factory.addResult(self.command, self.data, self.exitCode) 664 self.loseConnection() 665 666 self.conn.factory.channelClosed()
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
706 - def run(self):
707 """ 708 Start SSH collection. 709 """ 710 reactor.connectTCP(self.ip, self.port, self, self.loginTimeout)
711 712
713 - def runCommands(self):
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
721 - def channelClosed(self):
722 self.openSessions -= 1 723 if self.commandsFinished(): 724 if self.isLoseConnection: 725 self.transport.loseConnection() 726 self.clientFinished() 727 return 728 729 if self.workList: 730 cmd = self.workList.pop(0) 731 self.openSessions += 1 732 self.connection.addCommand(cmd)
733 734
735 - def serviceStarted(self, sshconn):
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
748 - def addCommand(self, commands):
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 # This code is required when we're reused by zencommand. 763 if self.connection: 764 self.runCommands()
765 766
767 - def clientConnectionFailed( self, connector, reason ):
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
784 - def loseConnection(self):
785 """ 786 Called when the connection gets closed. 787 """ 788 log.debug( "Connection closed" )
789 #self.connection.loseConnection() 790 791 792
793 -def main():
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 # Rather than getting info from zenhub, just pass our 820 # commands in 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