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