Netfilter and policy-routing setup for sslh in transparent mode, as a Komodo stack instead of a systemd unit.
sslh's transparent mode makes backend connections carry the original client IP. The
backends' replies therefore leave with a source address the kernel would route out the
wire, so they have to be marked and sent back to sslh's transparent sockets instead. That
marking is what this stack does, along with setting route_localnet — the backends
reply from 127.0.0.1, and the kernel drops 127/8 traffic routed off loopback without it.
Backends are host services (sshd, httpd), so everything runs in the host network namespace. There is no MASQUERADE rule here — that belongs to the setup where sslh and its backends live in separate namespaces, which this is not.
| Path | What it is |
|---|---|
sslh-tproxy.sh |
the rule script: apply, check, flush, run |
Dockerfile |
alpine + iptables, ip6tables, iproute2 |
compose.yaml |
the sslh-tproxy sidecar, sslh, and nginx-ui |
tests/run.sh |
runs the script against fake netfilter tools |
./tests/run.sh
No root, no netfilter, no container. tests/fake-bin stands in for iptables, ip6tables,
ip and sysctl, recording what the script asks for so repeated applies can be compared. Run this
before pushing; it is what keeps apply idempotent.
- Create the Komodo stack:
- source: this repo + branch, webhook enabled for redeploy on push
run_directory: the directory holdingcompose.yaml— also the build contextextra_args:--build, so a push that touches the Dockerfile or script rebuilds. Baredocker compose uponly builds when the image is missing.- environment:
TZ;SSLH_PORTSonly to override the default
sslh and sslh-tproxy must be in the same stack — depends_on does not cross compose
projects.
Writing route_localnet needs a writable /proc/sys. Docker mounts it read-only, and
compose's sysctls: key is rejected under network_mode: host. The alternatives were a
host-side /etc/sysctl.d file (one manual step per host) or privileged: true.
This stack takes privileged: true, so that nothing about a host lives outside the
repo and a reboot needs no help. The cost is real and accepted: this container can act as
root on the host. security_opt: systempaths=unconfined looks narrower but isn't — with a
writable /proc/sys, root can rewrite host-global settings such as kernel.core_pattern,
which is a known container escape. Keep the image minimal and its base pinned.
| Variable | Default |
|---|---|
SSLH_PORTS |
22 8443 |
SSLH_MARK |
0x1 |
SSLH_TABLE |
100 |
SSLH_IPV6 |
1 |
SSLH_RECONCILE_INTERVAL |
60 |
docker compose ps—sslh-tproxyhealthy,sslhrunning.sudo iptables -t mangle -S SSLHand-S OUTPUT; repeat withip6tables. One OUTPUT jump per port, no duplicates.sudo iptables -t nat -S POSTROUTING—-o lo -j SSLHis the first rule, above Docker'sMASQUERADErules.ip rule show | grep fwmarkandip route show table 100; repeat withip -6.sysctl net.ipv4.conf.all.route_localnet net.ipv4.conf.default.route_localnet— both= 1.- Redeploy three times, re-check 2 and 3. Counts must be identical.
sudo iptables -t mangle -F OUTPUT, wait 60s, re-check 2. The reconcile loop restores them.- From an outside client, connect through the sslh port, then confirm on the host that
sshd sees the real client address rather than 127.0.0.1:
sudo ss -tnp | grep ':22'and/var/log/auth.log. A clean ruleset is not proof that transparency works. sudo reboot, then re-run 1 through 3 and 6.- Only after all of the above: disable the old systemd unit, so the two cannot fight.
A container reaching this host's public address on the sslh port is delivered locally, so sslh sees the container's own address and connects to the backend as that address. Two Docker protections get in the way, and both are needed for this to work:
-
Docker masquerades traffic from a bridge network leaving through any other interface,
loincluded. The script'snatSSLHchain exempts backend connections overlo, and the reconcile loop keeps its jump first inPOSTROUTING. -
Docker drops traffic to a container address arriving on any interface but that network's bridge, and the backend's reply reaches sslh through
lo. Each network whose containers use this host's public names has to trustlo, in its own compose file:networks: default: driver_opts: com.docker.network.bridge.trusted_host_interfaces: "lo"
Driver options only apply when the network is created: destroy and redeploy the stack.
Without 2, connections from that network's containers time out; nothing else is affected.
The OUTPUT jumps only match replies sourced from loopback (-s 127.0.0.1, -s ::1),
which is what sslh's backends reply from. A direct connection to port 22 on the public
interface replies from the public address, is never marked, and works as normal — so
direct ssh on 22 stays available as a way in even when the sslh container is down.
Older versions of this script marked every reply on these ports and broke direct ssh;
apply removes those unscoped jumps on startup, so upgrading needs no manual cleanup.
Rules stay resident after the stack comes down; no teardown is wired into the container lifecycle, because a stop-time trap only fires on a graceful stop and is not a guarantee worth building around. Manual teardown:
docker compose run --rm sslh-tproxy flush