Implementing SSH tunnels in Elixir
Introduction
Erlang OTP includes a SSH application I thought I would attempt to implement SSH tunneling in Elixir, similar to how we can define tunnels using openSSH.
With openSSH one can define a local tunnel using the following command:
ssh -nNT -L 8585:127.0.0.1:9000 user@192.168.90.1
This will forward any traffic on localhost:8585
to 127.0.0.1:9000
through 192.168.90.1
. In other words localhost:8585
will connect to 192.168.90.1:9000
.
The -nNT option tells SSH to run no command, redirect Null to stdin, and not allocate a TTY. So it’s not possible to run any commands through the tunnel. -L
is for Local bind of the address, doing it in reverse is also possible by specifying the -R
option and it even works for sockets too:
ssh -nNT -L 9000:/var/lib/mysql/mysql.sock user@192.168.90.1
I’ve been writing this post alongside doing the implementation so it’s sort of a recollection of how it was implemented. The gists are for the most part correct and the full source can be found here.
Connecting to a ssh server
We’ll define a module with a simple connect
to create a connection to a ssh server:
Since we are calling the into erlang the host
string (binary) needs to be encoded as a charlist.
Here we are using username / password for authentication as well as not allowing user interactions, any options requiring interactions will fail the connection attempt. There are a lot more options available for more fine grained control, but for us these options will suffice.
The ssh
application needs to be started before we try to connect to the server:
# mix.exs
def application do
[
extra_applications: [:logger, :ssh]
]
end
And we can try it out:
iex(1)> {:ok, pid} = SSHt.connect(host: "192.168.90.15", user: "ubuntu", password: "")
{:ok, #PID<0.181.0>}
It works! With this we are ready to start implementing the tunnels!
Types of tunnels
There are two kinds of tunnels which we are interested in
directtcp-ip allows us (the client) to connect to an ip:port using the ssh server and direct-streamlocal allows us to connect to unix domain socket.
directtcp-ip forwarding has been a part of erlang :ssh
application in the past but has since been removed, however we can implement it by using :ssh_connection_handler:open_channel/6
(source). This is the function used internally for creating channels ( :ssh_connection.session_channel/2/4
for instance).
For reference this is what it looks like
:ssh_connection:open_channel(ConnectionHandler, ChannelType, ChannelSpecificData, InitialWindowSize, MaxPacketSize, Timeout)
ConnectionHandler
is thepid
we receive from:ssh.connect
ChannelType
is the type of message for us this will be either"dirrect-tcpip"
or"direct-streamlocal@openssh.com"
ChannelSpecificData
is the messag we’ll construct from the message format belowInitalWindowSize
Initial TCP window sizeMaxPacketSize
Max allowed packet size
and the directtcp-ip message format:
byte SSH_MSG_CHANNEL_OPEN
string "direct-tcpip"
uint32 sender channel
uint32 initial window size
uint32 maximum packet size
string host to connect
uint32 port to connect
string originator IP address
uint32 originator port
We’ll define a direct_tcpip/3
function in the ssht.ex
On line 21 we create a message by translating from directtcp-ip message format to a binary, due to the excellent bit syntax it reads basically the same as the original message format from the specification.
Since the host fields can be of variable size, the length is prepended. Note that the SSH_MSG_CHANNEL_OPEN
and sender channel
fields are not part of our message. These will be set internally in :ssh_connection_handler.open_channel
.
If you’re interested in reading more about binary pattern matching I think this article does a good job explaining it.
The type @direct_tcpip
is defined as a module attribute, remember since we are calling an erlang application it needs to be represented as a charlist instead of a string. @max_window_size
is set to the 32k as specified in rfc4253
6.1. Maximum Packet Length
All implementations MUST be able to process packets with an
uncompressed payload length of 32768 bytes or less and a total packet
size of 35000 bytes or less (including ‘packet_length’,
‘padding_length’, ‘payload’, ‘random padding’, and ‘mac’).
….
@ini_window_size
is trickier since I don’t know the impact of setting a value which is too low (or too high). We’ll set it to 105kb since I’m pretty sure I’ve seen it set to 1024 * 1024 somewhere so we just go with that.
If our call to:ssh_connection_handler.open_channel/6
is successful we’ll receive a {:open, channel}
. We’ll use the channel and the connection pid to call :ssh_connection.send/3
and send data to our forwarded ip. Let’s try it out by sending a raw HTTP message:
iex(1)> {:ok, pid} = SSHt.connect(host: "192.168.90.15", user: "ubuntu", password: "")
{:ok, #PID<0.166.0>}
iex(2)> data = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: ssht/0.0.1\r\nAccept: */*\r\n\r\n"
iex(3)> {:open, ch} = SSHt.direct_tcpip(pid, {"127.0.0.1", 8080}, {"192.168.90.15", 80})
{:open, 0}
iex(4)> :ssh_connection.send(pid, ch, data)
# handle the data returned on the connection with a receive block
iex(4)> receive do
...(4)> {:ssh_cm, _, {:data, ^ch, _, data}} -> IO.puts("#{data}")
...(4)> end
end
We can receive messages using a receive block or by creating the channel inside a process ( GenServer
for instance) and receive it using handle_info/2
callback:
def handle_info({:ssh_cm, _, {:data, _ch, _, data}}, state) do
IO.puts("Received data #{length(data)}
{:noreply, state}
end
NOTE! I should say that it is required to have something actually responding on the forwarded ip otherwise it will fail. I have a VM setup with a private ip 192.168.90.15 with nginx running on port 80.
So far we’ve achieved
- Connecting to a ssh server
- Creating a forwarded directtcp-ip channel which we can read & write to
What we don’t have is an actual TCP server to relay traffic from our host to the ssh server.
A TCP Server
For this I’m using ranch, add ranch to the deps:
defp deps do
[
{:ranch, "~> 1.4"}
]
end
For this part we are going to do the following:
- On demand TCP-servers
- Relay traffic from a TCP client to forwarded ssh channel and back
- Allow connecting using ip:port and a unix domain socket
The supervisor
The TCP servers need to be started on demand for this we’ll use DynamicSupervisor
which is introduced in elixir 1.5 and deprecates the :simple_one_for_one
strategy.
A cool feature unrelated to any of this is that DynamicSupervisor
allows you to have different kinds of workers, since the only requirement is that you provided a valid child_spec
when starting child processes.
We’ll define a lib/ssht/application.ex
and put the following in it:
Here we set that we want to use the dynamic supervisor and give it a name. The strategy needs to be set to :one_for_one
since it is the only supervison strategy supported by DynamicSupervisor
. Notable is that since DynamicSupervisor
implements the child_spec/1
function we can use the tuple shorthand. For more see the Supervisor docs.
Children can be started using:
DynamicSupervisor.start_child(SSHt.TunnelSupervisor, {MyGenServer, []})
First argument is the supervisor name, second is a child_spec
. As long as the module implements a child_spec/1
it is safe to use the tuple shorthand. GenServer
, Task
, Supervisor
all implements this so if you’re deriving your module from any of those you’re good to go. The second argument in the tuple are arguments to the child being started. In the example above we have no arguments so we specify an empty list.
We’ll define lib/ssht/tunnel.ex
to use as our interface for creating TCP listeners.
start_link/2
accepts a ssh connection pid and a tuple which can either be {:tcpip, {local_port, {ip_addr, remote_port}}
or {:local, socket_path}
.
We use :ranch.child_spec
in worker_spec/2
to create the specification for our TCP listener. worker_spec
takes name, target from the options list and pattern matches on the target to determine the kind of listener we should use :tcpip
for starting a port listener and :local
for a domain socket listener.
We have yet to define the SSHt.Tunnel.TCPHandler
which will be the process responsible for handling a TCP client connection. So let’s do that:
As you’ve probably already seen this looks a little bit different than usually. The reason for using :proc_lib.spawn_link
is due to how GenServer.start_link
works. start_link
does not return until the init
returns. Calling :ranch.accept_ack
would cause a deadlock. We use :gen_server.enter_loop/3
to fallback to the normal GenServer
execution loop after the initialization. (More here)
Time to receive TCP messages and send them onto the forwarded channel:
Whenever we get a tcp message we pattern match on {:tcp,_, data}
and send data
on to the ssh channel, when we receive a {:ssh_cm, _ {:data,_,_, data}
message we send it on to the tcp socket. We also implement the terminate/2
callback to close the ssh channel when the client closes the socket, a EOF is received or for any unexpected behavior.
There’s a potential problem with this implementation however, since we need to differentiate between the connections there’s a channel created for every TCP connection. I’m not certain if it is a problem our not, initially I thought I would define a tunnel (channel) per TCP server, but as the channel is linked to the process creating it the TCP server would receive all ssh messages with no apparent way of telling messages apart. By doing it this way, we’re certain that the ssh messages received are destined to the correct TCP client.
Conclusion / Final words
I think it’s pretty cool that we are able to implement this in pure Elixir in about 200 lines. It’s just a testament to how incredible Erlang/OTP and Elixir really is!
The source in it’s entirety can be found here.
Some caveats:
- The ssh pid should be monitored by each TCP server and trap it’s exit, since running a tunnel does not make much sense if the connection to the remote server is dead.
- Defining a new channel for every connection might be sub-optimal