tunnel

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,
  • -R is the same as -L but in reverse, it even works for sockets too: ssh -nNT -L 9000:/var/lib/mysql/mysql.sock user@192.168.90.1

TLDR Full source can be found here.

2021 Update This article was writen in 2018 ssh tunnels are now supported in OTP (from OTP 22 I think?) using tcpip_tunnel_from_server and tcpip_tunnel_to_server.

Let’s get started!

Connecting to a ssh server

We’ll define a module with a simple connect to create a connection to a ssh server:

# lib/ssht.ex
defmodule SSHt do
  def connect(opts \\ []) do
    host = Keyword.get(opts, :host, "127.0.0.1")
    port = Keyword.get(opts, :port, 22)
    ssh_config = defaults(opts)

    :ssh.connect(String.to_charlist(host), port, ssh_config)
  end

  defp defaults(opts) do
    user = Keyword.get(opts, :user, "")
    password = Keyword.get(opts, :password, "")

    [
      user_interaction: false,
      silently_accept_hosts: true,
      user: String.to_charlist(user),
      password: String.to_charlist(user)
    ]
  end
end

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 the ssh application in the past but has since been removed, however we can implement it by using :ssh_connection_handler:open_channel/6

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 the pid 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 below
  • InitalWindowSize Initial TCP window size
  • MaxPacketSize 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 ssht.ex

# lib/ssht.ex
defmodule SSHt do
  @ini_window_size 1024 * 1024
  @max_packet_size 32 * 1024
  @direct_tcpip String.to_charlist("direct-tcpip")

  def connect(opts \\ []) do
    host = Keyword.get(opts, :host, "127.0.0.1")
    port = Keyword.get(opts, :port, 22)
    ssh_config = defaults(opts)

    :ssh.connect(String.to_charlist(host), port, ssh_config)
  end

  def direct_tcpip(conn, from, to) do
    {orig_host, orig_port} = from
    {remote_host, remote_port} = to

    remote_len = byte_size(remote_host)
    orig_len = byte_size(orig_host)

    msg = <<
      remote_len::size(32),
      remote_host::binary,
      remote_port::size(32),
      orig_len::size(32),
      orig_host::binary,
      orig_port::size(32)
    >>

    :ssh_connection_handler.open_channel(
      conn,
      @direct_tcpip,
      msg,
      @ini_window_size,
      @max_packet_size,
      :infinity
    )
  end

  defp defaults(opts) do
    user = Keyword.get(opts, :user, "")
    password = Keyword.get(opts, :password, "")

    [
      user_interaction: false,
      silently_accept_hosts: true,
      user: String.to_charlist(user),
      password: String.to_charlist(password)
    ]
  end
end

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! You need 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

Next we’ll create a 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:

# mix.exs
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. To quote the docs

A DynamicSupervisor starts with no children. Instead, children are started on demand via start_child/2

Create an application module at lib/ssht/application.ex and put the following in it:

# lib/ssht/application.ex
defmodule SSHt.Application do
  @moduledoc """
  Application module
  """
  use Application

  def start(_type, _args) do
    children = [
      {DynamicSupervisor, name: SSHt.TunnelSupervisor, strategy: :one_for_one}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

Child processes can now be started using start_child/2 like: 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 one can use the tuple shorthand. GenServer, Task, Supervisor all implement this so if you’re deriving your module from any of those you’re good to go. The second argument is the arguments that will be passed 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.

# lib/ssht/tunnel.ex
defmodule SSHt.Tunnel do
  @type to :: {:tcpip, tuple()} | {:local, String.t}

  @spec start_link(pid(), to) :: {:ok, pid()} | {:error, term()}
  def start_link(ref, to) do
    DynamicSupervisor.start_child(
      SSHt.TunnelSupervisor,
      worker_spec(worker_opts(ref, to))
    )
  end

  defp worker_spec(opts) do
    name = Keyword.get(opts, :name)

    ranch_opts =
      case Keyword.get(opts, :target) do
        {:local, path} -> [{:local, path}]
        {:tcpip, {port, _}} -> [{:port, port}]
      end

    :ranch.child_spec(
      name,
      100,
      :ranch_tcp,
      ranch_opts,
      SSHt.Tunnel.TCPHandler,
      opts
    )
  end

  defp worker_opts(ref, {:tcpip, {port, _}} = to), do: basic_opts(ref, base_name(port), to)

  defp worker_opts(ref, {:local, socket_path} = to),
    do: basic_opts(ref, base_name(socket_path), to)

  defp basic_opts(ref, name, target) do
    Keyword.new()
    |> Keyword.put(:name, name)
    |> Keyword.put(:ssh_ref, ref)
    |> Keyword.put(:target, target)
  end

  defp base_name(port_or_path) do
    "#{__MODULE__}.#{port_or_path}" |> String.to_atom()
  end
end

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 module responsible for handling a TCP client connection. So let’s do that:

# lib/ssht/tunnel/tcp_handler.ex
defmodule SSHt.Tunnel.TCPHandler do
  use GenServer

  def start_link(ref, socket, transport, opts) do
    pid = :proc_lib.spawn_link(__MODULE__, :init, [{ref, socket, transport, opts}])
    {:ok, pid}
  end

  def init({ref, socket, transport, opts}) do
    target = Keyword.get(opts, :target)
    ssh_ref = Keyword.get(opts, :ssh_ref)

    {:open, channel} = ssh_forward(ssh_ref, target)
    :ok = :ranch.accept_ack(ref)
    :ok = transport.setopts(socket, [{:active, true}])

    :gen_server.enter_loop(__MODULE__, [], %{
      socket: socket,
      transport: transport,
      ssh_ref: ssh_ref,
      channel: channel
    })
  end

  defp ssh_forward(ref, target) do
    case target do
      {:local, path} -> SSHt.stream_local_forward(ref, path)
      {:tcpip, {port, to}} -> SSHt.direct_tcpip(ref, {"127.0.0.1", port}, to)
    end
  end
end

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:

# lib/ssht/tunnel/tcp_handler.ex
defmodule SSHt.Tunnel.TCPHandler do
  use GenServer
  require Logger

  def start_link(ref, socket, transport, opts) do
    pid = :proc_lib.spawn_link(__MODULE__, :init, [{ref, socket, transport, opts}])
    {:ok, pid}
  end

  def init({ref, socket, transport, opts}) do
    clientname = stringify_clientname(socket)
    target = Keyword.get(opts, :target)
    ssh_ref = Keyword.get(opts, :ssh_ref)

    {:open, channel} = ssh_forward(ssh_ref, target)
    :ok = :ranch.accept_ack(ref)
    :ok = transport.setopts(socket, [{:active, true}])

    :gen_server.enter_loop(__MODULE__, [], %{
      socket: socket,
      transport: transport,
      ssh_ref: ssh_ref,
      channel: channel,
      clientname: clientname
    })
  end

  def handle_info(
        {:tcp, _, data},
        %{ssh_ref: ssh, channel: channel, clientname: clientname} = state
      ) do
    :ok = :ssh_connection.send(ssh, channel, data)
    Logger.info(fn -> "Message from: #{clientname}: #{inspect(data)}." end)

    {:noreply, state}
  end

  def handle_info({:tcp_error, _, reason}, %{clientname: clientname} = state) do
    Logger.info(fn -> "Error #{clientname}: #{inspect(reason)}" end)
    {:stop, :normal, state}
  end

  def handle_info(
        {:tcp_closed, _},
        %{clientname: clientname, ssh_ref: ssh, channel: channel} = state
      ) do
    Logger.info(fn -> "Client #{clientname} disconnected channel #{channel}" end)

    {:stop, :normal, state}
  end

  def handle_info(
        {:ssh_cm, _, {:data, _, _, data}},
        %{socket: socket, transport: transport} = state
      ) do
    :ok = transport.send(socket, data)
    {:noreply, state}
  end

  def handle_info({:ssh_cm, _, {:eof, _channel_id}}, state) do
    {:stop, :normal, state}
  end

  def terminate(reason, %{ssh_ref: ssh, channel: channel}) do
    :ok = :ssh_connection.close(ssh, channel)
    Logger.info("terminated reason #{inspect(reason)}")
  end

  defp ssh_forward(ref, target) do
    case target do
      {:local, path} -> SSHt.stream_local_forward(ref, path)
      {:tcpip, {port, to}} -> SSHt.direct_tcpip(ref, {"127.0.0.1", port}, to)
    end
  end

  defp stringify_clientname(socket) do
    {:ok, {addr, port}} = :inet.peername(socket)

    address =
      addr
      |> :inet_parse.ntoa()
      |> to_string()

    "#{address}:#{port}"
  end
end

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.