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 streams[1]:
classCopyThread(Thread):def__init__(self, src, dst):super().__init__()
self.src= src
self.dst= dst
defrun(self):while True:
data= self.src.read(1024)iflen(data)==0:breakelse:
self.dst.write(data)
self.close()defclose(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()
:
classInteractiveThread(Thread):def__init__(self, pairs):super().__init__()
self.pairs= pairs
self.copy_to=dict(pairs)
self.finished= os.pipe()defrun(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()breakfor i in r:
o= self.copy_to[i]
data= i.read(1024)iflen(data)==0:# do not try to read any more from this filedel self.copy_to[i]else:
o.write(data)
self.close()defclose(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:
classInteractiveThread(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]=0tcsetattr(self.pairs[0][0], TCSADRAIN, [ iflag, oflag, cflag, lflag,
ispeed, ospeed, cc ])defclose(self):# reset term settingstcsetattr(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 :)
[1] 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 if src
is a kernel object that supports mmap()
, which sockets don't, and
even when splice()
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 of splice()
, but to be honest,
I hadn't finished reading it.
pythonayrton