SSH via Traefik 2

Traefik Diagram

At first it might seem like witchcraft, to see SSH (a protocol without any SNI) routed through traefik, but when you break it down its quite simple.

SSH

SSH (Secure SHell) is a protocol which allows you to remotely control servers, similar to telnet, but with encryption and authentication built right in. SSH doesn't contain any SNI (Server Name Indicator), which means we cant tell exactly what host to route a raw SSH socket through. Because of this, we should encapsulate the SSH socket in a socket which does provide SNI, like TLS.

TLS

Transport Layer Security, or TLS for short is a protocol which sits just above the transport layer in the OSI model, but before the application layer, making it layer 4.5.

TLS has a fancy extension called SNI, which allows us to know exactly what server we are talking to. To understand why this is useful, consider the following scenario; A web server (1.1.1.1) serves both example.com and example.net, however they both have 2 different TLS certificates. Jerry wants to connect to example.net, and during the TLS handshake, the web server has to send over a certificate so that Jerry can verify he is talking to the real web server for example.net (1.1.1.1). We haven't yet established the transport layer for HTTP to communicate on, so we cannot just read the Host header, instead we look at the SNI which Jerry has sent along in the handshake. Now the server can pick the certificate for example.net and send that off to Jerry so he can verify it, completing the opening of the TLS stream, so Jerry can get the webpage through HTTP.

The same principle applies to SSH - we can tunnel an SSH socket through a TLS socket, which will provide our router the correct hostname to be able to redirect the connection accordingly.

A note on security

SSH implements its own encryption, so the TLS socket is really only required for SNI, which means you could use the default traefik certificate as I have in the example to follow, but I dont recommend it - the more security the better.

Demo

For this demo, I have chosen to use Traefik 2 - the cloud native edge router, and a few test docker containers providing SSH servers. A more practical example would be for switching between a GitLab server and a SFTP server, or just for managing your home network from 1 port.

I set this all up with docker compose.

version: '3.7'
service:

First up, I set the compose version, and open up the service block for defining our services.

  traefik:
    image: traefik:v2.4
    command:
      - --api.insecure=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=true
      - --entrypoints.ssh.address=:22
    ports:
      - "8080:8080"
      - "2222:22"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

Next up is traefik - the magic behind all of this. We firstly use the traefik 2.4 image, as it was the latest at the time of authoring.

Next up is the command section, this is where we set some of the static config, such as enabling the Traefik Dashboard with --api.insecure=true (not actually required for this to work, its just a nice to have), enabling the docker provider --providers.docker=true, exposing containers by default (I don't recommend this in production, its just here to save me on some additional labels) --providers.docker.exposedbydefault=true and creating an entrypoint for SSH on port 22.

Then we just expose the ports, for firstly the dashboard 8080:8080, then SSH 2222:22. We map SSH onto port 2222 as port 22 is in use by the ssh server on my computer.

Finally, we pass the docker socket into the container, just so traefik can access it to read the labels off the following containers.

  ssh1:
    build: git://github.com/timlinux/docker-ssh
    labels:
      - traefik.tcp.routers.ssh1.rule=HostSNI(`ssh1.local`)
      - traefik.tcp.routers.ssh1.tls.passthrough=false

Then we have 3 simple ssh servers, all identical except the numbering.

We do a simple build of a docker image which provides an SSH server.

One of the magic parts is in the docker labels on this container.

Firstly is traefik.tcp.routers.ssh1.rule=HostSNI('ssh1.local'). This label gives traefik a rule for when to route SSH traffic to this container, i.e. when the HostSNI is ssh1.local.

We then want to assert that we shouldn't pass the TLS socket through after routing, essentially terminating the TLS at the traefik node. This is done with the label traefik.tcp.routers.ssh1.tls.passthrough=false.

The next portion of the magic is to tell our SSH client to tunnel through this TLS socket. This is done by editing your ~/.ssh/config.

Host *.local
        ProxyCommand openssl s_client -servername %h -connect server:2222

What this does is when we connect to any server matching the *.local selector, instead of the SSH client establishing a socket, it will instead pass socket data through STDIN from the listed command. In this case we are using the command openssl s_client -servername %h -connect server:2222. This command opens a TLS connection to server:2222, but indicating the servername is %h which is a placeholder replaced by SSH, corresponding to the hostname of the server we are connecting to.

Now when we connect to ssh1.local, it will proxy the connection through a TLS socket on server:2222, which will route it to the correct server.

Previous Post Next Post