sslh 2.3.1 on AWS User Guide
Overview
This guide covers launching and verifying sslh 2.3.1 on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg 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 other VPN, proxy and remote access servers in the listing are described on the listing's guide hub.
sslh is a protocol multiplexer. It accepts connections on one port, reads the first bytes each client sends, recognises the protocol (SSH, TLS, HTTP, OpenVPN, XMPP and others) and connects the client to the matching service. The practical result is that SSH and HTTPS share port 443, so you can reach SSH from networks that only allow outbound HTTPS, or publish several services on the one port a firewall leaves open.
On this image sslh listens on port 443 and routes, 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.
cloudimg builds sslh 2.3.1 from the upstream tag v2.3.1, whose signature is verified before the build, and pins the source archive by its SHA-256. The build is reproducible (two builds give the same binary), the compiler and every development package are removed again, and the source, flags and binary checksum are recorded in /usr/local/share/doc/sslh/BUILD-PROVENANCE.txt.
What is on the image:
- 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 443 at first boot (TLS 1.2 and 1.3) with a self-signed certificate generated on your instance
- Helpers:
sslh-selftest(proves all three protocols, and checks a configuration file), andsslh-set-host(your own DNS names and 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 generates 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: there is no host taken from the TLS server name, ALPN or the request, and noresolve_on_forward. - Only your names are served. nginx answers only the names first boot read from the instance metadata (public and private DNS names and IPv4 addresses). Any other
Hostgets no answer at all (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. sslh runs as the unprivileged
sslhuser, 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, and 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 (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 light; 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 |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once 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.largerecommended) and your key pair. - 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 (the image already requires IMDSv2), then choose Launch.
Option B: AWS CLI
Run this on your own workstation. It creates a security group that allows SSH on 22 and the multiplexed port 443 from your management range and port 80 from everyone, then launches the instance. Replace the AMI ID with the one shown for your Region on the Marketplace launch page, and 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: the AMI already makes IMDSv2 required.
Step 2: Connect and read the connection details
Connect as ubuntu with the key pair you chose, on port 22 as usual or, 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 generates the TLS key and certificate for the instance's names, pins those names in nginx, starts nginx and then sslh, and proves all three protocols on 443. The details are in /root/sslh-info.txt, readable only by root. Nothing in it 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://www.cloudimg.co.uk/guides/sslh-2-3-1-on-ubuntu-24-04-aws/
SSLH_HOST is the name HTTP on port 80 redirects to and 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).
Step 3: Confirm sslh is running
sslh, nginx and first boot are active, and sslh reports the features it was built with. sslh holds port 443 on every interface, nginx port 80 on every interface and its two backends on 127.0.0.1 only, and 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' || true
active
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
0
Step 4: Run the self-test
The shipped self-test checks the whole path from the instance itself: 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, and port 80 answers the health check and redirects to the configured name:
sudo sslh-selftest
sslh-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_OK
Step 5: One port, three protocols
From your workstation (with 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>cloudimg 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 keep using port 443 for SSH without typing it, add a host entry to ~/.ssh/config on your workstation (Port 443 under Host <your-name>).
On the instance, 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/cloudimg/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>cloudimg sslh multiplexer :: TLS backend</h1>
<h1>cloudimg 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
000
The 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.
Step 6: 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. For example, an OpenVPN server on another instance in your VPC, and 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; /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 throwaway sslh with your file on a test port, as the same user and with the same restrictions as the service, and reports whether it parses and starts:
sudo sslh-selftest --check-config /etc/sslh/sslh.cfg
CONFIG_OK /etc/sslh/sslh.cfg
Then apply it with sudo systemctl restart sslh. A restart replaces only the listener: established connections, including the SSH session over 443 you restart it from, stay up.
Step 7: 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 -3
ssh: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:8080
The 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 watching 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, or leave 443 restricted 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, but it needs extra capabilities (CAP_NET_RAW/CAP_NET_ADMIN) and policy routing on the instance; see "Transparent proxy support" in config.md before enabling it.
Step 8: 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/cloudimg/sslh-hostnames, issues a new self-signed certificate for them (a new key every time), 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 attaching an Elastic IP or after a stop and start that changed the public address (it re-reads the instance metadata), and 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 cloudimg page, point the tls entry in sslh.cfg at it (Step 6).
Step 9: Metadata access is blocked and the targets are fixed
The metadata addresses are denied to every sslh and nginx process by systemd, and the routes in sslh.cfg are fixed addresses:
systemctl show -p IPAddressDeny sslh.service nginx.service
grep -v '^[[:space:]]*#' /etc/sslh/sslh.cfg | grep -o 'host: "[^"]*"' | sort | uniq -c
IPAddressDeny=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"
cloudimg's build checks probe the metadata service from inside each service's own control group (no answer), and prove the rule is what blocks it by removing it for one probe (the metadata service then answers) before restoring it. A TLS client that asks for the server name 169.254.169.254 still lands on the local HTTPS backend.
Step 10: How the fail-closed start works, and what is shipped
sslh-firstboot.serviceruns once: it reads the instance's names from IMDSv2, mints the TLS key and certificate, renders the nginx site and pages, writes a ready marker, starts nginx and then 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/cloudimg/sslh-ready.- sslh runs as
sslhwithCAP_NET_BIND_SERVICEonly and upstream'sKillMode=process:
systemctl show -p User -p AmbientCapabilities -p CapabilityBoundingSet -p KillMode sslh.service
CapabilityBoundingSet=cap_net_bind_service
AmbientCapabilities=cap_net_bind_service
User=sslh
KillMode=process
/usr/local/share/doc/sslh/holds the licence, 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.txt
BUILD-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 a2ceca67c69b9deb8ef172a3306e7f88c883fc50f7abd48ac14d8e50ef77966a
Step 11: Logs and updates
sslh logs to the systemd journal (sudo journalctl -u sslh): 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/cloudimg-firstboot.log.
Ubuntu security updates, including nginx and the libraries sslh uses, install automatically (unattended-upgrades). sslh itself is not an apt package here (Ubuntu's own sslh package is an older 1.22 release); cloudimg 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.
Step 12: Security recommendations
- Restrict 22 and 443 to your management and client ranges; 443 carries SSH as well as HTTPS.
- Keep SSH key-only (the default) and use your own DNS name and a CA-issued certificate in production.
- 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.
cloudimg provides the hardened image, its first-boot configuration and these guides. For help with this image, contact cloudimg support through the AWS Marketplace listing. sslh's own documentation is at github.com/yrutschle/sslh/tree/v2.3.1/doc.