Before a deep dive in the configuration part of the proxy, you should take a look at the getting started documentation if you haven't yet.
The configuration file uses the .toml format.
Sōzu configuration process involves 3 major sources of parameters:
- The
globalsection, which sets process-wide parameters. - The definition of the protocols like
https,http,tcp. - The clusters sections under:
[clusters].
Parameters in the global section allow you to define the global settings shared by the main process and workers (like the log level):
| parameter | description | possible values |
|---|---|---|
saved_state |
path from which sozu tries to load its state at startup | |
automatic_state_save |
if saved_state is set, persists state to it whenever a command changes routing. Defaults to false |
true, false |
log_level |
possible values are | debug, trace, error, warn, info |
log_target |
possible values are | stdout, tcp or udp address |
log_colored |
emit ANSI colour codes on the main log stream. Only honoured when log_target is stdout (tcp / udp / file sinks ignore this flag). Defaults to false |
true, false |
access_logs_target |
possible values are (if activated, sends access logs to a separate target) | stdout, tcp or udp address |
access_logs_format |
wire format of access logs. Defaults to ascii |
ascii, protobuf |
access_logs_colored |
emit ANSI colour codes on access logs. Only honoured when the access-log target is stdout. If unset, inherits log_colored. Defaults to false |
true, false |
command_socket |
path to the unix socket command | |
command_buffer_size |
size, in bytes, of the buffer used by the main process to handle commands. | |
max_command_buffer_size |
maximum size of the buffer used by the main process to handle commands. | |
worker_count |
number of workers | |
worker_automatic_restart |
if activated, workers that panicked or crashed are restarted (activated by default) | |
worker_timeout |
maximum time (in seconds) the main process waits for a worker reply before marking it NotAnswering. Defaults to 10 |
seconds |
disable_cluster_metrics |
if true, per-cluster metrics are not registered. Defaults to false (cluster metrics enabled) |
true, false |
handle_process_affinity |
bind workers to cpu cores. | |
max_connections |
maximum number of simultaneous / opened connections | |
max_buffers |
maximum number of buffers use to proxying | |
min_buffers |
minimum number of buffers preallocated for proxying | |
buffer_size |
size, in bytes, of requests buffer used by the workers. Must be at least 16393 for HTTP/2 (16384 max frame size + 9 byte frame header) | |
slab_entries_per_connection |
how many slab entries each max_connections reserves. Defaults to 4 (1 frontend + up to 3 backend H2 connections). Raise for fan-out topologies that exceed 4 backends per session; clamped to [2, 32]. Slab capacity is 10 + slab_entries_per_connection * max_connections. |
integer 2-32 |
splice_pipe_capacity_bytes |
requested kernel-pipe capacity, in bytes, per splice(2) direction on Protocol::TCP listeners (Linux only, requires the splice cargo feature). Omitted or None keeps the kernel default of 64 KiB. Applied via fcntl(F_SETPIPE_SZ) per pipe at session start; the kernel rounds up to a page boundary and clamps at /proc/sys/fs/pipe-max-size (default 1 MiB unprivileged; CAP_SYS_RESOURCE goes higher). The realised capacity is read back via fcntl(F_GETPIPE_SZ) and used as the per-call len for splice_in. Larger values amortise syscalls and reduce wakeups for bulk-transfer workloads at the cost of per-session pinned memory; raise /proc/sys/fs/pipe-max-size first if you want above 1 MiB without root. |
integer (bytes), e.g. 262144 |
command_allowed_uids |
optional allowlist of POSIX UIDs permitted to invoke command-socket requests (PR #1209). Omitted or None preserves the historical "any same-UID local process" behaviour. Set to [<operator_uid>] to restrict mutating verbs to a specific UID even when other same-UID daemons coexist (CI runners, monitoring scrapers). Rejected requests still appear in the audit trail. |
TOML array of integers, e.g. [1000] |
ctl_command_timeout |
maximum time the command line will wait for a command to complete | |
pid_file_path |
stores the pid in a specific file location | |
front_timeout |
maximum time of inactivity for a front socket | |
back_timeout |
maximum time of inactivity for a backend socket (seconds). Defaults to 30. Can be overridden per listener. |
seconds |
connect_timeout |
maximum time of inactivity for a request to connect | |
accept_queue_timeout |
maximum time (in seconds) a TCP connection stays in sozu's accept queue before being dropped. Defaults to 60. |
seconds |
request_timeout |
maximum time of inactivity for a request | |
zombie_check_interval |
duration between checks for zombie sessions | |
evict_on_queue_full |
evict the least-recently-active sessions when max_connections is reached, making room for new accepts. Defaults to false: during a DDoS the existing connections are more likely to be legitimate clients than the queued ones, so refusing new accepts is the safer mitigation. Enable when overload is dominated by normal traffic spikes. Triggers a config-load warn! when max_connections < 100 because the 1% eviction batch clamps to 1 (so the per-round share grows). |
true, false (default: false) |
activate_listeners |
automatically start listeners | |
max_connections_per_ip |
global default per-(cluster, source-IP) connection limit. 0 disables the feature (default). The source IP is taken from the parsed PROXY-protocol header when present, else peer_addr. HTTP/HTTPS clients hitting the limit receive 429 Too Many Requests; TCP clients see a graceful FIN. Each cluster may override via its own max_connections_per_ip field (None inherits, Some(0) is explicit unlimited, Some(n > 0) overrides). Counters are kept per (cluster_id, source_ip), so two clusters never share a counter. |
integer (0 = unlimited) |
retry_after |
global default Retry-After header value (seconds) for HTTP 429 responses. 0 omits the header — Retry-After: 0 invites an immediate retry that defeats the limit. Each cluster may override. TCP listeners ignore this value (no HTTP envelope). |
integer (0 = omit) |
Example:
command_socket = "./command_folder/sock"
saved_state = "./state.json"
log_level = "info"
log_target = "stdout"
command_buffer_size = 16384
worker_count = 2
handle_process_affinity = false
max_connections = 500
max_buffers = 500
buffer_size = 16393
activate_listeners = truelog_target at debug/trace emits per-connection context on every protocol
log line, including:
- peer IP (client source address)
- SNI (TLS hostname the client requested) on TLS listeners
- a per-session ULID generated at connection accept
- mio frontend token and cluster/backend identifiers
Each of these is an identifier under most data-protection regimes (GDPR, CNIL
guidance on proxy logs). Keep production workers at log_level = "info" or
tighter unless debugging; access logs (access_logs_target) carry the same
fields in a shape meant for long-term retention and should be the durable store.
Retention of the live log stream depends on the sink:
stdout: inherited from the surrounding process supervisor (journald, the init system, the container runtime). On Clever Cloud ADCs the journald cap is size-bounded at ~4 GB — time coverage varies with traffic.tcp:///udp://: forwarded to the remote collector; retention becomes the collector's responsibility. Confirm DPA coverage before routing logs to a third party.
If logs egress beyond Clever Cloud infrastructure, the per-session ULID plus peer IP plus SNI combination is a durable cross-system correlator — treat it accordingly in your privacy impact assessment.
The listener section describes a set of listening sockets accepting client connections. You can define as many listeners as you want. They follow the format:
General parameters:
[[listeners]]
# possible values are http, https, tcp or udp
protocol = "http"
# listening address
address = "0.0.0.0:8080"
# address = "[::]:8080"
# specify a different IP than the one the socket sees, for logs and forwarded headers
# public_address = "1.2.3.4:80
# Configures the client socket to receive a PROXY protocol header
# (TCP listeners only — not supported on UDP listeners)
# expect_proxy = falseSince version 1.0.0, Sōzu allows custom HTTP answers defined for HTTP and HTTPS listeners.
These answers are customizable:
- 301 Moved Permanently
- 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 408 Request Timeout
- 413 Payload Too Large
- 502 Bad Gateway
- 503 Service Unavailable
- 504 Gateway Timeout
- 507 Insufficient Storage
These answers are to be provided in plain text files of whichever extension (we
recommend .http for clarity) and may look like this:
HTTP/1.1 404 Not Found Cache-Control: no-cache Connection: close Sozu-Id:
%REQUEST_ID
<style>
pre {
background: #eee;
padding: 10px;
border: 1px solid #aaa;
border-radius: 5px;
}
</style>
<h1>404 Not Found</h1>
<p>
insert your custom text here, in fact, all HTML is changeable, including the
CSS.
</p>
<pre>
{
\"route\": \"%ROUTE\",
\"request_id\": \"%REQUEST_ID\",
}
</pre>
<footer>This is an automatic answer by Sozu.</footer>
",There are a number of available template variables, like REQUEST_ID or
CLUSTER_ID, that will be replaced by the proxying logic when producing the
error.
To create your own custom HTTP answers, we highly suggest you first copy the
default answers present in lib/src/protocol/kawa_h1/answers.rs, and then
change them to your liking. Feel free to remove the \r newlines of the default
strings for clarity. Sōzu will parse your file and replace whatever newline
symbol(s) you use.
Then, for each listener, provide the absolute paths of each custom answer.
# a 404 response is sent when sozu does not know about the requested domain or path
answer_404 = "/path/to/my-404-answer.http"
# a 503 response is sent if there are no backend servers available
answer_503 = "/path/to/my-503-answer.http"
# answer_507 = ...If a frontend has a sticky_session, the sticky name is defined at the listener
level.
# defines the sticky session cookie's name, if `sticky_session` is activated format
# a cluster. Defaults to "SOZUBALANCEID"
sticky_name = "SOZUBALANCEID"Crypto provider: The cryptographic backend used by Rustls is a compile-time choice (feature flags
crypto-ring,crypto-aws-lc-rs,crypto-openssl,fips). It cannot be changed at runtime. See Getting started — Choosing a crypto provider for build instructions.
# supported TLS versions. Possible values are "SSL_V2", "SSL_V3",
# "TLS_V12", "TLS_V13". Defaults to "TLS_V12" and "TLS_V13"
tls_versions = ["TLS_V12", "TLS_V13"]HSTS = HTTP Strict Transport Security (RFC 6797). When configured on an HTTPS listener or frontend, Sōzu emits the Strict-Transport-Security response header so conformant browsers refuse to talk to the host over plaintext HTTP for the configured max-age duration.
A [hsts] block under an HTTPS listener is the operator-default; per-frontend overrides live under [clusters.<id>.frontends.hsts]. Per-frontend enabled = true overrides the listener default; per-frontend enabled = false explicitly suppresses an inherited listener default for that frontend.
Per RFC 6797 §7.2 the Strict-Transport-Security header MUST NOT appear on plaintext-HTTP responses. Sōzu enforces this in three layers: (1) TOML config-load rejects [hsts] on a plain-HTTP listener (ConfigError::HstsOnPlainHttp), (2) the worker IPC entry rejects AddHttpFrontend carrying an enabled HSTS (ProxyError::HstsOnPlainHttp and the http.hsts.suppressed_plaintext counter), and (3) the runtime per-stream snapshot copy is gated on context.protocol == Protocol::HTTPS so plaintext connections never apply HSTS edits even when one slips through.
No — it is optional everywhere. Omitting the block on every listener and every frontend is a fully valid configuration: no Strict-Transport-Security header is ever emitted.
| Surface | Required? | Notes |
|---|---|---|
[hsts] under a plain-HTTP listener / frontend |
N/A | The field does not exist on the HTTP listener proto. Setting it on an HTTP frontend is rejected at config-load (RFC §7.2). |
[hsts] under an HTTPS listener (operator default) |
Optional | Omit the block and no listener-default HSTS is set. HTTPS frontends without their own block then carry no HSTS. |
[hsts] under a per-frontend section |
Optional | Omit and the frontend inherits the listener default (or nothing if the listener has none). |
[hsts] is not a cluster-level field |
N/A | HSTS lives on frontends (Caddy / Traefik shape), not on clusters. |
UpdateHttpsListenerConfig.hsts (hot-reconfig partial patch) |
Optional | Absent in the patch preserves the current listener HSTS value. Present = full-object replacement (see below). |
The only conditional required-field rule is inside the block itself: when the [hsts] block is present (TOML or partial-update patch), the enabled field must also be present. This disambiguates three otherwise-conflated semantics:
| TOML / patch shape | Meaning |
|---|---|
| Block omitted | Inherit listener default (or no HSTS if the listener has none). |
[hsts] with enabled = true |
Explicitly enable HSTS at this scope. max_age defaults to 31_536_000 if omitted. |
[hsts] with enabled = false |
Explicitly disable HSTS at this scope, suppressing any inherited listener default. |
[hsts] without enabled |
Error — ConfigError::HstsEnabledRequired (TOML) or ListenerError::HstsEnabledRequired (partial update). |
A minimal HTTPS deployment with no HSTS is therefore just:
[[listeners]]
protocol = "https"
address = "0.0.0.0:443"
# no [hsts] block — fine
[[clusters.api.frontends]]
address = "0.0.0.0:443"
hostname = "api.example.com"
# no [hsts] block — fine# Listener-level default — every HTTPS frontend on this listener
# inherits unless it declares its own [hsts] block. The `[hsts]`
# table nests under the enclosing `[[listeners]]` entry.
[[listeners]]
protocol = "https"
address = "0.0.0.0:443"
[hsts]
# REQUIRED whenever the [hsts] block is present. `false` is the
# explicit-disable signal on a partial-update; new TOML deployments
# will normally set `true`.
enabled = true
# `Strict-Transport-Security: max-age=<seconds>`. When omitted with
# `enabled = true`, sozu substitutes 31_536_000 seconds (1 year — the
# Chrome HSTS preload list minimum) at config-load.
max_age = 31536000
# Append `; includeSubDomains` to the rendered header.
include_subdomains = true
# Append `; preload` to the rendered header. Opt-in only — once
# submitted to https://hstspreload.org/, removal is slow and partial
# (RFC 6797 §14.2). NEVER default-true.
preload = falsePer-frontend override or explicit disable:
[[clusters.api.frontends]]
address = "0.0.0.0:443"
hostname = "api.example.com"
# Override the listener default with a longer 2-year max-age and
# opt the host into the preload list.
[clusters.api.frontends.hsts]
enabled = true
max_age = 63072000
include_subdomains = true
preload = true
# Suppress the inherited listener default for a legacy frontend that
# cannot commit to HSTS yet:
[[clusters.legacy.frontends]]
address = "0.0.0.0:443"
hostname = "legacy.example.com"
[clusters.legacy.frontends.hsts]
enabled = false| Configuration | Outcome |
|---|---|
[hsts] block without enabled |
Error HstsEnabledRequired at config-load. enabled is the explicit-disambiguator between disable / enable on partial updates. |
[hsts] on a plain-HTTP listener or frontend |
Error HstsOnPlainHttp at config-load (and ProxyError::HstsOnPlainHttp if it slipped through to the worker). RFC 6797 §7.2. |
enabled = true, max_age omitted |
Substituted to DEFAULT_HSTS_MAX_AGE = 31_536_000 (1 year). Matches the HSTS preload list minimum. |
max_age = 0 |
Allowed silently — RFC 6797 §11.4 kill switch. Conformant UAs stop treating the host as a Known HSTS Host. |
0 < max_age < 86_400 |
Warning at config-load (likely misconfiguration — sub-day HSTS only makes sense for testing). |
preload = true with max_age < 31_536_000 |
Warning at config-load. The Chrome HSTS preload list rejects hosts below the minimum. |
preload = true without include_subdomains = true |
Warning at config-load. Same preload-list rejection. |
Backend emits its own Strict-Transport-Security |
Pass-through unchanged by default. Sōzu's HSTS edit uses HeaderEditMode::SetIfAbsent so a single header reaches the wire (RFC 6797 §6.1). Set force_replace_backend = true to override the backend value with sōzu's typed policy (HeaderEditMode::Set — delete-then-insert). |
| HTTPS-served default answer (3xx redirect, 401, 503) | Carries the HSTS header per RFC 6797 §8.1. The per-stream snapshot copy fires before the early returns in mux/router.rs, gated on Protocol::HTTPS. |
Per-frontend override (replaces the listener default for one frontend):
sozu frontend https add \
--address 0.0.0.0:443 \
--hostname api.example.com \
--hsts-max-age 31536000 \
--hsts-include-subdomains
Listener-default patch (replaces the policy for every frontend that inherits from the listener):
sozu listener https update \
-a 0.0.0.0:443 \
--hsts-max-age 31536000 \
--hsts-include-subdomains
Both surfaces share the same flag set: --hsts-max-age, --hsts-include-subdomains, --hsts-preload, --hsts-force-replace-backend, and the kill-switch --hsts-disabled. The latter is mutually exclusive with the four enabling flags on either path; combining them surfaces CtlError::ArgsNeeded from the shared build_hsts_from_cli helper rather than a silent pick.
UpdateHttpsListenerConfig.hsts follows full-object replacement semantics: when present in the patch, the entire HSTS block replaces the listener's current value. enabled is REQUIRED whenever hsts is present (ListenerError::HstsEnabledRequired rejects an enabled = None block). Absent hsts field on the patch preserves the current value. The CLI surface above (sozu listener https update --hsts-*) feeds this same partial-update message; supplying any --hsts-* flag on the command line replaces the listener's HSTS policy and supplying --hsts-disabled substitutes the explicit-disable block (enabled = Some(false)).
Inheriting frontends are refreshed automatically. Patching the listener-default HSTS reflows the new policy onto every existing frontend that inherited from the listener (i.e. has no per-frontend [hsts] block at add time). Router::refresh_inheriting_hsts walks the routing trie via two paths:
- Existing
Route::Frontendentries withinherits_listener_hsts == truehave theirheaders_responserebuilt — any priorStrict-Transport-Securityentry is stripped and a freshly rendered one appended. - Lightweight
Route::ClusterIdandRoute::Denyentries — the routing fast path stores frontends without per-frontend policy fields (no redirect, no rewrite, no headers, no auth, no HSTS) under these compact variants, NOT asRoute::Frontend— are promoted in place to a minimalRoute::Frontendcarrying just the HSTS edit onheaders_response. The promoted entry is markedinherits_listener_hsts = trueso subsequent patches refresh it through path 1. Routing semantics are preserved (the promoted Frontend forwards / denies identically to the original variant). When the new policy resolves to "no HSTS" (None / disabled) the lightweight routes are left untouched — no allocation is created just to hold an empty edit.
Per-frontend explicit overrides (inherits_listener_hsts == false on path 1) are never touched. The http.hsts.listener_default_patched counter fires once per patch and http.hsts.frontend_refreshed increments per refreshed frontend (path 1 + path 2 promotions combined), so dashboards can correlate the rate of patches with the size of the affected fleet.
Path 2 was added after the initial HSTS rollout to fix a silent skip that affected fleets where most frontends are added without per-frontend policy fields (e.g. Clever Cloud cleverapps.io shared, where 91 k+ frontends were stored as Route::ClusterId and never received the listener-default HSTS edit).
| Metric | Kind | Emitted when |
|---|---|---|
http.hsts.frontend_added |
counter | A frontend is added with hsts.enabled = true and the HSTS edit is materialised in headers_response. |
http.hsts.suppressed_plaintext |
counter | An AddHttpFrontend IPC was rejected because it carried an enabled HSTS policy (RFC 6797 §7.2 defense in depth). |
http.hsts.unrendered |
counter | Defense-in-depth: a frontend reached Frontend::new with hsts.enabled = true but render_hsts returned None (max_age missing). The TOML loader and the CLI helper both substitute DEFAULT_HSTS_MAX_AGE so this counter only fires when a programmatic IPC sender ships an ill-formed HstsConfig. |
http.hsts.listener_default_patched |
counter | A UpdateHttpsListenerConfig.hsts patch was applied. Fires once per patch. |
http.hsts.frontend_refreshed |
counter | An inheriting frontend was refreshed during a listener-default HSTS patch (one increment per refreshed entry). Sum across a patch interval = number of frontends touched by that patch. |
# Sets the list of available cipher suites, in order of preference.
# If omitted, the following default list is used (ANSSI-recommended order):
cipher_list = [
# TLS 1.3 cipher suites
"TLS13_AES_256_GCM_SHA384",
"TLS13_AES_128_GCM_SHA256",
"TLS13_CHACHA20_POLY1305_SHA256",
# TLS 1.2 cipher suites
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
]HTTP/2 is available on HTTPS listeners through ALPN (Application-Layer Protocol
Negotiation). During the TLS handshake, Sōzu advertises protocols from the
alpn_protocols list. The server selects the first protocol from its list that
the client also supports.
By default, Sōzu advertises both h2 and http/1.1, preferring HTTP/2:
# Default: both protocols, H2 preferred
alpn_protocols = ["h2", "http/1.1"]| Value | Protocol | Notes |
|---|---|---|
h2 |
HTTP/2 | Multiplexed, binary framing (RFC 9113) |
http/1.1 |
HTTP/1.1 | Traditional text-based protocol (RFC 9112) |
Invalid values are rejected at configuration load time. Order matters: the first entry is the most preferred protocol.
Examples:
# HTTP/1.1 only — disables HTTP/2 on this listener
alpn_protocols = ["http/1.1"]
# HTTP/2 only — clients without H2 support will fail TLS negotiation
alpn_protocols = ["h2"]
# Prefer HTTP/1.1 over HTTP/2
alpn_protocols = ["http/1.1", "h2"]When alpn_protocols is omitted or empty, the default ["h2", "http/1.1"] is
used. Clients that do not send an ALPN extension default to HTTP/1.1.
Note: HTTP/2 is only supported over TLS (HTTPS listeners). Plain HTTP listeners always use HTTP/1.1.
Note: When using the
fipscrypto provider, CHACHA20_POLY1305 cipher suites are not available. Only AES-GCM suites are FIPS-approved. Sōzu'scipher_suite_by_namefilters its result through the active provider's supported set so a misconfiguredcipher_listcannot silently downgrade an FIPS build.
The groups_list option controls which key exchange algorithms are offered
during the TLS handshake. Groups are listed in order of preference.
# Default: ["X25519MLKEM768", "x25519", "P-256", "P-384"]
groups_list = ["X25519MLKEM768", "x25519", "P-256", "P-384"]| Group name | Description | Provider support |
|---|---|---|
x25519 / X25519 |
Curve25519 ECDHE | All providers |
secp256r1 / P-256 |
NIST P-256 ECDHE | All providers |
secp384r1 / P-384 |
NIST P-384 ECDHE | All providers |
X25519MLKEM768 |
Post-quantum hybrid (X25519 + ML-KEM 768) | crypto-aws-lc-rs, crypto-openssl (OpenSSL 3.5+) |
Unknown or unsupported group names are silently skipped with a log warning. This
allows using the same configuration across different crypto providers — for
example, X25519MLKEM768 is safely ignored when building with crypto-ring.
Examples:
# Post-quantum enabled (default). PQ-capable clients negotiate X25519MLKEM768,
# others fall back to classical X25519.
groups_list = ["X25519MLKEM768", "x25519", "P-256", "P-384"]
# Classical only (explicitly disable post-quantum)
groups_list = ["x25519", "P-256", "P-384"]
# FIPS 140-3 compliant (NIST curves only, no X25519)
groups_list = ["P-256", "P-384"]Post-quantum key exchange: X25519MLKEM768 is a hybrid scheme that combines classical X25519 with the ML-KEM 768 post-quantum algorithm. It protects against future quantum computer attacks while maintaining security against current classical attacks. The handshake is slightly larger (~1 KB overhead) but has negligible latency impact. Clients that do not support it automatically negotiate a classical group.
TLS certificates can be configured in two ways:
1. Default certificate on the HTTPS listener (without SNI):
[[listeners]]
protocol = "https"
address = "0.0.0.0:8443"
certificate = "/path/to/certificate.pem"
certificate_chain = "/path/to/chain.pem"
key = "/path/to/private-key.pem"2. Per-frontend certificates (with SNI, recommended):
[clusters.MyCluster]
protocol = "http"
frontends = [
{ address = "0.0.0.0:8443", hostname = "example.com",
certificate = "/path/to/example.com.pem",
certificate_chain = "/path/to/chain.pem",
key = "/path/to/example.com.key" },
]
backends = [
{ address = "127.0.0.1:8080" }
]Sōzu supports the following certificate and key types:
| Type | Key format | Notes |
|---|---|---|
| RSA 2048+ | PKCS#1 or PKCS#8 PEM | Most common, widely supported |
| ECDSA P-256 | SEC1 or PKCS#8 PEM | Faster handshakes, smaller certificates |
| ECDSA P-384 | SEC1 or PKCS#8 PEM | Higher security margin |
All certificate files must be PEM-encoded. The certificate_chain should
contain intermediate CA certificates (not the root CA).
Generating test certificates:
# RSA 2048
openssl req -newkey rsa:2048 -nodes -keyout rsa.key -x509 -days 365 \
-subj "/CN=example.com" -addext "subjectAltName=DNS:example.com" -out rsa.pem
# ECDSA P-256
openssl ecparam -name prime256v1 -genkey -out ecdsa.key
openssl req -new -key ecdsa.key -x509 -days 365 \
-subj "/CN=example.com" -addext "subjectAltName=DNS:example.com" -out ecdsa.pemImportant: Certificates must include a Subject Alternative Name (SAN) extension matching the frontend hostname. Certificates without SANs may cause TLS handshake failures.
A protocol = "tcp" listener forwards raw bytes to a cluster's backends
without terminating TLS. By default one TCP listener routes to exactly one
cluster (the legacy shape). Since #1279
a TCP listener can instead fan out to multiple clusters on the same
address:port by reading the TLS ClientHello's SNI (RFC 6066 §3) and, per
route entry, ALPN (RFC 7301 §3.1) — all without decrypting or terminating
the connection. The backend still performs its own TLS handshake with the
unmodified client bytes.
[[listeners]]
protocol = "tcp"
address = "0.0.0.0:8443"
# Time allowed to receive enough bytes of the TLS ClientHello to read the SNI
# extension, in seconds. Only meaningful once at least one SNI-scoped
# frontend (see below) targets this listener — a listener with only
# no-SNI (legacy) frontends never prereads and ignores this value.
# Defaults to 5. Must not exceed this listener's front_timeout
# (rejected at TOML config-load; see the validation matrix below).
sni_preread_timeout = 5
# Maximum bytes buffered while prereading the ClientHello looking for the SNI
# extension. Only meaningful under the same condition as above. Clamped to
# the global buffer_size at runtime (>= sni_preread_max_bytes is required at
# TOML config-load; see the validation matrix below). Defaults to 16384.
sni_preread_max_bytes = 16384A TCP frontend opts into SNI-scoped routing with hostname (mapped to the
wire sni field) and, optionally, alpn:
[clusters.web-a]
protocol = "tcp"
frontends = [
{ address = "0.0.0.0:8443", hostname = "a.example.com" },
]
[clusters.web-b]
protocol = "tcp"
frontends = [
# Only matches when the client also offers "h2" in its ALPN extension.
{ address = "0.0.0.0:8443", hostname = "b.example.com", alpn = ["h2"] },
]
[clusters.web-b-fallback]
protocol = "tcp"
frontends = [
# Catch-all for b.example.com when the client's ALPN offer doesn't match
# any explicit entry above (or offers no ALPN at all). At most one
# catch-all (empty `alpn`) per (address, hostname) is allowed.
{ address = "0.0.0.0:8443", hostname = "b.example.com" },
]
[clusters.legacy-passthrough]
protocol = "tcp"
frontends = [
# No `hostname` at all: the legacy single-cluster catch-all. A listener
# cannot mix this shape with any SNI-scoped frontend above.
{ address = "0.0.0.0:9443" },
]hostname accepts either an exact host ("example.com") or a single
leading *. wildcard label ("*.example.com", matching a.example.com but
neither the apex example.com nor a.b.example.com) — the same trie the
HTTP/HTTPS router uses (crate::router::pattern_trie). No other wildcard
shape is accepted (*.*.example.com, an embedded * outside the leading
label, and a bare * are all rejected at config-load). The pattern must be
plain ASCII: on-wire SNI is always an ASCII A-label (RFC 6066 §3 / IDNA), so
write the punycode A-label form for non-ASCII hostnames (e.g.
"xn--mnchen-3ya.example" for "münchen.example") rather than a Unicode
U-label, which would load but never match.
Within one (address, hostname) pair, route entries are tried in client
preference order: the client's first offered protocol that is claimed by
ANY entry's alpn list wins, regardless of route-table order. Only once no
offered protocol matched any explicit entry does the catch-all (the one
entry, if any, with an empty/absent alpn) win — including for a client that
offers no ALPN extension at all.
Once at least one SNI-scoped frontend targets a listener, EVERY connection on that listener is preread — including ones destined for a no-SNI-configured host. A connection is rejected (TCP FIN, no bytes forwarded) when:
- the first bytes aren't a TLS ClientHello at all (plain-TCP client, health check payload, ...);
- the ClientHello is malformed, or fragments past
sni_preread_timeoutwithout becoming complete; - the ClientHello fails to complete within the first
sni_preread_max_bytesbytes (the preread never reads past the cap, so a hello genuinely larger than it is rejected; a hello that completes within the cap routes no matter how much data the client has already coalesced after it — the excess stays in the kernel socket buffer and reaches the backend intact); - the ClientHello carries no usable SNI (absent, empty, or hidden behind Encrypted Client Hello with no outer name);
- the SNI matches no configured
hostnameon this listener, or matches one but no route entry'salpnaccepts the client's offer.
Each of these eleven rejection reasons has its own metric — see TCP SNI preread below. A bare TCP health check (connect + close, zero bytes) is not counted as a rejection — see that section's note.
| Configuration | Outcome |
|---|---|
hostname is neither an exact host nor a single leading *. label |
Error InvalidSniPattern |
hostname contains a non-ASCII character |
Error NonAsciiSniPattern — write the punycode A-label form |
alpn is non-empty but hostname is absent |
Error AlpnWithoutSni — a no-SNI frontend installs the raw catch-all path, which never consults alpn; the protocol list would silently never be enforced |
Two frontends on the same (address, hostname) share an alpn protocol |
Error TcpFrontendAlpnOverlap — routing must be deterministic, not iteration-order-dependent |
More than one frontend on the same (address, hostname) omits alpn |
Error TcpFrontendMultipleAlpnCatchAll — at most one catch-all per (address, hostname) |
A listener has both a no-hostname frontend and at least one hostname-scoped frontend |
Error TcpListenerMixesSniAndNoSni |
sni_preread_timeout exceeds front_timeout on an SNI-enabled listener |
Error SniPrereadTimeoutExceedsFrontTimeout — the preread phase cannot outlive the timeout that would already have closed the connection |
sni_preread_max_bytes is below 5 bytes (a full TLS record header) on an SNI-enabled listener |
Error SniPrereadMaxBytesTooSmall — 0 in particular makes the shell issue reads that can never make progress, spinning until the event-loop iteration guard trips |
sni_preread_max_bytes exceeds the global buffer_size on an SNI-enabled listener |
Error SniPrereadMaxBytesExceedsBufferSize — the preread buffer is carved out of the same per-session buffer used for relaying |
**The routing-SHAPE validations — the mixing ban (
TcpListenerMixesSniAndNoSni), ALPN-overlap / catch-all uniqueness (TcpFrontendAlpnOverlap/TcpFrontendMultipleAlpnCatchAll), ALPN-without-SNI (AlpnWithoutSni), and the pattern-SHAPE checks (InvalidSniPattern/NonAsciiSniPattern— ishostnamean exact host or one leading*.label, every label non-empty, and pure ASCII) — are all enforced BOTH at TOML config-load (command/src/config.rs) AND on the worker's hot path (TcpListener::validate_new_tcp_frontinlib/src/tcp.rs, which calls the identical shared pattern validator config-load uses), so anAddTcpFrontendsent directly over the command socket, a hot-addedsozu frontend tcp add --sni, or a replay from a hand-edited/staleLoadStatesnapshot, is rejected exactly like a bad TOML file would be — a malformed pattern is never silently installed. Only the listener-level timeout/buffer checks (SniPrereadTimeoutExceedsFrontTimeout/SniPrereadMaxBytesTooSmall/SniPrereadMaxBytesExceedsBufferSize) still run at TOML config-load only:sozu listener tcp add/updateaccepts out-of-range preread knobs without error. An out-of-rangesni_preread_max_bytesis at least degraded safely at the point of use — the worker clamps the effective cap to the session buffer's capacity and floors it at the 5-byte TLS record header, so a hot-added0cannot spin the preread loop — but asni_preread_timeoutabovefront_timeoutis applied as-is during the preread phase. Prefer declaring TCP SNI routing in the TOML file and reloading, or double-check hot-added listener knobs carefully, until that remaining gap is closed.
sozu frontend tcp add \
--id web-b \
--address 0.0.0.0:8443 \
--sni b.example.com \
--alpn h2
sozu frontend tcp remove \
--id web-b \
--address 0.0.0.0:8443 \
--sni b.example.com \
--alpn h2
sozu listener tcp add \
--address 0.0.0.0:8443 \
--sni-preread-timeout 5 \
--sni-preread-max-bytes 16384
--sni/--alpn are optional on both add and remove — omit both for a
legacy no-SNI catch-all frontend. On remove, they must match the exact
values the frontend was added with (same string, same wildcard shape, same
alpn set): a mismatch removes nothing, silently, rather than erroring.
--sni-preread-timeout/--sni-preread-max-bytes are add-only: the
UpdateTcpListenerConfig hot-reconfig message has no fields for them, so
sozu listener tcp update cannot patch either knob on a live listener —
remove and re-add the listener (or edit the TOML and reload) to change them.
A protocol = "udp" listener load-balances datagram traffic (DNS, syslog, NTP,
generic UDP) to a cluster's backends. UDP is plaintext only and is served by
exactly one worker per listener (see Limitations below).
[[listeners]]
protocol = "udp"
address = "0.0.0.0:53"
# public_address = "203.0.113.10:53"
# client / upstream flow idle timeout, in seconds. A flow is reaped once it has
# been idle for this long. Defaults to 30.
front_timeout = 30
back_timeout = 30
# maximum received datagram size, in bytes. Defaults to 1500 (one Ethernet
# frame). Capped at the global `buffer_size` (>= 16393): a value larger than
# `buffer_size` is clamped to it at config-load with a warning. A datagram on
# the wire that exceeds the effective read size is truncated (MSG_TRUNC) and
# dropped (`udp.datagrams.dropped`, reason `truncated`) — never a panic. Raise
# it for large EDNS0 / DNS responses; keep responses within PMTU where possible.
max_rx_datagram_size = 1500
# maximum number of concurrent flows on this listener. `0` (the default)
# auto-derives a cap from ~70% of the soft RLIMIT_NOFILE, because every flow
# owns one connected upstream socket (one fd). An explicit value above that
# bound is accepted but warns at config-load. New flows beyond the cap are shed
# (dropped + `udp.flows.shed`), never crashing the worker on EMFILE/ENFILE.
max_flows = 0Note: inbound UDP PROXY-protocol decode (
expect_proxy) is not supported on UDP listeners — the field is rejected. (Sōzu can still send a PROXY v2 header to UDP backends; see the clustersend_proxy_protocolknob below.) UDP listeners also have noconnect_timeout(there is no connect handshake) and norequest_timeout.
- One worker per UDP listener. A UDP listener is bound and served by exactly one worker, so per-listener throughput is one core. Scale across cores by running multiple UDP listeners (each owned by one worker) rather than expecting a single listener to span workers.
- Flows reset on hot-upgrade. On a zero-downtime upgrade the listener socket is handed to the new worker over SCM_RIGHTS, but per-flow state (the flow table and the per-flow connected upstream sockets) lives in the old worker's heap and is not migrated. The old worker drains its in-flight flows (bounded by the idle timeout) while the new worker serves new flows. For short datagram flows (DNS = one request + reply) this is near-invisible; a flow caught in-flight loses at most a datagram and the client retries.
- Plaintext only. No DTLS termination, no QUIC/HTTP3 CID-aware routing, no UDP-over-HTTP tunnelling.
- Other non-goals: Direct Server Return (DSR), io_uring / XDP / eBPF
datapaths, eBPF
SK_REUSEPORTsingle-listener multi-core, inbound UDP PROXY-protocol decode, and transparent (IP_TRANSPARENT) return are all out of scope.
You can declare the list of your clusters under the [clusters] section. They
follow the format:
Mandatory parameters:
[clusters]
[clusters.NameOfYourCluster]
# possible values are http or tcp
# https proxies will use http here
protocol = "http"
# per cluster load balancing algorithm. The possible values are
# "ROUND_ROBIN", "RANDOM", "LEAST_LOADED", "POWER_OF_TWO", "HRW" and
# "MAGLEV". HRW and MAGLEV are flow-affine policies designed for UDP
# clusters (see "UDP clusters" below). Defaults to "ROUND_ROBIN"
# load_balancing = "ROUND_ROBIN"
# force cluster to redirect http traffic to https
# https_redirect = true
frontends = [
{ address = "0.0.0.0:8080", hostname = "lolcatho.st" },
{ address = "0.0.0.0:8443", hostname = "lolcatho.st", certificate = "../lib/assets/certificate.pem", key = "../lib/assets/key.pem", certificate_chain = "../lib/assets/certificate_chain.pem" }
]
# additional options for frontends: sticky_session (boolean)
backends = [
{ address = "127.0.0.1:1026" }
]By default, Sōzu speaks HTTP/1.1 to backend servers. You can enable cleartext
HTTP/2 (h2c) for backend connections on a per-cluster basis using the http2
option:
[clusters.MyH2Cluster]
protocol = "http"
http2 = true
frontends = [
{ address = "0.0.0.0:8443", hostname = "app.example.com", certificate = "cert.pem", key = "key.pem", certificate_chain = "chain.pem" }
]
backends = [
{ address = "127.0.0.1:8080" }
]When http2 = true, Sōzu opens cleartext HTTP/2 connections to the backend
servers. This is useful when your backends natively support HTTP/2 (e.g., gRPC
servers).
Important:
http2is a backend-capability hint — it tells Sōzu whether the backend speaks H2, nothing more. It does not gate H2 acceptance at the frontend. Frontend H2 is negotiated entirely via TLS ALPN (thealpn_protocolslistener option) and is independent of per-cluster configuration. A cluster withhttp2 = false(or omitted) can still receive H2 requests from clients; Sōzu will translate them to H1 before forwarding to the backend. Seecommand/src/config.rs:998for the field definition.
You can also toggle HTTP/2 at runtime on an existing cluster via the CLI:
sozu cluster h2 enable --id MyH2Cluster
sozu cluster h2 disable --id MyH2ClusterThe frontend and backend protocols are independent. All four combinations work:
| Client → Sōzu | Sōzu → Backend | Configuration |
|---|---|---|
| HTTP/1.1 | HTTP/1.1 | Default (no http2 flag) |
| HTTP/2 | HTTP/1.1 | Client negotiates H2 via ALPN, default backend |
| HTTP/1.1 | HTTP/2 | http2 = true on cluster |
| HTTP/2 | HTTP/2 | Client negotiates H2 via ALPN + http2 = true |
Note: The
http2option controls the backend protocol only. The frontend protocol is determined by TLS ALPN negotiation between the client and Sōzu.
A cluster fronting a UDP listener selects its load-balancing algorithm with the
shared cluster-level load_balancing key, and carries its datagram-specific
knobs (flow affinity, teardown counters, PROXY-protocol, health checks) under an
optional [clusters.<id>.udp] block.
Two source-hash algorithms are added for flow-affine UDP selection (both are
also valid for the existing load_balancing field):
load_balancing |
Affinity | Notes |
|---|---|---|
ROUND_ROBIN |
none | Rotate through backends. The existing default; no source affinity. |
HRW |
yes | Highest-Random-Weight / rendezvous hashing — the recommended UDP default. O(N) per selection, no precomputed table, so no rebuild stall on hot-reconfig and provably-minimal flow remapping when backends change. |
MAGLEV |
yes | Maglev consistent hashing — O(1) per-packet table lookup, near-perfect balance, for large backend sets / high pps. The lookup table is rebuilt in the control-plane reconcile step on a backend-set change (not per-packet) and remaps ~2× more keys than HRW on churn. |
Note: the
load_balancingenum is parsed inSCREAMING_SNAKE_CASE("ROUND_ROBIN","HRW","MAGLEV", …) — the same casing as every other proto-backed enum in this file (e.g.tls_versions).
The [clusters.<id>.udp] block:
| Key | Default | Description |
|---|---|---|
affinity_key |
SOURCE_IP |
Flow affinity key for hash LBs. SOURCE_IP pins every port from one client to one backend; SOURCE_IP_PORT keys on the full source 2-tuple. |
responses |
0 |
Expected replies per flow. A DNS flow sets responses = 1 so the flow closes immediately after its single reply; 0 = unlimited (syslog-style fire-and-forget). |
requests |
0 |
Maximum client datagrams per flow before teardown. 0 = unlimited. |
send_proxy_protocol |
false |
Prepend a PROXY protocol v2 header (carrying the real client SocketAddr) to the backend. By default it is sent on the first datagram of the flow only. Backend PPv2-over-UDP parse support is not guaranteed by the spec — verify per backend. |
proxy_protocol_every_datagram |
false |
When true, prepend the PPv2 header to every datagram instead of the first only. Useful when a flow may be re-created after idle eviction and the backend needs the client context on each datagram. |
The [clusters.<id>.udp.health] sub-block configures active backend health
checks, bound to the endpoint:
| Key | Default | Description |
|---|---|---|
mode |
TCP_PROBE |
HEALTH_OFF disables health checking; TCP_PROBE opens a non-blocking TCP connection to a companion port (the industry-standard liveness hint — a hint, not proof of UDP reachability); UDP_PROBE sends an application datagram and expects any reply. Defaults to TCP_PROBE when a […udp.health] block is present. |
tcp_port |
data port | Companion TCP probe port. Unset = the backend's data port. |
rise |
2 |
Consecutive successes before a backend is marked up (hysteresis). |
fall |
3 |
Consecutive failures before a backend is marked down (hysteresis). A flapping probe never re-hashes live flows; flows on a now-unhealthy backend stay pinned via the flow table until idle-timeout. |
fail_open |
true |
When all backends read unhealthy, still run the normal LB over the full configured backend set (ignoring health) rather than black-holing traffic. |
udp_probe_payload |
— | Literal payload sent for a UDP_PROBE (e.g. a DNS query). |
probe_interval_seconds |
5 |
Delay between probes, in seconds. |
probe_timeout_seconds |
2 |
Per-probe response timeout, in seconds. |
A complete DNS cluster, with HRW affinity, single-reply flows, PROXY v2 to the backend, and a TCP-probe health check:
[[listeners]]
protocol = "udp"
address = "0.0.0.0:53"
[clusters.dns]
protocol = "tcp" # cluster transport family; UDP knobs live under [clusters.dns.udp]
load_balancing = "HRW" # ROUND_ROBIN | HRW (source-hash, recommended for UDP) | MAGLEV
frontends = [
{ address = "0.0.0.0:53" }
]
backends = [
{ address = "10.0.0.10:53" },
{ address = "10.0.0.11:53" }
]
[clusters.dns.udp]
affinity_key = "SOURCE_IP" # SOURCE_IP | SOURCE_IP_PORT
responses = 1 # DNS = one reply per query; closes the flow when reached
requests = 0 # 0 = unlimited client datagrams per flow
send_proxy_protocol = true # PROXY v2 to the backend, first datagram only
[clusters.dns.udp.health]
mode = "TCP_PROBE" # HEALTH_OFF | TCP_PROBE | UDP_PROBE
tcp_port = 53 # companion probe port; default = the data port
rise = 2
fall = 3
fail_open = true # all-unhealthy ⇒ LB over the full configured setHTTP/2 uses a default maximum frame size of 16384 bytes (16 KiB) per RFC 9113
§6.5.2. Sōzu needs at least 9 additional bytes for the frame header (§4.1). Set
buffer_size in the global section to at least 16393:
buffer_size = 16393This is also the default — no action is needed unless an operator explicitly
lowers buffer_size. As of
PR #1209 Sōzu rejects start-up
if buffer_size < 16393 and any HTTPS listener advertises h2 in its ALPN
list, with a BufferSizeTooSmallForH2 error pointing at the conflicting
listeners. The previous behaviour (silently accept the typo, then deadlock H2
mux on full-size frames) is gone.
To run with a smaller buffer, remove h2 from the listeners' alpn_protocols:
[[listeners]]
address = "0.0.0.0:443"
protocol = "https"
alpn_protocols = ["http/1.1"] # h2 removed; buffer_size < 16393 is now validSozu includes built-in flood detection for HTTP/2 connections. When a client
sends an excessive number of certain frame types within a rolling window, Sozu
terminates the connection with a GOAWAY(ENHANCE_YOUR_CALM) frame. This
protects against several known HTTP/2 denial-of-service vectors.
Six per-window thresholds are configurable per-listener. When omitted, compile-time defaults are used (see also RST_STREAM lifetime caps for connection-lifetime counters):
| Parameter | Default | Protects against | CVE |
|---|---|---|---|
h2_max_rst_stream_per_window |
100 | Rapid Reset attack: client opens and immediately resets streams in a tight loop | CVE-2023-44487 |
h2_max_ping_per_window |
100 | Ping flood: client sends PING frames faster than the server can respond | CVE-2019-9512 |
h2_max_settings_per_window |
50 | Settings flood: client sends SETTINGS frames requiring ACKs, exhausting server resources | CVE-2019-9515 |
h2_max_empty_data_per_window |
100 | Empty DATA flood: client sends zero-length DATA frames to consume processing time | CVE-2019-9518 |
h2_max_window_update_stream0_per_window |
100 | Connection-level (stream 0) WINDOW_UPDATE flood: client sends a torrent of non-zero stream-0 WINDOW_UPDATE frames to burn server CPU parsing each one (zero-increment frames short-circuit into GOAWAY(PROTOCOL_ERROR) per RFC 9113 §6.9). |
|
h2_max_continuation_frames |
20 | CONTINUATION flood: client sends many small CONTINUATION frames to exhaust header memory | CVE-2024-27316 |
h2_max_glitch_count |
100 | Cumulative protocol violations: total number of minor protocol errors before disconnection |
Configuration example:
[[listeners]]
address = "0.0.0.0:443"
protocol = "https"
# H2 flood detection thresholds (optional, defaults shown)
h2_max_rst_stream_per_window = 100 # Rapid Reset (CVE-2023-44487)
h2_max_ping_per_window = 100 # Ping flood (CVE-2019-9512)
h2_max_settings_per_window = 50 # Settings flood (CVE-2019-9515)
h2_max_empty_data_per_window = 100 # Empty DATA flood (CVE-2019-9518)
h2_max_window_update_stream0_per_window = 100 # Connection-level WINDOW_UPDATE flood (stream 0)
h2_max_continuation_frames = 20 # CONTINUATION flood (CVE-2024-27316)
h2_max_glitch_count = 100 # Cumulative protocol violationsNote: When any threshold is exceeded, the connection is terminated with a
GOAWAYframe using theENHANCE_YOUR_CALMerror code (HTTP/2 error code 0xb). The event is logged atwarnlevel with the specific flood type that triggered disconnection.
h2_max_glitch_count is a catch-all counter for low-severity protocol drift
that no other flood counter covers. It is incremented on stream-close races
(RST_STREAM / WINDOW_UPDATE / DATA on a closed stream), WINDOW_UPDATE
with zero increment on a closed stream, and unknown SETTINGS identifiers. The
counter uses a 1-second sliding window with half-decay (it halves at each
window roll rather than resetting), so a threshold of N tolerates a one-shot
burst of N glitches or a sustained rate of roughly N/2 glitches per second.
The default of 100 is conservative and protects a lightly-loaded edge well,
but busy proxies that terminate aggressive-cancellation traffic (mobile clients,
gRPC with deadlines, browser prefetch, fuzz harnesses) routinely trip it on
legitimate races. If h2.flood.violation.glitch_window fires on traffic you
know is benign, raise the threshold per-listener:
| Traffic profile | Suggested h2_max_glitch_count |
|---|---|
| Default / low traffic | 100 |
| Busy public edge, mixed clients | 500 |
| gRPC / mobile / high cancellation | 1000 – 2000 |
| Load-test absorption only | 5000 |
Before raising blindly, drop the relevant module to debug level and check
which branch dominates — a single misbehaving backend or client emitting
WINDOW_UPDATE on closed streams can be fixed upstream instead of hiding behind
a larger budget. Never set the threshold to u32::MAX; the catch-all is the
last line of defence against peers that stay just under every specific per-frame
cap.
Additional H2 parameters control connection-level behavior. All are optional per-listener with safe compile-time defaults:
| Parameter | Default | Description |
|---|---|---|
h2_initial_connection_window |
1048576 (1MB) | Connection-level receive window size in bytes (RFC 9113 §6.9.2). Clamped to [65535, 2^31-1]. |
h2_max_concurrent_streams |
100 | Maximum concurrent H2 streams the proxy accepts (SETTINGS_MAX_CONCURRENT_STREAMS). Minimum: 1. |
h2_stream_shrink_ratio |
2 | Shrink threshold ratio for recycled stream slots. The internal stream Vec is shrunk when total_slots > active_streams * ratio. Minimum: 2. |
h2_max_header_list_size |
65536 | Maximum accumulated HPACK-decoded header list size per request (SETTINGS_MAX_HEADER_LIST_SIZE, RFC 9113 §6.5.2). Accounted as name + value + 32 octets per field, per §6.5.2; the per-field overhead also bounds the field count. |
h2_stream_idle_timeout_seconds |
max(30, back_timeout) |
Per-stream idle timeout in seconds. An open H2 stream that receives no meaningful application data (non-empty DATA or HEADERS) for this duration is cancelled (RST_STREAM / CANCEL) to defend against slow-multiplex Slowloris. When unset the listener inherits back_timeout (floored at 30 s) so streams are not cancelled before the backend socket budget elapses; set explicitly to cap the per-stream deadline below back_timeout when under a slow-multiplex attack. Active uploads that trickle DATA frames reset the timer on each frame. The same deadline also governs a second, independent guard: a stream whose buffered response cannot drain because the peer keeps its receive window exhausted (HTTP/2 window-stall / WINDOW_UPDATE-drip) is reaped too. That flow-control-stall guard is NOT refreshed by inbound activity, so neither an inbound DATA drip nor a WINDOW_UPDATE(+1) drip can keep a window-stalled stream alive: the deadline clears only on a genuinely open send window or once cumulative outbound progress reaches one max DATA frame (16 KiB), so a trickle below that floor ages out and is reaped, while legitimate slow-but-steady transfers (sustaining more than ~0.5 KiB/s at the 30 s default) are unaffected. The guard is bidirectional — it also reaps a stalled request upload to a slow H2 backend (returning the client a 502), so raise this timeout if you proxy large uploads to slow H2 backends. A continuously window-blocked peer sustaining less than ~0.5 KiB/s for a full timeout is the one disclosed residual (raise the timeout to tolerate it). |
h2_max_header_table_size |
65536 | Maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE) accepted from the peer. Caps the peer-advertised value to prevent unbounded HPACK encoder memory growth. |
h2_max_header_fields |
128 | Maximum number of materialized header fields, enforced per HEADERS block and (independently) per trailers block — HPACK fields plus expanded cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK indexed-reference "header bomb", where 1-byte indexed references amplify into per-entry bookkeeping; cookie crumbs are counted individually (cf. Apache CVE-2026-49975). Minimum: 1. |
h2_graceful_shutdown_deadline_seconds |
5 | Maximum wall-clock seconds to wait for in-flight H2 streams after GOAWAY(NO_ERROR) has been sent during soft-stop. Once the deadline elapses the connection is forcibly closed. Set to 0 to disable the forced close entirely — shutdown then waits for every stream to drain naturally (use with caution: a long-running request can delay the whole soft-stop indefinitely). |
Configuration example:
[[listeners]]
address = "0.0.0.0:443"
protocol = "https"
# H2 connection tuning (optional, defaults shown)
h2_initial_connection_window = 1048576 # 1MB, min 65535, max 2147483647
h2_max_concurrent_streams = 100 # min 1
h2_stream_shrink_ratio = 2 # min 2
h2_max_header_list_size = 65536 # HPACK decoded header budget
h2_stream_idle_timeout_seconds = 30 # per-stream idle timeout (default: max(30, back_timeout))
h2_max_header_table_size = 65536 # HPACK dynamic table size cap
h2_max_header_fields = 128 # max header fields/request (incl. cookie crumbs)
h2_graceful_shutdown_deadline_seconds = 5 # soft-stop forced-close deadline (0 = wait forever)In addition to the per-window h2_max_rst_stream_per_window threshold, three
lifetime counters limit the total number of RST_STREAM frames associated with a
single connection — two on the received side (Rapid Reset, CVE-2023-44487)
and one on the emitted side (MadeYouReset, CVE-2025-8671). Together they
catch patient-attacker patterns that stay just below the per-window threshold.
| Parameter | Default | Description |
|---|---|---|
h2_max_rst_stream_lifetime |
10000 | Absolute lifetime cap on RST_STREAM frames received on this connection. |
h2_max_rst_stream_abusive_lifetime |
50 | Lifetime cap on "abusive" received RST_STREAM frames — resets sent by the peer before a response starts, the Rapid Reset signature (CVE-2023-44487). |
h2_max_rst_stream_emitted_lifetime |
500 | Absolute lifetime cap on RST_STREAM frames emitted by the server (CVE-2025-8671 "MadeYouReset"). Increments on every non-NoError reset triggered by an attacker-crafted frame (Content-Length mismatch, header parse error, rejected priority, zero-increment WINDOW_UPDATE on an open stream). Graceful NoError cancels (stream recycle, propagated client cancel) are exempt. Crossing the threshold emits GOAWAY(EnhanceYourCalm). |
Configuration example:
[[listeners]]
address = "0.0.0.0:443"
protocol = "https"
h2_max_rst_stream_lifetime = 10000
h2_max_rst_stream_abusive_lifetime = 50
h2_max_rst_stream_emitted_lifetime = 500| Parameter | Default | Description |
|---|---|---|
strict_sni_binding |
true | Every HTTP request must have its :authority / Host covered by a SAN dNSName entry of the certificate served on this TLS session, with RFC 6125 §6.4.3 wildcard handling (CWE-346 / CWE-444). Per RFC 6125 §6.4.4, when the SAN dNSName list is present the Common Name is ignored; CN is only consulted as a fallback identity when the cert omits the SAN extension. Applies to HTTPS listeners only; plaintext listeners never have an SNI to compare against. Default-cert handshakes (rustls fell back to the bundled cert because no SAN matched the SNI) fall back to the legacy SNI exact-match check — pre-fix posture. Misses are answered with 421 Misdirected Request (RFC 9110 §15.5.20). This matches Firefox / Chrome HTTP/2 connection-coalescing semantics (RFC 7540 §9.1.1 / RFC 9113 §9.1.1) — browsers reuse one H2 connection for any origin covered by the served cert. |
disable_http11 |
false | Only accept HTTP/2 connections; clients that do not negotiate h2 via TLS ALPN (including those that omit ALPN entirely) are dropped at handshake instead of silently downgrading to HTTP/1.1. |
Configuration example:
[[listeners]]
address = "0.0.0.0:443"
protocol = "https"
strict_sni_binding = true # require :authority covered by served cert SANs (RFC 6125 wildcards, default)
disable_http11 = false # allow HTTP/1.1 fallback (default)
sozu_id_header = "Sozu-Id" # rename the per-request correlation header (default "Sozu-Id")The sozu_id_header knob renames the correlation header Sozu injects on every
request AND response. Each request gets a unique ULID whose value is written to
both sides — operators can grep the same identifier across client logs, proxy
access logs, and backend logs. Default is Sozu-Id; a common rebrand is
X-Request-Trace or X-Edge-Id. The value must be a valid HTTP header name
(token chars per RFC 9110 §5.1). Applies to both HTTP and HTTPS listeners.
Two listener-scoped flags control how Sōzu handles the X-Real-IP header. They
default off (current behaviour: client-supplied value passes through, no proxy
injection) and are independently combinable:
| Parameter | Default | Description |
|---|---|---|
elide_x_real_ip |
false | Strip any client-supplied X-Real-IP header from forwarded requests before they reach the backend. Anti-spoofing — without this, a client can claim any value and downstream apps that key on X-Real-IP trust it verbatim. |
send_x_real_ip |
false | Append a proxy-generated X-Real-IP header carrying session_address.ip() — the connection peer IP after PROXY-v2 unwrap, i.e. the original client IP. |
The four valid combinations cover the typical use cases:
elide_x_real_ip |
send_x_real_ip |
Behaviour |
|---|---|---|
false |
false |
Default. Client header passes through, no proxy injection. |
false |
true |
Send-only. Both client and proxy headers reach the backend. Operator caveat: backends that read the first X-Real-IP header (rather than the last) trust the client value over the proxy value. Use elide_x_real_ip = true alongside for full anti-spoofing. |
true |
false |
Anti-spoof only. Client header stripped, no proxy injection. |
true |
true |
Anti-spoof + send. Client header stripped, proxy header carries the connection peer IP. |
The injected value is the IP of the connection peer after PROXY-v2 unwrap.
When the listener has expect_proxy = true and the upstream sends a PROXY-v2
frame, session_address is rewritten to the original client IP before this
header is generated, so the carried address is the real client even with one (or
more) PROXY-v2 hops in front of Sōzu.
Both flags apply uniformly to HTTP/1 and HTTP/2 because the elision and
injection live on the shared HttpContext::on_request_headers callback. The H2
trailer-block code path additionally honours elide_x_real_ip for trailer
HEADERS frames, so an H2 client cannot spoof x-real-ip as a trailer to bypass
the anti-spoof.
Both knobs are runtime-patchable via UpdateHttpListenerConfig /
UpdateHttpsListenerConfig. Patches apply immediately to all H1 sessions and to
new H2 connections; already-open H2 connections continue using the values
captured at their handshake (same connection-scoped-capture semantic as
strict_sni_binding). Long-lived H2 connections (CDN-style, mobile keep-alive)
therefore observe a delayed flag flip — this is the established mux precedent,
not a new defect.
[[listeners]]
address = "0.0.0.0:443"
protocol = "https"
elide_x_real_ip = true # strip client-supplied X-Real-IP before forwarding
send_x_real_ip = true # inject a proxy-generated X-Real-IP carrying the peer IPSōzu tiers the log severity of every rustls handshake error by root cause so
scanner noise does not crowd out real configuration errors (commit 156cc217).
Operators monitoring logs should expect the following classification:
| Error variant | Level | Typical cause |
|---|---|---|
NoApplicationProtocol, NoKxGroupsInCommon, NoCipherSuitesInCommon, NoCertificatesPresented |
warn |
Remote scanner / ssllabs probe with restricted cipher or ALPN set. Benign — no action required unless the client is legitimate. |
InappropriateMessage, InappropriateHandshakeMessage, InvalidMessage, PeerMisbehaved, PeerIncompatible, PeerSentOversizedRecord |
warn |
Peer protocol violation — typically a broken or outdated TLS client, occasionally a port-scan. Review if persistent from a known client. |
InvalidCertificate, DecryptError |
warn |
Client certificate rejected (e.g. expired, unknown CA) or bad shared key material. Relevant only when mTLS is configured. |
AlertReceived(_) |
debug |
Client sent a TLS alert (e.g. user_canceled, close_notify_required) — conversation-level noise. |
| Everything else (server-side faults, unexpected internal errors) | error |
Real bug or misconfiguration on the proxy. Actionable — triage immediately. |
In particular, the recurring
Could not look up a certificate for server name "<name>" line (issue #774) is
emitted at warn when the rustls::server::ClientHello SNI does not match any
configured frontend — the usual culprit is a generic scanner (Shodan, ssllabs,
Censys) probing default.example or an IP-only hello. No action required unless
the name matches a frontend you expect to serve.
This section covers operator-facing rules that surfaced as #1220, #1221, and #1222.
A protocol = "https" listener terminates TLS at the listener boundary. After
termination, the request is plaintext HTTP/1.1 or HTTP/2 (the listener decides
via ALPN), and the routed cluster sees it as protocol = "http". Frontends
bound to an HTTPS listener address therefore declare protocol = "http" on the
cluster — there is no separate protocol = "https" cluster mode.
Working configuration (one HTTP listener, one HTTPS listener, both routing to the same backend pool):
[[listeners]]
protocol = "http"
address = "[::]:80"
[[listeners]]
protocol = "https"
address = "[::]:443"
# Certificates are loaded via the runtime API or [listeners.https.certificates]
# (see "Listeners" §Certificates and TLS).
[clusters.example]
protocol = "http" # plaintext after TLS termination at the listener
frontends = [
{ address = "[::]:80", hostname = "subdomain.domain.tld" },
{ address = "[::]:443", hostname = "subdomain.domain.tld" },
]
backends = [
{ address = "[::1]:8080" },
]Sōzu refuses to start when an HTTPS listener has no matching frontend — no graceful fallback. The listener-end-to-end design is intentional: TLS settings (cipher list, ALPN, SNI binding, X-Real-IP elision) live on the listener; per-cluster TLS-to-backend remains a separate v2.1.0 feature (#1218).
Path filtering in Sōzu is frontend-level, not backend-level. Backends inside
a cluster cannot be restricted to a path; if you need different backend pools
for / and /some-path on the same hostname, declare two clusters that share
the hostname and differ on the frontend path rule. For example, sending
example.com to one backend pool and example.com/instance to another:
[clusters.example-main]
protocol = "http"
frontends = [
{ address = "0.0.0.0:443", hostname = "example.com" },
]
backends = [
{ address = "127.0.0.1:8001" },
{ address = "127.0.0.1:8002" },
]
[clusters.example-instance]
protocol = "http"
frontends = [
{ address = "0.0.0.0:443", hostname = "example.com", path = "/instance" },
]
backends = [
{ address = "127.0.0.1:8011" },
{ address = "127.0.0.1:8012" },
]A request to https://example.com/instance/foo matches the longer prefix
/instance and lands on example-instance; everything else falls back to the
empty-prefix frontend on example-main. Per-frontend certificate / key /
certificate_chain fields are omitted here for brevity but are valid on each
frontend entry.
When multiple frontends share the same (address, hostname) tuple and differ
only on path / path_type, the lookup picks the most specific rule using this
fixed precedence:
path_type = "EQUALS"— exact match wins first.path_type = "REGEX"— anchored at both ends (\A...\z) since v2.0.0; longer literal substrings within the regex are not weighted, so multiple regex rules competing on the same authority produce undefined ordering between them.path_type = "PREFIX"— fall-through default. Longest prefix wins among PREFIX rules.
Operators wanting to route /.well-known/acme-challenge separately from / on
the same hostname should declare:
[[clusters.acme-helper.frontends]]
address = "[::]:80"
hostname = "mail.example.tld"
path = "/.well-known/acme-challenge"
path_type = "PREFIX"
[[clusters.mail-server.frontends]]
address = "[::]:80"
hostname = "mail.example.tld"
path = "/"
path_type = "PREFIX"Both PREFIX rules match a request, but the longer prefix
(/.well-known/acme-challenge) wins per the longest-match rule above.
Configuration order does not affect routing: lookup is by trie specificity,
not declaration order.
Sōzu uses the standard Rust regex crate which does
not support look-around (negative or positive lookahead / lookbehind). Patterns
like ^(?!\/\.well-known\/acme-challenge) will not compile or will be rejected
at frontend registration.
If you need "match all paths except X", decompose into two PREFIX rules per the example above and rely on the longest-prefix rule, or declare an explicit list of EQUALS rules.
The fancy-regex crate supports look-around but
is not currently a Sōzu dependency.
#1222 tracks the discussion if
look-around becomes a real operator constraint.
The PROXY-protocol forwarding/expecting flags send_proxy = true and
expect_proxy = true on a backend or frontend are valid only for clusters
with protocol = "tcp". HTTP/HTTPS clusters use the forwarding HTTP headers
(X-Forwarded-For, X-Real-IP, and — once v2.1.0 lands — RFC 7239 Forwarded)
instead.
Setting send_proxy = true on an HTTP/HTTPS cluster is silently ignored at
runtime. This contradicts a literal reading of
#1221; the documentation
tracks reality but operators upgrading from older configs should remove the
no-op flag from HTTP cluster definitions.
See #PROXY Protocol below for the supported TCP-mode shape.
Sōzu lets operators replace any default error response with a templated body.
Templates are specified as a [listeners.<id>.answers] map at listener scope —
the global default that fires whenever no cluster-level override matches —
or as a [clusters.<id>.answers] map at cluster scope, which overrides the
matching listener entry on requests routed through that cluster. Both layers
accept the same key/value shape: the key is the HTTP status code (e.g. "503");
the value is either an inline literal body (the default — the value is taken
verbatim) or file://<path> to load the body off disk. The inline form is
convenient for short canned responses, secrets-free containers where mounting a
template directory is awkward, and test rigs that want to avoid disk
dependencies.
# listener-level: global default for every status not overridden by a cluster.
[listeners.https.answers]
"401" = "file:///etc/sozu/templates/401.http" # load from disk
"404" = "file:///etc/sozu/templates/404.http"
"503" = """HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 4\r\n\r\nbusy""" # inline literal (no prefix)
# cluster-level: overrides the listener default for THIS cluster only.
[clusters.MyCluster.answers]
"503" = "file:///etc/sozu/templates/MyCluster.503.http"Each template is a complete HTTP response (status line + headers + body) with optional placeholders. Sōzu substitutes the placeholders at render time:
| Placeholder | Scope | Meaning |
|---|---|---|
%REQUEST_ID |
header & body | Per-request ULID (matches Sozu-Id) |
%CLUSTER_ID |
header & body | Cluster the request was routed to (or empty) |
%BACKEND_ID |
header & body | Backend the request was forwarded to (or empty) |
%ROUTE |
header & body | Request method + authority + path |
%REDIRECT_LOCATION |
header & body | Resolved Location URL (301 only) |
%WWW_AUTHENTICATE |
header only | Realm string for 401 (header is elided when empty) |
%MESSAGE, %PHASE, %SUCCESSFULLY_PARSED, %PARTIALLY_PARSED, %INVALID, %CAPACITY, %DURATION |
varies | Diagnostic detail for parse / size / timeout errors |
When the template carries a Content-Length: <N> header, the engine recomputes
the value from the actual rendered body size after %-substitutions — so a
literal value that drifted from the body length cannot land on the wire (RFC
9110 §8.6 / RFC 7230 §3.3.2 anti- smuggling). Templates that omit
Content-Length keep the byte-for-byte shape they were written with; nothing is
synthesised. Operators who want a Content-Length include one; those who rely on
Connection: close for body framing get a clean header-only response.
When a template carries Connection: close, the response will close the
frontend connection after delivery; a custom template without that header keeps
frontend keep-alive on. The HAProxy parallel is errorfile NNN /path.
The legacy answer_NNN = "/path" per-status fields under [listeners.<name>]
continue to work — they are merged into the new map at load time so existing
state files round-trip cleanly. New configs should prefer the
[listeners.<name>.answers] shape.
Each frontend can carry a routing decision richer than "forward to a cluster". Three top-level knobs drive different policies:
[[clusters.MyCluster.frontends]]
address = "0.0.0.0:80"
hostname = "old.example.com"
# Force a permanent 301 to the canonical name on a different port.
redirect = "permanent" # forward (default) | permanent | found | permanent_redirect | unauthorized
redirect_scheme = "use-https" # use-same (default) | use-http | use-https
rewrite_host = "new.example.com"
rewrite_port = 8443 # paired with `cluster.https_redirect_port`redirect = "permanent" returns a 301 with a resolved Location URL built from
redirect_scheme, the (optionally rewritten) host, the cluster's
https_redirect_port (or the rewritten rewrite_port), and the original
request path. redirect = "found" and redirect = "permanent_redirect" share
the same Location-resolution machinery and emit 302 (RFC 9110 §15.4.3) or 308
(RFC 9110 §15.4.9) respectively. The semantic distinction matters to clients:
301 and 302 permit user agents to rewrite POST → GET on follow, whereas 308 (and
the IETF-modern 307) MUST preserve the request method. Pick permanent_redirect
(308) when you want a stable cacheable redirect that keeps method semantics —
e.g. an API endpoint relocation. Pick found (302) for short-lived forwards
(login flows, A/B-testing gates). redirect = "unauthorized" returns 401
unconditionally — useful for blanket deny-by-default frontends that still want
to surface a login prompt with the cluster's www_authenticate realm.
rewrite_host and rewrite_path accept a small template grammar: $HOST[n]
references the n-th host capture (0 is the full hostname; 1+ are regex /
wildcard subgroups), and $PATH[n] references the n-th path capture. When the
host is rewritten, Sōzu injects the original host into X-Forwarded-Host so
backends can reconstruct the request URL.
Custom headers attach to the same frontend:
[[clusters.MyCluster.frontends.headers]]
position = "request" # request | response | both
key = "X-Forwarded-Proto"
value = "https"
[[clusters.MyCluster.frontends.headers]]
position = "response"
key = "X-Cache-Backend"
value = "" # empty value DELETES the header by name
# (HAProxy `del-header` parity)HAProxy parallels: http-request redirect (permanent / scheme), set-uri and
set-path (rewrite), http-request set-header and http-request del-header
(custom headers).
When an HTTP listener carries a redirect_scheme = "use-https" frontend
that pushes traffic to an HTTPS counterpart, HSTS belongs on the HTTPS
side, never on the redirect itself. RFC 6797 §7.2 forbids
Strict-Transport-Security on plaintext-HTTP responses; conformant browsers
ignore the header on plain-HTTP and Sōzu refuses the configuration at
load time on HTTP listeners.
The recommended pattern is two paired frontends with HSTS attached to the HTTPS one:
# Plain-HTTP frontend that pushes everything to HTTPS via 301.
[[clusters.api.frontends]]
address = "0.0.0.0:80"
hostname = "api.example.com"
redirect = "permanent" # 301 (or "permanent_redirect" for 308)
redirect_scheme = "use-https"
# NO [hsts] block here — Sōzu would reject it (RFC §7.2).
# HTTPS counterpart that serves traffic and pins HSTS.
[[clusters.api.frontends]]
address = "0.0.0.0:443"
hostname = "api.example.com"
[clusters.api.frontends.hsts]
enabled = true
max_age = 31536000 # 1 year — preload list minimum
include_subdomains = true
preload = false # opt-in onlyFor convenience, declare HSTS once at the listener default and let every HTTPS frontend inherit:
[[listeners]]
protocol = "https"
address = "0.0.0.0:443"
[hsts]
enabled = true
max_age = 31536000
include_subdomains = truePer-frontend hsts.enabled = false suppresses the inherited default for
a single frontend that cannot commit to HSTS yet.
The header is emitted on every successful HTTPS response, including
proxy-generated 3xx redirects (e.g. redirect_scheme = "use-https" from
an HTTPS frontend, or a redirect = "permanent" shape served on the
HTTPS side), 401 auth-deny, and 502 / 503 / 504 default answers — RFC
6797 §8.1 requires HSTS on every response code from the host.
Operators can require a valid Authorization: Basic header on a frontend and
validate it against a list of pre-hashed credentials on the cluster. The mux
iterates the entire authorized list in constant time (via
subtle::ConstantTimeEq), so neither the matching index nor a successful lookup
leak through the time spent validating.
[clusters.MyCluster]
# Realm rendered into `WWW-Authenticate: Basic realm="…"` on a 401.
www_authenticate = 'Basic realm="MyCluster"'
# Each entry is `username:hex(sha256(password))`. The runtime hashes
# ONLY the password — the username appears verbatim before the colon
# and is NOT part of the hashed input. Generate the hash for password
# `secret` with:
# printf 'secret' | sha256sum | awk '{print $1}'
# `printf` (not `echo`) omits the trailing newline so the digest matches
# the bytes carried in `Authorization: Basic <base64>`.
authorized_hashes = [
"admin:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b",
]
[[clusters.MyCluster.frontends]]
address = "0.0.0.0:80"
hostname = "secured.example.com"
required_auth = true # gate this frontend on basic-authFailure modes:
- Missing
Authorization: Basicheader → 401 with the cluster's realm - Malformed credential (bad base64, missing
:) → 401 - Wrong username or password → 401
- Empty
authorized_hasheswhile the frontend hasrequired_auth = true→ 401 (closed-by-default policy)
The WWW-Authenticate header is rendered through the answer template's
%WWW_AUTHENTICATE placeholder; when no realm is configured the entire header
line is elided from the 401 response. HAProxy parallel:
http-request auth realm Foo unless { http_auth(...) }.
A hostile peer can send arbitrarily long Authorization: Basic <token> values.
Sōzu base64-decodes the token in a transient allocation; an unbounded decode per
failed-auth attempt is a memory pressure vector. The worker caps the decoded
length to 4096 bytes by default — well above the realistic
username:password shape (typical credentials are <100 bytes). Operators
running hardened tenants can lower this in the main TOML config:
# in the top-level config (alongside `buffer_size`, `max_connections`, etc.)
basic_auth_max_credential_bytes = 256The override is committed once on each worker at boot and applies to every
cluster. Setting 0 is a no-op (the built-in default stays in force) so an
explicit zero in a config file does not accidentally disable the cap.
The config validator emits a warn! line at boot when the configured cap is
>= buffer_size / 3: at that point a single failed-auth attempt can pin
~33% of the per-frontend buffer's worth of bytes, which combined with in-flight
request/response framing pushes the buffer toward back-pressure under load. The
warning is informational only — operators with a deliberate trade-off can keep
the value, but the surprise stays visible in the boot log.
sozu listener {http,https,tcp} update patches a live listener in place
without cycling the listening socket. Only the fields you pass are written; all
others are preserved exactly as they are. Existing sessions continue with their
configuration snapshot; only new sessions, connections, or TLS handshakes —
depending on the field — pick up the new values. Use sozu listener list to
inspect current values before patching.
Bind-only fields are not patchable. The address, TLS crypto parameters, and the
activeflag can only be changed by removing and re-adding the listener:sozu listener https remove -a 0.0.0.0:8443 sozu listener https add --address 0.0.0.0:8443 [...]Bind-only fields:
address,tls_versions,cipher_list,cipher_suites,signature_algorithms,groups_list,certificate,certificate_chain,key,send_tls13_tickets,active.
Fields are grouped by the earliest point at which a patched value takes effect for connections already in progress. All fields apply to new sessions immediately after the patch is acknowledged.
| Field | Type | Mutability class | Default | Notes |
|---|---|---|---|---|
public_address |
SocketAddr |
session-at-accept | — | Source address reported to backends / logs |
expect_proxy |
bool |
session-at-accept | false |
Enable PROXY protocol v1/v2 on new sessions |
sticky_name |
string |
session-at-accept | "SOZUBALANCEID" |
Sticky-session cookie name |
front_timeout |
u32 (seconds) |
session-at-accept | 60 |
Max idle time on the client socket |
back_timeout |
u32 (seconds) |
session-at-accept | 30 |
Max idle time on the backend socket |
connect_timeout |
u32 (seconds) |
session-at-accept | 3 |
Max time to establish a backend connection |
request_timeout |
u32 (seconds) |
session-at-accept | 10 |
Max time to send a complete request |
http_answers |
file paths | session-at-accept | built-in defaults | Listener-default HTTP error bodies (301/401/404/408/413/421/502/503/504/507). Per-cluster answer_503 overrides are preserved. |
sozu_id_header |
string |
session-at-accept | "Sozu-Id" |
Correlation header name (RFC 9110 §5.1 token; reject empty or containing CR/LF/colon/space) |
h2_max_rst_stream_per_window |
u32 (≥ 1) |
per-connection setup | 100 |
RST_STREAM flood cap — CVE-2023-44487, CVE-2019-9514 |
h2_max_ping_per_window |
u32 (≥ 1) |
per-connection setup | 100 |
PING flood cap — CVE-2019-9512 |
h2_max_settings_per_window |
u32 (≥ 1) |
per-connection setup | 50 |
SETTINGS flood cap — CVE-2019-9515 |
h2_max_empty_data_per_window |
u32 (≥ 1) |
per-connection setup | 100 |
Empty DATA flood cap — CVE-2019-9518 |
h2_max_continuation_frames |
u32 (≥ 1) |
per-connection setup | 20 |
CONTINUATION flood cap — CVE-2024-27316 |
h2_max_glitch_count |
u32 (≥ 1) |
per-connection setup | 100 |
Cumulative protocol-anomaly budget |
h2_max_window_update_stream0_per_window |
u32 (≥ 1) |
per-connection setup | 100 |
Stream-0 WINDOW_UPDATE flood cap |
h2_max_rst_stream_lifetime |
u64 (≥ 1) |
per-connection setup | 10000 |
Lifetime RST_STREAM received cap — CVE-2023-44487 |
h2_max_rst_stream_abusive_lifetime |
u64 (≥ 1) |
per-connection setup | 50 |
Lifetime abusive RST_STREAM cap (Rapid Reset signature) |
h2_max_rst_stream_emitted_lifetime |
u64 (≥ 1) |
per-connection setup | 500 |
Lifetime server-emitted RST_STREAM cap — CVE-2025-8671 |
h2_initial_connection_window |
u32 |
per-connection setup | 1048576 |
Connection receive window (bytes, RFC 9113 §6.9.2) |
h2_max_concurrent_streams |
u32 (≥ 1) |
per-connection setup | 100 |
SETTINGS_MAX_CONCURRENT_STREAMS |
h2_stream_shrink_ratio |
u32 (≥ 2) |
per-connection setup | 2 |
Stream-slot Vec shrink threshold |
h2_max_header_list_size |
u32 |
per-connection setup | 65536 |
HPACK decoded header budget (SETTINGS_MAX_HEADER_LIST_SIZE) |
h2_max_header_table_size |
u32 |
per-connection setup | 65536 |
HPACK dynamic table size cap (SETTINGS_HEADER_TABLE_SIZE) |
h2_stream_idle_timeout_seconds |
u32 |
per-connection setup | max(30, back_timeout) |
Per-stream idle timeout (slow-multiplex Slowloris defence). When unset, inherits back_timeout floored at 30 s; set explicitly to cap below back_timeout. |
h2_graceful_shutdown_deadline_seconds |
u32 |
per-connection setup | 5 |
Forced-close deadline after GOAWAY(NO_ERROR) on soft-stop. 0 = wait forever. |
| Field | Type | Mutability class | Default | Notes |
|---|---|---|---|---|
alpn_protocols |
string[] |
per-handshake | ["h2","http/1.1"] |
Rebuilds the rustls ServerConfig. In-flight handshakes finish on the old config. Pass --reset-alpn on the CLI to restore the default. |
strict_sni_binding |
bool |
per-handshake | true |
Require :authority/Host covered by served cert SAN dNSName (RFC 6125 §6.4.3/6.4.4, CWE-346/CWE-444). Default-cert handshakes fall back to legacy SNI exact-match. Miss → 421 (RFC 9110 §15.5.20). |
disable_http11 |
bool |
per-handshake | false |
Drop clients that do not negotiate h2 via ALPN |
| Field | Type | Mutability class | Default | Notes |
|---|---|---|---|---|
public_address |
SocketAddr |
session-at-accept | — | |
expect_proxy |
bool |
session-at-accept | false |
|
front_timeout |
u32 (seconds) |
session-at-accept | 60 |
|
back_timeout |
u32 (seconds) |
session-at-accept | 30 |
|
connect_timeout |
u32 (seconds) |
session-at-accept | 3 |
Patched values apply to new flows; flows already in progress keep their
captured value. address and active are bind-only — change them with
RemoveListener + add.
| Field | Type | Mutability class | Default | Notes |
|---|---|---|---|---|
public_address |
SocketAddr |
flow-at-admit | — | Source address reported to backends / logs |
front_timeout |
u32 (seconds) |
flow-at-admit | 30 |
Client flow idle timeout |
back_timeout |
u32 (seconds) |
flow-at-admit | 30 |
Upstream flow idle timeout |
max_rx_datagram_size |
u32 (bytes) |
flow-at-admit | 1500 |
Clamped to the effective buffer_size |
max_flows |
u32 |
flow-at-admit | 0 |
0 = auto (~70% of soft RLIMIT_NOFILE) |
# Inspect current values first
sozu listener list
# Halve the Rapid Reset budget on the HTTPS listener
sozu listener https update -a 0.0.0.0:8443 \
--h2-max-rst-stream-per-window 50 \
--h2-max-rst-stream-abusive-lifetime 25
# Confirm the new values are live
sozu listener listExisting H2 connections continue with their original thresholds. New connections opened after the patch acknowledge see the tighter limits.
# Require all clients to negotiate h2 via TLS ALPN
sozu listener https update -a 0.0.0.0:8443 --disable-http11
# Revert — allow HTTP/1.1 fallback again
sozu listener https update -a 0.0.0.0:8443 --enable-http11The existing HTTP/1.1 sessions in progress are not disrupted; only new TLS
handshakes that omit h2 from their ALPN offer are refused after the patch.
# Rename "Sozu-Id" to "X-Edge-Id" organisation-wide
sozu listener http update -a 0.0.0.0:80 --sozu-id-header "X-Edge-Id"
sozu listener https update -a 0.0.0.0:8443 --sozu-id-header "X-Edge-Id"The new header name takes effect for sessions accepted after the patch.
Previously accepted sessions continue to inject the old Sozu-Id header until
they close.
Two worker metrics track update outcomes:
| Metric | Description |
|---|---|
listener.updated |
Incremented each time a worker successfully applies a patch |
listener.update_failed |
Incremented when the worker-side apply returns an error |
The control-plane command server also emits a LISTENER_UPDATED event on the
SubscribeEvents bus (carrying the listener address and type) and writes a
structured audit log line at info! level in the MUX Session(...) layout. See
observability.md for the full
format.
The same bus carries EventKind::ClusterRecovered (proto tag 29) on the
AllDown → Available transition for any cluster that previously emitted
EventKind::NoAvailableBackends. Subscribers that already track the all-down
event get the recovery side for free without polling
cluster.available_backends.
When a session terminates on a timeout, Sōzu populates the access-log message
field with a stable, structured token so dashboards and log-pipeline rules can
attribute the outcome without inspecting the HTTP status. The vocabulary is
closed (operator-visible API once shipped):
| Token | Trigger | Status seen by client |
|---|---|---|
client_timeout |
Frontend timer fired while waiting for the request to arrive (TimeoutStatus::Request in the H1 path; StreamState::Idle in the mux path) |
408 Request Timeout |
client_timeout_during_response |
Frontend timer fired while the backend was still composing the response — ambiguous case where timeout responsibility should already have switched. Mapped to gateway-timeout for client clarity | 504 Gateway Timeout |
backend_timeout |
Backend timer fired before any response byte arrived — connection-level slowness or backend stuck pre-headers. The H1 invariant-break arm (TimeoutStatus::Request on the backend) collapses into the same token because the operator-visible cause is identical; the internal error! log keeps the diagnostic signal for sozu maintainers |
504 Gateway Timeout |
backend_response_timeout |
Backend timer fired while the response body was streaming — partial response in flight. Mux replies with RST_STREAM (H2Error::InternalError); H1 forcibly closes the session because no default-answer can replace an in-flight response body |
RST_STREAM (mux) / connection close (H1) |
Non-timeout default-answer paths (e.g. 503 from Router::route_from_request,
401 from auth, 301 from redirects) leave the field as None and the access log
emits message: None exactly as before. Sessions that complete normally are
also unaffected.
You can optionally configure active HTTP health checks for backends. See health_checks.md for full details.
[clusters.NameOfYourCluster.health_check]
uri = "/health"
interval = 10
timeout = 5
healthy_threshold = 3
unhealthy_threshold = 3
expected_status = 0Sōzu reports its own state to another network component through a UDP socket.
The main process and the workers are responsible to send their states. We
implement the statsd protocol to send
statistics. Any service that understands the statsd protocol can then gather
metrics from Sōzu.
Metrics are collected via thread-local storage macros (count!, gauge!,
gauge_add!, time!, incr!, decr!) and dispatched to two drains:
- Local drain: Accumulates metrics in-memory with HDR histograms for latency
percentiles. Queried via the CLI (
sozu metrics get). - Network drain: Sends metrics over UDP using the statsd protocol. Supports both plain dotted format and InfluxDB-style tagged format.
Metric types:
| Type | Macro | StatsD suffix | Description |
|---|---|---|---|
| Counter | count!, incr!, decr! |
|c |
Monotonically increasing. Network drain: zeroed after each successful UDP send (per-interval delta). Local drain: cumulative since worker start. |
| Gauge | gauge!, gauge_add! |
|g |
Snapshot value (absolute or delta). Last-value-wins; never automatically reset. |
| Time | time! |
|ms |
Latency in milliseconds. Local drain stores an HDR histogram (Histogram<u64>, sigfig=3); samples accumulate since worker start. |
Metrics have three scopes:
- Proxy-level: Global to the worker (no cluster or backend context)
- Cluster-level: Tagged with a
cluster_id - Backend-level: Tagged with both
cluster_idandbackend_id
In your config.toml, you can define the address and port of your external
service by adding:
[metrics]
address = "127.0.0.1:8125"
# use InfluxDB's statsd protocol flavor to add tags
# tagged_metrics = false
# metrics key prefix
# prefix = "sozu"
# cardinality knob — defaults to "cluster" (preserves historical behaviour)
# detail = "cluster"Metrics are sent at most once per second per key (if updated). Cluster/backend metrics that have not been updated for 10 minutes are automatically dropped from the network drain.
- The network drain (StatsD UDP) sends per-second deltas; counters are
zeroed after each successful send. On
RemoveCluster/RemoveBackend, the drain immediately drops the cluster'scluster_metrics,backend_metrics, and queuedMetricLineentries — any unsent statsd interval for the cluster is discarded (no final flush). Without an explicitRemoveCluster, the existing 10-minute idle GC still applies. - The local drain (queryable via
sozu metrics) is cumulative since worker start for bothproxy_metricsand per-cluster / per-backendcluster_metrics. There is no implicit hourly reset. Operators reset it explicitly withsozu metrics clear, which wipes everything (counts, gauges, histograms, proxy-wide and per-cluster, AND the master-processmain_metricsaggregator). - Per-cluster local-drain entries are dropped on
RemoveCluster/RemoveBackendso the keyspace is bounded by the live configuration. - Implication for dashboards: counters in
sozu metricsoutput are monotonic. Charts must computerate()/irate()rather than treat successive snapshots as windowed counts. Histograms accumulate every sample since worker start, so percentiles in the CLI snapshot are lifetime values, not windowed.
Mirrors HAProxy's extra-counters opt-in: operators choose the lowest level
that satisfies their dashboards so the StatsD keyspace stays bounded. The level
filters the (cluster_id, backend_id) labels at emission time — both the local
CLI drain and the network drain see the same filtered labels, so dashboards stay
consistent.
Each level is a SUPERSET of the previous one:
| Level | Behaviour | Use when |
|---|---|---|
process |
Both cluster_id and backend_id are dropped — proxy-only counters. |
The smallest possible keyspace; dashboards aggregate everything at the worker level. |
frontend |
Same as process today. Reserved for the per-listener (frontend) counters that are tracked as a follow-up — operators can opt in already, the value is just stored on ServerMetricsConfig.detail and applied on every emission. |
Forward-compatible config that picks up per-listener counters when they ship without a config-file change. |
cluster |
Keeps cluster_id, drops backend_id. Default — preserves the historical pre-knob behaviour. |
The current shape of every existing dashboard. |
backend |
Keeps both labels. Highest cardinality. | Per-backend SLOs / hotspot debugging when the cluster has few backends. |
Memory note for Time histograms: per-bucket counters are Histogram<u64>
(widened from Histogram<u32> to avoid saturation at sustained
high-RPS — u32 saturates a single popular bucket in ≈72 minutes at
1 M samples/s in a 64-bit count). Memory footprint per Time histogram is
bounded by hdrhistogram's sigfig=3 shape, roughly 16–32 KB per
histogram, doubled vs. the prior u32 shape. With cluster-level
cardinality this multiplies by the number of live clusters × Time-key
distinct names; with backend-level, also by per-cluster backend count.
Plan capacity accordingly.
Filtering happens centrally in Aggregator::receive_metric
(lib/src/metrics/mod.rs) via the pure helper filter_labels_for_detail,
unit-tested exhaustively across all four levels. Workers receive the level over
the SCM socket as a proto enum (MetricDetail); old binaries on either side
fall back to cluster so a mixed-version rollout keeps emitting the historical
metric shape.
Operators can elevate a worker's effective metric_detail for the lifetime of
an interactive session without rewriting config.toml. sozu top uses this
mechanism to enable backend detail while the TUI is attached, and reverts it
on exit. Other tooling (per-host scraper agents, ad-hoc debugging) can use the
same surface.
The lease is keyed by an operator-supplied client_id and stored on each
worker. The effective level is max(configured, max(active leases)), so a
lease never lowers the configured detail; it only elevates. When the last
lease expires (TTL pop) or is explicitly cleared, the effective level falls
back to the configured value.
The proto verb that exposes this surface is SetMetricDetail (request tag
55); the response shape is MetricDetailStatus carrying the master's
(configured, effective, previous_effective) triple plus a per-worker
WorkerMetricDetailStatus map. Every apply, clear, and TTL expiry emits an
audit-log event of kind MetricDetailChanged (EventKind::METRIC_DETAIL_CHANGED,
tag 30) on the text and JSON sinks, with lease_id= and
metric_detail_reason= as dedicated columns so operator-supplied strings
cannot smuggle a forged adjacent column.
Server-side caps and defaults (all defined in lib/src/metrics/mod.rs):
| Knob | Default | Cap | Notes |
|---|---|---|---|
ttl_seconds |
60 |
300 |
Lease lifetime. The TUI renews every ttl/2 seconds; on TUI crash the lease self-expires after at most one ttl window. The master rejects out-of-range TTLs before fan-out so a buggy or malicious request cannot N×amplify worker-side rejections. |
LEASE_TABLE_CAP |
— | 64 |
Maximum number of simultaneous leases per worker. Renewals of existing entries always succeed; only new inserts are subject to this cap. Mitigates the CWE-770 vector where a misbehaving client rolls client_id faster than expiry. |
LEASE_CLIENT_ID_MAX_BYTES |
— | 64 |
Maximum client_id length. Operator tooling should pick a stable identifier (the TUI uses top:<pid>:<8-hex>); arbitrary user input must be capped at this length before submission. |
Trust model: the lease records the connecting peer's PID and master session
ULID (from SO_PEERCRED on Linux). Subsequent clear requests are
authorised against the apply-time binding — a different operator on the same
host cannot clear another operator's lease even if they guess the
client_id. Pre-binding callers and platforms without SO_PEERCRED degrade
to "binding unknown" → accept any clear. Clients NEVER set the peer fields
themselves; the master populates them from the ClientSession before
fan-out.
Reversibility: leases self-expire, so a crashed dashboard does not leave a worker permanently emitting at elevated cardinality. The audit-log trail records every transition; SOC tooling can reconstruct the cardinality posture of every worker at any point in time without polling.
Renewals are not state transitions — SetMetricDetail is deliberately
not in the systemd RELOADING=1 / READY=1 bracket set, so a long-lived
TUI session does not flap the unit state. The audit-log event remains the
authoritative trail for cardinality changes.
Untagged (default, tagged_metrics = false):
sozu.WRK-00.http.requests:1|c
sozu.WRK-00.cluster.my-cluster.http.errors:0|c
sozu.WRK-00.cluster.my-cluster.backend.backend-1.backend_response_time:125|ms
Tagged (InfluxDB format, tagged_metrics = true):
sozu.http.requests,origin=WRK-00,version=1.1.0:1|c
sozu.cluster.http.errors,origin=WRK-00,version=1.1.0,cluster_id=my-cluster:0|c
sozu.backend.backend_response_time,origin=WRK-00,version=1.1.0,cluster_id=my-cluster,backend_id=backend-1:125|ms
Sōzu emits the following metrics via statsd. All metrics are emitted by both
HTTP/1.1 and HTTP/2 code paths unless noted otherwise. The prefix (default
sozu) is omitted from metric names below.
| Metric | Type | Scope | Description |
|---|---|---|---|
panic |
counter | proxy | Worker thread panicked (logged before crash) |
configuration.clusters |
gauge | proxy | Number of configured clusters |
configuration.backends |
gauge | proxy | Number of configured backend servers |
configuration.frontends |
gauge | proxy | Number of configured frontends |
client.connections |
gauge | proxy | Active frontend connections |
client.connections_max |
gauge | proxy | Configured max_connections. Renamed from client.max_connections |
client.connections_percent |
gauge | proxy | Percentage of max_connections in use. Renamed from client.connections_percentage |
connections.rejected_per_cluster_ip |
counter | cluster, backend | HTTP/HTTPS request answered with 429 (or TCP session closed pre-backend) because the per-(cluster, source-IP) connection limit was reached. Labels carry cluster_id and backend_id (always empty for this counter — the rejection happens before backend selection). |
slab.entries |
gauge | proxy | Session slab allocator slots used |
slab.capacity |
gauge | proxy | Configured slab capacity (10 + slab_entries_per_connection * max_connections) |
slab.usage_percent |
gauge | proxy | slab.entries * 100 / slab.capacity. Pure slab-utilisation gauge |
slab.accept_threshold_percent |
gauge | proxy | slab.entries * 100 / (10 + 2 * max_connections). Charts proximity to the at_capacity() accept gate, which can flip true while slab.usage_percent still shows headroom (configured slab is larger when slab_entries_per_connection > 2) |
process.uptime_seconds |
gauge | proxy | Seconds since the worker started. Captured once in Server::new; never reset on hot upgrade (the new worker starts its own counter) |
server.live |
gauge | proxy | 1 while the worker accepts traffic, 0 once a graceful shutdown is requested. Mirrors Envoy server.live semantics — L4 health checks (HAProxy / cloud LBs) can poll this gauge to drain a worker before the OS-level termination signal lands |
buffer.in_use |
gauge | proxy | Buffers currently checked out of the buffer pool. Renamed from buffer.number |
buffer.capacity |
gauge | proxy | Configured buffer pool capacity |
buffer.usage_percent |
gauge | proxy | buffer.in_use * 100 / buffer.capacity |
zombies |
counter | proxy | Zombie sessions detected and removed |
| Metric | Type | Scope | Description |
|---|---|---|---|
accept_queue.connections |
gauge | proxy | Sockets waiting in the accept queue |
accept_queue.backpressure |
gauge | proxy | 1 when max connections reached, 0 when accepting again |
accept_queue.wait_time |
time | proxy | How long a socket waited in the accept queue (ms) |
accept_queue.timeout |
counter | proxy | Sockets that timed out in the accept queue and were closed |
accept_queue.saturated_seconds |
counter | proxy | Incremented at 1 Hz while SessionManager::can_accept is false. Distinguishes "queue spent N seconds at max" from "queue briefly hit max" — the binary accept_queue.backpressure gauge collapses that duration |
listener.accepted.total |
counter | proxy | Sockets accepted by the worker, all listeners combined |
listener.accepted.tcp |
counter | proxy | Sockets accepted on TCP listeners |
listener.accepted.http |
counter | proxy | Sockets accepted on HTTP listeners |
listener.accepted.https |
counter | proxy | Sockets accepted on HTTPS listeners |
listener.connection_capped |
counter | proxy | Sockets refused by create_sessions because SessionManager::check_limits returned false (max connections reached or slab at capacity) |
client.connect.per_source.bucket_000 … bucket_255 |
counter | proxy | Per-accept counter bucketed by masked source subnet. Source IPs are masked to /24 (IPv4) or /48 (IPv6) and hashed (DefaultHasher) into 256 fixed buckets. The bucket noise is intentional: incr! requires &'static str keys, and a per-IP counter would be unbounded under SYN flood (OWASP A05, NIST SP 800-92). Operators wanting per-IP attribution should pair these counters with structured access logs or a downstream rate-limiter |
sessions.evicted |
counter | proxy | Sessions force-closed by evict_on_queue_full to make room for new accepts. Counts the number of evictions DECIDED in a cap event (one per call to evict_least_active_sessions); the helper may skip already-closed tokens, so a small drift versus the slab-removal count is possible. Only emitted when evict_on_queue_full = true |
The accept-loop telemetry above does not label by listener address. incr!
requires &'static str keys, and listener addresses can be added or removed at
runtime via the control plane — labelling by address would either require
runtime Box::leak of unbounded strings or a fixed bucket cap. We chose the
per-protocol breakdown (3 keys + aggregate) instead. Operators needing
per-listener-address attribution should run distinct workers per listener or
correlate via access logs.
Per-cluster signals that pair the existing EventKind::NoAvailableBackends with
EventKind::ClusterRecovered. Latched on BackendList so the log + counter +
event fire exactly once per Available ↔ AllDown transition, not once per
request. Empty clusters (total == 0) emit the gauges but never log a
transition — avoids spam during cluster bootstrap when backends are still being
registered.
| Metric | Type | Scope | Description |
|---|---|---|---|
cluster.available_backends |
gauge | cluster | Backends currently passing the availability predicate (status == Normal && health.is_healthy() && !retry_policy.is_down()). Updated on every routing decision and every health-check tick |
cluster.total_backends |
gauge | cluster | Backends configured for the cluster, regardless of state. Pairs with cluster.available_backends so dashboards can compute health ratios per cluster |
cluster.no_available_backends |
counter | cluster | Incremented exactly once per Available → AllDown transition. Pairs with the existing EventKind::NoAvailableBackends event and the error! log line cluster X: all N backends are down |
cluster.available_recovered |
counter | cluster | Incremented exactly once per AllDown → Available transition. Pairs with EventKind::ClusterRecovered (proto tag 29) and the info! log line cluster X: backends recovered (i/N available) |
backend.available |
gauge | backend | 1 when the backend passes is_available() (health + retry policy + status), 0 after a transition to unavailable. Emitted at the up/down transition sites in health_check.rs, kawa_h1, mux, and tcp — not per-request, so the cardinality cost is bounded by transition frequency |
The health_check.healthy_backends gauge is now labelled with cluster_id;
prior emissions overwrote each other across clusters because the unlabelled key
collapsed every cluster's value into a single bucket.
The tracker is driven by both the data-plane connect path and the active
health-check tick. Clusters that have not configured a
[clusters.<id>.health_check] block still see the full cluster.* and
backend.available surface — transitions are detected through the per-backend
retry policy as TCP connect attempts succeed or fail.
- Every TCP connect failure on the data path (
lib/src/tcp.rs,lib/src/protocol/kawa_h1/mod.rs::fail_backend_connection,lib/src/protocol/mux/mod.rs) callsBackend::retry_policy.fail(), arming an exponential-backoff window. Aftermax_triesconsecutive failures (default6) the policy reportsis_down() == true. When every backend in the cluster reaches that state, the next routing call observesavailable == 0 && total > 0, flips the cell toAllDown, and emits theerror!log line +cluster.no_available_backendscounter +EventKind::NoAvailableBackendsevent. - Every successful TCP connect calls
Backend::retry_policy.succeed(), which clearsis_down()immediately. The success arm of the routing call then re-evaluates and flips the cell back toAvailable, emitting theinfo!log line +cluster.available_recoveredcounter +EventKind::ClusterRecoveredevent.
Backends in the brief WAIT window (post-fail, pre-budget-exhaustion) are
intentionally counted as available because the predicate is
!retry_policy.is_down(), not Backend::can_open(). This avoids flapping
AllDown ↔ Available when every backend happens to be in a 1-second backoff at
the same instant. The WAIT state is transient by design; the cluster is still
healthy from the operator's perspective, just briefly throttled. Only budget
exhaustion (current_tries >= max_tries) drives the cell to AllDown.
Configuring an active health check on top of the data-plane signal adds a second observation cadence — useful when a cluster is idle (no requests, so no passive observations) but you still want timely up/down detection — but is not required for the per-cluster availability surface to function.
| Metric | Type | Scope | Description |
|---|---|---|---|
epoll_time |
time | proxy | Time spent in epoll_wait/kqueue (ms) |
event_loop_time |
time | proxy | Total event loop iteration time (ms) |
These gauges track how many sessions are in each protocol phase. A session transitions through phases (e.g., Expect → TLS Handshake → HTTPS → WSS).
| Metric | Type | Scope | Description |
|---|---|---|---|
protocol.proxy.expect |
gauge | proxy | Sessions expecting a PROXY protocol header |
protocol.proxy.send |
gauge | proxy | Sessions sending a PROXY protocol header to a backend |
protocol.proxy.relay |
gauge | proxy | Sessions relaying a PROXY protocol header |
protocol.tls.handshake |
gauge | proxy | Sessions in TLS handshake (HTTPS only) |
protocol.http |
gauge | proxy | Active HTTP sessions |
protocol.https |
gauge | proxy | Active HTTPS sessions |
protocol.tcp |
gauge | proxy | Active TCP proxy sessions |
protocol.ws |
gauge | proxy | Active WebSocket sessions (over HTTP) |
protocol.wss |
gauge | proxy | Active WebSocket sessions (over HTTPS) |
websocket.active_requests |
gauge | proxy | Active WebSocket requests (HTTP + HTTPS) |
| Metric | Type | Scope | Description |
|---|---|---|---|
http.requests |
counter | proxy, cluster, backend | Total HTTP requests received (incremented when headers are fully parsed) |
http.active_requests |
gauge | proxy | Currently in-flight requests |
http.e2e.http11 |
counter | proxy | Completed HTTP/1.1 request/response cycles |
http.e2e.h2 |
counter | proxy | Completed HTTP/2 request/response cycles |
http.errors |
counter | proxy, cluster, backend | General HTTP processing errors. Labels are filtered centrally per metrics.detail: process / frontend collapse to a proxy-wide counter, cluster (default) attributes per cluster, backend keeps the per-backend split |
tcp.requests |
counter | proxy | TCP proxy connection requests |
| Metric | Type | Scope | Description |
|---|---|---|---|
bytes_in |
counter | proxy, cluster, backend | Bytes received from frontend clients |
bytes_out |
counter | proxy, cluster, backend | Bytes sent to frontend clients |
back_bytes_in |
counter | proxy | Bytes received from backend servers |
back_bytes_out |
counter | proxy | Bytes sent to backend servers |
These are recorded as HDR histograms locally (queryable as percentiles: p50,
p90, p99, p99.9, p99.99, p99.999, p100) and sent as |ms values over StatsD.
| Metric | Type | Scope | Description |
|---|---|---|---|
request_time |
time | proxy, cluster | Total request time: first byte received to last byte sent (ms) |
service_time |
time | proxy, cluster | Internal processing time excluding backend I/O (ms) |
backend_response_time |
time | cluster, backend | Time from backend connection to last response byte (ms) |
backend_connection_time |
time | cluster, backend | TCP connection establishment time to backend (ms) |
frontend_matching_time |
time | cluster | Cluster/frontend route matching time (ms) |
regex_matching_time |
time | proxy | Regex evaluation time for path-based routing (ms) |
Incremented per backend response (scope: cluster + backend for 1xx–5xx).
Buckets are emitted for every response with a status; per-code counters below
are emitted in addition to the bucket for the eighteen short-listed codes Sōzu
either generates as a default answer or that operators routinely chart. Status
codes outside that list contribute only to their bucket so the metric keyspace
stays bounded.
| Metric | Type | Scope | Description |
|---|---|---|---|
http.status.1xx |
counter | cluster, backend | 1xx informational responses |
http.status.2xx |
counter | cluster, backend | 2xx success responses |
http.status.3xx |
counter | cluster, backend | 3xx redirection responses |
http.status.4xx |
counter | cluster, backend | 4xx client error responses |
http.status.5xx |
counter | cluster, backend | 5xx server error responses |
http.status.other |
counter | proxy | Non-standard status codes |
http.status.none |
counter | proxy | Responses without a status code |
http.status.200 / 201 / 204 |
counter | cluster, backend | Common 2xx success codes (in addition to http.status.2xx) |
http.status.301 / 302 / 304 |
counter | cluster, backend | Common 3xx redirect/cache codes (in addition to http.status.3xx) |
http.status.400 / 401 / 403 / 404 / 408 / 413 / 429 |
counter | cluster, backend | Common 4xx client-error codes (in addition to http.status.4xx) |
http.status.500 / 502 / 503 / 504 / 507 |
counter | cluster, backend | Common 5xx server-error codes (in addition to http.status.5xx) |
Incremented when Sōzu generates a default error response instead of proxying:
| Metric | Type | Scope | Description |
|---|---|---|---|
http.301.redirection |
counter | proxy | 301 Moved Permanently (HTTP→HTTPS redirect, RedirectPolicy::Permanent) |
http.302.redirection |
counter | proxy | 302 Found (RedirectPolicy::Found — UA may rewrite POST → GET) |
http.308.redirection |
counter | proxy | 308 Permanent Redirect (RedirectPolicy::PermanentRedirect — method MUST be preserved) |
http.redirect_template.compile_error |
counter | proxy | Operator-supplied per-frontend redirect_template failed to compile at request time; the listener default fired instead |
http.400.errors |
counter | proxy | 400 Bad Request (cannot parse hostname) |
http.401.errors |
counter | proxy | 401 Unauthorized |
http.404.errors |
counter | proxy | 404 Not Found (no matching cluster) |
http.408.errors |
counter | proxy | 408 Request Timeout |
http.413.errors |
counter | proxy | 413 Payload Too Large |
http.502.errors |
counter | proxy | 502 Bad Gateway |
http.503.errors |
counter | proxy | 503 Service Unavailable (no backends or circuit breaker triggered) |
http.504.errors |
counter | proxy | 504 Gateway Timeout |
http.507.errors |
counter | proxy | 507 Insufficient Storage (buffer full) |
http.other.errors |
counter | proxy | Non-standard error response code (mux path only) |
| Metric | Type | Scope | Description |
|---|---|---|---|
http.frontend_parse_errors |
counter | proxy | Frontend request parsing failures (malformed HTTP/1.1 or HPACK decode errors in HTTP/2) |
http.frontend.transfer_encoding_smuggling |
counter | proxy | H1 request rejected (400) for ambiguous Transfer-Encoding framing: more than one non-elided Transfer-Encoding header, or one whose final coding is not chunked (e.g. chunked, gzip), or one present without kawa adopting chunked framing (RFC 9110 §7.6 / RFC 9112 §6.1; #726). Whitespace around a coding is not ambiguity — kawa >= 0.7.1 excludes it from the field value (RFC 9112 §5), so chunked\t frames as chunked and is forwarded canonically rather than counted here |
http.backend_parse_errors |
counter | proxy | Backend response parsing failures |
| Metric | Type | Scope | Description |
|---|---|---|---|
backend.connections |
gauge | proxy | Active backend connections |
connections_per_backend |
gauge | cluster, backend | Per-backend connection count |
backend.up |
counter | proxy | Backend marked as healthy (after successful connection) |
backend.down |
counter | proxy | Backend marked as unhealthy (retry policy triggered) |
backend.connections.error |
counter | proxy | Backend connection failures |
backend.connect.retries_exhausted |
counter | cluster, backend | Per-session backend-connect retry budget (CONN_RETRIES = 3) was exhausted. Emitted once per event at the TCP, HTTP/1, and HTTP/2-mux gates. Alert on this counter's rate instead of grepping WARN / ERROR logs — the underlying log line is warn! since the condition is peer-driven backpressure, not a Sōzu invariant break. |
H2 mux reuses backend connections via
Router::backends: HashMap<Token, Connection>
(lib/src/protocol/mux/router.rs). There is no separate pool abstraction: the
map is the pool. Reuse picks an existing non-draining H2 multiplex slot (below
SETTINGS_MAX_CONCURRENT_STREAMS) or an H1 keep-alive socket; misses dial a
fresh backend socket.
| Metric | Type | Scope | Description |
|---|---|---|---|
backend.pool.hit |
counter | proxy | Request attached to an existing backend connection (H2 multiplex slot or H1 keep-alive). |
backend.pool.miss |
counter | proxy | No reusable connection found; a fresh dial starts. Incremented before backend_from_request, so failed selections still count. Dial may still fail — in that case backend.pool.size is not bumped. |
backend.pool.size |
gauge | proxy | Live mux router entries. +1 at router.rs::connect new-dial commit; -1 at connection.rs::pre_close_client_bookkeeping and mod.rs::close_backend. Mirrors the backend.connections site set in mux exactly, so gauge symmetry follows from backend.connections correctness. Non-mux H1/TCP paths are NOT counted here. |
backend.flow_control.paused |
counter | proxy | Direction-scoped counterpart of h2.flow_control_stall: emitted only when the converter stalls while writing toward an upstream backend (Position::Client) because the backend's HTTP/2 receive window is empty. |
Intentionally not emitted in this slice (no corresponding lifecycle exists):
backend.pool.idle_closed— mux has no connection-level idle eviction. The H2stream_idle_timeoutcancels individual streams (slow-multiplex guard), not pool entries.backend.pool.overflow—Router::backendsis unbounded. The only new-connection refusal is HTTP/2 buffer-pool exhaustion (MaxBuffers), unrelated to pool sizing.backend.flow_control.resumed— the converter has no "resumed" boundary; the next writable cycle just succeeds when the backend ACKs window updates. Plumbing an explicit marker throughflush_stream_outwas deferred.
These metrics are recorded with cluster_id and backend_id labels via the
record_backend_metrics! macro at the end of each request:
| Metric | Type | Scope | Description |
|---|---|---|---|
requests |
counter | cluster, backend | Requests handled by this backend |
bytes_in |
counter | cluster, backend | Bytes received from this backend |
bytes_out |
counter | cluster, backend | Bytes sent to this backend |
backend_response_time |
time | cluster, backend | Response time for this backend (ms) |
backend_connection_time |
time | cluster, backend | Connection setup time for this backend (ms) |
| Metric | Type | Scope | Description |
|---|---|---|---|
http.alpn.h2 |
counter | proxy | TLS connections where client negotiated HTTP/2 via ALPN |
http.alpn.http11 |
counter | proxy | TLS connections where client negotiated HTTP/1.1 via ALPN (or no ALPN) |
https.alpn.rejected.http11_disabled |
counter | proxy | TLS connection refused on an H2-only listener because the peer offered http/1.1 or no ALPN value |
https.alpn.rejected.unsupported |
counter | proxy | TLS connection refused because the peer negotiated an ALPN protocol Sōzu does not recognise (anything other than h2, http/1.1, or absent ALPN). Add to the same SOC bucket as https.alpn.rejected.http11_disabled. |
Incremented once per TLS connection after the handshake completes.
| Metric | Type | Scope | Description |
|---|---|---|---|
tls.version.SSLv2 |
counter | proxy | Connections using SSLv2 |
tls.version.SSLv3 |
counter | proxy | Connections using SSLv3 |
tls.version.TLSv1_0 |
counter | proxy | Connections using TLS 1.0 |
tls.version.TLSv1_1 |
counter | proxy | Connections using TLS 1.1 |
tls.version.TLSv1_2 |
counter | proxy | Connections using TLS 1.2 |
tls.version.TLSv1_3 |
counter | proxy | Connections using TLS 1.3 |
tls.version.DTLSv1_0 |
counter | proxy | Connections using DTLS 1.0 |
tls.version.DTLSv1_2 |
counter | proxy | Connections using DTLS 1.2 |
tls.version.DTLSv1_3 |
counter | proxy | Connections using DTLS 1.3 |
tls.version.Unknown |
counter | proxy | Unrecognized TLS version |
tls.version.unimplemented |
counter | proxy | TLS version not yet handled in code |
tls.default_cert_used |
counter | proxy | Fallback to default certificate (no SNI match) |
Negotiated cipher suite (rustls):
| Metric | Type | Scope |
|---|---|---|
tls.cipher.TLS13_AES_128_GCM_SHA256 |
counter | proxy |
tls.cipher.TLS13_AES_256_GCM_SHA384 |
counter | proxy |
tls.cipher.TLS13_CHACHA20_POLY1305_SHA256 |
counter | proxy |
tls.cipher.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 |
counter | proxy |
tls.cipher.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 |
counter | proxy |
tls.cipher.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 |
counter | proxy |
tls.cipher.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 |
counter | proxy |
tls.cipher.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 |
counter | proxy |
tls.cipher.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 |
counter | proxy |
tls.cipher.Unsupported |
counter | proxy |
Emitted by the rustls handshake driver at lib/src/protocol/rustls.rs. Failure
counters sit next to the tiered log emission in log_handshake_error so every
warn!/error!/debug! about a broken handshake also bumps the matching
counter. The histogram is recorded at the moment the handshake transitions out
of is_handshaking() (both through the readable and writable exit paths).
| Metric | Type | Scope | Description |
|---|---|---|---|
tls.handshake.failed.alert_received |
counter | proxy | Remote peer sent a fatal TLS alert (RustlsError::AlertReceived). Typical causes: cert-pinning client, stale CA bundle, scanner. Logged at debug!. |
tls.handshake.failed.peer_incompatible |
counter | proxy | Peer advertised a version/feature mix we cannot negotiate (PeerIncompatible). Logged at warn!. |
tls.handshake.failed.peer_misbehaved |
counter | proxy | Peer deviated from the TLS state machine (PeerMisbehaved). Logged at warn!. |
tls.handshake.failed.invalid_message |
counter | proxy | Wire-level record parse failure (InvalidMessage). Logged at warn!. |
tls.handshake.failed.inappropriate_message |
counter | proxy | Peer sent a record type that was valid on the wire but not allowed in the current phase (InappropriateMessage). Logged at warn!. |
tls.handshake.failed.inappropriate_handshake_message |
counter | proxy | Peer sent a handshake sub-type the state machine did not expect (InappropriateHandshakeMessage). Logged at warn!. |
tls.handshake.failed.oversized_record |
counter | proxy | Peer sent a record larger than the RFC 8446 §5.1 cap (PeerSentOversizedRecord). Logged at warn!. |
tls.handshake.failed.no_alpn |
counter | proxy | ALPN negotiation failed (NoApplicationProtocol) — e.g. peer offered only protocols the listener does not serve. Logged at warn!. |
tls.handshake.failed.invalid_certificate |
counter | proxy | Peer-supplied certificate failed verification (InvalidCertificate). Logged at warn!. Only relevant when mTLS is enabled. |
tls.handshake.failed.decrypt_error |
counter | proxy | Record failed to decrypt (DecryptError) — almost always an attack or a broken middlebox. Logged at warn!. |
tls.handshake.failed.no_certificates_present |
counter | proxy | mTLS: peer sent an empty Certificate message (NoCertificatesPresented). Logged at warn!. |
tls.handshake.failed.other |
counter | proxy | Catch-all for local/config/provider failures (General, Other, EncryptError, FailedToGetRandomBytes, CRL errors, future rustls variants). Logged at error! — these indicate a server-side problem, not a bad client. |
tls.handshake_ms |
time | proxy | Wall-clock duration in milliseconds from the first TLS byte observed on the socket to the handshake leaving is_handshaking(). Histogram — not a counter — so alert rules should use quantiles. |
| Metric | Type | Scope | Description |
|---|---|---|---|
tls.cert.min_expires_at_seconds |
gauge | proxy | Unix-seconds timestamp of the soonest-expiring certificate currently loaded in the CertificateResolver. Recomputed on every add/remove/replace at lib/src/tls.rs. Aggregate only — per-SNI granularity is intentionally omitted because statsd has no label support and the resolver can hold tens of thousands of names; operators query per-cert detail through the command API. Already-expired certificates clamp to 0, which dashboards should interpret as the "rotate now" alert condition. |
| Metric | Type | Scope | Description |
|---|---|---|---|
h2.connection.active_streams |
gauge | proxy | Aggregate count of open HTTP/2 streams across every live H2 connection on the worker. Emitted as a [gauge_add!] lifecycle delta from ConnectionH2::gauge_connection_state; the per-connection contribution is subtracted on connection drop, so the value sums correctly under multi-connection load. |
h2.connection.window_bytes |
gauge | proxy | Aggregate sum of available connection-level flow-control window bytes across every live H2 connection. Negative per-connection windows clamp to 0 — the aggregate measures available capacity, not deficit. Lifecycle-delta semantics as above. |
h2.connection.pending_window_updates |
gauge | proxy | Aggregate number of queued (un-flushed) per-stream WINDOW_UPDATE entries across every live H2 connection. Lifecycle-delta semantics as above. |
Migration note (renamed metrics). The three keys above replace the earlier
h2.connection_window,h2.active_streams, andh2.pending_window_updatesgauges. The old keys had per-connection snapshot semantics implemented via absolutegauge!: under concurrent load every H2 connection clobbered the previous one's value, so the dashboard saw the last writer rather than the aggregate. The new keys emit lifecycle deltas viagauge_add!, so the value is the sum across all live H2 connections on the worker. Update dashboards and alerts accordingly — the new values typically rise into the hundreds or thousands under load instead of cycling 0…N.
| h2.flow_control_stall | counter | proxy | Converter stalled due to flow
control | | h2.streams.reaped.idle_timeout | counter | proxy | Streams reaped by the per-stream bidirectional-idle guard (slow-multiplex Slowloris) | | h2.streams.reaped.window_stall | counter | proxy | Streams reaped by the outbound flow-control-stall guard (a buffered response or backend upload the peer will not drain — the HTTP/2 window-stall / WINDOW_UPDATE-drip vector) | | h2.streams.reaped.stall_budget | counter | proxy | Subset of window_stall: reaps where the stream dribbled progress below the 16 KiB cumulative-stall floor (the WINDOW_UPDATE-drip the budget closes) | | h2.close_with_active_streams | counter | proxy | H2 connections
closed while streams were still active | | h2.window_update_dropped | counter
| proxy | WINDOW_UPDATE frame dropped because the per-connection pending-update
queue was already at capacity | | h2.headers_no_stream.error | counter | proxy
| HEADERS frame received with no matching stream (protocol error) | |
h2.frames.tx.headers | counter | proxy | HEADERS frames emitted by the H2
block converter (one per response, plus the first frame of any header block
split into HEADERS+CONTINUATION when the encoded headers exceed the negotiated
max_frame_size) | | h2.frames.tx.continuation | counter | proxy |
CONTINUATION frames emitted by the converter when a single response's encoded
headers cross max_frame_size (default 16 KB). Stays at zero for typical
responses | | h2.frames.tx.data | counter | proxy | DATA frames emitted by the
converter — both the normal data path and the empty END_STREAM-only marker
emitted when the response has zero bytes after headers |
| Metric | Type | Scope | Description |
|---|---|---|---|
http.sni_authority_mismatch |
counter | proxy | Request rejected because its :authority (HTTP/2) or Host header (HTTP/1.1) was not covered by any SAN dNSName of the certificate served on this TLS session (RFC 6125 §6.4.4, with RFC 6125 §6.4.3 wildcard handling). Default-cert handshakes fall back to legacy SNI exact-match. Defence against cross-tenant frontend confusion (CWE-346). Increment site: lib/src/protocol/mux/router.rs. The historical sni_* prefix is preserved so existing operator dashboards keep working. |
h2.coalescing.accepted |
counter | proxy | HTTP/2 stream accepted via RFC 7540 §9.1.1 / RFC 9113 §9.1.1 connection coalescing — :authority differed from the TLS SNI negotiated at handshake but matched a SAN dNSName of the served certificate (with RFC 6125 §6.4.3 wildcards). ALPN-gated to h2 so HTTP/1.1 cross-Host keep-alive does not double-count. Multi-tenant observability for Firefox / Chrome coalescing on shared wildcard certs. Increment site: lib/src/protocol/mux/router.rs. |
Per-frame-type counters split by direction. Receive side counts every H2 frame
the parser hands to handle_frame — single chokepoint, so adding a new H2 frame
type fails the build inside the metric helper. Send side covers the control
frames Sozu emits; HEADERS and DATA tx flow through the H2 block converter and
are not yet broken out per type (tracked as follow-up — pair with
back_bytes_out for the byte view today).
| Metric | Type | Scope | Description |
|---|---|---|---|
h2.frames.rx.data |
counter | proxy | DATA frames received |
h2.frames.rx.headers |
counter | proxy | HEADERS frames received |
h2.frames.rx.push_promise |
counter | proxy | PUSH_PROMISE frames received (always rejected; see h2.goaway.sent.protocol_error) |
h2.frames.rx.priority |
counter | proxy | RFC 7540 PRIORITY frames received |
h2.frames.rx.rst_stream |
counter | proxy | RST_STREAM frames received (paired with h2.rst_stream.received.<code> for per-error breakdown) |
h2.frames.rx.settings |
counter | proxy | SETTINGS frames received (both peer-settings and ACKs) |
h2.frames.rx.ping |
counter | proxy | PING frames received (both probes and ACKs) |
h2.frames.rx.goaway |
counter | proxy | GOAWAY frames received (paired with h2.goaway.received.<code>) |
h2.frames.rx.window_update |
counter | proxy | WINDOW_UPDATE frames received |
h2.frames.rx.continuation |
counter | proxy | Reachable only via the defensive fallback path (RFC 9113 §6.10 standalone CONTINUATION) — the inline header parser absorbs CONTINUATION during HEADERS decoding, so under normal conditions this stays at zero |
h2.frames.rx.unknown |
counter | proxy | Unknown frame type ignored per RFC 9113 §5.5. A non-zero rate is the early-warning signal for a peer trying H2 extensions |
h2.frames.tx.settings |
counter | proxy | SETTINGS frames emitted (initial + later updates) |
h2.frames.tx.window_update |
counter | proxy | WINDOW_UPDATE frames emitted (per-frame in the queued-update flush loop) |
h2.frames.tx.rst_stream |
counter | proxy | RST_STREAM frames emitted (across both the queued flush loop and the end-stream cancel path) |
h2.frames.tx.goaway |
counter | proxy | GOAWAY frames emitted (one per phase of graceful_goaway, plus error-path goaway) |
h2.frames.tx.ping_ack |
counter | proxy | PING ACK frames emitted in response to peer probes |
LIFECYCLE §9 invariant 15 compliance counters. Each firing records a site
where sozu paired Ready::WRITABLE with signal_pending_write so the
edge-triggered epoll scheduler re-runs writable() on the next tick — required
whenever bytes land in sozu-owned buffers (the kernel never signals WRITABLE for
buffers it does not own). Useful as a regression baseline: the first two
correlate with 504/500/400 rate and RFC 9218 PRIORITY_UPDATE rate respectively,
the last two correlate with backend-H2 traffic volume. A 10× spike on any
counter without a matching spike on the upstream driver is the early warning for
a hot loop regression.
| Metric | Type | Scope | Description |
|---|---|---|---|
h2.signal.writable.rearmed.default_answer |
counter | proxy | Fired in mux/answers.rs::set_default_answer (504 backend timeout, 500/400 parse errors). Rate should track the default-answer render rate closely. |
h2.signal.writable.rearmed.forcefully_terminate_answer |
counter | proxy | Fired in mux/answers.rs::forcefully_terminate_answer when the proxy injects a default answer mid-response (e.g. backend disconnects after partial body). Companion to default_answer; the forcefully-terminate path is exercised on backend hard-failure and proxy-initiated stream resets. |
h2.signal.writable.rearmed.priority_update |
counter | proxy | Fired in mux/h2.rs::handle_priority_update_frame whenever a PRIORITY_UPDATE mutates Prioriser state. Pairs with h2.frames.rx.priority_update. Low-to-zero on Firefox-only fleets (Firefox does not emit 0x10). |
h2.signal.writable.rearmed.peer_data |
counter | proxy | Fired in mux/h2.rs::handle_data_frame when an H2 DATA frame wakes the linked peer. Non-zero only on clusters that use H2 to the origin. |
h2.signal.writable.rearmed.peer_headers |
counter | proxy | Fired in mux/h2.rs::handle_headers_frame when an H2 HEADERS frame wakes the linked peer. Non-zero only on clusters that use H2 to the origin. |
h2.signal.writable.rearmed.control_queue |
counter | proxy | Fired in mux/h2.rs::flush_pending_control_frames when a queued WINDOW_UPDATE or RST_STREAM forces an extra writable pass. Pairs with h2.frames.tx.window_update / h2.frames.tx.rst_stream. Persistent non-zero rate without matching tx growth points to a control-frame queue that fills faster than it drains. |
h2.streams.ready_incremental.by_urgency |
gauge | proxy | Post-scheduling-pass snapshot of the sum of ready incremental streams across all urgency buckets (RFC 9218 §4). Debug hint for scheduler fairness — a consistently non-zero value under load means the round-robin is active. |
h2.trailers_dropped_content_length |
counter | proxy | Fired in mux/pkawa.rs::handle_trailer when an H2 trailer block is rejected for carrying a Content-Length header (RFC 9113 §8.1 disallows pseudo-headers and Content-Length in trailers). Spikes correlate with malformed gRPC clients or smuggling attempts; aggregate cardinality is bounded. |
h1.backend_eof_before_message_complete |
counter | proxy | Fired in mux/h1.rs::back_readable when the H1 backend closes the socket before the response body is fully delivered (chunked-EOF or Content-Length-EOF mid-stream). The H2 converter surfaces this as RST_STREAM(InternalError) to the H2 client; non-zero rate maps to backend application crashes or proxy-side reads. |
Counters split by direction (sent vs received) and RFC 9113 §7 error code. Every
variant in the H2Error enum gets its own counter so SOC dashboards can
distinguish a sustained protocol_error (parser issue / fuzzer) from
enhance_your_calm (flood-detector trip) from no_error (graceful drain).
Codes the wire delivers but RFC 9113 does not define are bucketed under
unknown_error to keep cardinality bounded.
| Metric pattern | Type | Scope | Description |
|---|---|---|---|
h2.goaway.sent.<code> |
counter | proxy | GOAWAY emitted by Sozu. <code> ∈ no_error, protocol_error, internal_error, flow_control_error, settings_timeout, stream_closed, frame_size_error, refused_stream, cancel, compression_error, connect_error, enhance_your_calm, inadequate_security, http_1_1_required. The graceful drain (graceful_goaway) emits two no_error increments per connection — one per phase. |
h2.goaway.received.<code> |
counter | proxy | GOAWAY received from peer. Same code suffixes as sent, plus unknown_error for codes outside RFC 9113 §7. Useful on backend H2 connections to detect upstreams under pressure (enhance_your_calm) or with bugs (internal_error). |
h2.rst_stream.sent.<code> |
counter | proxy | RST_STREAM emitted by Sozu. Same code suffixes. The no_error and cancel increments are graceful (stream recycle, propagated client cancel); the rest are server-side defences against attacker-crafted frames. |
h2.rst_stream.received.<code> |
counter | proxy | RST_STREAM received from peer. Same code suffixes plus unknown_error. |
h2.rst_stream.received.pre_response_start |
counter | proxy | Subset of h2.rst_stream.received.* where the RST arrived before the backend started answering. The canonical Rapid Reset signature (CVE-2023-44487). Emitted alongside the per-code counter, not instead of, so a Rapid Reset attack surfaces both as a cancel rate spike and as the pre-response signal. |
Counters emitted whenever the HPACK decoder in the H2→H1 converter rejects a
header. h2.headers.rejected.total is bumped on every reject; a per-reason
counter is bumped alongside so total == sum(per_reason). Rejection is silent
on the wire (the mux either RSTs the stream or treats the request as malformed)
— this counter family is the only externally visible signal for
request-smuggling probes, HPACK fuzzing, and H2-specific protocol abuse.
| Metric | Type | Scope | Description |
|---|---|---|---|
h2.headers.rejected.total |
counter | proxy | Aggregate count of all HPACK rejections |
h2.headers.rejected.invalid_name_byte |
counter | proxy | Header name with uppercase / CTL / separator / space byte (CWE-93) |
h2.headers.rejected.connection_specific_header |
counter | proxy | connection, proxy-connection, transfer-encoding, upgrade, keep-alive (RFC 9113 §8.2.2) |
h2.headers.rejected.te_not_trailers |
counter | proxy | te with value other than trailers (RFC 9113 §8.2.2) |
h2.headers.rejected.crlf_in_value |
counter | proxy | CR or LF in a header value — request-smuggling vector (CWE-444) |
h2.headers.rejected.nul_in_value |
counter | proxy | NUL byte in a header value |
h2.headers.rejected.oversized_pseudo_value |
counter | proxy | Pseudo-header value exceeded storage cap |
h2.headers.rejected.cl_te_conflict |
counter | proxy | Content-Length declared alongside a Transfer-Encoding — classic smuggling vector |
h2.headers.rejected.duplicate_cl |
counter | proxy | Multiple disagreeing Content-Length values (RFC 9110 §8.6) |
h2.headers.rejected.duplicate_pseudo |
counter | proxy | Same pseudo-header appeared twice |
h2.headers.rejected.pseudo_after_regular |
counter | proxy | Pseudo-header appeared after a regular header (RFC 9113 §8.3) |
h2.headers.rejected.unknown_pseudo |
counter | proxy | Unknown : -prefixed pseudo-header |
h2.headers.rejected.empty_pseudo |
counter | proxy | Pseudo-header value was empty (RFC 9113 §8.3.1) |
h2.headers.rejected.invalid_method |
counter | proxy | :method value was not a valid RFC 9110 §9 token |
h2.headers.rejected.invalid_scheme |
counter | proxy | :scheme was not http or https |
h2.headers.rejected.invalid_path |
counter | proxy | :path contained a # fragment (not allowed on the wire) |
h2.headers.rejected.invalid_status |
counter | proxy | Response :status was not three ASCII digits |
h2.headers.rejected.header_list_size |
counter | proxy | HEADERS/trailer block exceeded SETTINGS_MAX_HEADER_LIST_SIZE (name + value + 32 octets/field, RFC 9113 §6.5.2) — the indexed-reference "header bomb" byte budget. Block-level reject (ENHANCE_YOUR_CALM) |
h2.headers.rejected.header_fields |
counter | proxy | HEADERS/trailer block exceeded h2_max_header_fields materialized fields (cookie crumbs counted individually, RFC 9113 §8.2.3). Block-level reject (ENHANCE_YOUR_CALM) |
Sōzu preserves or generates an x-request-id header on every H1 request and
every H2 stream (both paths share the same H1 editor callback via pkawa.rs).
The value also lands on the access log's x_request_id field — same value Sōzu
forwarded to the backend, end to end.
| Metric | Type | Scope | Description |
|---|---|---|---|
http.x_request_id.propagated |
counter | proxy | Request already carried an x-request-id header; Sōzu preserved it verbatim |
http.x_request_id.generated |
counter | proxy | Request had no x-request-id; Sōzu generated one from the request ULID and injected it before forwarding |
The x_request_id access-log field (wire tag ProtobufAccessLog.x_request_id
#24) carries whichever value was sent to the backend.
Five additional access-log fields surface the negotiated TLS metadata and the
upstream-attested forwarded chain. They are wire-compatible appends to
ProtobufAccessLog (tags 25–29) and are populated end-to-end on H1 and H2 mux
paths, plus the WSS post-upgrade pipe. Pure plaintext paths (HTTP, WS, TCP) emit
None for all five.
| Field | Wire tag | Source | Notes |
|---|---|---|---|
tls_version |
ProtobufAccessLog.tls_version #25 |
rustls_version_label(handshake.session.protocol_version()) |
Short form (e.g. TLSv1.3). Captured once at handshake completion in lib/src/https.rs::upgrade_handshake. None when rustls reports an unknown variant. |
tls_cipher |
ProtobufAccessLog.tls_cipher #26 |
rustls_ciphersuite_label(handshake.session.negotiated_cipher_suite()) |
Short form (e.g. TLS_AES_128_GCM_SHA256). None when rustls reports an unsupported cipher. |
tls_sni |
ProtobufAccessLog.tls_sni #27 |
handshake.session.server_name() |
Pre-lowercased, no port. Same value the routing layer uses to enforce the SNI ↔ :authority binding. None when the client omitted SNI. |
tls_alpn |
ProtobufAccessLog.tls_alpn #28 |
ALPN negotiation in upgrade_handshake |
h2, http/1.1, or None when no ALPN was negotiated. |
xff_chain |
ProtobufAccessLog.xff_chain #29 |
Verbatim X-Forwarded-For header value |
Snapshotted in editor.rs::on_request_headers before Sōzu appends its own peer hop, so the log records the upstream-attested chain (e.g. 203.0.113.5, 198.51.100.10). None when the request has no X-Forwarded-For header. |
TLS fields are connection-scoped: they are stamped once on the mux Context at
handshake time and propagated to every per-stream HttpContext via
Context::create_stream, so an H2 connection multiplexing N streams pays the
cost once. The labels are &'static str borrows into rustls's static label
tables — no per-request allocation.
client_rtt and server_rtt carry the kernel-measured TCP round-trip time on
each side of the proxy at the moment the access log is emitted. Source:
getsockopt(TCP_INFO) (Linux SOL_TCP, BSD IPPROTO_TCP, Darwin IPPROTO_TCP
opt 0x106) wrapped in lib/src/socket.rs::stats::socket_rtt. Unit on the
wire: microseconds (uint64); on Darwin the kernel reports tcpi_srtt in
milliseconds and the helper multiplies by 1000 before exposing the same
Duration.
| Access-log field | Wire tag | Populated on |
|---|---|---|
client_rtt |
ProtobufAccessLog.client_rtt #9 (optional uint64) |
every protocol path: H1 (kawa_h1), H2 (mux), Pipe (TCP/WS), TCP frontend |
server_rtt |
ProtobufAccessLog.server_rtt #10 (optional uint64) |
every protocol path that has a backend socket; None for the TCP frontend (no upstream) |
Capture is at access-log emission time and is cheap (one getsockopt(TCP_INFO)
syscall per side), so the cell reflects the most recent kernel SRTT estimate
rather than a session-wide average. None on AF_UNIX or any FSM state where
TCP_INFO is not usable (pre-handshake, dead socket). Real implementations
exist for Linux, FreeBSD, NetBSD, OpenBSD, DragonFly, macOS and iOS; non-Unix
stub builds short-circuit to None.
Incremented once per connection at the moment the H2 flood detector trips its
threshold and the proxy escalates to GOAWAY(ENHANCE_YOUR_CALM). Every CVE
mitigation in the H2 family (Rapid Reset, MadeYouReset, the CONTINUATION / PING
/ SETTINGS / empty-DATA flood family, oversized header lists, and the generic
glitch budget) routes through ConnectionH2::handle_flood_violation, which
emits both the contextual log line and the per-kind counter below.
| Metric | Type | Scope | Description |
|---|---|---|---|
h2.flood.violation.rst_stream_window |
counter | proxy | Per-window RST_STREAM rate ceiling exceeded. Generic stream-cancel storm signal — usually a misbehaving client, sometimes Rapid Reset. |
h2.flood.violation.rst_stream_lifetime |
counter | proxy | Lifetime received-RST ceiling exceeded. Catches a patient Rapid Reset attacker that stays under the windowed cap (CVE-2023-44487). |
h2.flood.violation.rst_stream_pre_response_lifetime |
counter | proxy | Lifetime received-RST ceiling exceeded for streams that the backend had not yet started answering. The canonical Rapid Reset signature (CVE-2023-44487). |
h2.flood.violation.rst_stream_emitted_lifetime |
counter | proxy | Lifetime server-emitted RST ceiling exceeded. MadeYouReset mitigation (CVE-2025-8671) — peer kept feeding the server crafted frames that forced it to reset streams. |
h2.flood.violation.ping_window |
counter | proxy | Per-window PING flood (CVE-2019-9512). |
h2.flood.violation.ping_lifetime |
counter | proxy | Lifetime PING ceiling exceeded — catches sustained low-rate PING abuse that stays under the windowed cap. |
h2.flood.violation.settings_window |
counter | proxy | Per-window SETTINGS flood (CVE-2019-9515). |
h2.flood.violation.settings_lifetime |
counter | proxy | Lifetime SETTINGS ceiling exceeded. |
h2.flood.violation.empty_data_window |
counter | proxy | Per-window flood of empty DATA frames (CVE-2019-9518). |
h2.flood.violation.continuation_per_block |
counter | proxy | Single header block split across more CONTINUATION frames than the configured cap (CVE-2024-27316). |
h2.flood.violation.header_size_per_block |
counter | proxy | Single header block accumulated more bytes than the configured cap (CVE-2024-27316 sibling — header overflow). |
h2.flood.violation.glitch_window |
counter | proxy | Generic anomaly budget exceeded (unknown SETTINGS, WINDOW_UPDATE on closed stream, other low-severity protocol drift). |
Incremented when a session fails to transition between protocol phases:
| Metric | Type | Scope | Description |
|---|---|---|---|
http.upgrade.expect.failed |
counter | proxy | HTTP: PROXY protocol expect → mux transition failed |
http.upgrade.mux.failed |
counter | proxy | HTTP: mux protocol upgrade failed |
http.upgrade.ws.failed |
counter | proxy | HTTP: WebSocket upgrade failed |
https.upgrade.expect.failed |
counter | proxy | HTTPS: PROXY protocol expect → handshake transition failed |
https.upgrade.handshake.failed |
counter | proxy | HTTPS: TLS handshake → mux transition failed |
https.upgrade.mux.failed |
counter | proxy | HTTPS: mux protocol upgrade failed |
https.upgrade.wss.failed |
counter | proxy | HTTPS: WebSocket over TLS upgrade failed |
tcp.upgrade.pipe.failed |
counter | proxy | TCP: pipe protocol upgrade failed |
tcp.upgrade.send.failed |
counter | proxy | TCP: PROXY protocol send transition failed |
tcp.upgrade.relay.failed |
counter | proxy | TCP: PROXY protocol relay transition failed |
tcp.upgrade.expect.failed |
counter | proxy | TCP: PROXY protocol expect transition failed |
tcp.upgrade.sni_preread.failed |
counter | proxy | TCP: SNI-preread → pipe / PROXY-protocol-send transition failed (see below) |
Emitted by the SNI+ALPN preread routing feature
(#1279) on a TCP listener
with at least one hostname-scoped frontend. tcp.sni_preread.active is a
gauge and is guaranteed not to underflow: it is incremented exactly once per
session entering the preread state and decremented exactly once on whichever
of its three mutually-exclusive exits fires (reject / upgrade-to-backend /
teardown). A bare TCP health check (connect then close, zero bytes sent) is
not counted in any rejected.* reason — see the reject-policy note in
Options specific to TCP listeners.
| Metric | Type | Scope | Description |
|---|---|---|---|
tcp.sni_preread.routed |
counter | proxy | A cluster was chosen from the ClientHello's SNI (+ optional ALPN) |
tcp.sni_preread.active |
gauge | proxy | Sessions currently prereading. Never underflows |
tcp.sni_preread.duration |
time | proxy | Wall-clock time spent prereading, recorded on every exit (including the defensive upgrade-time early-return paths, which record it immediately rather than waiting on the active decrement, since the latter may only land later, in close()) |
tcp.sni_preread.rejected.not_tls |
counter | proxy | First TLS record's ContentType wasn't handshake |
tcp.sni_preread.rejected.malformed_record |
counter | proxy | Bad TLS record framing (declared length lies, or a non-handshake record interrupts an in-progress hello) |
tcp.sni_preread.rejected.malformed_handshake |
counter | proxy | Not a ClientHello, or a length-prefixed field inside it lies about the bytes available |
tcp.sni_preread.rejected.fragmented |
counter | proxy | The preread deadline (sni_preread_timeout) fired before a terminal verdict |
tcp.sni_preread.rejected.too_large |
counter | proxy | The ClientHello failed to complete within sni_preread_max_bytes (the preread never reads past the cap; a hello that completes within it routes even when coalesced bytes follow) |
tcp.sni_preread.rejected.no_sni |
counter | proxy | The server_name extension was absent, empty, or its host_name entry missing |
tcp.sni_preread.rejected.ech_outer_absent |
counter | proxy | Encrypted Client Hello (0xfe0d) was present with no usable outer SNI — distinct from no_sni |
tcp.sni_preread.rejected.sni_unmatched |
counter | proxy | The normalized SNI matched no configured hostname on this listener |
tcp.sni_preread.rejected.alpn_unmatched |
counter | proxy | The SNI matched a route but no entry's alpn accepted the client's offer (and no catch-all was present) |
tcp.sni_preread.rejected.proxy_header_invalid |
counter | proxy | The inbound PROXY-v2 header (on an expect_proxy listener) failed to parse |
tcp.sni_preread.rejected.front_closed |
counter | proxy | The frontend closed before a decision was reached (bytes were seen; a bare zero-byte health check is silent, not counted here) |
See lib/src/protocol/tcp_preread/LIFECYCLE.md for the full state lifecycle,
the four proxy_protocol handoff paths out of preread, and the reasoning
behind each reject reason.
| Metric | Type | Scope | Description |
|---|---|---|---|
socket.read.infinite_loop.error |
counter | proxy | TCP socket read loop safety breaker triggered |
socket.write.infinite_loop.error |
counter | proxy | TCP socket write loop safety breaker triggered |
tcp.read.error |
counter | proxy | TCP socket read error |
tcp.write.error |
counter | proxy | TCP socket write error |
tcp.infinite_loop.error |
counter | proxy | TCP session event loop safety breaker triggered |
rustls.read.error |
counter | proxy | TLS read error |
rustls.write.error |
counter | proxy | TLS write error |
rustls.read.infinite_loop.error |
counter | proxy | TLS read loop safety breaker triggered |
rustls.write.infinite_loop.error |
counter | proxy | TLS write loop safety breaker triggered |
Emitted by protocol = "udp" listeners. udp.active_flows is a gauge and is
guaranteed not to underflow on close / timeout / shed / error paths.
| Metric | Type | Scope | Description |
|---|---|---|---|
udp.datagrams.in |
counter | cluster, backend | Client datagrams received |
udp.datagrams.out |
counter | cluster, backend | Datagrams forwarded (to backend) and returned (to client) |
udp.bytes.in |
counter | cluster, backend | Bytes received from clients |
udp.bytes.out |
counter | cluster, backend | Bytes sent (to backend and back to client) |
udp.active_flows |
gauge | proxy | Currently tracked flows (one connected upstream socket each). Never underflows |
udp.flows.created |
counter | cluster, backend | Flows admitted and tracked |
udp.flows.evicted |
counter | cluster, backend | Flows torn down (idle timeout / responses reached / requests reached / drain) |
udp.flows.shed |
counter | proxy | New flows dropped at the max_flows cap or on EMFILE/ENFILE (existing flows protected) |
udp.datagrams.dropped |
counter | proxy | Datagrams dropped, by reason: invalid / truncated / no-backend / shed / wq-full (write-queue full) / unknown-flow / send-error |
udp.backend.health |
gauge | cluster, backend | 1 when the backend passes its UDP health check, 0 when down |
udp.flow.duration |
time | cluster, backend | Flow lifetime, from admission to teardown |
| Metric | Type | Scope | Description |
|---|---|---|---|
http.infinite_loop.error |
counter | proxy | HTTP event loop safety breaker triggered |
http.failed_backend_matching |
counter | proxy | Frontend matched but no backend could be selected |
http.early_response_close |
counter | proxy | Client closed before response was fully sent |
http.trusting.x_proto |
counter | proxy | Request had an existing X-Forwarded-Proto header (trusted) |
http.trusting.x_proto.diff |
counter | proxy | Trusted X-Forwarded-Proto differed from actual protocol |
http.trusting.x_port |
counter | proxy | Request had an existing X-Forwarded-Port header (trusted) |
http.trusting.x_port.diff |
counter | proxy | Trusted X-Forwarded-Port differed from actual port |
pipe.errors |
counter | proxy | Pipe/WebSocket protocol errors |
proxy_protocol.errors |
counter | proxy | PROXY protocol v1/v2 parsing errors |
unsent-access-logs |
counter | proxy | Access log entries that could not be sent |
access_logs.count |
counter | cluster, backend | Access log entries emitted per cluster/backend |
The opentelemetry compile-time feature flag enables W3C Trace Context
passthrough: Sōzu parses, generates, and forwards traceparent headers across
the proxy hop, and records the trace identifiers in its access logs. The feature
name is historic — the implementation is intentionally a propagator only. There
is no OpenTelemetry SDK dependency, no span lifecycle, and no OTLP exporter. See
"Out of scope" below.
Build Sōzu with the opentelemetry feature:
cargo build --release --features opentelemetryOr in Cargo.toml:
[dependencies]
sozu-lib = { path = "lib", features = ["opentelemetry"] }When the opentelemetry feature is enabled, Sōzu acts as a trace context
propagator for both HTTP/1.1 and HTTP/2 frontends. The kawa H1 editor
(lib/src/protocol/kawa_h1/editor.rs) runs on every request, including those
decoded from HPACK on the H2 mux path (lib/src/protocol/mux/pkawa.rs calls
back into the same editor). On the wire the H2 backend leg re-encodes the
rewritten traceparent via H2BlockConverter.
-
Incoming request with
traceparentheader: Sōzu parses the W3C traceparent (format:00-<trace_id>-<parent_id>-<flags>), preserves the trace ID, generates a new span ID for the Sōzu hop, and rewrites the header before forwarding to the backend. -
Incoming request without
traceparentheader: Sōzu generates a new random trace ID and span ID, and injects atraceparentheader into the request before forwarding. -
tracestateheader: Preserved if a validtraceparentis present. Elided if notraceparentaccompanies it (per W3C spec). -
Access logs: The trace context (trace ID, span ID, parent span ID) is included in access log entries and in the protobuf
AccessLogmessage, enabling correlation between Sōzu access logs and distributed traces in your observability platform.
When OpenTelemetry is enabled, access logs include:
| Field | Format | Description |
|---|---|---|
trace_id |
32 hex characters | W3C trace ID (propagated or generated) |
span_id |
16 hex characters | Sōzu-generated span ID for this hop |
parent_span_id |
16 hex characters or - |
Parent span ID from incoming traceparent, if present |
The opentelemetry feature is intentionally narrow. It does NOT:
- Pull
opentelemetry,opentelemetry-sdk,opentelemetry-otlp, ortracing-opentelemetryas dependencies. The feature gate (lib/Cargo.toml) wires no extra crates. - Emit spans. There is no
Span::start/Span::end, no in-process exporter, no OTLP/gRPC client. Use your access log pipeline to feed Jaeger, Tempo, Honeycomb, or Datadog with the per-request trace identifiers. - Honour the sampled-flag bit of incoming
traceparent. Today the rewritten header is always emitted with-01(sampled). Downstream backends that respect the sampled flag will see every request as sampled. - Provide runtime configuration. The feature is selected at compile time only.
A real span model and an OTLP exporter are tracked separately and would land as
their own feature flag rather than expanding the scope of opentelemetry.
When a network stream goes through a proxy, the backend server will only see the IP address and port used by the proxy as client address. The real source IP address and port will only be seen by the proxy. Since this information is useful for logging, security, etc, the PROXY protocol was developed to transmit it to backend servers. With this protocol, after connecting to the backend server, the proxy will first send a small header indicating the client IP address and port, and the proxy's receiving IP address and port, and will then send the stream from the client.
Sōzu support the version 2 of the PROXY protocol in three configurations:
- "send" protocol: Sōzu, in TCP proxy mode, will send the header to the backend server
- "expect" protocol: Sōzu receives the header from a proxy, interprets it for its own logging and metrics, and uses it in HTTP forwarding headers
- "relay" protocol: Sōzu, in TCP proxy mode, can receive the header, and transmit it to a backend server
More information here: proxy-protocol spec
Configures the client-facing connection to receive a PROXY protocol header before any byte sent by the client is read from the socket.
send PROXY expect PROXY
protocol header protocol header
+--------+
| client | +---------+ +------------+ +-----------+
| | | proxy | | Sozu | | upstream |
+--------+ ---------> | server | ---------------> | |------| server |
/ / | | | | | |
/________/ +---------+ +------------+ +-----------+It is supported by HTTP, HTTPS and TCP proxies.
Configuration:
[[listeners]]
address = "0.0.0.0:80"
expect_proxy = trueSend a PROXY protocol header over any connection established to the backends declared in the cluster.
send PROXY
+--------+ protocol header
| client | +---------+ +-----------------+
| | | Sozu | | proxy/upstream |
+--------+ ---------> | | ------------> | server |
/ / | | | |
/________/ +---------+ +-----------------+Configuration:
[[listeners]]
address = "0.0.0.0:81"
[clusters]
[clusters.NameOfYourTcpCluster]
send_proxy = true
frontends = [
{ address = "0.0.0.0:81" }
]NOTE: Only for TCP clusters (HTTP and HTTPS proxies will use the forwarding headers).
Sōzu will receive a PROXY protocol header from the client connection, check its validity and then forward it to an upstream backend. This allows for chains of reverse-proxies without losing the client connection information.
send PROXY expect PROXY send PROXY
protocol header protocol header protocol header
+--------+
| client | +---------+ +------------+ +-------------------+
| | | proxy | | Sozu | | proxy/upstream |
+--------+ +--------> | server | +-----------------> | | +---------> | server |
/ / | | | | | |
/________/ +---------+ +------------+ +-------------------+Configuration:
This only concerns TCP clusters (HTTP and HTTPS proxies can work directly in expect mode, and will use the forwarding headers).
[[listeners]]
address = "0.0.0.0:80"
expect_proxy = true
[clusters]
[clusters.NameOfYourCluster]
send_proxy = true
frontends = [
{ address = "0.0.0.0:80" }
]