Adding an SSH management console to a Swift server
I notices NIOSSH today for SSH support in Swift. I'm going to try it as a management console for one of my HTTP backends. For an idea of how much effort is involved I'll give a running tally here.
T+0
-
Go look at https://github.com/apple/swift-nio-ssh. Read a lot of it.
-
Discover that there is already an example server hiding in Sources/NIOSSHServer
-
Copy it in, drop out the code for port forwarding
T+1hr
-
I can connect to my server, and it crashes. Progress.
-
Realize the sample server is trying to use
/usr/local/bin/bash
which I don't have. -
Get rid of all the pipe code since I'm not going to be interactive, at least yet. Introduce a bunch of bugs. Get lost in event loops, futures, and promises and the mysteries of
unwrapInboundIn
and friends.`
T+2hr
- Success! I have built a really complicated
echo
server.
T+4hr
-
Much better understanding of NIO ChannelHandler, threw out about half the previous code.
-
Got in a fight with Swift over how to say a parameter conforms to a protocol. Ended up with an ugly generic solution. Not happy.
-
Boiled down to a SSHCommandHandler class which is used as a channel handler. It gets a command and dispatches it to a
doCommand(command:to:environment:)
function which you override. -
The Protocol fight was because I just want that
to:
parameter to be aTextOutputStream
. I don't want to tell you about my specific version. That collides with the inout nature of the print(_:to:inout TextOutputStream) call.
T+6hr
Mostly liking the shape of the code. In your main
or somewhere likely you will…
let console = SSHConsole(passwordDelegate: Authenticator())
try console.listen()
…later you can…
try? console.stop().wait()`
T+10hr
And just like that I hit a wall. Swift Argument Parser does not have a mechanism for you to pass in state as you dispatch the command. There is a run()
with no arguments and that's it. I spent 4 hours exploring the vast array of things which don't work. I landed on a few that do, but they are a bit gross.
Here's what I ended up with:
-
Define a struct for my context.
-
Extend the
ParsableCommand
to require a context variable of my type. I called itConsoleCommand
-
I define my commands with structs conforming to
ConsoleCommand
. Each one has to have avar context:ConsoleContext? = nil
in it. -
Now my
run()
can look at itscontext
and everything is happy -
Except…
ParsableCommand
conforms toDecodable
and anOptional<Foo>
only conforms ifFoo
conforms, and my context object can't conform because there is no sane null value for an output stream! -
The textbook solution is to specify my own Codable keys and leave out the trouble maker, except these objects are full of variables because they have entries for all the command syntax elements.
-
… hours are wasted …
-
I finally implemented a
@transient
property wrapper for nil-able values which sets them to nil on adecode
and doesn't write on anencode
. This is inspired by a feature rejected by the Swift designers. -
I don't like it. I resent having to put a
@transient var context:ConsoleContext?=nil
in every command, but when I tried usingclass
es instead ofstructs
for the commands it didn't parse, so that may not be supported, so no inheriting my boilerplate. -
I may go back and make my context be
Decodable
even if I have to make some insane values to populate it. Then I can lose the property wrapper and also save some unwrapping.
All in all a typical day of programming. Lot's of good progress then I step in a bear trap and have to chew my leg off before continuing.
T+20 Hours
All wrapped up and published on GitHub with API level documentation and very little high level documentation. Just the way I hate to use software. You can find it at SSHConsole