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:
ssh-server1.example.local (IP address 192.168.0.201)
ssh-server2.example.local (IP address 192.168.0.202)
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 thefrontend
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 thelog-format
line.The
use_backend
directive uses thessl_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.