Skip to content
renaudallardPublic

About

Proxies incoming HTTP and TLS connections based on the hostname contained in the initial request of the TCP session.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

sniproxy: hardened SNI routing

Hardened SNI Proxy

Route HTTP, TLS, DTLS, XMPP and Minecraft connections by hostname, without decrypting traffic.

Latest release Downloads Build and Fuzz Sanitizers Valgrind Continuous Fuzzing License PayPal


SNIProxy inspects the first packet of an inbound connection, extracts the client-requested hostname (SNI for TLS/DTLS, Host for HTTP, :authority for HTTP/2, the stream to attribute for XMPP, the handshake server address for Minecraft), and forwards the connection to a backend selected by hostname pattern. The encrypted payload is never decrypted, so no private key material is ever installed on the proxy host. This makes name-based virtual hosting work for HTTPS the same way it does for HTTP.

The fork is a hardened, production-oriented continuation of the original sniproxy by Dustin Lundquist, with privilege separation, encrypted IPC, per-platform sandboxing (pledge/unveil, Capsicum, seccomp), continuous fuzzing, and active maintenance.

Primary platform: OpenBSD. Best-effort support on Linux, FreeBSD and macOS.

Highlights

  • Name-based proxying without decryption: TLS/DTLS SNI, HTTP/1 Host, HTTP/2 HPACK :authority, XMPP stream to, Minecraft handshake. No certificates or private keys on the proxy.
  • Five protocols, one binary: TLS, DTLS (UDP), HTTP/1 + HTTP/2, XMPP (with STARTTLS), Minecraft Java Edition (FML and BungeeCord markers ignored when routing).
  • Pattern matching: exact hostnames or PCRE2 (JIT-compiled where available), per-table backend selection with optional client-IP affinity.
  • Wildcard backends: route to the dynamically resolved hostname the client asked for (*:443).
  • TCP half-close: a client or backend that shuts down its sending side has that passed on once its data is through, while the other direction goes on, as nc -N or shutdown(SHUT_WR) expects. A backend that refuses the connection gets the client the protocol's error (503 for HTTP), and one that fails mid answer gets it reset.
  • HAProxy PROXY protocol: emit v1 or v2 headers to backends; accept v1 or v2 from upstream load balancers on listeners with proxy_protocol on (the version is auto-detected), which should admit only the load balancer, as the header is taken from any peer. TCP only: DTLS listeners refuse these options.
  • Privilege separation: four cooperating processes: sniproxy-mainloop, sniproxy-binder, sniproxy-logger, sniproxy-resolver. All IPC is encrypted with ChaCha20-Poly1305.
  • Per-platform sandboxing: pledge(2) + unveil(2) on OpenBSD, Capsicum on FreeBSD (capability mode for the logger), seccomp BPF on Linux.
  • DTLS source check: a new UDP session is held until a second datagram arrives from the same source address and port before any backend traffic is sent, so a single spoofed packet reaches no backend. When the session table is full, the session that has waited longest for its second datagram, if any, makes room for the new one, which only helps against floods below about 16000 first datagrams a second.
  • Per-IP rate limiting: token buckets in a hash table keyed with an arc4random seed cap new TCP connections and UDP sessions; a full chain forgets its least recently seen source, and sources first seen in a datagram may only fill half of one.
  • Backend ACLs: deny_except or allow_except CIDR policies stop abuse as an open proxy to reach internal hosts.
  • Listener ACLs: the same CIDR policies, applied to inbound clients (unix socket listeners rely on the permissions of their directory).
  • DNS-over-TLS upstreams: nameserver dot://9.9.9.9/dns.quad9.net/tls1.2 inside the resolver block; IP literals require a TLS hostname or an explicit /insecure. TLS 1.2 is enforced by default.
  • Hot reload: SIGHUP re-reads the config and updates listeners and tables in place without dropping live connections; connections that are already routed keep their backend. Hostname backends are resolved for every new connection, so DNS changes need no reload.
  • Timeouts: a client has 5 seconds to send its request, and a connection with no data moving either way is closed after idle_timeout seconds (60 by default, 0 disables it).
  • Zero-copy on OpenBSD: SO_SPLICE moves data in the kernel after the handshake is parsed, user buffers shrink to 4 KiB and the idle timer checks the kernel byte counters, so one silent direction does not end a live connection.
  • Bounded memory: per-connection buffer caps, a global soft limit that aggressively trims idle buffers, and a 4096-entry shrink queue stop slow clients from pinning unbounded RAM. An HTTP/1 request is only parsed again once a new line arrives, so a client trickling a large request does not make every byte cost a pass over all of it.
  • Continuous fuzzing: dedicated harnesses for TLS, DTLS, HTTP/1, HTTP/2, XMPP, Minecraft, hostname, address, config, config tokenizer, table lookup, listener ACL, IPC crypto, IPC messages, IPC state and resolver responses run in CI and on a separate continuous-fuzzing job.

Protocol support

Protocol Hostname source Notes
TLS 1.0–1.3 SNI extension in ClientHello TLS 1.2+ enforced by default; -T 1.0/1.1/1.2/1.3 overrides
DTLS SNI extension in UDP ClientHello Session held until a second datagram arrives from the same source; a ClientHello fragmented over several datagrams goes to the fallback; unix socket backends and fallbacks are refused
HTTP/1.x Host: request header, or the host of an absolute-form or CONNECT target, which Host: must then name Header count capped by http_max_headers (default 100)
HTTP/2 HPACK :authority pseudo-header of the first request Bounded HPACK table (per-conn 64 KiB / global 4 MiB); later requests are not searched
XMPP to attribute on <stream:stream> STARTTLS negotiation passes through untouched
Minecraft (Java Edition) Server address in handshake packet FML and BungeeCord NUL-delimited trailers ignored when routing, forwarded unchanged

Architecture

SNIProxy runs as four cooperating processes:

  1. sniproxy-mainloop: accepts connections, parses the first protocol header, picks a backend and forwards bidirectionally.
  2. sniproxy-binder: keeps the privilege to bind so that a listener added by a SIGHUP reload can still bind a privileged port once the main loop has dropped its privileges; the initial listeners are bound by the main loop before it drops root. A binder restarted after it died, or after it left a request unanswered for 30 seconds, runs without that privilege, as it is forked from the main loop, until sniproxy is restarted. It idles otherwise, and only binds Unix socket paths whose directory really lies under /run or /var/run, symlinks resolved. On Linux it does not keep root: it switches to the configured user with only the CAP_NET_BIND_SERVICE capability, and CAP_DAC_OVERRIDE as well when a Unix socket listener at startup is under /run or /var/run, so a Unix socket listener a reload adds in a directory that user cannot write needs one already configured there, or a restart. Its seccomp filter lets it open directories only, list none, connect nowhere, create no Unix datagram socket, move no descriptor and send to no address of its choosing, so that with CAP_DAC_OVERRIDE a compromised binder cannot read root's files or reach root's Unix sockets, such as a container engine's, nor send from a privileged port, except on i386, s390x and other systems where socket(), sendto() and sendmsg() go through socketcall(2); it could still create a socket node in any directory, and learn which paths exist. It connects to syslog before its filter, and its few log lines are lost after the syslog daemon restarts, until sniproxy does. Elsewhere it stays root, but creates each IP socket as the configured user and only binds it as root, so the kernel refuses it an address another user's socket holds, as it would refuse that user: a compromised main loop cannot have it take over the port of another service. It never binds a multicast group, whose ports the kernel lets root share with any user's socket.
  3. sniproxy-logger: writes the log files. The main loop sends it log lines over an encrypted, authenticated Unix socket.
  4. sniproxy-resolver: runs c-ares for async DNS and DNS-over-TLS. The main loop caps the queries in flight, overall and per client, and restarts the resolver if it exits, at most once a second once it has died three times in quick succession.

These are the names ps and pgrep show on Linux, truncated there to 15 characters. On OpenBSD every process keeps the command name sniproxy, and ps shows the helpers as sniproxy: sniproxy-logger and so on, while the main process keeps its command line so that rc.d can find it. On macOS, where sniproxy does not rename its processes, all four keep the command line it was started with.

All IPC channels are encrypted with ChaCha20-Poly1305. The master key is generated and locked in memory once in the parent and inherited across fork(), and each channel's keys are derived from it with a fresh salt when its child starts, so children never have to read key material from disk or call mlock() after pledge(). The logger drops root once the listeners are bound and the resolver is started after that, so both run unprivileged; the binder keeps the privilege to bind, which is its purpose, as root or, on Linux, as capabilities. On a platform that provides one (pledge/unveil, Capsicum, or seccomp), each process enters its sandbox before it handles client traffic; on FreeBSD only the logger enters capability mode (see Installation).

See ARCHITECTURE.md for the full design and process boundaries, and SANITIZERS.md for how to build under ASan/MSan/UBSan/TSan.

Quick start

user daemon
group daemon

pidfile /var/run/sniproxy.pid

error_log {
    filename /var/log/sniproxy/error.log
    priority notice
}

listener 0.0.0.0:443 {
    protocol tls
    table https_hosts

    # Used when the ClientHello has no usable SNI, cannot be parsed,
    # names a host that matches no table entry, or names an IP literal
    # that a '*' entry matches
    fallback 192.0.2.50:443

    access_log {
        filename /var/log/sniproxy/access.log
    }
}

table https_hosts {
    # Exact host. Bare hostnames are auto-anchored, so this matches
    # "example.com" only, never "sub.example.com".
    example.com         192.0.2.10:443

    # PCRE2 regular expression. The config parser consumes one
    # backslash, so double them, and end with $: a regex is not
    # anchored and would also match "example.net.attacker.org".
    .*\\.example\\.net$     192.0.2.11:443

    # Wildcard backend: connect to whatever the client asked for
    .*\\.cdn\\.example$     *:443
}

Validate with sniproxy -t -c /etc/sniproxy.conf, then start in foreground with sniproxy -f -c /etc/sniproxy.conf.

Usage

Usage: sniproxy [-c <config>] [-f] [-g] [-t] [-n <max fd>] [-V] [-T <min TLS>] [-d]
    -c  configuration file (default: /etc/sniproxy.conf)
    -f  run in foreground
    -g  allow group-readable (0640) config for SIGHUP reload
    -t  test configuration and exit
    -n  override file descriptor limit
    -V  print version and exit
    -T  minimum accepted TLS ClientHello version (1.0|1.1|1.2|1.3, default 1.2)
    -d  enable verbose resolver debug tracing

Installation

Every release carries prebuilt packages made by the Release Packages workflow: x86_64 .deb packages for Debian stable and oldstable and for two Ubuntu releases, x86_64 .rpm packages for Fedora, the two latest Rocky Linux releases, openSUSE Leap and SUSE Linux Enterprise 15, an x86_64 .apk for Alpine, a FreeBSD amd64 tarball and a macOS arm64 tarball.

The .deb, .rpm and .apk packages install a default /etc/sniproxy.conf (mode 0640, group daemon), the /var/log/sniproxy directory and a logrotate file. The .deb and .rpm packages add a systemd unit and the .apk an OpenRC service, neither enabled. Both start sniproxy as root, which binds its listeners and drops to the user in the configuration, and pass -g so that user can read the configuration again on reload. The systemd unit makes the file system read-only apart from /var/log/sniproxy and /run/sniproxy, so a unix socket listener has to be in /run/sniproxy there. Enable the unit with systemctl enable --now sniproxy, or the OpenRC service with rc-update add sniproxy and rc-service sniproxy start; its options go in /etc/conf.d/sniproxy (SNIPROXY_CONFIG, SNIPROXY_OPTS). On a Debian host without systemd, the .deb init script is installed disabled as well: enable it with update-rc.d sniproxy enable and start it with service sniproxy start.

Prerequisites

  • autoconf 2.71 or later and automake
  • libev, libpcre2-8, c-ares, OpenSSL (or LibreSSL) development headers
  • On Linux, libseccomp: optional, but without it the build has no seccomp sandbox (configure warns when it is missing, and fails with --enable-seccomp, which the packages use; it finds the header through pkg-config where it is not in the default path, as on openSUSE; --disable-seccomp leaves it out)
  • libbsd, only where libc lacks arc4random or strlcpy, such as glibc before 2.38 (OpenBSD, FreeBSD and macOS have both)
  • Perl and cURL for the test suite

From source

./autogen.sh && ./configure && make check && sudo make install

Lines written to log files and stderr are stamped with the local time, as 2026-10-06 18:42:07; syslog adds its own stamp. ./configure --enable-rfc3339-timestamps stamps them in UTC instead, in the RFC 3339 form 2026-10-06T16:42:07Z.

Debian / Ubuntu

sudo apt-get install autotools-dev cdbs debhelper dh-autoreconf dpkg-dev \
    gettext libev-dev libpcre2-dev libc-ares-dev libssl-dev libbsd-dev \
    libseccomp-dev pkg-config fakeroot
dpkg-buildpackage -us -uc -b
sudo dpkg -i ../sniproxy_<version>_<arch>.deb

The build runs autoreconf itself, so there is no need for autogen.sh.

Alpine

doas apk add build-base abuild autoconf automake libtool pkgconf \
    libev-dev pcre2-dev c-ares-dev openssl-dev libbsd-dev libseccomp-dev
doas addgroup "$USER" abuild # then log in again
abuild-keygen -a -i -n
./autogen.sh && ./configure && make dist
mkdir -p ~/aports/sniproxy
cp alpine/APKBUILD sniproxy-<version>.tar.gz ~/aports/sniproxy/
cd ~/aports/sniproxy
sed -i -e 's/^pkgver=.*/pkgver=<version>/' \
    -e 's/^source=.*/source="sniproxy-<version>.tar.gz"/' APKBUILD
abuild checksum && abuild -r
doas apk add ~/packages/aports/<arch>/sniproxy-<version>-r0.apk

abuild refuses to run as root, and the APKBUILD carries a placeholder version that the two sed expressions replace, as the release workflow does.

Fedora / RHEL

sudo dnf install gcc make rpm-build autoconf automake curl gettext-devel \
    libev-devel pcre2-devel c-ares-devel openssl-devel libbsd-devel \
    libseccomp-devel systemd-rpm-macros
./autogen.sh && ./configure && make dist
rpmbuild --define "_sourcedir $(pwd)" -ba redhat/sniproxy.spec
sudo dnf install ~/rpmbuild/RPMS/<arch>/sniproxy-<version>-1.<dist>.<arch>.rpm

On RHEL and its rebuilds, enable EPEL and CRB first (libbsd-devel comes from EPEL); RHEL 9 also ships autoconf 2.69, older than the 2.71 that configure.ac requires.

FreeBSD

pkg install autoconf automake libtool pkgconf libev pcre2 c-ares
./autogen.sh && ./configure LDFLAGS="-L/usr/local/lib" CPPFLAGS="-I/usr/local/include" && make
sudo make install
sudo cp scripts/sniproxy.rc /usr/local/etc/rc.d/sniproxy
sudo sysrc sniproxy_enable=YES
sudo service sniproxy start

The rc script runs sniproxy with -c /usr/local/etc/sniproxy.conf (change it with sysrc sniproxy_config=...), and its stop and reload find the process through /var/run/sniproxy.pid, so the config needs pidfile /var/run/sniproxy.pid. It passes -g (sniproxy_flags), as a reload reads the config as the configured user: give the file that user's group, for example chown root:daemon /usr/local/etc/sniproxy.conf and chmod 640.

Only the logger runs in Capsicum capability mode. The main process and the resolver have to connect() to backends and nameservers, which capability mode does not permit, so they only get limited rights on their IPC descriptors; the binder is not confined. SNIPROXY_DISABLE_CAPSICUM=1 turns Capsicum off.

macOS (best effort)

brew install libev pcre2 c-ares openssl autoconf automake
./autogen.sh
inc= lib=
for dep in libev pcre2 c-ares openssl; do
    inc="$inc -I$(brew --prefix $dep)/include"
    lib="$lib -L$(brew --prefix $dep)/lib"
done
./configure CPPFLAGS="$inc" LDFLAGS="$lib" && make

configure only looks in the compiler's default paths, so it has to be told where Homebrew keeps each library, as the CI macOS jobs do.

Configuration

A config file has a small set of global directives followed by one or more listener <addr> and table <name> blocks. SIGHUP triggers a zero-downtime reload. A few settings only change on restart, and the reload logs a warning when it ignores one: user, group, pidfile, the resolver's nameserver, search, mode and dnssec_validation (its query limits do change), and, for listeners that already exist, tcp_fastopen, reuseport and ipv6_v6only. So does a source client added by a reload when sniproxy was started as root with no listener using it. SIGUSR1 dumps the live connection table to a temporary connections-XXXXXXXX file under $XDG_RUNTIME_DIR/sniproxy, /var/run/sniproxy, or /tmp/sniproxy-<uid> (tried in that order, without the first on OpenBSD, where unveil leaves it hidden), which must belong to the user sniproxy runs as and be closed to others, and is checked again when it was replaced since the last dump; after a root start it has mode 0200, as the log files do, so read it as root. Under the packaged systemd unit, /run/sniproxy belongs to root and /tmp is private to the service, so the logged /tmp/sniproxy-<uid> path is found on the host under /tmp/systemd-private-*-sniproxy.service-*/tmp. Both signals are meant for the main process; the helper processes ignore them, so signalling them all, as pkill -HUP sniproxy does, is safe.

Global directives

user daemon
group daemon
pidfile /var/run/sniproxy.pid

# Let libev batch I/O readiness and timer wakeups (seconds). A
# non-zero io_collect_interval delays every read by at least one
# clock tick, 10 ms on OpenBSD; its default is 0.
io_collect_interval      0
timeout_collect_interval 0.005

# Cap total simultaneous connections. 0 (the default) derives it from
# the file descriptor limit: 80% of it, two descriptors per connection.
max_connections 20000

# Per-IP token-bucket rate, for TCP connections and for UDP sessions
# apart (default 30/s; 0 disables).
per_ip_connection_rate 50

# Per-IP cap on simultaneous connections, and on UDP sessions apart
# (default 0, disabled).
per_ip_max_connections 100

# Prefix length used to group native IPv6 clients for every limit keyed on
# the client address, so a client cannot rotate addresses within its
# allocation to evade them. This covers the two per-IP limits above plus
# max_concurrent_queries_per_client and the backend_affinity hash, so
# clients sharing a prefix also share a DNS query budget and a backend.
# Default 64; set to 128 to key on the exact address.
per_ip_ipv6_prefix 64

# Per-side buffer caps. connection_buffer_limit sets both sides; for
# each side the directive that comes last wins. Defaults: 1 MiB each.
connection_buffer_limit 4M
# client_buffer_limit   4M
# server_buffer_limit   8M

# Cap accepted HTTP/1 headers per request (default 100). HTTP/2
# requests have a fixed limit of 100.
http_max_headers 200

# Close a TCP connection after this many seconds with no data moving
# either way (default 60, 0 disables it). A client always has 5 seconds
# to send its request.
idle_timeout 3600

# Only connect to backends in these ranges, so a wildcard backend cannot
# be used to reach arbitrary hosts. allow_except does the opposite:
# everything except the listed ranges, e.g. to keep a wildcard backend
# out of internal address space, which then has to list loopback
# (127.0.0.0/8, ::1/128), link-local (169.254.0.0/16, fe80::/10), IPv6
# unique local (fc00::/7) and shared (100.64.0.0/10) addresses and the
# host's own public addresses too, and behind NAT64 the translation
# prefix when it reaches private IPv4 addresses, such as 64:ff9b:1::/48.
# Unix socket backends match no range, and 0.0.0.0, :: and multicast
# addresses are always refused.
backend_acl deny_except {
    10.0.0.0/8
    172.16.0.0/12
    192.168.0.0/16
}

# Enable TCP Fast Open (Linux 3.7+/4.11+, FreeBSD 12+). On a platform
# built without TFO support, such as OpenBSD, this line is a
# configuration error.
tcp_fastopen on

Resolver block

resolver {
    # ipv4_only | ipv6_only | ipv4_first | ipv6_first | default
    mode ipv4_first

    # DNS-over-TLS upstream.
    # IP literals require either a TLS verification hostname after the
    # slash, or an explicit "/insecure" to opt out of verification.
    # The optional third segment pins the minimum TLS version
    # (tls1.2 default, tls1.3 if your OpenSSL supports it).
    nameserver dot://9.9.9.9/dns.quad9.net/tls1.2

    # Or cleartext upstreams. Do not mix them with dot:// entries: c-ares
    # uses all servers as one failover pool, so a cleartext entry lets a
    # failed TLS handshake fall back to unauthenticated DNS.
    # nameserver 8.8.8.8
    # nameserver 2001:4860:4860::8888

    # DTLS sessions may use at most half of max_concurrent_queries (the
    # one lookup when it is 1), and count apart from TCP connections
    # against the per-client limit, as their source addresses may be
    # forged.
    max_concurrent_queries 512
    max_concurrent_queries_per_client 16

    # off | relaxed (default); this does not validate DNSSEC,
    # see "DNS resolution" below
    dnssec_validation relaxed
}

Security note: prefer IP literals with an explicit TLS hostname for DoT servers. A hostname-only entry is looked up in cleartext, over TCP, through the servers in /etc/resolv.conf when the resolver process starts (after it has entered its sandbox), which reveals the name and lets anyone who can tamper with that lookup break name resolution. The certificate is still checked against the name, so a forged answer cannot redirect queries to another server. Up to four of the addresses found are used, those the host can reach first, and queries move on to the next one when a server does not answer:

# Recommended
nameserver dot://9.9.9.9/dns.quad9.net

# Less secure: needs cleartext DNS before DoT becomes available
nameserver dot://dns.quad9.net

Each DoT lookup not answered from the c-ares cache opens a new connection, with its TCP and TLS handshakes, and closes it once answered. Connections are not kept open between lookups, as some servers drop such a connection with a query on it still unanswered.

Listener and table

listener [::]:443 {
    protocol tls
    table secure_hosts

    # Multi-process scale-out via SO_REUSEPORT
    reuseport yes

    # Preserve the client source IP on outbound (IP_TRANSPARENT, Linux
    # only). The main process keeps CAP_NET_RAW for it after dropping
    # root; replies must be routed back to sniproxy by the host.
    source client

    # Add a debug line with the size and parser result of each request
    # that fails to parse (the contents are not logged)
    bad_requests log

    # Allow listener: every CIDR not listed is blocked
    acl deny_except {
        10.0.0.0/8
        2001:db8::/32
    }

    # Fallback for requests with no hostname, that cannot be parsed, or
    # that match no table entry, sent with a PROXY v1 header. When a
    # matching backend's name fails to resolve, the connection is closed
    # instead, as it is when the -T check refuses or cannot read the
    # ClientHello.
    fallback 192.0.2.50:443
    fallback proxy_protocol
    # ...or v2:
    # fallback proxy_protocol_v2
}

table secure_hosts {
    # Per-backend PROXY protocol
    secure.example.com  192.0.2.20:443 proxy_protocol
    other.example.com   192.0.2.21:443 proxy_protocol_v2

    # Same client IP reaches the same backend when DNS returns several
    # records for the name. The hash is seeded per process, so the
    # mapping changes on restart and differs between reuseport workers.
    # TCP only. Without it, and for DTLS, a record is picked at random.
    backend_affinity on
    .*\\.cdn\\.example\\.com$ *:443
}

All listener acl blocks must use the same policy: mixing allow_except and deny_except across listeners aborts startup. The backend_acl policy is independent of them. On a dtls listener the acl only checks the datagrams' source address, which can be forged, so rely on ingress filtering to keep forged sources out. IPv4 and IPv6 networks can be mixed in the same block; IPv4-mapped IPv6 connections are matched against the IPv4 CIDRs, and a CIDR written in that form (::ffff:192.0.2.0/120) is read as the IPv4 one it stands for. With a backend_acl, a backend address of 0.0.0.0 or :: is refused whatever the policy, since connecting to it reaches the local host, so a hostname resolving to it cannot get around a block of the loopback range. Multicast addresses (224.0.0.0/4, ff00::/8) are refused even without a backend_acl: a DTLS session to one would send its client's datagrams to every member of the group. A dtls listener opens no session for datagrams from the backend socket of a session that a '*' entry routed, so that a client cannot have such an entry lead back to sniproxy and chain sessions without end; an explicit entry may still lead to another dtls listener. On a source client listener the backend socket takes the client's address and port, and a backend that leads back to the session's own listener, whatever the route, would send the same datagrams round one session for ever: a session of a source client listener drops a datagram that comes back to it, as it arrives at the address and port the session sends to, where a client cannot send since a listener only takes datagrams for addresses of this host. For the same reason a gateway that takes DTLS traffic addressed to other hosts, with a local route, cannot use a source client dtls listener: such datagrams would be dropped. A loop through a NAT on this host is not caught, and only the per-IP session rate bounds it.

XMPP

listener 0.0.0.0:5222 {
    protocol xmpp
    table xmpp_servers
    fallback 192.0.2.50:5222
}

table xmpp_servers {
    example.com      192.0.2.10:5222
    chat.example.org 192.0.2.11:5222
    .*\\.xmpp\\.net$ *:5222
}

The proxy extracts the to attribute from the opening <stream:stream> element and routes accordingly. The STARTTLS negotiation that follows is transparent. Hostnames are validated (alphanumeric, dot, hyphen, underscore, bracketed IPv6); control characters, path traversal and injection metacharacters are rejected. Maximum hostname length is 255 bytes, maximum stream header size is 4096 bytes. Internationalized domains cannot be routed: XMPP clients send their labels in UTF-8 (RFC 7622), which is refused, so such a stream goes to the fallback.

Minecraft

listener 0.0.0.0:25565 {
    protocol minecraft
    table minecraft_servers
    fallback 192.0.2.50:25565
}

table minecraft_servers {
    mc.example.com   192.0.2.10:25565
    play.example.org 192.0.2.11:25565
    .*\\.mc\\.net$   *:25565
}

The handshake packet is the very first data in the TCP stream, so sniproxy reads it, cuts the server address at the first NUL byte to drop any Forge Mod Loader or BungeeCord forwarding trailer, and routes on what is left. The packet itself reaches the backend unchanged, trailer included. The server list ping of clients before 1.7 is not a handshake: the 1.6 form, which names the server in a plugin message sniproxy does not read, goes to the fallback, while the older ones, a lone FE or FE 01, cannot be told from the start of a handshake and are closed after the 5 second request timeout.

As the trailer reaches the backend unchanged, any player can send one with the address and UUID of its choosing: a backend with BungeeCord forwarding enabled (bungeecord: true in spigot.yml) trusts it, so it must only be reachable through a BungeeCord or Velocity proxy, never through a sniproxy listener open to players.

Security and hardening

SNIProxy is built with defense-in-depth as a design goal, not an afterthought.

  • TLS 1.2+ by default: older clients can be re-enabled with -T 1.1 or -T 1.0, or TLS 1.3 required with -T 1.3. The flag applies to every TLS listener and is checked against the supported_versions extension too, when a ClientHello sends one, as that is where a server takes the version from. A ClientHello whose version cannot be read is refused rather than sent to the fallback.
  • Cryptographically random seeds: the per-IP hashes (rate limiter, connection counts, DNS client tracking, backend affinity) and the UDP session table are keyed with an arc4random seed, backend affinity with one of its own; a full chain of the rate limiter forgets its least recently seen source, one of the connection counts refuses new clients, and sources first seen in a datagram may fill only half of a chain in either, so that forged ones cannot crowd out TCP clients. Request IDs between the main loop and the resolver come from arc4random as well.
  • Bounded parsers: TLS rejects SSL 2.0/3.0 ClientHellos and NUL bytes in server names; HTTP caps headers (default 100); TLS extension count is capped at 64 on every code path; HTTP/2 HPACK is bounded per connection (64 KiB) and globally (4 MiB), and a request header block that is larger, or whose fields decode to more, is routed on the fields that fit in the first 64 KiB. A server name, :authority, XMPP to or Minecraft address ending in white space is refused rather than trimmed, so that sniproxy routes on the name the backend gets; an HTTP/1 Host header may still end in spaces and tabs, as HTTP allows.
  • Regex DoS mitigation: PCRE2 match limits scale with the lengths of the hostname and of the pattern, so a crafted SNI cannot trigger catastrophic backtracking while large alternations still match. A hostname that runs into the limit is refused rather than tried against the next entries or sent to the fallback.
  • DTLS amplification defense: a new UDP session is held until a second datagram arrives from the same source address and port within 3 seconds, and nothing is sent to the client or a backend before that. Real DTLS clients retransmit by design (RFC 6347 section 4.2.4), so a single spoofed packet never reaches a backend. The second datagram is not compared with the first and no HelloVerifyRequest is sent, so an attacker who sends two spoofed packets does get through; from there it is the backend's own DTLS cookie exchange that limits amplification. A session ends after 30 seconds without a datagram from its client, whatever its backend sends, and an ICMP error about its backend, which anyone can forge, does not end it: a session keeps the backend address it started with as long as its client sends, even once that backend no longer answers.
  • Privilege separation: the privileged binder, the log writer and the resolver are each their own process, communicating over encrypted Unix sockets with framed, length-checked messages.
  • Strict config and pidfile checks: config files must be owned by root, or by the user sniproxy is started as when that is not root, and must not be accessible to group or others (-g allows group read only); the pidfile and log file paths must be absolute; resolver search domains are treated as literal suffixes, not re-parsed by the system resolver. Pidfiles refuse to be written over stale sockets, FIFOs or symlinks, and log files must be regular files with a single link: a symlink, FIFO or hard link at a log path is refused rather than opened as root, and so is a symlink among its directories that root does not own. On a root start an existing log file in a directory that others than root may write to must already belong to the user sniproxy runs as, so that a link to a file of root's, put there and removed again around the single-link check, is not handed over. A root start also refuses a pidfile or a Unix socket listener whose path leads through a directory, above the one holding it, that others than root may write to, through its group too, or through a symlink root does not own: root creates and removes these by path, which such a user could make lead to any directory.
  • Privilege drop verification: startup aborts if real or effective UID is still 0 after setuid(). On Linux the main process keeps CAP_NET_RAW, and only when a listener uses source client, whether it was started as root or with capabilities given to its file or inherited as ambient ones; the seccomp filter keeps it from using it for raw or packet sockets, or to give a socket a firewall mark or a priority above 6 with setsockopt(), except on i386, s390x and other systems where socket() and setsockopt() go through socketcall(2). It can still make a socket transparent, which is what source client needs it for, and a compromised main process could still give its sockets IPv6 hop-by-hop and destination options, and the datagrams it sends a firewall mark or any priority in the control messages of sendmsg(), which a filter cannot read. The binder keeps CAP_NET_BIND_SERVICE, and CAP_DAC_OVERRIDE with a Unix socket listener under /run or /var/run; the logger and resolver keep none.
  • OpenBSD sandboxing: every process runs under pledge(2), and the main loop and the logger narrow their promises again once startup is done. unveil(2) limits the main loop, and the binder and resolver it forks afterwards, to the paths they need, which include /etc/resolv.conf and /etc/hosts, read only, for the resolver, the log files, the pidfile and listening, backend and fallback Unix socket paths themselves rather than their directories, and the connection dump directory, made at startup for the user sniproxy runs as. That view is fixed at startup, so a Unix socket path that a reload names outside it only works after a restart, and a reload cannot follow a configuration file symlink repointed to a file outside it. The logger is forked before that and unveils only its own log files just before it drops privileges, so a log file first named by a reload is only opened after a restart. A logger restarted by the health check runs within the main loop's view and promises instead. pledge(2) cannot let the resolver connect to its nameservers without letting it bind and accept as well, so a compromised resolver could take the clients of a listener that belongs to the same user, as one bound after the privilege drop does.
  • FreeBSD sandboxing: the logger enters Capsicum capability mode with its log directories pre-opened for openat(). The main process and the resolver stay out of it, since capability mode forbids connect(2) and they must reach backends and nameservers; the rights on their IPC descriptors are limited with cap_rights_limit(2) instead. SNIPROXY_DISABLE_CAPSICUM=1 turns Capsicum off for debugging.
  • Linux sandboxing: seccomp BPF filters per process type, when built with libseccomp (configure uses it if it finds it, and the build has no seccomp otherwise). The filters cover 32-bit systems (i386, armhf) as well, whose libc calls variants such as mmap2 and fcntl64, and refuse the TIOCSTI and TIOCLINUX ioctls, so that a process started with -f cannot push input into the operator's terminal. The main process may fork but not create namespaces, and netlink sockets are limited to NETLINK_ROUTE, except where socket(2) goes through socketcall(2), as on i386, so that a compromised process cannot reach kernel interfaces such as nf_tables from a user namespace of its own. IP sockets are limited to TCP and UDP, so that SCTP, L2TP and the like are neither loaded nor reached. Only the main process may listen and accept, and only it and the binder may bind: the helpers run as the same user, and could otherwise join a reuseport listener and take its clients. For the same reason the resolver may not set SO_REUSEPORT or SO_REUSEADDR, with which a socket that sends before it is bound can be given the port of such a listener, except where setsockopt() goes through socketcall(2). The binder may only receive and send with sendmsg() on its IPC socket, descriptor 0, which it may neither close nor replace, and read nothing, so that a socket it binds to a reuseport dtls listener's address does not simply give it that listener's datagrams, except where recvmsg() goes through socketcall(2). SNIPROXY_DISABLE_SECCOMP=1 turns it off for debugging. A helper killed by its filter, or on OpenBSD by a pledge violation, is logged by the main process as "process N was killed by signal S" before it is restarted. In daemon mode the first logger, forked before sniproxy detaches, is not a child of the main process: its death is not logged, and the health check restarts it within 30 seconds, logging that it was lost, or that it could not be pinged when nothing was logged in the meantime. Every process is also made non-dumpable, so that another process of the same user, a compromised helper included, cannot read or write its memory through /proc; as a consequence no core file is written.
  • macOS has no sandbox: sandbox_init(3) and its named profiles are deprecated, and a process opting into one is killed outright when built against the macOS 27.0 SDK or later, so adopting them would buy a hard failure rather than protection. Apple's replacement, App Sandbox, is built around entitlements and a per-application container and does not fit a daemon that binds a privileged port and writes system logs. Everything that does not need kernel support still applies: privilege separation, the privilege drop, encrypted IPC and the resource limits.
  • Continuous fuzzing: protocol fuzzers under tests/fuzz/ run in CI and on a dedicated continuous-fuzzing job. The job only files an issue when a real crash/leak/timeout artifact is produced (build errors are not treated as false-positive crashes).

Run the regression suite with:

make check

ASan, MSan, UBSan and a combined ASan+UBSan build all run on every push and pull request via the Sanitizers workflow. TSan is available locally through a configure flag (see SANITIZERS.md).

DNS resolution

Hostnames in table entries and fallbacks, and the name a client asked for when it matches a wildcard backend, are resolved by a dedicated sniproxy-resolver child built on c-ares. That gives:

  • Process isolation for DNS code paths
  • Configurable nameservers and search domains independent of the system resolver
  • IPv4/IPv6 preference modes for mixed-stack deployments
  • Concurrency caps, globally and per client, to bound resolver memory
  • Burst tolerance: when many new names arrive faster than the resolver process reads them, the lookups wait in order for room on the channel to it instead of failing with a 503

sniproxy does not validate DNSSEC itself. dnssec_validation relaxed (the default) only turns on EDNS0 in c-ares, and off leaves c-ares at its defaults. Nothing checks the AD flag, since c-ares cannot report it, so strict is accepted for existing configurations but treated as relaxed, with a warning when the configuration is read.

For production, run a local validating resolver (Unbound, dnsmasq) and point sniproxy at it. That is what provides DNSSEC protection, and it also reduces spoofing exposure and upstream query volume. Over plain UDP to a remote nameserver, forged replies could not only poison lookups but also make them fail: recent c-ares versions drop their socket, and count a try against every query on it, when a malformed datagram comes from the nameserver's address and port, whatever its query ID.

Performance

  • Event-driven I/O via libev; thousands of concurrent connections per process.
  • Per-connection buffers: each connection starts with a 16 KiB client buffer and a 32 KiB server buffer, which grow on demand up to the configured caps. Idle buffers shrink back, the client one down to 8 KiB and the server one to its initial 32 KiB.
  • Memory-pressure trimming: a global soft limit drives an aggressive shrink pass against idle buffers before total RAM balloons; the shrink candidate queue is itself bounded (4096 entries).
  • TCP_NODELAY on both sides to avoid Nagle coalescing delays.
  • SO_SPLICE zero-copy on OpenBSD: once the handshake is parsed the kernel splices client and server sockets directly; user-space buffers shrink to 4 KiB and the idle timer polls the kernel byte counters of both directions before closing a quiet connection.
  • JIT regex: PCRE2 JIT compilation is used where available. The packaged systemd unit forbids writable executable memory (MemoryDenyWriteExecute), so patterns are matched by the PCRE2 interpreter there.
  • HPACK ring buffer: HTTP/2 dynamic table inserts are O(1).
  • SO_REUSEPORT: run several sniproxy instances on the same port; on Linux 3.9+ the kernel spreads new connections across them. Linux only lets sockets share a port when the same user created them, so sniproxy creates its listeners as the user it runs as, even when started as root: instances sharing a port must run as the same user.
  • Hot reload: SIGHUP updates routing tables in place; connections that are already routed keep their backend.

Troubleshooting

"Address already in use" on start

A previous instance or another service is bound to the listener address. Inspect with ss -tlnp or netstat -tlnp. For multi-worker setups, set reuseport yes on the listener.

Connections are not routed (or hit the fallback)

  • Confirm the listener references the right table <name>.
  • Verify the pattern is a valid regex when it contains metacharacters (.*\\.example\\.com$, not *.example.com). The config parser consumes one backslash, so a backslash meant for the regex must be doubled; sniproxy -t prints each pattern as it will be compiled. Bare hostnames are auto-anchored, regexes are not.
  • An IPv6 address in an HTTP Host header is looked up with its brackets: match it with ^\\[2001:db8::1\\]$, since a bare 2001:db8::1 never matches and [2001:db8::1] is a character class.
  • Check the error log: a request without a hostname or one that fails to parse is logged as a warning, with the client address.

DNS is not working

  • Check that the resolver process is alive: on Linux pgrep -l sniproxy lists every sniproxy process by name, truncated to 15 characters, so it shows up there as sniproxy-resolv. On OpenBSD, where they are all named sniproxy, pgrep -lf sniproxy-resolver finds it. On macOS the processes cannot be told apart by name; the next step shows whether the resolver answers.
  • Run in the foreground with -d (see Debug mode below) to trace each lookup.
  • Verify the resolver { nameserver ... } config and network reachability.

Memory keeps climbing

  • Look for connections stuck in DNS resolution with a flaky upstream; lower max_concurrent_queries and max_concurrent_queries_per_client.
  • Lower connection_buffer_limit or server_buffer_limit: a connection whose client reads slower than its backend sends can buffer up to that much, although server buffers stop growing once all connections together use 64 MiB.

Permission errors on start

  • The configured user/group must exist.
  • The config file must be owned by root, or by the user sniproxy is started as when that is not root, and grant no permission to group or others; -g allows group read.
  • Log files are created at startup, before privileges are dropped, and handed over to that user, which may append to them but not read them: they lose their owner's read permission, and files created later have mode 0200. Read them as root or through a group read permission. If logs are rotated by renaming them, the log directory must be writable by that user so SIGHUP can create the new file. A log path that is a symlink, a FIFO or a file with more than one hard link is refused ("Too many links" for the latter), and on a root start so is one with a symlink not owned by root among its directories, or an existing file that user does not own in a directory others than root may write to. The files go to that user as soon as that is checked, so a start that fails later leaves none of root's behind, and -t run as root hands them over too. Where any user may hard link another's files, as on the BSDs, keep the log directory writable only by root and that user: a third user who may write to it can link one of that user's files at the log path for the moment of the checks, and have the log written to that file.
  • On OpenBSD, the directories holding the log files and the pidfile must already exist before launch, because unveil cannot reveal what is not there. The files themselves may be missing.

HTTP/2 connection coalescing routes to the wrong backend

HTTP/2 clients (browsers) will reuse a single TLS connection for any second hostname when (1) the two names resolve to the same IP and (2) the server certificate is valid for both (typical wildcard cert *.example.com). Since every name proxied by sniproxy resolves to the sniproxy IP, condition (1) is always satisfied. If the backend serves a shared cert, the browser will multiplex requests for different names over one connection, and sniproxy routes once per TCP connection from the SNI and cannot see the encrypted HTTP/2 frames, so subsequent requests are sent to the wrong backend.

Symptoms: 404s, CORS failures, "Access denied" responses, or content from the wrong site. Restarting the browser clears it temporarily.

Workarounds (in order of cleanness):

  1. Per-domain certificates on the backends instead of wildcards (Let's Encrypt makes this trivial). This is the most effective fix.
  2. Backends return HTTP 421 (Misdirected Request) for hostnames they do not serve. RFC 9110 says compliant browsers must retry on a fresh connection.
  3. Separate IPs per backend so the browser's IP-match check fails (IPv6 makes this easy).
  4. Disable HTTP/2 on backends by stripping h2 from ALPN. Loses HTTP/2 performance but eliminates coalescing.

For third-party services where you control neither the cert nor the backend (CDNs, hosted SaaS), there is no in-proxy workaround; use a TLS-terminating reverse proxy for those names.

A table limits which backends a client reaches, not which hosts

sniproxy routes a connection on the hostname of its first request and then passes the rest on untouched. Later requests on the same connection, whether HTTP/1 keep-alive or pipelined requests, other HTTP/2 streams or an h2c upgrade, reach the same backend whatever host they name, and over TLS sniproxy cannot see them at all. A client can therefore reach every virtual host of a backend the table sends it to, including hosts the table does not route: a backend that serves such hosts must refuse them itself, for instance with HTTP 421 (Misdirected Request) for a host other than the one its connection was routed for.

Debug mode

sniproxy -f -d -c /etc/sniproxy.conf

-f keeps the process in the foreground; -d turns on verbose resolver tracing. The resolver process writes it to stderr or to syslog: it cannot write to a file error log owned by the main process, so with error_log { filename ... } its messages go to syslog with the daemon facility.

How it compares

All five of these can route a connection by the name the client asked for without decrypting it. They differ in what else they are, and in what they bring to that job.

sniproxy (this fork) sniproxy (upstream) HAProxy nginx stream Envoy
Routes by name, no decryption yes yes yes (req.ssl_sni, mode tcp) yes (ssl_preread) yes (TLS inspector)
Protocols routed by name TLS, HTTP/1, HTTP/2, XMPP, Minecraft, DTLS TLS, HTTP TLS, HTTP TLS (SNI, ALPN) TLS, HTTP
Name-based UDP / DTLS yes, with a source address check no no no, ssl_preread is TCP only no
Process model 4 processes, separate privileges 2 processes (main + privileged binder) single process, threaded (optional master) master + workers single process, threaded
Sandbox shipped with it pledge/unveil, Capsicum, seccomp privilege drop chroot, privilege drop privilege drop (user) left to the deployment
Encrypted IPC between its own processes ChaCha20-Poly1305 no no no n/a
What else it is an SNI router an SNI router a full L4/L7 load balancer a web server and L4 proxy a full service proxy

The table covers the name-routing path only. HAProxy, nginx and Envoy are general-purpose proxies with far larger feature sets, and on a host where you already run one of them, adding sniproxy buys you little. It earns its own process when you want name-based routing on its own, with a small attack surface, on a machine that terminates no TLS at all.

Third-party cells come from each project's own documentation or source code: ssl_preread, HAProxy configuration manual, HAProxy management guide, Envoy TLS inspector, Envoy UDP proxy, upstream sniproxy.

Project status

SNIProxy is actively maintained with a focus on security, stability and standards compliance. Recent releases have mostly carried fixes from repeated audits of the whole source tree, in the resolver and DNS-over-TLS, configuration handling, the per-IP limits and logging, along with sandboxing fixes for OpenBSD, FreeBSD and Linux.

Common deployments:

  • Name-based HTTPS virtual hosting without TLS termination
  • TLS / SSL load balancing by SNI across backend pools
  • Multi-tenant hosting (multiple domains, distinct backend infrastructure, single public IP)
  • CDN origin selection by hostname
  • XMPP federation routing with STARTTLS passthrough
  • Multi-server Minecraft Java Edition hosting behind one IP and port
  • DTLS routing by hostname, without decryption, for clients that send SNI, such as CoAP over DTLS (WebRTC and OpenConnect clients send none, so they can only be sent to the fallback)
  • Local development HTTPS routing
  • Lightweight SNI routing on IoT and embedded systems

Contributing

Contributions are welcome. Areas of particular interest:

  • Additional protocol parsers
  • Performance work
  • Additional fuzz harnesses or sanitizer coverage
  • Documentation
  • Bug reports with reproducers

Please build with the sanitizers and run make check locally before opening a pull request. The ASan, MSan, UBSan and Valgrind workflows run automatically on every push and pull request.

Resources

Credits

Current maintainer: Renaud Allard <renaud@allard.it>

Original author: Dustin Lundquist <dustin@null-ptr.net>

Contributors: Chris Lundquist, Igor Novgorodov, Nikos Mavrogiannopoulos, Vit Herman, Remi Gacogne, Pieter Lexis, Oldrich Jedlicka, Nick Kugaevsky, Manuel Kasper, Lars Reemts, Bearnard Hibbins, Robin Balyan, Andrej Manduch, Andreas Loibl, Aaron Schrab, Zhang Sen, Udit Raikwar, Thomas Nordquist, Theophile Helleboid, Sebastian Wiedenroth, RickieL, Pierre-Olivier Mercier, Peter van Dijk, Naveen Nathan, Marc Haber, Kirill Ponomarev, John Wang, imlonghao, Christopher Galtenberg, Bram Gotink, Arni Birgisson.

Built on:

All production testing is performed on OpenBSD. Patches and bug reports for other platforms are welcome.

About

Proxies incoming HTTP and TLS connections based on the hostname contained in the initial request of the TCP session.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages