Monday, June 13, 2011

Twisted Conch in 60 Seconds: Detecting EOF on input

Greetings patient readers.  Welcome once again to Twisted Conch in 60 Seconds, a series of articles in which I attempt to convey the simplicity of writing SSH applications using Twisted.  For those of you who have been reading since the beginning, thank you for bearing with me through the unfortunate hiatus.

When last we parted, I had just explained how to accept input on a custom server channel. In this edition, I'll explain the various callbacks you can expect when your channel is not going to receive any further data.

Recall the channel class which echoes all of its input back to the client from the previous example:


class SimpleSession(SSHChannel):
name = 'session'

def request_pty_req(self, data):
return True

def request_shell(self, data):
return True

def dataReceived(self, bytes):
self.write("echo: " + repr(bytes) + "\r\n")

When the client disconnects, the SimpleSession instance will get garbage collected (as long as we have no extra code of our own keeping a reference to it, thus keeping it alive). You can also receive a callback when this disconnection happens, though, before the instance is discarded. Actually, you can receive one or more of three different callbacks:


def eofReceived(self):
self.write("eof received\r\n")

This eofReceived callback happens when the client sends an End-Of-File notification for this channel. You might see this if the client is invoked like this:


$ ssh username@host < input-file

After the contents of input-file have been sent, the client will send the EOF notification and wait for server output. However, if the client is invoked in this more common manner:


$ ssh username@host

then it's more likely that it will exit via the magic ~. sequence, or by being killed (explicitly or by a SIGHUP when the user logs out). In this case, there is no EOF notification. Instead, the SSH connection is just closed. This results in the channel being closed as well, which can be handled like this:


def closed(self):
print "Channel closed"

This notification may also happen after an EOF notification, so be prepared to handle them both. Finally, it's possible for the client to request that just one particular channel be closed. This can be handled like this:


def closeReceived(self):
print "Close received"

However, this condition is difficult to trigger with the standard ssh command line client (in fact, I don't know how to trigger it). A custom SSH client might want to do this, though - for example if it creates and destroys many different channels in the lifetime of a single SSH connection.

As a rule of thumb, closed is probably the most commonly useful of these three callbacks. You can use it to reliably clean up resources when a channel is no longer in use and be confident that it is going to be called.

Here's a complete server using the echo channel definition from the previous post, plus the three new callbacks introduced in this post:

 


from twisted.cred.portal import Portal
from twisted.cred.checkers import FilePasswordDB
from twisted.conch.ssh.factory import SSHFactory
from twisted.internet import reactor
from twisted.conch.ssh.keys import Key
from twisted.conch.interfaces import IConchUser
from twisted.conch.avatar import ConchUser
from twisted.conch.ssh.channel import SSHChannel

with open('id_rsa') as privateBlobFile:
privateBlob = privateBlobFile.read()
privateKey = Key.fromString(data=privateBlob)

with open('id_rsa.pub') as publicBlobFile:
publicBlob = publicBlobFile.read()
publicKey = Key.fromString(data=publicBlob)

def nothing():
pass

class SimpleSession(SSHChannel):
name = 'session'

def request_pty_req(self, data):
return True

def request_shell(self, data):
return True

def dataReceived(self, bytes):
self.write("echo: " + repr(bytes) + "\r\n")

def eofReceived(self):
print 'connection lost'

def closed(self):
print 'closed'

def closeReceived(self):
print 'closeReceived'

class SimpleRealm(object):
def requestAvatar(self, avatarId, mind, *interfaces):
user = ConchUser()
user.channelLookup['session'] = SimpleSession
return IConchUser, user, nothing

factory = SSHFactory()
factory.privateKeys = {'ssh-rsa': privateKey}
factory.publicKeys = {'ssh-rsa': publicKey}
factory.portal = Portal(SimpleRealm())
factory.portal.registerChecker(FilePasswordDB("ssh-passwords"))

reactor.listenTCP(2022, factory)
reactor.run()

By this point, I've explained enough to make a complete (if simple) SSH server application - authentication, output, pty requests, input, and disconnection handling. In the next entry, I'll explain how you can hook a normal Protocol instance up to a channel, useful for abstracting your application logic somewhat away from the SSH details and (therefore) for re-using existing Twisted-based protocol implementations with SSH as their transport.

1 comment:

  1. Regarding closing just one channel from a standard client: "~C", followed by "-KR" will cancel an existing -R port forward, which should trigger a channel close. Likewise for -KD and -KL.

    ReplyDelete