TlsServer
TlsServer is the server-side TLS wrapper for WaterWall. It is built on OpenSSL. It receives encrypted TLS records from
the previous node, performs a real server handshake, decrypts application data for the next node, and encrypts downstream
cleartext back into TLS records for the previous node.
This node behaves like a generic nginx stream TLS terminator, not like nginx http. It does not know about HTTP,
HTTP/2, Trojan, VLESS, or any other application protocol placed after it.
Typical Placement
HTTP server chain:
TcpListener -> TlsServer -> HttpServer
Proxy-protocol server chain:
TcpListener -> TlsServer -> TrojanServer -> TcpUdpConnector
TcpListener -> TlsServer -> VlessServer -> TcpUdpConnector
Generic TLS termination before routing:
TcpListener -> TlsServer -> Router
The next node receives cleartext application data, not TLS records.
Basic Example
[
{
"name": "tls-server",
"type": "TlsServer",
"settings": {
"cert-file": "/etc/waterwall/fullchain.pem",
"key-file": "/etc/waterwall/privkey.pem",
"min-version": "TLSv1.2",
"max-version": "TLSv1.3",
"select-alpns": ["http/1.1"],
"session-cache": "none",
"session-tickets": true,
"verbose": false
},
"next": "http-server"
},
{
"name": "http-server",
"type": "HttpServer",
"settings": {},
"next": "service"
}
]
Fallback Example
{
"name": "tls-server",
"type": "TlsServer",
"settings": {
"cert-file": "/etc/waterwall/fullchain.pem",
"key-file": "/etc/waterwall/privkey.pem",
"fallback-node-name": "nginx-fallback",
"fallback-intentional-delay-ms": 7,
"fallback-intentional-delay-jitter-ms": 1,
"select-alpns": ["http/1.1"]
},
"next": "protected-protocol"
}
Fallback is only used before TlsServer has committed to TLS. It is meant for malformed or plaintext probes on a public
TLS port, not for authenticated application routing.
Required Fields
Top-level fields:
| Field | Type | Description |
|---|---|---|
name | string | User-chosen node name. Must be unique inside the config file. |
type | string | Must be exactly "TlsServer". |
settings | object | Non-empty TLS server settings. |
next | string | Node that receives decrypted cleartext after TLS succeeds. Required in normal use. |
Required settings fields:
| Field | Type | Description |
|---|---|---|
cert-file | string | Path to the server certificate PEM file. |
key-file | string | Path to the server private key PEM file. |
Both files must be valid for OpenSSL context creation.
Optional Settings
| Field | Default | Description |
|---|---|---|
sni | not set | Case-insensitive exact SNI match. Mismatch or absent SNI routes to fallback when configured; otherwise rejects the handshake. |
select-alpns | ["http/1.1"] | Preferred ALPN selection list. |
alpns | not set | Deprecated alias for ALPN selection. Prefer select-alpns. |
min-version | "TLSv1.2" | Minimum TLS version. |
max-version | "TLSv1.3" | Maximum TLS version. |
ciphers | "HIGH:!aNULL:!MD5" | OpenSSL cipher string for TLS 1.2 and earlier. |
prefer-server-ciphers | false | Enables server cipher preference. |
session-timeout | 300 | TLS session lifetime in seconds. |
handshake-timeout-ms | 60000 | Hard deadline for TLS handshake completion. Set 0 to disable. |
session-cache | "none" | Session cache mode. |
session-cache-size | 20480 | Builtin OpenSSL session cache size. |
session-tickets | true | Enables TLS session tickets. |
fallback-node-name | not set | Fallback branch for clearly non-TLS input or mismatched/absent configured SNI before TLS commit. Aliases: fallback-node, fallback. |
fallback-intentional-delay-ms | 7 | Delay for upstream payloads sent to fallback. Nonzero delay uses configured jitter. At 0, an initialized fallback with no older FIFO batch or scheduled drain forwards inline, including during Pause; retained bytes drain in FIFO order. |
fallback-intentional-delay-jitter-ms | 1 | Random jitter around delayed fallback payload scheduling. Ignored when delay is 0. |
tls13-record-shaping | absent | Experimental sender-side TLS 1.3 padding and delay configuration. |
verbose | false | Enables detailed TLS lifecycle logging. |
sni can be combined with fallback to route mismatched or absent SNI to a real TLS listener before local TLS output.
Experimental TLS 1.3 Record Shaping
tls13-record-shaping optionally pads and delays the first locally sent TLS 1.3 application records. It is disabled
when absent. It never changes received ciphertext, handshake records, alerts, empty application records, fallback bytes,
or TLS 1.2 records. Configure the local TlsServer and remote TlsClient separately when both sending directions should
be shaped.
Custom form:
"tls13-record-shaping": {
"scope": {"first-application-records": 8},
"outcomes": [
{
"probability": 50,
"padding-bytes": [100, 200],
"delay": {"probability": 75, "ms": [10, 20]}
},
{"probability": 10, "padding-bytes": [400, 600]}
]
}
padding-bytes and delay.ms accept either an integer or an inclusive [minimum, maximum] integer range. There may be
1 through 16 outcomes; first-application-records is 1 through 1024, padding is 1 through 4096 bytes, and delay is 0
through 1000 milliseconds. Outcome probabilities are cumulative, mutually exclusive percentages whose sum may not exceed
100. One roll selects at most one outcome; the unused percentage is an unchanged record. An outcome's delay probability
is rolled only after that outcome is selected. Unknown keys and non-integer or out-of-range values are startup errors.
Every eligible record consumes one scope position even when its roll selects no outcome.
Only the custom scope plus outcomes form is currently accepted. The profile key is rejected until representative
capture, overhead, connection-success, and classifier measurements justify publishing a versioned preset.
Padding is standard zero-valued TLS 1.3 TLSInnerPlaintext padding and adds the selected number of bytes to ciphertext,
subject to the remaining legal TLS record capacity. Delay starts after encryption and adds up to the selected latency.
Records stay in wire order with release_at = max(now + delay, previous_release_at). Queued ciphertext is bounded to 8
MiB per line, with locally originated producer Pause at 6 MiB and Resume at 3 MiB. Incoming Pause and Resume
are relayed transparently, including duplicates. Due ciphertext drains keep their existing timing and FIFO order without
waiting for Resume; Pause does not cancel the release timer. After peer close_notify finishes the cleartext side, TlsServer sends no Payload, Pause, or Resume
toward it; application bytes received later or already decrypted by an outer callback are discarded. If the cleartext
side finishes, already accepted server ciphertext and an unpadded close_notify drain before downstream
Finish; a wire-side finish cancels and discards queued output and is propagated only toward cleartext. Timer allocation
failure drains immediately in order.
After the configured application-record scope is consumed, the current shaping queue first drains completely so no later TLS record can overtake delayed ciphertext. While that ordering barrier is active, the node retains its local shaping hold even below the normal low watermark; relayed Resume remains advisory. At an empty queue and complete write-BIO boundary, the timer and per-line shaping queue are released; later TLS output is forwarded directly without record parsing or shaping metadata, including during advisory Pause. The 8 MiB limit and 6/3 MiB watermarks therefore bound only the finite active shaping window. TLS record encryption and decryption remain active, so shaping retirement does not permanently bypass the tunnel.
Record shaping can make early sizes and timings less deterministic, but it cannot remove an inner handshake round trip,
make a burst smaller, or hide every direction and timing feature. Combine it with MuxClient/MuxServer when
multiplexing fits the deployment.
TLS Versions
Supported version strings:
TLSv1
TLSv1.1
TLSv1.2
TLSv1.3
TLS1.0
TLS1.1
TLS1.2
TLS1.3
1.0
1.1
1.2
1.3
min-version must not be greater than max-version.
ALPN Selection
TlsServer uses ALPN as a selection hint, not as an access gate.
The configured array order is the server preference order. For example:
"select-alpns": ["h2", "http/1.1"]
If the client offers both values, h2 is selected. If the client sends ALPN but no configured value overlaps, the TLS
handshake continues without negotiated ALPN. If the client sends no ALPN extension, OpenSSL does not call the ALPN
selection callback and the handshake also continues without ALPN.
select-alpns modes:
| Configuration | Behavior |
|---|---|
| omitted | Uses default ["http/1.1"]. |
[] | Disables ALPN selection completely. |
| non-empty array | Selects the first configured value also offered by the client. |
alpns is deprecated but still parsed. Entries may be strings or objects with a value string. Do not configure both
alpns and select-alpns with non-empty values.
ALPN does not tell later tunnels what protocol was chosen. If you advertise h2, the chain behind TlsServer must
actually behave plausibly for HTTP/2 or the deployment can become fingerprintable.
Session Cache
Supported session-cache values:
| Value | Behavior |
|---|---|
"none" | OpenSSL server cache mode with internal lookup/store disabled. This is the default. |
"off" | Disables OpenSSL session caching. |
"builtin" | Enables OpenSSL builtin server session cache using session-cache-size. |
"builtin:SIZE" | Enables builtin cache and sets the cache size inline, for example "builtin:4096". |
shared is intentionally unsupported in the current implementation.
Runtime Behavior
TlsServer accepts line initialization only through upstream Init. Downstream
Init is a fatal callback misuse in every state, including fallback close drain;
it logs the violation and terminates the process. Downstream response payloads
remain supported on lines initialized upstream.
On upstream Init, TlsServer:
- creates per-line OpenSSL state
- allocates memory BIOs
- switches the SSL object into server mode
- arms the handshake deadline unless
handshake-timeout-msis0 - starts the protected
nextbranch immediately if no fallback is configured - otherwise waits for the first bytes to classify TLS versus fallback
Encrypted upstream bytes from the previous node are fed into OpenSSL. After the TLS handshake completes, decrypted
application bytes are forwarded upstream to next.
Downstream cleartext from next is queued until the TLS handshake completes. After that, it is encrypted with
SSL_write() and sent downstream as TLS records.
Establishment Semantics
WaterWall Est represents the underlying transport establishment, not TLS readiness.
TlsServer forwards downstream Est only once. It can happen before the TLS handshake has completed. TLS readiness is
tracked internally and depends on OpenSSL finishing the handshake.
Fallback Behavior
Fallback is selected only before TlsServer has committed to TLS.
The classifier looks at the initial upstream bytes:
- bytes that are clearly not TLS go to fallback
- bytes that start like TLS handshake records, such as
16 03, are parsed by OpenSSL; whensniand fallback are configured, the early ClientHello callback routes mismatched or absent SNI to fallback - once OpenSSL produces a ServerHello, the line is committed to the protected TLS branch
When fallback starts, TlsServer releases TLS resources for that line and initializes the fallback branch. During normal
live operation, nonzero delay forwards saved bytes and later upstream batches under the configured delay and jitter. At
delay zero, an initialized fallback with no older FIFO batch or scheduled drain forwards inline, including during Pause.
Retained bytes drain in FIFO order without waiting for Resume, and later input cannot overtake them. From then on, payloads pass through
fallback without TLS encryption or decryption. The delayed upstream fallback FIFO is bounded to 1 MiB per line.
Malformed ClientHello/SNI input rejected before routing stays on the TLS error path. Incomplete or slow input waits for the handshake deadline; the fallback server handles TLS negotiation and errors after an SNI fallback selection.
With SNI routing enabled, original input is retained in compact, coalescing buffers up to 1 MiB of input per line until TLS commitment or fallback selection. Socket fragmentation and ClientHello fragmentation across TLS records are supported, and any coalesced trailing bytes are replayed in order. The existing handshake-timeout-ms deadline covers incomplete input; 0 disables that deadline. Exceeding the buffer limit closes the connection without selecting a branch. A ServerHello (including HelloRetryRequest) commits the TLS branch; later errors never switch it to fallback.
Fallback delay is applied only to normal live upstream payloads. Downstream responses from fallback are not intentionally
delayed. On upstream Finish, queued bytes synchronously flush in FIFO order before fallback Finish, including during
advisory Pause. A close while fallback Init is still running discards the batch. The remaining intentional delay is not
kept alive. Fallback routing remains active because it selects a branch outside the ordinary next path.
Before selecting a branch, the latest receiver Pause is retained only for replay after that branch's Init; Resume cancels it. Once initialized, the selected branch receives every Pause and Resume, and these notifications do not gate output.
For public camouflage, the best fallback is usually another real TLS service, often nginx, with similar certificate, SNI, ALPN, cipher, ticket, and protocol-version behavior. A plain HTTP fallback behind a TLS public port is usually easier to fingerprint.
SNI Routing with a Real TLS Fallback
Nginx distinguishes two kinds of unexpected public inputs on an HTTPS listener:
- Plaintext HTTP on the HTTPS port: Nginx classifies a regular request sent to an HTTPS port with internal status 497. Without
error_pagecustomization, its stock response is normally the recognizable outward 400 HTML page.TlsServer'sfallback-node-namehandles this by forwarding non-TLS bytes to a fallback connector before TLS commitment. See nginx's SSL error processing. - TLS ClientHello with unknown or mismatched SNI: A connection begins in nginx's default-server context and, without an SNI match, ordinarily completes the TLS handshake using that context's certificate and settings. A default server can instead reject these handshakes explicitly with
ssl_reject_handshake on. See nginx's virtual-server selection.
With both sni and fallback-node-name, TlsServer routes mismatched or absent visible SNI directly to the fallback before emitting TLS output. Matching SNI continues through the protected next branch. No SniffRouter is required for this single-name route. Without fallback, sni remains a strict handshake rejection filter.
TcpListener :443 -> TlsServer (sni: protected.example.com)
|-- next -> protected backend
`-- fallback-node-name -> TcpConnector -> real nginx HTTPS listener
Configure the TLS node with:
{
"name": "tls-server",
"type": "TlsServer",
"settings": {
"cert-file": "server.crt",
"key-file": "server.key",
"sni": "protected.example.com",
"fallback-node-name": "nginx-fallback-connector"
},
"next": "protected-backend"
}
The connector must point to a real TLS listener: fallback receives the original TLS wire bytes, not decrypted HTTP. Visible outer SNI is used with ECH; encrypted inner SNI is unavailable. SNI is routing metadata, not authentication.
SniffRouter remains an alternative for multiple domain routes or mixed-protocol routing:
TcpListener :443 -> SniffRouter
|-- route (expected SNI) -> TlsServer -> protected backend
`-- default (unmatched) -> TcpConnector -> real nginx HTTPS listener
Minimal configuration pattern:
{
"name": "sniff-router",
"type": "SniffRouter",
"settings": {
"routes": [
{
"domain": "vpn.example.com",
"detection": "tls",
"next": "protected-tls-server"
}
]
},
"next": "nginx-fallback-connector"
}
In this architecture:
SniffRouterclassifies the visible ClientHello SNI before TLS termination.- Matching ClientHellos proceed to
TlsServer(which does not configurefallback-node-name). An exactsnigate onTlsServeris optional as a redundant fail-closed check, but is not the routing mechanism. - Mismatched visible SNIs, ClientHellos without visible SNI, and plaintext HTTP follow
SniffRouter's defaultnextbranch to the real nginx HTTPS listener without TLS decryption by WaterWall. - ECH hides the inner SNI, but its outer ClientHello may still contain a visible server name.
SniffRouterroutes on that visible outer value: a matching outer name reaches the protected branch, while an absent or nonmatching outer name follows the default branch. It cannot route on the encrypted inner name. - Complete ClientHellos are classified before threshold evaluation. An incomplete ClientHello below the 8192-byte
NeedMorebuffer threshold remains unassigned;SniffRouterhas no classification timer, so listener idle timeouts (e.g. inTcpListener) govern unassigned timeouts. - See
tests/examples/vless_tls_sni_camouflage_server.jsonfor a complete end-to-end configuration example.
Residual Detection Boundaries:
- SNI is routing metadata, not authentication: Any probe that presents the expected SNI will reach the protected
TlsServer. Authentication must be enforced by inner protocols (such as VLESS or Trojan). - Inner-protocol behavior remains observable: Rejection or fallback response bytes, connection-close behavior, and timing after a probe reaches the protected SNI can still identify the inner protocol or deployment.
- Fallback health and observability: If the real nginx fallback service is unreachable, returns unexpected error pages, or exhibits distinct certificate/ALPN/cipher/session properties, the deployment can be identified.
- Timing and timeouts:
SniffRouterbuffering and connector relay introduce small timing characteristics. AlignTcpListeneridle timeouts with the cover nginx server. WaterWall does not claim complete timing indistinguishability or byte-level TLS fingerprint equivalence.
Handshake Timeout
handshake-timeout-ms is a hard deadline that starts when TlsServer initializes the line. It is not refreshed by
received bytes. It is separate from listener idle settings such as TcpListener's initial-idle-timeout-ms and
active-idle-timeout-ms.
Set handshake-timeout-ms to 0 only when you intentionally want no hard TLS handshake deadline.
Finish Behavior
For clean downstream closes, TlsServer sends a TLS close_notify alert when the TLS handshake completed, flushes it,
destroys local TLS state, and then propagates WaterWall Finish downstream.
If the peer sends a clean TLS shutdown, TlsServer treats it as upstream finish toward the next node.
Fatal OpenSSL failures destroy local state and close the relevant directions. In fallback mode, an initialized fallback branch is also closed before the previous side is finished.
Padding
TlsServer does not prepend WaterWall payload bytes directly, so it advertises:
required_padding_left = 0
TLS record construction happens through OpenSSL memory BIO output buffers.
Nginx Matching Notes
The defaults are intended to resemble a basic nginx stream TLS server with stock-like behavior. Changing these settings
can still produce valid TLS, but it may no longer look like that baseline:
ciphersmin-versionormax-versionprefer-server-cipherssession-cachesession-tickets- ALPN selection
sni- fallback target and fallback timing
If your goal is wire-level similarity to default nginx, keep defaults unless you have measured a reason to change them.
Common Mistakes
- Do not expect
TlsServerto parse HTTP; putHttpServerafter it when you need HTTP behavior. - When combining
sniwith fallback, point fallback to a TLS listener that can handle the original ClientHello. - Do not use ALPN as an authentication or rejection gate; no-overlap ALPN offers continue without negotiated ALPN.
- Do not advertise
h2unless the protected and fallback behavior can plausibly answer HTTP/2. - Do not use
sharedsession cache; it is not implemented.
Node Metadata
Source-backed metadata:
This node explicitly disables splice for the entire chain through
kNodeFlagBlocksSplice, even if kNodeFlagSupportsSplice is also set.
| Property | Value |
|---|---|
| node flags | kNodeFlagBlocksSplice |
can_have_prev | true |
can_have_next | true |
layer_group | kNodeLayer4 |
layer_group_prev_node | kNodeLayer4 |
layer_group_next_node | kNodeLayer4 |
required_padding_left | 0 bytes |