Skip to main content

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:

FieldTypeDescription
namestringUser-chosen node name. Must be unique inside the config file.
typestringMust be exactly "TlsServer".
settingsobjectNon-empty TLS server settings.
nextstringNode that receives decrypted cleartext after TLS succeeds. Required in normal use.

Required settings fields:

FieldTypeDescription
cert-filestringPath to the server certificate PEM file.
key-filestringPath to the server private key PEM file.

Both files must be valid for OpenSSL context creation.

Optional Settings​

FieldDefaultDescription
sninot setCase-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.
alpnsnot setDeprecated 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-ciphersfalseEnables server cipher preference.
session-timeout300TLS session lifetime in seconds.
handshake-timeout-ms60000Hard deadline for TLS handshake completion. Set 0 to disable.
session-cache"none"Session cache mode.
session-cache-size20480Builtin OpenSSL session cache size.
session-ticketstrueEnables TLS session tickets.
fallback-node-namenot setFallback branch for clearly non-TLS input or mismatched/absent configured SNI before TLS commit. Aliases: fallback-node, fallback.
fallback-intentional-delay-ms7Delay 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-ms1Random jitter around delayed fallback payload scheduling. Ignored when delay is 0.
tls13-record-shapingabsentExperimental sender-side TLS 1.3 padding and delay configuration.
verbosefalseEnables 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:

ConfigurationBehavior
omittedUses default ["http/1.1"].
[]Disables ALPN selection completely.
non-empty arraySelects 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:

ValueBehavior
"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:

  1. creates per-line OpenSSL state
  2. allocates memory BIOs
  3. switches the SSL object into server mode
  4. arms the handshake deadline unless handshake-timeout-ms is 0
  5. starts the protected next branch immediately if no fallback is configured
  6. 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; when sni and 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:

  1. Plaintext HTTP on the HTTPS port: Nginx classifies a regular request sent to an HTTPS port with internal status 497. Without error_page customization, its stock response is normally the recognizable outward 400 HTML page. TlsServer's fallback-node-name handles this by forwarding non-TLS bytes to a fallback connector before TLS commitment. See nginx's SSL error processing.
  2. 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:

  • SniffRouter classifies the visible ClientHello SNI before TLS termination.
  • Matching ClientHellos proceed to TlsServer (which does not configure fallback-node-name). An exact sni gate on TlsServer is 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 default next branch 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. SniffRouter routes 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 NeedMore buffer threshold remains unassigned; SniffRouter has no classification timer, so listener idle timeouts (e.g. in TcpListener) govern unassigned timeouts.
  • See tests/examples/vless_tls_sni_camouflage_server.json for 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: SniffRouter buffering and connector relay introduce small timing characteristics. Align TcpListener idle 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:

  • ciphers
  • min-version or max-version
  • prefer-server-ciphers
  • session-cache
  • session-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 TlsServer to parse HTTP; put HttpServer after it when you need HTTP behavior.
  • When combining sni with 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 h2 unless the protected and fallback behavior can plausibly answer HTTP/2.
  • Do not use shared session 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.

PropertyValue
node flagskNodeFlagBlocksSplice
can_have_prevtrue
can_have_nexttrue
layer_groupkNodeLayer4
layer_group_prev_nodekNodeLayer4
layer_group_next_nodekNodeLayer4
required_padding_left0 bytes