sslh 2.3.1
Overview
sslh is a protocol multiplexer. It accepts connections on one port, reads the first bytes each client sends, recognizes the protocol (SSH, TLS, HTTP, OpenVPN, XMPP and others) and connects the client to the matching service. As a result, SSH and HTTPS share port 443. You can reach SSH from networks that only allow outbound HTTPS, or publish several services on the one port a firewall leaves open.
This guide covers launching and verifying sslh 2.3.1 on Ubuntu 24.04 from SecureAnyCloud on Amazon EC2. The image is one version of the SecureAnyCloud Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace. You pick it as the software version when you subscribe and launch. The listing's guide hub describes the other VPN, proxy and remote access servers.
On this image sslh listens on port 443 and routes traffic as follows, out of the box:
Protocol arriving on 443 Routed to
SSH the instance's own OpenSSH server, 127.0.0.1:22
TLS (HTTPS) nginx on 127.0.0.1:8443, with a certificate generated on your instance's first boot
plain HTTP nginx on 127.0.0.1:8080 (an information page)
anything else nginx on 127.0.0.1:8080
sshd keeps listening on port 22 as usual, and nginx answers port 80 with a health check and a redirect to HTTPS. You can repoint any route at your own services.
SecureAnyCloud builds sslh 2.3.1 from the upstream tag v2.3.1 and verifies the tag's signature before the build. The source archive is pinned by its SHA-256. The build is reproducible (two builds give the same binary), and the compiler and every development package are removed again. The source, flags and binary checksum are recorded in /usr/local/share/doc/sslh/BUILD-PROVENANCE.txt.
What you get:
- sslh 2.3.1 (
sslh.service, thesslh-forkvariant) as/usr/local/sbin/sslh, configured in/etc/sslh/sslh.cfg - nginx 1.24 (Ubuntu package) as the local HTTPS and HTTP backends and the port 80 health check
- HTTPS through port 443 from first boot (TLS 1.2 and 1.3), with a self-signed certificate created on your instance
- Helper commands:
sslh-selftest(proves all three protocols and checks a configuration file) andsslh-set-host(sets your own DNS names and issues a new certificate) - No metadata access. Neither sslh nor nginx can reach the instance metadata service.
Default user: ubuntu. Architecture: x86_64. Root volume: 8 GiB gp3.
Security posture
- No default credential and no shared secret. sslh has no login of its own. SSH over 443 is the same key-only OpenSSH login as on port 22 (no passwords, no root login). The only secret on the instance is the TLS private key of the HTTPS backend, which first boot creates for this instance only. No key or certificate is baked into the image.
- Fixed, local targets only. Every route in
sslh.cfgis a fixed address on the instance. sslh never connects anywhere a client chooses: no host is taken from the TLS server name, ALPN or the request, and there is noresolve_on_forward. - Only your names are served. nginx answers only the names that first boot read from the instance metadata (public and private DNS names and IPv4 addresses). Any other
Hostgets no answer at all and the connection is closed, on HTTPS, on HTTP over 443 and on port 80. Port 80 redirects to the configured name, never to the name in the request. - Client addresses. sslh logs the real client address of every connection. The backends see the connection coming from sslh, so sshd and nginx log
127.0.0.1for everything that arrives on port 443 (see Step 7 for what this means for fail2ban and similar tools). Transparent mode, which would pass the client address through, is not enabled. - No SSRF to instance credentials.
sslh.serviceandnginx.servicecarry anIPAddressDenyrule for the metadata addresses (169.254.169.254, fd00:ec2::254, 168.63.129.16). Nothing broader is blocked, so you can point routes at other hosts in your VPC. The AMI is registered IMDSv2 only. - Least privilege.
sslhruns as the unprivileged sslh user, which owns no file, with one capability,CAP_NET_BIND_SERVICE(to bind 443). Each per-connection process drops even that. sslh confines its own file access with the kernel's Landlock. systemd adds no new privileges, a read-only file system, a private/tmp, a system call filter and no access to home directories. - Modern TLS. nginx accepts TLS 1.2 and 1.3 only and does not show its version. The certificate is an end-entity certificate, so it cannot sign other certificates.
- Fails closed. sslh and nginx start only after first boot has written this instance's key, certificate and names and a ready marker. If first boot cannot finish, both stay stopped and first boot tries again on the next boot.
Prerequisites
- An AWS account and permission to launch EC2 instances
- An EC2 key pair in the Region you launch in
- A VPC subnet that gives instances a public IP address and DNS name (the default VPC does), or your own DNS name for the instance
Recommended instance type: m5.large. sslh is lightweight, so size the instance for the services behind it.
Connecting to your instance
Version Operating system SSH user
sslh 2.3.1 on Ubuntu 24.04 Ubuntu 24.04 LTS ubuntu
Launch the instance
Option A: AWS Marketplace console
- Open the SecureAnyCloud Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace. Choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- When the subscription is active, choose Launch your software.
- Choose One-click launch from AWS Marketplace (or Launch from EC2 Console for the full launch wizard). In Version, choose sslh 2.3.1 on Ubuntu 24.04, then choose your Region.
- Pick an instance type (
m5.largeis recommended) and your key pair. - Review the security group. The listing's default group allows SSH (22), HTTP (80) and HTTPS (443). Port 443 carries SSH too, so restrict 22 and 443 to the addresses that should reach them.
- Choose your VPC and a public subnet (or attach an Elastic IP later). Leave the metadata defaults, because the image already requires IMDSv2. Then choose Launch.
Option B: AWS CLI
Run these commands on your own workstation. They create a security group that allows SSH on port 22 and the multiplexed port 443 from your management range, and port 80 from everyone. They then launch the instance. Replace the AMI ID with the one shown for your Region on the Marketplace launch page. Replace the VPC, subnet, key pair and CIDR range with your own:
SG=$(aws ec2 create-security-group --group-name sslh \
--description "sslh multiplexer" --vpc-id vpc-0123456789abcdef0 \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type m5.large \
--key-name my-key-pair \
--security-group-ids "$SG" \
--subnet-id subnet-0123456789abcdef0 \
--associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=sslh}]'You do not need --metadata-options, because the AMI already requires IMDSv2.
Connect and read the connection details
Connect as ubuntu with the key pair you chose. Use port 22 as usual, or connect through sslh on port 443:
ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>
ssh -i ~/.ssh/my-key-pair.pem -p 443 ubuntu@<public-ip>First boot finishes within a minute of launch. It creates the TLS key and certificate for the instance's names and pins those names in nginx. It then starts nginx and sslh and proves all three protocols on 443. The details are in /root/sslh-info.txt, which only root can read. Nothing in the file is a secret:
sudo cat /root/sslh-info.txt# sslh 2.3.1 on this instance - written by sslh-set-host. Nothing here is a secret.
# The TLS key in /etc/nginx/tls was generated on this instance; none was baked into the image. SSLH_HOST=ec2-18-206-159-119.compute-1.amazonaws.com SSLH_URL=https://ec2-18-206-159-119.compute-1.amazonaws.com/ SSLH_PORT=443 SSLH_NAMES=ec2-18-206-159-119.compute-1.amazonaws.com 18.206.159.119 172.31.89.149 ip-172-31-89-149.ec2.internal localhost 127.0.0.1 TLS_CERT=/etc/nginx/tls/sslh.crt TLS_SHA256_FINGERPRINT=AA:86:E5:C6:80:24:77:F0:56:52:04:8F:39:EB:92:85:77:89:7F:DC:08:A5:FB:95:37:7E:73:B5:33:1F:31:DD
#
# One port, three protocols (open TCP 443 in the security group to the clients that need it):
# SSH over 443:
ssh -p 443 ubuntu@ec2-18-206-159-119.compute-1.amazonaws.com
# HTTPS over 443: https://ec2-18-206-159-119.compute-1.amazonaws.com/ (compare the certificate fingerprint above)
# HTTP over 443: http://ec2-18-206-159-119.compute-1.amazonaws.com:443/
# Configuration: /etc/sslh/sslh.cfg, then:
sudo systemctl restart sslh
# New DNS name / IP:
sudo sslh-set-host <dns-name> ... (no argument: re-read instance metadata)
# Self-test:
sudo sslh-selftest
# Guide: https://secureanycloud.com/stacks/sslh-2-3-1/SSLH_HOST is the name that HTTP on port 80 redirects to, and it is the certificate's subject. TLS_SHA256_FINGERPRINT is the fingerprint of this instance's certificate. Compare it with the one your browser or client shows before you accept it.
The certificate is self-signed. Do not add it to a trust store. Replace it with a CA-issued certificate instead (Step 8).
Confirm sslh is running
Confirm that sslh, nginx and first boot are active, and check the features sslh was built with. sslh holds port 443 on every interface. nginx holds port 80 on every interface and its two backends on 127.0.0.1 only. Nothing listens on UDP:
systemctl is-active sslh.service nginx.service sslh-firstboot.service /usr/local/sbin/sslh -V 2>&1 | head -5 sudo ss -Htlnp '( sport = :22 or sport = :80 or sport = :443 or sport = :8080 or sport = :8443 )' | awk '{print $4}' | sort -u sudo ss -Hulnp | grep -c -E 'sslh|nginx' || trueactive active active sslh-fork 2.3.1 ENABLE_REGEX LIBCONFIG HAVE_LANDLOCK HAVE_LIBCAP 0.0.0.0:22 0.0.0.0:443 0.0.0.0:80 127.0.0.1:8080 127.0.0.1:8443 [::]:22 0Run the self-test
The shipped self-test checks the whole path from the instance itself. It confirms five things:
- HTTPS on 443 reaches the TLS backend, with this instance's certificate verified for the instance's name.
- SSH on the same port reaches OpenSSH.
- Plain HTTP on the same port reaches the HTTP backend.
- A host name the instance does not serve gets no answer.
- Port 80 answers the health check and redirects to the configured name.
Run it:
sudo sslh-selftestsslh-selftest: 443 -> SSH (SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.20), HTTPS (verified certificate for ec2-18-206-159-119.compute-1.amazonaws.com), HTTP; unknown Host refused; :80 healthz + redirect sslh-selftest: certificate SHA-256 AA:86:E5:C6:80:24:77:F0:56:52:04:8F:39:EB:92:85:77:89:7F:DC:08:A5:FB:95:37:7E:73:B5:33:1F:31:DD SELFTEST_OKOne port, three protocols
From your workstation, with port 443 open to it in the security group, SSH, HTTPS and HTTP all go to port 443. The SSH login is the ordinary key-only login. The HTTPS certificate fingerprint is the one in /root/sslh-info.txt. Port 80 redirects to the HTTPS name:
ssh -i ~/.ssh/my-key-pair.pem -p 443 ubuntu@ec2-18-206-159-119.compute-1.amazonaws.com ubuntu@ip-172-31-89-149:~$ echo | openssl s_client -connect ec2-18-206-159-119.compute-1.amazonaws.com:443 \
-servername ec2-18-206-159-119.compute-1.amazonaws.com 2>/dev/null | openssl x509 -noout -fingerprint -sha256 sha256 Fingerprint=AA:86:E5:C6:80:24:77:F0:56:52:04:8F:39:EB:92:85:77:89:7F:DC:08:A5:FB:95:37:7E:73:B5:33:1F:31:DD curl -s http://ec2-18-206-159-119.compute-1.amazonaws.com:443/ | grep -o '<h1>[^<]*</h1>' <h1>sac sslh multiplexer :: HTTP backend</h1> curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://18.206.159.119/ 301 https://ec2-18-206-159-119.compute-1.amazonaws.com/To use port 443 for SSH without typing it each time, add a host entry to ~/.ssh/config on your workstation (Port 443 under Host <your-name>).
On the instance, you can check the same routes: HTTPS through 443 with the certificate verified, plain HTTP and an SSH greeting on the same port, the port 80 redirect and health check, and no answer (000) for a name the instance does not serve:
H=$(cat /etc/sac/sslh-host);
PRIV=$(hostname -I | awk '{print $1}')
sudo curl -s --cacert /etc/nginx/tls/sslh.crt --connect-to "$H:443:127.0.0.1:443" "https://$H/" | grep -o '<h1>[^<]*</h1>' curl -s -H "Host: $H" http://127.0.0.1:443/ | grep -o '<h1>[^<]*</h1>' timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/443; printf "SSH-2.0-test\r\n" >&3; head -1 <&3' | tr -d '\r' curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -H "Host: $PRIV" http://127.0.0.1/docs curl -s http://127.0.0.1/healthz curl -sk -o /dev/null -w '%{http_code}\n' -H 'Host: unknown.example' https://127.0.0.1/ || true<h1>sac sslh multiplexer :: TLS backend</h1> <h1>sac sslh multiplexer :: HTTP backend</h1> SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.20 301 https://ec2-18-206-159-119.compute-1.amazonaws.com/docs ok 000The port 80 health check (/healthz) answers ok for any host name, with no version or host detail, so a load balancer can probe the instance by its address.
Point sslh at your own services
/etc/sslh/sslh.cfg has one listen entry (0.0.0.0:443) and a protocols list that sslh tries in order. Each entry names a protocol probe and the address to connect a matching client to. To publish your own service on 443, change the address of the matching entry or add an entry above anyprot.
This example routes to an OpenVPN server on another instance in your VPC, and to a second TLS service chosen by the name the client asks for (SNI):
{ name: "openvpn"; host: "10.0.1.20"; port: "1194"; }, { name: "tls"; host: "10.0.1.30"; port: "443"; sni_hostnames: [ "git.example.com" ]; }, { name: "tls"; host: "127.0.0.1"; port: "8443"; },Entries with sni_hostnames or alpn_protocols go above the plain tls entry. The probe list (ssh, tls, http, openvpn, wireguard, tinc, xmpp, adb, socks5, syslog, teamspeak, msrdp, regex and anyprot) and every option are described in man sslh and /usr/local/share/doc/sslh/config.md. The file /usr/local/share/doc/sslh/example.cfg is upstream's annotated example.
Check a changed file before you apply it. sslh-selftest --check-config starts a temporary sslh with your file on a test port, as the same user and with the same restrictions as the service. It reports whether the file parses and starts:
sudo sslh-selftest --check-config /etc/sslh/sslh.cfgCONFIG_OK /etc/sslh/sslh.cfgThen apply the change with sudo systemctl restart sslh. A restart replaces only the listener. Established connections stay up, including the SSH session over 443 that you restart it from.
Client addresses and what the backends log
sslh writes one line per connection to the systemd journal, with the real client address, the protocol it recognised and the backend it chose:
sudo journalctl -u sslh -n 20 --no-pager -o cat | grep 'connection from' | tail -3ssh:connection from 203.0.113.25:57979 to 172.31.89.149:443 forwarded from 127.0.0.1:34424 to 127.0.0.1:22 tls:connection from 203.0.113.25:57981 to 172.31.89.149:443 forwarded from 127.0.0.1:55880 to 127.0.0.1:8443 http:connection from 203.0.113.25:57985 to 172.31.89.149:443 forwarded from 127.0.0.1:58994 to 127.0.0.1:8080The backends only see sslh. An SSH login over 443 shows 127.0.0.1 in /var/log/auth.log and who, and nginx logs 127.0.0.1 for requests that arrived on 443. This matters for anything that acts on the source address:
- fail2ban and similar tools that watch sshd or nginx logs would ban
127.0.0.1, which is every client on 443. Point them at sslh's journal lines (the real address) instead. Alternatively, restrict 443 to trusted ranges in the security group and keep public SSH on port 22. - sshd's per-source limits (
MaxStartupsand similar) count all 443 clients as one source. - Connections on port 22 and port 80 still reach sshd and nginx directly with the real address.
sslh's transparent mode can pass the client address through. It needs extra capabilities (CAP_NET_RAW/CAP_NET_ADMIN) and policy routing on the instance. See "Transparent proxy support" in config.md before you enable it.
Use your own domain name and certificate
Point a DNS record (for example gateway.example.com) at the instance's public or Elastic IP address, then pin that name. sslh-set-host writes the names to /etc/sac/sslh-hostnames and issues a new self-signed certificate for them, with a new key every time. It then rewrites the nginx names and reloads nginx:
sudo sslh-set-host gateway.example.com sslh-set-host: nginx now answers for: gateway.example.com ec2-18-206-159-119.compute-1.amazonaws.com 18.206.159.119 172.31.89.149 ip-172-31-89-149.ec2.internal localhost 127.0.0.1 sslh-set-host: certificate SHA-256 fingerprint ...The first name is the redirect target and the certificate subject. Run sudo sslh-set-host with no argument after you attach an Elastic IP, or after a stop and start that changed the public address. It re-reads the instance metadata. Run sudo sslh-set-host --reset to go back to the instance's own names only.
To use a certificate from a public CA, copy the key and full chain to /etc/nginx/tls/sslh.key (mode 0600, owner root) and /etc/nginx/tls/sslh.crt, then run sudo nginx -t && sudo systemctl reload nginx. To publish your own HTTPS service instead of the SecureAnyCloud page, point the tls entry in sslh.cfg at it (Step 6).
Metadata access is blocked and the targets are fixed
systemd denies the metadata addresses to every sslh and nginx process, and the routes in sslh.cfg are fixed addresses. Verify both:
systemctl show -p IPAddressDeny sslh.service nginx.service grep -v '^[[:space:]]*#' /etc/sslh/sslh.cfg | grep -o 'host: "[^"]*"' | sort | uniq -cIPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32 IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32 1 host: "0.0.0.0" 4 host: "127.0.0.1"SecureAnyCloud's build checks probe the metadata service from inside each service's own control group and get no answer. The checks then remove the rule for one probe, and the metadata service answers, which proves that the rule is what blocks it. The rule is restored afterwards. A TLS client that asks for the server name 169.254.169.254 still lands on the local HTTPS backend.
How the fail-closed start works, and what is shipped
sslh-firstboot.serviceruns once. It reads the instance's names from IMDSv2, creates the TLS key and certificate, renders the nginx site and pages, and writes a ready marker. It then starts nginx and sslh, runssslh-selftestand writes/root/sslh-info.txt. If any step fails, sslh and nginx stay stopped and first boot runs again on the next boot.sslh.serviceandnginx.servicehaveConditionPathExists=/var/lib/sac/data/sslh-ready.sslhruns as sslh withCAP_NET_BIND_SERVICEonly and upstream'sKillMode=process:
systemctl show -p User -p AmbientCapabilities -p CapabilityBoundingSet -p KillMode sslh.serviceCapabilityBoundingSet=cap_net_bind_service AmbientCapabilities=cap_net_bind_service User=sslh KillMode=process/usr/local/share/doc/sslh/holds the license, the source offer, the unmodified source archive, the build provenance and upstream's documentation:
ls /usr/local/share/doc/sslh grep -E '^(source sha256|reproducible|binary sha256)' /usr/local/share/doc/sslh/BUILD-PROVENANCE.txtBUILD-PROVENANCE.txt ChangeLog LICENSE README.md SOURCE config.md example.cfg sslh-2.3.1.tar.gz source sha256 416b401cf8f0a43b575f7a1571bafd5375401d741833b27918aaecb200445f92 reproducible two builds from a fresh extract at the same path produced the same binary binary sha256 a2ceca67c69b9deb8ef172a3306e7f88c883fc50f7abd48ac14d8e50ef77966aLogs and updates
sslh logs to the systemd journal (sudo journalctl -u sslh). The journal holds its start-up configuration, one line per connection, and probe or connection errors. nginx logs to /var/log/nginx/. First boot logs to /var/log/sac/sac-firstboot.log.
Ubuntu security updates, including nginx and the libraries sslh uses, install automatically (unattended-upgrades). sslh itself is not an apt package here, because Ubuntu's own sslh package is an older 1.22 release. SecureAnyCloud publishes a new version of this listing when sslh releases one. To move to it, launch the new version and copy your /etc/sslh/sslh.cfg and certificates across.
Security recommendations
- Restrict ports 22 and 443 to your management and client ranges. Port 443 carries SSH as well as HTTPS.
- Keep SSH key-only (the default). In production, use your own DNS name and a CA-issued certificate.
- Remember that sshd and nginx see
127.0.0.1for 443 traffic. Base source-address controls on sslh's journal or the security group (Step 7). - Point routes at private addresses in your VPC and let only the sslh instance reach them (security group to security group).
- Run
sudo sslh-selftest --check-configbefore every restart, andsudo sslh-selftestafter changes.
Support and licensing
sslh is free software by Yves Rutschle, licensed under the GNU General Public License, version 2 or (at your option) any later version (/usr/local/share/doc/sslh/LICENSE). The bundled argtable3 parser is BSD-3-Clause. This image runs sslh 2.3.1 unmodified. Its complete corresponding source is shipped on the image (/usr/local/share/doc/sslh/sslh-2.3.1.tar.gz, see SOURCE) and is the upstream tag at github.com/yrutschle/sslh/tree/v2.3.1.
SecureAnyCloud provides the hardened image, its first-boot configuration and these guides. For help with this image, contact SecureAnyCloud support through the AWS Marketplace listing. sslh's own documentation is at github.com/yrutschle/sslh/tree/v2.3.1/doc.
Deploy on AWS
Launch sslh 2.3.1 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com