TCP routing

Sōzune can forward raw TCP traffic to your services. Two pieces:

  • Listeners are declared statically in config.yaml. Each listener binds a port at startup.
  • Backends attach dynamically through Docker labels and reference a listener by name.

A label that points at an undeclared listener is ignored with a warning. Sōzune does not open ports on the fly from labels alone — by design, to keep startup state predictable.

Declare listeners

proxy:
  http:
    listen_address: 80
  https:
    listen_address: 443
  tcp:
    - name: postgres
      listen: 5432
      ip_allow_list: ["94.23.3.96", "172.16.0.0/12"]
    - name: redis
      listen: 6379
FieldDescription
nameIdentifier referenced by service labels. Must be unique across proxy.tcp.
listenPort to bind on 0.0.0.0.
ip_allow_listCIDRs / bare IPs allowed to connect, checked at accept(). Empty (default) = allow all. See Source-IP allow-list.
sni_preread_timeoutSeconds to wait for a complete TLS ClientHello before dropping the connection. Default 5. Only consulted when the listener carries SNI routes. See Route by SNI.
sni_preread_max_bytesCap on the bytes buffered while looking for the ClientHello. Default 16384. Bounds the hello, not the connection.

Attach a backend with Docker labels

services:
  db:
    image: postgres:16
    labels:
      - "sozune.enable=true"
      - "sozune.tcp.db.entrypoint=postgres"
      - "sozune.tcp.db.port=5432"
LabelDescription
sozune.tcp.<svc>.entrypointName of a listener declared under proxy.tcp. Required. Routes are dropped with diagnostic E005 if missing.
sozune.tcp.<svc>.portBackend port on the container. Defaults to 8080 (informational diagnostic emitted).
sozune.tcp.<svc>.priorityHigher wins when multiple services share the same listener (default 0).
sozune.tcp.<svc>.sniRoute TLS connections to this backend by the name in the ClientHello, without terminating TLS. Absent (default) = the listener forwards everything to one backend. See Route by SNI.

The container's IP is resolved through the same network rules as HTTP — see Docker labels and sozune.network to pick a network when the container is on several.

Source-IP allow-list

A TCP listener can restrict which source IPs may connect, set on the listener (not the backend) since it gates the public port:

proxy:
  tcp:
    - name: postgres
      listen: 5432
      ip_allow_list: ["94.23.3.96", "172.16.0.0/12"]

Sōzune owns the public port and runs a small forwarder in front of the Sōzu worker (which binds a private loopback port). The forwarder checks the connecting peer's IP against the list at accept(); a connection from a non-listed source is closed without reaching the backend. Entries are bare IPs (promoted to /32 or /128) or CIDR blocks. An empty or absent ip_allow_list allows all sources. Invalid entries are dropped (the list can only narrow, never widen), and an all-invalid list falls back to allow-all rather than black-holing the listener.

This is the direct equivalent of HAProxy's tcp-request connection reject unless <acl> and is sufficient for the common case (allow a bastion + internal ranges, reject the rest).

For a database exposed to the public internet, you can additionally put a kernel firewall in front — it drops packets before the TCP handshake (the forwarder, like HAProxy, filters after accept), which also blunts SYN floods:

nft add rule inet filter input tcp dport 5432 ip saddr { 94.23.3.96, 172.16.0.0/12 } accept
nft add rule inet filter input tcp dport 5432 drop

Sōzune neither reads nor manages these rules — they live entirely in your firewall. The allow-list above is enough on its own for most deployments; the firewall is an optional hardening layer.

Anti-flood (per-source connection rate)

A listener can cap the connection rate per source IP, enforced by the same forwarder:

proxy:
  tcp:
    - name: postgres
      listen: 5432
      rate_limit:
        max_conns: 10        # burst absorbed at once
        per_seconds: 3       # sustained refill: max_conns / per_seconds per second
        exempt: ["172.16.0.0/12"]

This is a token bucket: a source may open max_conns connections back-to-back (the burst), after which it refills at max_conns / per_seconds per second. A source over its budget is dropped at accept(). Sources matching exempt are never limited — use it for internal ranges (e.g. Docker) that open legitimate startup bursts.

It covers the same ground as HAProxy's stick-table … conn_rate(3s) + reject if { src_conn_rate gt N } !exempt. The token bucket smooths the rate rather than counting a fixed 3-second window, so a brief startup burst is tolerated and a sustained flood is throttled — without the boundary double-burst a fixed window allows. Absent rate_limit = no limit.

Route by SNI (TLS passthrough)

Without an sni label, a TCP listener forwards everything it accepts to a single backend. Add one and Sōzune reads the server name from the TLS ClientHello — which travels in the clear, before any key exchange — and picks a backend from it. TLS is never terminated: the client completes its handshake with the backend, using the backend's own certificate.

services:
  app-a:
    image: my/app-a
    labels:
      - "sozune.enable=true"
      - "sozune.tcp.appa.entrypoint=tlsgw"
      - "sozune.tcp.appa.port=8443"
      - "sozune.tcp.appa.sni=a.example.com"

  app-b:
    image: my/app-b
    labels:
      - "sozune.enable=true"
      - "sozune.tcp.appb.entrypoint=tlsgw"
      - "sozune.tcp.appb.port=8443"
      - "sozune.tcp.appb.sni=*.b.example.com"

Both share one public port; the name decides where each connection lands. Use this when the backend must own its certificates — end-to-end encryption to the service, a tenant terminating its own TLS, or a protocol Sōzune does not speak.

Accepted patterns. An exact hostname (a.example.com) or a single leading wildcard label (*.b.example.com). The wildcard matches exactly one label: *.b.example.com matches api.b.example.com, but neither b.example.com itself nor x.y.b.example.com. Anything else — a wildcard elsewhere in the name, a bare *, a /, an empty label, a non-ASCII character — is refused with diagnostic W028, and the route falls back to catch-all. Write internationalised names as punycode A-labels (xn--caf-dma.example.com); a Unicode pattern would load and then never match what is on the wire.

A listener is either SNI-routed or catch-all, never both. This is a Sōzu constraint, and it differs from what Traefik or HAProxy allow. If some entrypoints on a listener declare an sni and others do not, Sōzune logs which ones are the odd ones out and leaves them unapplied — the rest of the listener keeps working. Give them an sni of their own, or move them to a separate listener, and the next reload picks them up.

Unmatched names are dropped. Once a listener carries SNI routes there is no fallback: a connection whose name matches nothing, or which sends no SNI at all, is closed without reaching any backend. Declare a route for every name you intend to serve.

sni is TCP-only. On a sozune.udp.* service it emits W028 and is ignored — datagrams carry no ClientHello.

Limitations

  • No TLS termination. Sōzu's TCP path is pure passthrough — TLS bytes flow as-is. For client-side STARTTLS protocols (PostgreSQL, MySQL) this is fine; for terminating TLS, use an HTTPS entrypoint instead. Passthrough can still route by name without decrypting — see Route by SNI.
  • No half-close. Sōzu treats a client FIN as a full disconnect, so protocols that rely on half-closing one direction to signal end-of-stream may misbehave. Most request/response and long-lived stream protocols are unaffected.
  • The allow-list and rate limit are filtered after accept(), not before the handshake — see the firewall note above for pre-handshake dropping.
  • The rate limit resets on restart. Token buckets are in-memory per process; a Sōzune restart clears them. Listeners (and their limits) are static config, applied at boot.

Errors and diagnostics

CodeMeaning
E005A sozune.tcp.<svc> service has no entrypoint= label. The route is dropped.
W028An sni= label is not a routable pattern, or was set on a UDP service. The SNI is dropped and the route falls back to catch-all. Run sozune explain W028 for the accepted shapes.
Log: references undeclared listenerThe label points at a name not present in proxy.tcp. The route is dropped. Add the listener to the config or fix the label.
Log: mixes SNI-routed and catch-all entrypointsOne listener carries both shapes, which Sōzu refuses. The named entrypoints are left unapplied and retried on the next reload. Give them an sni, or move them to their own listener.