A TLS termination proxy for services that do not speak TLS. It accepts the TLS connection, forwards the decrypted bytes to your server over plain TCP, and never looks at them, so it works for any protocol that runs over TCP rather than only HTTP.
About 2,950 lines of C99 on an epoll event loop with an nginx-style master/worker model. The only runtime dependencies are OpenSSL and libcyaml.
docker run --name tlsproxy \
--volume ${PWD}/example-docker/config/:/etc/tlsproxy:ro \
--publish 443:8443 \
--cap-drop ALL --security-opt no-new-privileges:true \
novotimo/tlsproxy:latest- Any TCP protocol. No HTTP parser anywhere in the program. MQTT, a game
server, an RPC service, a database,
telnetsif you must. - No capabilities. The image runs as UID 2001 and is never root, so there is
no privilege to drop and no window in which it holds one. It listens on 8443
inside the container and lets Docker publish 443, so it does not need
NET_BIND_SERVICEeither. The shipped compose file iscap_drop: ALLwith nothing added back, plusread_only,no-new-privilegesand explicit pid, memory and descriptor limits. - Reload without dropping connections.
SIGHUPre-reads the config file. One that does not parse, does not pass validation, or names a certificate that will not load leaves the running configuration untouched. - Logs a log analyser can read. Every line is logfmt, every event has a type, and anything a client can influence is escaped, so nobody injects a record by putting a newline in a hostname.
- Several listeners in one process, each with its own backend, certificate
chain and timeouts. Point two at the same address and port with different
backends and
SO_REUSEPORTspreads connections across them by 4-tuple hash, which is rough load balancing for free. It is random rather than least-connections, so it will not replace haproxy. - IPv4 and IPv6, binding dual-stack with
IPV6_V6ONLYoff.
Against nginx and haproxy on the same host, same OpenSSL, same certificate,
groups and ciphersuite, logging off in all three, eight workers each on four
physical cores. These are the columns where tlsproxy comes out ahead; the full
set, including where it does not, is in
benchmark/BASELINE.md.
| tlsproxy | nginx | haproxy | |
|---|---|---|---|
| Memory, 20,000 held connections | 906 MB | 1,443 MB | 1,112 MB |
| Memory, 1,000 held connections | 78 MB | 307 MB | 113 MB |
| Message p99, 20,000 held connections at one message a second | 2.7 ms | 831 ms | 657 ms |
| Handshakes a second, 1,024 concurrent | 10,313 | 10,154 | 9,481 |
| Throughput, 64 KB payloads | 2,221 MB/s | 2,162 MB/s | 1,791 MB/s |
| Failed connections, all sweeps | 0 | 0.19% at 4,096 concurrent | 0.005% throughout |
nginx is a few percent ahead on handshake rate below 1,024 concurrent and on 1 MB payloads, and holds a lower p99 once the offered handshake rate passes 10,000 a second.
Read the caveats before quoting any of it: the per-subject tuning sweeps have
not been run, so nginx and haproxy are on settings I chose rather than their
own best, and only the ECDSA certificate has been measured. Everything needed to
re-run it is in benchmark/, including the harness, the
committed CSVs and a profiler.
The full demo brings up a proxy on 443 in front of an nginx backend over a Docker network, so you can point a browser at https://localhost. The certificate is a self-signed test one, so the browser will object.
cd example-docker
docker compose upFor your own backend, take the config and certificates from
example-docker/config/, point target-ip and target-port at your service,
leave listen-port at 8443, and publish whatever you like on the outside with
the docker run at the top of this page.
One YAML file, /etc/tlsproxy/tlsproxy.yml by default or named on the command
line. example/default.yml is commented throughout and
doubles as the reference: worker count, log file and level, then a list of
listeners, each with a backend, a listen address, a certificate chain, a connect
timeout, TCP keepalive and shutdown deadlines. The keepalive, shutdown and
connect timeout keys are optional and an absent one takes the default named
beside it. Every timeout is in seconds.
nworkers wants to be the number of hardware threads you are giving it. Half
that leaves half the machine idle, and measurably so.
SIGHUP reloads the configuration. The master re-reads and re-validates the
file, and only then tells the workers to cycle, so a file with a bad port, an
nworkers of zero, a tcp-keep* above what the kernel accepts, or a
cert-chain given together with cacerts is rejected with the name of the
listener it came from and the running configuration carries on. The same checks
run at startup, where they go to stderr instead of the log.
The private key's file mode is checked as a warning rather than a requirement,
since a mode that is wrong in one deployment is deliberate in another. A
servkey that group or other can read, write or execute draws a line at startup
and on every reload.
Needs OpenSSL 3.x and libcyaml 1.4.2, which does not use CMake, so install it their way first. CMocka and Doxygen are optional, for the tests and the docs.
cmake -B build
cmake --build buildIf cmake reports that libcyaml was not found, it has installed itself under
/usr/local and pkg-config is not looking there:
export PKG_CONFIG_PATH=/usr/local/lib/pkgconfigTwo vendored files in external/ need nothing from you: nginx's red-black tree
and Verstable for the closed set.
- Configurable TLS: groups including the post-quantum hybrids, ciphersuites, version range, and the negotiated group logged (#44).
- Handshake, idle and connection-lifetime timeouts, which is what bounds a client that connects and goes quiet (#54).
- Descriptor exhaustion: raise
RLIMIT_NOFILE, surviveEMFILE, addmax-connections, then measure the real ceiling (#45). - Client certificate verification (#17).
- A security model section in
doc/ARCHITECTURE.md: the trust boundary, what enforces each part of it, and what is deliberately out of scope (#55). - A session cache in the shared memory that is already there (#11).
- Bulk throughput above a 16 KB payload, which is where it stops scaling (#71).
- Re-resolve backend addresses, so a backend that moves is noticed without cycling workers (#9).
- Privilege separation for the private key, so a worker compromise is not a key disclosure.
doc/ARCHITECTURE.md, the design and why it is that shapeTESTING.md, running the suite and how it is writtenbenchmark/README.md, the benchmark methodCHANGELOG.md
MIT. See LICENSE.