Route incoming TLS connections to Docker containers based on the SNI hostname — entirely in the kernel after initial connection setup. No nginx, no HAProxy, no TLS termination. The encrypted stream is forwarded opaquely; the backend handles its own certificates.
- Client connects to the host on port 443.
snipraccepts the TCP handshake and reads the TLS ClientHello.- The SNI hostname is extracted and matched against the route table
(exact match first, then
*.example.comwildcards). sniprconnects to the matched backend container.- Both sockets are inserted into a BPF sockmap.
- A kernel
sk_skbstream verdict program redirects all subsequent data between the two sockets — no userspace copies, no context switches. The proxy task just parks until the connection closes.
Additionally, a TC-ingress BPF classifier monitors all port-443 traffic and emits perf events with SNI hostnames, connection metadata, and route match results for observability.
Label your containers with snipr.hostname:
docker run -d \
--label snipr.hostname=example.com \
--label snipr.port=443 \
my-tls-server
docker run -d \
--label snipr.hostname=other.org,blog.other.org \
--label snipr.port=8443 \
another-tls-serverStart snipr:
sudo snipr run \
--iface eth0 \
--proxy \
--listen 0.0.0.0:443 \
--docker \
--proxy-protocolThat's it. snipr discovers the containers, resolves their IPs,
and routes TLS connections by SNI. --proxy-protocol sends a PROXY
protocol v1 header so your backends see the real client IP.
Routes are managed automatically — when containers start or stop,
snipr adds and removes routes in real time.
sudo snipr run --config /etc/snipr/config.toml --proxy --listen 0.0.0.0:443# /etc/snipr/config.toml
default_route = "172.17.0.2:443"
[routes]
"example.com" = "172.17.0.2:443"
"other.org" = "172.17.0.3:8443"
"*.dev.example.com" = "172.17.0.4:443"sudo snipr run \
--iface eth0 \
--proxy \
--listen 0.0.0.0:443 \
--route 'example.com=172.17.0.2:443' \
--route '*.example.com=172.17.0.2:443' \
--default-route 172.17.0.2:443Config file and --route flags can be combined; CLI flags take
precedence on conflict.
Routes are stored in a pinned BPF map that persists across restarts.
While snipr run is active, manage routes from another terminal:
sudo snipr route add blog.example.com 172.17.0.5:443
sudo snipr route add '*.staging.example.com' 172.17.0.6:443
sudo snipr route del blog.example.com
sudo snipr route listChanges take effect immediately — no restart needed.
sudo snipr statsShows BPF counters (packets seen, conntrack hits, SNI parse success/failure, route matches/misses, DNAT/SNAT operations) and the current route table.
The live event stream is printed to stdout while snipr run is active:
CONNECTION SNI BACKEND
───────────────────────────────────────────────────────────────────────────────────────────────────
203.0.113.42:51234 -> 192.168.1.10:443 example.com -> 172.17.0.2:443
203.0.113.99:41010 -> 192.168.1.10:443 blog.other.org -> 172.17.0.3:8443
- SNI-based routing — route by hostname, not by port or IP
- Wildcard routes —
*.example.commatchesfoo.example.com,bar.baz.example.com - Sockmap acceleration — after connection setup, data flows entirely in kernel via BPF sk_skb redirect
- Docker integration — auto-discover containers via
snipr.hostnamelabel - PROXY protocol v1 — backends see the real client IP (supports TCP4 and TCP6)
- IPv4 and IPv6 — dual-stack throughout (BPF parsing, DNAT/SNAT, proxy, display)
- Runtime route management —
snipr route add/del/listvia pinned BPF maps - TOML config file — load routes from a file
- Default route — fallback for unmatched hostnames or missing SNI
- Connection limits —
--max-connectionssemaphore prevents resource exhaustion - Timeouts — 5s ClientHello read, 5s backend connect
- Graceful shutdown — drains active connections on Ctrl-C (30s timeout)
- BPF counters — per-CPU stats for packets, conntrack, SNI parsing, routing, NAT
- TC DNAT/SNAT mode — alternative to proxy mode; rewrites packets in-flight with conntrack
client ──TCP──► snipr proxy (userspace)
│ accept, read ClientHello, parse SNI
│ look up route (exact -> wildcard -> default)
│ connect to backend container
│ insert both sockets into BPF sockmap
│ forward ClientHello to backend
▼
┌─────────────────────────────────────┐
│ BPF sk_skb stream_verdict program │
│ redirects data between sockets │
│ entirely in kernel — zero copies │
└─────────────────────────────────────┘
│
│ FIN/RST wakes userspace -> cleanup
▼
connection closed, map entries removed
A separate TC-ingress classifier runs in parallel for monitoring, emitting perf events for every TLS ClientHello seen on the wire.
Without --proxy, snipr attaches TC ingress/egress classifiers that
rewrite packet headers in-flight using a conntrack table. This avoids
a userspace proxy entirely but has a fundamental limitation: the TCP
SYN arrives before the ClientHello, so the first packet of a connection
cannot be DNATed based on SNI. Post-handshake packets are handled
fully in kernel.
The original design considered BPF_PROG_TYPE_SK_LOOKUP, which runs
when the kernel looks up a listening socket for an incoming connection.
In theory, it could assign the SYN directly to the backend container's
socket, eliminating the proxy entirely — one TCP connection, zero
userspace involvement.
In practice, this does not work for SNI routing:
-
sk_lookup fires on the SYN packet. At SYN time, there is no application data. The TLS ClientHello (which contains the SNI hostname) arrives only after the 3-way handshake completes. The sk_lookup program sees the 5-tuple (IPs + ports) but never sees the SNI.
-
TC-level DNAT has the same problem. A TC-ingress classifier can parse the ClientHello from the first data packet, but by then the SYN has already been delivered to the host's TCP stack with the original destination. Rewriting subsequent packets to a different backend breaks the TCP state machine — the backend receives data for a connection it never established and sends RST.
-
The only hook that sees reassembled stream data is sk_skb (or sk_msg), but these require a socket to already exist — they operate on established connections, not during connection setup.
The fundamental constraint is that TCP requires a completed handshake before any application data, and SNI is application data. No BPF hook can see the SNI at SYN time.
The sockmap approach solves this pragmatically:
- Userspace handles the 3-packet handshake (SYN, SYN-ACK, ACK) and the ClientHello — roughly 10 microseconds of work per connection.
- After route resolution, both sockets are inserted into a BPF
sockmap. The
sk_skbstream verdict program redirects all subsequent data entirely in kernel. - Userspace parks and never touches the data plane again.
This is the same architecture used by Cilium and Cloudflare's Spectrum for L4 proxying. The userspace cost is limited to connection setup; the steady-state data path is pure kernel.
SNIpR/
├── Cargo.toml # workspace
├── Makefile
├── config.example.toml
├── sni-router-common/ # shared repr(C) types — compiles in no_std + std
│ └── src/lib.rs
├── sni-router-ebpf/ # BPF kernel programs (no_std, target bpfel-unknown-none)
│ └── src/main.rs
└── snipr/ # userspace binary
└── src/main.rs
The BPF object contains six programs:
| Program | Type | Purpose |
|---|---|---|
sni_tc |
TC classifier (ingress) | Parse ClientHello, extract SNI, look up route, DNAT, emit perf event |
sni_egress_tc |
TC classifier (egress) | Reverse SNAT on return traffic from backends |
snipr_stream_parser |
sk_skb stream parser | Accept all data as a single message (pass-through) |
snipr_stream_verdict |
sk_skb stream verdict | Look up peer socket and redirect data in kernel |
And seven BPF maps:
| Map | Type | Purpose |
|---|---|---|
SNI_ROUTES |
HashMap (pinned) | Hostname -> backend IP:port |
CONNTRACK |
LruHashMap | TCP 4-tuple -> NAT state (TC DNAT mode) |
SNI_EVENTS |
PerfEventArray | ClientHello events to userspace |
SOCK_PAIR |
SockHash | Paired client/backend sockets (proxy mode) |
PEER_LOOKUP |
HashMap | Socket 4-tuple -> peer's sockmap key |
STATS |
PerCpuArray (pinned) | Packet/routing/NAT counters |
SCRATCH |
PerCpuArray | Stack-overflow avoidance for large structs |
| Component | Requirement |
|---|---|
| Linux kernel | 5.15+ recommended |
| Rust toolchain | nightly (for -Z build-std) |
bpf-linker |
cargo install bpf-linker |
| Runtime privilege | sudo / CAP_NET_ADMIN + CAP_BPF |
# One-time: install toolchain and bpf-linker
make install-tools
# Build everything
make allCopyright (c) 2026 Stefan Reinauer. All rights reserved.
This project is licensed under the BSD 2-Clause License.