Route SSH Connections With HAProxy

Route SSH connections through HAProxy using the SSH ProxyCommand feature and SNI.

Did you know that you can proxy SSH connections through HAProxy and route based on hostname? The advantage is that you can relay all SSH traffic through one public-facing server instead of needing to grant users direct access to potentially hundreds of internal servers from outside the network.

Some of you may already handle SSH connections through HAProxy with HAProxy’s TCP mode. Although TCP mode is simple to use, it requires you to listen on multiple ports or addresses and map those ports and addresses to specific backends. This limitation is due to the fact that the SSH protocol doesn’t provide any hint about its final destination, as well as HAProxy doesn’t analyze the protocol.

In this blog post, you will learn a method that bypasses this limitation. You will see how you can expose only one public-facing address but:

  • route your SSH connections to a predefined list of backend servers,

  • route your SSH connections to a specific server,

  • add a security layer in order to restrict the login ability based on client certificates.

In order to route the SSH connections to different servers, you have to know which server the user wants to access. As said previously, HAProxy doesn’t analyze the SSH protocol and, anyway, this protocol doesn’t provide any hint about the destination. So, we have to wrap the connections inside another protocol that will help on that point.

We’ll use the TLS protocol and its SNI extension together with the SSH ProxyCommand feature. Or, said another way, we will wrap our connections with TLS, but we do so simply to leverage SNI so that the client can tell us which server they want to connect to. We will make no use of TLS’s cryptographic features.

Watch our on-demand webinar in French, How to Route SSH Connections with HAProxy.

Route the Connections to a Predefined List of Backend Servers

Let’s say you have an HAProxy server (IP address 172.16.0.10) between your clients and the following three servers:

  1. ssh-server1.example.local (IP address 192.168.0.201)

  2. ssh-server2.example.local (IP address 192.168.0.202)

  3. ssh-server3.example.local (IP address 192.168.0.203)

The first task is to define a frontend. Here is its configuration:

frontend fe_ssh
bind *:2222 ssl crt /etc/haproxy/certs/ssl.pem
mode tcp
log-format "%ci:%cp [%t] %ft %b/%s %Tw/%Tc/%Tt %B %ts %ac/%fc/%bc/%sc/%rc %sq/%bq dst:%[var(sess.dst)] "
tcp-request content set-var(sess.dst) ssl_fc_sni
use_backend %[ssl_fc_sni]

This section configures the following:

  • The bind line says the frontend listens on TCP port 2222 and expects TLS connections. We chose 2222 instead of the standard SSH port 22 because port 22 is likely already used to host SSH connections to the HAProxy server itself.

  • The log-format line sets a specific log format with additional information like the payload validity and the SNI field content, which we’ll use as the destination hint.

  • The tcp-request content set-var rules save the SNI field content in in-memory variables, which are logged by the log-format line.

  • The use_backend directive uses the ssl_fc_sni fetch to extract the SNI for choosing the correct backend.

Now let’s define our backends. Each one lists only a single server. Each backend name is the server name to expect in the SNI:

backend server1
mode tcp
server s1 192.168.0.201:22 check
backend server2
mode tcp
server s2 192.168.0.202:22 check
backend server3
mode tcp
server s2 192.168.0.203:22 check

From your clients, you can reach your SSH servers with these commands:

$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:2222 -servername server1" dummyName1
$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:2222 -servername server2" dummyName2
$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:2222 -servername server3" dummyName3

Note that the ssh command requires you to send the name of the server that you wish to connect to. We set it to dummyName because we’re specifying the server name using the ProxyCommand field instead. The servername switch lets you set the SNI field content. To make this command shorter, consider creating a bash alias or a script.

Route the Connections to a Specific Server

Although the previous method works well, the list of available servers is static and in some contexts, this can be annoying. Instead, we could say “Hey HAProxy, the server I want to reach is located here!”

This time we use only one backend and we set the destination address dynamically. Let’s see how to do it. Before we had set the backend dynamically by using the use_backend directive with the fetch method ssl_fc_sni. Replace the use_backend directive with a default_backend directive that points to a single backend:

frontend fe_ssh
bind *:2222 ssl crt /etc/haproxy/certs/ssl.pem
mode tcp
log-format "%ci:%cp [%t] %ft %b/%s %Tw/%Tc/%Tt %B %ts %ac/%fc/%bc/%sc/%rc %sq/%bq dst:%[var(sess.dst)] "
tcp-request content set-var(sess.dst) ssl_fc_sni
default_backend ssh-all

Now let’s see the backend definition:

backend ssh-all
mode tcp
tcp-request content set-dst var(sess.dst)
server ssh 0.0.0.0:22

The tcp-request content set-dst action allows you to dynamically set the destination server IP address. We use the SNI content saved earlier for this purpose. From your clients, you can reach your SSH servers with these commands:

$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:2222 -servername 192.168.0.201" dummyName1
$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:2222 -servername 192.168.0.202" dummyName2
$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:2222 -servername 192.168.0.203" dummyName3

Notice that the servername switch is no longer set to a server name, but to the destination IP address.

Setting the destination address dynamically is handy, but it allows your clients to reach any server behind your HAProxy server, including your HAProxy server itself. This kind of setup could drive your security officer a little bit crazy. So let’s make him happier!

Set an ACL that controls which address can be reached, like this:

backend ssh-all
mode tcp
acl allowed_destination var(sess.dst) -m ip 192.168.0.201
acl allowed_destination var(sess.dst) -m ip 192.168.0.202
acl allowed_destination var(sess.dst) -m ip 10.0.12.0/24
tcp-request content set-dst var(sess.dst)
tcp-request content accept if allowed_destination
tcp-request content reject
server ssh 0.0.0.0:22

Here we ask to HAProxy to accept the connection only when one of the following is true:

  • The destination IP address is 192.168.0.201

  • The destination IP address is 192.168.0.202

  • The destination IP address is in the IP network 10.0.12.0/24

Route the Connections to a Specific Server by Using Its Internal DNS Name

Setting the destination address dynamically is handy, although in some contexts it is not enough. If your server addresses change frequently, it would be easier to say to HAProxy “I want to access the server named ssh-server1.example.local. Sorry. I don’t know its IP address.”

For this purpose, we use a resolvers section with the tcp-request content do-resolve action. First, add a resolvers section like this:

resolvers internal
accepted_payload_size 8192
nameserver dns1 192.168.0.20:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold other 30s
hold refused 30s
hold nx 30s
hold timeout 30s
hold valid 10s
hold obsolete 30s

The nameserver line lets you define the DNS server to use for the name resolution. You can define multiple DNS servers. Simply add additional nameserver lines. You can also add the parse-resolv-conf line to this section to add nameservers listed in your /etc/resolv.conf file.

Below, we change the frontend definition a little bit. On the log-format line, record the sess.dstName variable, which contains the result of the name resolution. The tcp-request content do-resolve line takes the SNI content ssl_fc_sni as an input value, resolves the name to an IP address and stores it in the sess.dstIP variable:

frontend fe_ssh
bind *:2222 ssl crt /etc/haproxy/certs/ssl.pem
mode tcp
log-format "%ci:%cp [%t] %ft %b/%s %Tw/%Tc/%Tt %B %ts %ac/%fc/%bc/%sc/%rc %sq/%bq dstName:%[var(sess.dstName)] dstIP:%[var(sess.dstIP)] "
tcp-request content do-resolve(sess.dstIP,internal,ipv4) ssl_fc_sni
tcp-request content set-var(sess.dstName) ssl_fc_sni
default_backend ssh-all

The backend definition stays identical to the previous mode of operation. From your clients, you can reach your SSH servers with these commands:

$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:222 -servername ssh-server1.example.local" dummyName1
$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:222 -servername ssh-server2.example.local" dummyName2
$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:222 -servername ssh-server3.example.local" dummyName3

Same as before, let’s keep your security officer happy. This time you can restrict access based on the destination IP address and/or the internal DNS name. Here is the backend definition:

backend ssh-all
mode tcp
acl allowed_destination var(sess.dstIP) -m ip 192.168.0.201
acl allowed_destination var(sess.dstIP) -m ip 192.168.0.202
acl allowed_server_names var(sess.dstName) -i -- ssh-server3.example.local
tcp-request content set-dst var(sess.dstIP)
tcp-request content accept if allowed_server_names
tcp-request content accept if allowed_destinations
tcp-request content reject
server ssh 0.0.0.0:22

Here we use an allowlist filtering model, although you can use a blocklist model as well.

Restrict Clients to SSH Only

Currently, we are routing SSH communication through HAProxy to backend servers. It’s possible that a client could try to connect using the wrong protocol, such as trying to connect using a web browser. Although the backend servers will rebuff these connections, you could stop them at the HAProxy layer. Add the following lines your frontend section to check whether the connection is SSH and reject it otherwise:

frontend fe_ssh
# ...other settings...
tcp-request inspect-delay 5s
acl valid_payload req.payload(0,7) -m str "SSH-2.0"
tcp-request content reject if !valid_payload
tcp-request content accept if { req_ssl_hello_type 1 }

The line inspect-delay 5s instructs HAProxy to wait five seconds before closing the connection unless the string SSH-2.0 is seen in the payload. In this way, we allow only SSH connections.

Additional RBAC Security Layer

Now that we have SSH connection routing working, the time has come to filter these connections based on who is trying to get access to your servers. If you have security policies that demand restricting SSH access, maybe you want to be able to control who can get a login prompt. This goal can be achieved by using client certificate authentication.

Allow specific users to specific servers

Here we change the bind line. We add the ca-file option with the path to the CA certificate file and we add verify required, which means we accept incoming connections only if they present a valid client certificate:

frontend fe_ssh
bind *:222 ssl crt /etc/haproxy/certs/ssl.pem ca-file /etc/haproxy/certs/LabCA.pem verify required
mode tcp
log-format "%ci:%cp [%t] %ft %b/%s %Tw/%Tc/%Tt %B %ts %ac/%fc/%bc/%sc/%rc %sq/%bq dstName:%[var(sess.dstName)] dstIP:%[var(sess.dstIP)] user:%[ssl_c_s_dn(CN)]"
tcp-request content do-resolve(sess.dstIP,internal,ipv4) ssl_fc_sni
tcp-request content set-var(sess.dstName) ssl_fc_sni
default_backend ssh-all

Then change your backend definition by adding the authorized_users ACL:

backend ssh-all
mode tcp
tcp-request content set-dst var(sess.dst)
acl authorized_users ssl_c_s_dn(CN),concat(:,sess.dst) -i -f /etc/haproxy/user_authorization.acl
tcp-request content reject if !authorized_users
server ssh 0.0.0.0:22

The ACL authorized_users reads a file named users_authorization.acl and blocks incoming connections if the concatenation of the client certificate CN content and the destination IP address is not present in it. For example, if you want to allow user1 to access the server located at 192.168.0.201, fill the ACL file with this line:

user1:192.168.0.201

From your clients, you can reach your SSH servers with these commands:

$ ssh -o ProxyCommand="openssl s_client -quiet -connect 172.16.0.10:222 -servername 192.168.0.201 -cert mycert.crt -key mykey.key" dummyName1

Allow specific user groups to specific servers

Authorizing each user might be painful with a large user base. So, you could use the OU field of the certificate as a group field. In this case, you simply have to rewrite your filtering ACL like this:

acl authorized_users ssl_c_s_dn(OU),concat(:,sess.dst) -i -f /etc/haproxy/user_authorization.acl

And now, fill the ACL file with group names instead of user names. Obviously, you can mix these methods, like authorizing groups on server names with exceptions for specific users, denying a complete network or domain to some user groups, and many more. The sky’s the limit!

Conclusion

In this blog post, you learned that there are several ways to configure HAProxy for proxying SSH. All solutions rely on the ssh command’s ProxyCommand field, which allows you to set SNI content. By wrapping SSH in TLS, HAProxy can extract SNI and use it to select the appropriate backend server. You can also employ HAProxy’s ability to resolve DNS queries to connect to servers using their internal DNS names. If you are security conscious, limit access to servers by requiring client certificates. HAProxy can restrict access to specific users or groups, which ensures that your other servers remain under lock and key.

Want to stay up to date on similar topics? Subscribe to our blog! You can also follow us on Twitter and join the conversation on Slack.

Interested in advanced security and administrative features? HAProxy Enterprise is the world’s fastest and most widely used software load balancer. It powers modern application delivery at any scale and in any environment, providing the utmost performance, observability, and security. Organizations harness its cutting edge features and enterprise suite of add-ons, backed by authoritative expert support and professional services. Ready to learn more? Sign up for a free trial.

Subscribe to our blog. Get the latest release updates, tutorials, and deep-dives from HAProxy experts.