Skip to content

Repository files navigation

snipr — eBPF SNI-based TLS router

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.

How it works

  1. Client connects to the host on port 443.
  2. snipr accepts the TCP handshake and reads the TLS ClientHello.
  3. The SNI hostname is extracted and matched against the route table (exact match first, then *.example.com wildcards).
  4. snipr connects to the matched backend container.
  5. Both sockets are inserted into a BPF sockmap.
  6. A kernel sk_skb stream 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.

Quick start with Docker

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-server

Start snipr:

sudo snipr run \
    --iface eth0 \
    --proxy \
    --listen 0.0.0.0:443 \
    --docker \
    --proxy-protocol

That'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.

Configuration

Config file (TOML)

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"

CLI flags

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:443

Config file and --route flags can be combined; CLI flags take precedence on conflict.

Runtime route management

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 list

Changes take effect immediately — no restart needed.

Monitoring

sudo snipr stats

Shows 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

Features

  • SNI-based routing — route by hostname, not by port or IP
  • Wildcard routes*.example.com matches foo.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.hostname label
  • 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 managementsnipr route add/del/list via pinned BPF maps
  • TOML config file — load routes from a file
  • Default route — fallback for unmatched hostnames or missing SNI
  • Connection limits--max-connections semaphore 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

Architecture

Proxy mode (recommended)

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.

TC DNAT mode (alternative)

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.

Why sockmap, not sk_lookup

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:

  1. Userspace handles the 3-packet handshake (SYN, SYN-ACK, ACK) and the ClientHello — roughly 10 microseconds of work per connection.
  2. After route resolution, both sockets are inserted into a BPF sockmap. The sk_skb stream verdict program redirects all subsequent data entirely in kernel.
  3. 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.

Repository layout

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

BPF programs

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

Requirements

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

Build

# One-time: install toolchain and bpf-linker
make install-tools

# Build everything
make all

License

Copyright (c) 2026 Stefan Reinauer. All rights reserved.

This project is licensed under the BSD 2-Clause License.

About

snipr — eBPF SNI-based TLS router

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages