Implementing an ssh client in Python
One of ayrton
's features is the remote execution of code and programs via ssh
. For this I
initially used paramiko
, which is a complete reimplementation of the ssh
protocol
in pure Python. It manages to connect, authenticate and create channels and port
forwardings with any recent ssh
server, and is quite easy:
import paramiko c= paramiko.SSHClient () c.connect (...) # get_pty=True so we emulate a tty and programs like vi and mc work i, o, e= c.execute_command (command, get_pty=True)
So far so good, but the interface is those 3 objects, i
, o
and e
, that
represent the remote command's stdin
, stdout
and stderr
. If one wants to fully
implement a client, one needs to copy everything from the local process' standard
streams to those.
For this, the most brute force approach is to create a thread for each pair of streams1:
class CopyThread (Thread): def __init__ (self, src, dst): super ().__init__ () self.src= src self.dst= dst def run (self): while True: data= self.src.read (1024) if len (data)==0: break else: self.dst.write (data) self.close () def close (self): self.src.close () self.dst.close ()
This for some reason does not work out of the bat. When I implemented it in ayrton
,
what I got was that I didn't get anything from stdout
or stderr
until the remote
code was finished. I tiptoed a little around the problem, but at the end I took cue
from one of
paramiko
's examples
and implemented a single copy loop with select()
:
class InteractiveThread (Thread): def __init__ (self, pairs): super ().__init__ () self.pairs= pairs self.copy_to= dict (pairs) self.finished= os.pipe () def run (self): while True: wait_for= list (self.copy_to.keys ()) wait_for.append (self.finished[0]) r, w, e= select (wait_for, [], []) if self.finished[0] in r: self.self.finished[0].close () break for i in r: o= self.copy_to[i] data= i.read (1024) if len (data)==0: # do not try to read any more from this file del self.copy_to[i] else: o.write (data) self.close () def close (self): for k, v in self.pairs: for f in (k, v): f.close () self.finished[1].close () t= InteractiveThread (( (0, i), (o, 1), (e, 2) )) t.start () [...] t.close ()
The extra pipe, finished
, is there to make sure we don't wait forever for stdin
to finish.
This completely solves the problem of handling the streams, but that's not the only
problem. The next step is to handle the fact that when we do some input via stdin
,
we see it twice. This is because both the local and the remote terminals are echoing
what we type, so we just need to disable the local echoing. In fact, ssh
does
quite more than that:
class InteractiveThread (Thread): def __init__ (self, pairs): super ().__init__ () [...] self.orig_terminfo= tcgetattr (pairs[0][0]) # input, output, control, local, speeds, special chars iflag, oflag, cflag, lflag, ispeed, ospeed, cc= self.orig_terminfo # turn on: # Ignore framing errors and parity errors iflag|= IGNPAR # turn off: # Strip off eighth bit # Translate NL to CR on input # Ignore carriage return on input # XON/XOFF flow control on output # (XSI) Typing any character will restart stopped output. NOTE: not needed? # XON/XOFF flow control on input iflag&= ~( ISTRIP | INLCR | IGNCR | ICRNL | IXON | IXANY | IXOFF ) # turn off: # When any of the characters INTR, QUIT, SUSP, or DSUSP are received, generate the corresponding signal # canonical mode # Echo input characters (finally) # NOTE: why these three? they only work with ICANON and we're disabling it # If ICANON is also set, the ERASE character erases the preceding input character, and WERASE erases the preceding word # If ICANON is also set, the KILL character erases the current line # If ICANON is also set, echo the NL character even if ECHO is not set # implementation-defined input processing lflag&= ~( ISIG | ICANON | ECHO | ECHOE | ECHOK | ECHONL | IEXTEN ) # turn off: # implementation-defined output processing oflag&= ~OPOST # NOTE: whatever # Minimum number of characters for noncanonical read cc[VMIN]= 1 # Timeout in deciseconds for noncanonical read cc[VTIME]= 0 tcsetattr(self.pairs[0][0], TCSADRAIN, [ iflag, oflag, cflag, lflag, ispeed, ospeed, cc ]) def close (self): # reset term settings tcsetattr (self.pairs[0][0], TCSADRAIN, self.orig_terminfo) [...]
I won't pretend I understand all of that. Checking the file's history, I'm
tempted to bet that neither the openssh
developers do. I would even bet that it
was taken from a telnet
or rsh
implementation or something. This is the kind
of things I meant when I wrote
my previous post
about implementing these complex pieces of software as a library with a public API
and a shallow frontend in the form of a program. At least the guys from openssh
say that
they're going in that direction.
That's wonderful news.
Almost there. The last stone in the way is the terminal emulation. As is,
SSHClient.execute_command()
tells the other end that we're running in a 80x25
VT100
terminal. Unluckily the API does not allow us to set it by ourselves, but
SSHClient.execute_command()
is
a very simple method
that we can rewrite:
channel= c.get_transport ().open_session () term= shutil.get_terminal_size () channel.get_pty (os.environ['TERM'], term.columns, term.lines)
Reacting to SIGWINCH
and changing the terminal's size is left as an exercise for
the reader :)
-
In fact this might seem slightly wasteful, as data has to be read into user space and then pushed down back to the kernel. The problem is that
os.sendfile()
only works ifsrc
is a kernel object that supportsmmap()
, which sockets don't, and even whensplice()
is available in a 3dr party module, one of the parameters must be a pipe. There is at least one huge thread spread over 4 or 5 kernel mailing lists discussing widening the applicability ofsplice()
, but to be honest, I hadn't finished reading it. ↩