TlsClient
TlsClient is the client-side TLS wrapper for WaterWall. It is built on the bundled BoringSSL code in the repository.
It receives cleartext from the previous tunnel, encrypts it into TLS records for the next tunnel, and decrypts downstream
TLS records back into cleartext.
This node performs a real TLS client handshake. Its handshake is shaped to stay close to modern Chrome behavior, including
Chrome-like ALPN defaults, ALPS, GREASE, extension permutation, certificate compression support, and the optional
X25519MLKEM768 hybrid group.
Typical Placement
HTTPS-style client chain:
HttpClient -> TlsClient -> TcpConnector
Proxy-protocol client chain:
TrojanClient -> TlsClient -> TcpConnector
VlessClient -> TlsClient -> TcpConnector
TlsClient does not open sockets by itself. Put a transport such as TcpConnector after it. The connector carries the
encrypted TLS bytes to the remote server.
Example
[
{
"name": "tls-client",
"type": "TlsClient",
"settings": {
"sni": "example.com",
"alpns": ["http/1.1"],
"verify": true,
"x25519mlkem768": true,
"verbose": false
},
"next": "server-out"
},
{
"name": "server-out",
"type": "TcpConnector",
"settings": {
"address": "example.com",
"port": 443,
"nodelay": true
}
}
]
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 "TlsClient". |
settings | object | TLS client settings. |
next | string | Stream-facing node that carries encrypted TLS records. Required in normal use. |
Required settings field:
| Field | Type | Description |
|---|---|---|
sni | string | TLS Server Name Indication containing between 1 and 255 bytes. |
sni is also the hostname used by BoringSSL for certificate verification when verify is enabled.
Optional Settings
| Field | Default | Description |
|---|---|---|
alpns | ["h2", "http/1.1"] | Ordered ALPN protocol offer. The JSON order is preserved in the ClientHello. |
verify | true | Enables BoringSSL peer certificate verification. |
x25519mlkem768 | true | Advertises the X25519MLKEM768 hybrid group for Chrome-like behavior. |
tls13-record-shaping | absent | Experimental sender-side TLS 1.3 padding and delay configuration. |
verbose | false | Enables extra TLS state logging. |
When verify is true, TlsClient loads the built-in CA bundle from utils/cacert.h into the BoringSSL context. When
verify is false, the TLS handshake is still real, but certificate-chain verification is disabled for this tunnel
instance.
Optional defaults apply only when their keys are absent. A present alpns value must be an array of strings. Each name
must contain between 1 and 255 bytes, duplicate names are rejected, and the encoded list may not exceed 65,533 bytes.
Present verify, x25519mlkem768, and verbose values must be JSON booleans. A wrong type is a startup error rather
than a request to use the default.
x25519mlkem768 should usually stay enabled. Disabling it makes the ClientHello smaller, but also makes it less close to
current Chrome behavior.
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 TlsClient and remote TlsServer 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 1
MiB per line, with producer backpressure at 768 KiB and release at 384 KiB. Pause and Resume propagation follows the
current wire state: if draining during Resume re-enters with another wire Pause, the stale Resume is suppressed until a
later wire Resume. If the cleartext side finishes, the timer is canceled and queued client ciphertext is synchronously
released in FIFO order while the wire remains writable. If the wire is paused, any remainder is discarded; local state
and upstream Finish are completed before the callback returns.
During this synchronous final drain, downstream Payload and Est are discarded, while Pause and Resume only update the
local wire-paused state; none of those callbacks is forwarded toward the finished cleartext owner. A wire-side finish
cancels and discards queued output and is propagated only toward cleartext. Timer allocation failure drains immediately
in order.
Delay shaping is rejected for internal handshake-takeover users such as RealityClient, because raw takeover cannot
inherit a pending delayed TLS record. Padding-only shaping remains compatible when the takeover boundary is empty.
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.
Ordered ALPN And Application-Protocol Responsibility
alpns controls the ordered ALPN offer in the TLS ClientHello. For example:
"alpns": ["http/1.1"]
The array order is preserved. If alpns is absent, the Chrome-like default is:
"alpns": ["h2", "http/1.1"]
An explicit empty array disables ALPN. The singular key alpn is not supported.
The configured order expresses the client's offer, but the TLS server selects the negotiated protocol. TlsClient does
not report that selection to the previous WaterWall node, does not switch that node's HTTP mode, and does not verify that
the cleartext application protocol matches the selected ALPN. The user is responsible for making these configurations
agree.
This matters especially in an HttpClient -> TlsClient chain:
- For HTTP/1.1 WebSocket Upgrade, configure
HttpClientfor HTTP/1.1 and configureTlsClientwith only"alpns": ["http/1.1"]. - For WebSocket over HTTP/2, configure
HttpClientfor HTTP/2 so it uses the HTTP/2 CONNECT path, and configureTlsClientwith only"alpns": ["h2"]. - Offering both
h2andhttp/1.1is valid, commonly used, and closer to Chrome's default ClientHello. When using that offer with a fixed precedingHttpClient, the user must know which protocol the target server will select and configureHttpClientfor that version. If the target's choice is unknown or can vary, offer only the protocol that matchesHttpClient.
As of July 2026, for ordinary HTTPS through a Cloudflare-proxied hostname with HTTP/2
enabled, Cloudflare selects h2 when it is
offered. It is therefore reasonable to keep the Chrome-like "alpns": ["h2", "http/1.1"] offer and configure the
preceding HttpClient for HTTP/2.
This does not apply to WebSocket. Cloudflare's currently documented proxied WebSocket
behavior does not support HTTP/2 WebSocket extended CONNECT
(RFC 8441). For Cloudflare WebSocket, force HTTP/1.1 WebSocket Upgrade by configuring TlsClient with only
"alpns": ["http/1.1"] and configuring HttpClient for HTTP/1.1. Re-check Cloudflare's current capabilities before
depending on either behavior, because provider settings and support can change.
Multiple ALPN offers are appropriate when the target server's selection is known and stable. They are not automatic
application-protocol discovery: the preceding node must already be configured for the protocol the server will select.
Changing alpns also changes a fingerprint-relevant part of the otherwise Chrome-like ClientHello.
When the handshake completes, TlsClient writes the negotiated TLS version, cipher, and ALPN to the debug log. A selected
protocol appears as alpn="..."; a handshake without negotiated ALPN appears as alpn=<none>. This is diagnostic output
only. The selected value is still not passed to the preceding application tunnel.
The TLS context is configured with:
| Behavior | Current value |
|---|---|
| Minimum TLS version | TLS 1.2 |
| Maximum TLS version | TLS 1.3 |
| Session cache | client session cache enabled |
| Session timeout | 7200 seconds |
| GREASE | enabled |
| Extension permutation | enabled |
| Certificate compression | Brotli decompression support |
| Signed certificate timestamps | enabled |
| OCSP stapling request | enabled per line |
Supported groups are:
X25519MLKEM768:X25519:P-256:P-384:P-521
When x25519mlkem768 is false, the first group is removed:
X25519:P-256:P-384:P-521
Signature algorithms are pinned in Chrome-like order, and the bundled BoringSSL copy includes local handshake shaping work for cipher ordering and ClientHello layout.
ALPS Handling
TlsClient adds its known Chrome-like ALPS application setting only when the corresponding protocol is present in
alpns:
| Protocol | ALPS payload |
|---|---|
h2 | fixed Chrome payload 02 68 32 |
http/1.1 | empty payload |
The h2 ALPS value is the raw Chrome payload, not a serialized HTTP/2 SETTINGS frame. BoringSSL builds the final ALPS
wire representation after the per-protocol values are registered. Custom ALPN values are offered without a
TlsClient-defined ALPS payload.
Runtime Behavior
On upstream Init, TlsClient:
- initializes per-line BoringSSL state
- creates memory BIOs
- switches the SSL object into client mode
- sets the configured SNI
- forwards upstream
Initto the next tunnel - calls
SSL_connect()to generate the first ClientHello flight - forwards generated TLS handshake bytes upstream
This is why TlsClient works naturally before TcpConnector: the connector can buffer the ClientHello until the real
socket connection completes.
Establishment Semantics
WaterWall Est is transport establishment, not TLS readiness.
In normal mode, downstream Est from the next node is forwarded to the previous node immediately. The TLS handshake may
finish later when downstream TLS records arrive. If the previous tunnel sends application payload before the TLS handshake
is complete, TlsClient queues it and flushes it through SSL_write() after the handshake finishes.
In handshake-takeover mode, TlsClient suppresses the normal downstream Est until the TLS handshake completes.
It also frames takeover input one TLS record at a time and stops feeding BoringSSL at the exact record that completes the
handshake, leaving later records outside the SSL object for explicit dispatch.
Payload Flow
Upstream cleartext:
- before handshake completion, payload is queued
- after handshake completion, payload is passed to
SSL_write() - generated TLS records are read from the write BIO and sent to the next node
Downstream TLS records:
- encrypted bytes are written into the read BIO
SSL_connect()advances the handshake while needed- generated protocol bytes are flushed upstream
- decrypted application bytes from
SSL_read()are forwarded to the previous node
Certificate verification failures are logged with the BoringSSL verify result and reason.
Tunnel API
TlsClient exposes a small API for generating a raw ClientHello buffer:
generateTlsHello:<sni>
The generated ClientHello uses this tunnel instance's configured behavior, including alpns and x25519mlkem768.
The API SNI must contain between 1 and 255 bytes.
Handshake Takeover API
Internal WaterWall code can use TlsClient in handshake-takeover mode:
| API | Purpose |
|---|---|
tlsclientTunnelEnableHandshakeTakeover() | Enables takeover behavior on the tunnel. |
tlsclientTunnelIsHandshakeCompleted() | Checks whether a line has completed TLS handshake. |
tlsclientTunnelGetHandshakeBinding() | Captures the negotiated version, cipher, randoms, and TLS 1.2 sequence state while BoringSSL is retained. |
tlsclientTunnelDeinitAfterHandshake() | TLS 1.2-only immediate release into raw pass-through. It rejects TLS 1.3. |
tlsclientTunnelBeginTakeoverDrain() | Starts TLS 1.3 external post-handshake dispatch and returns accumulated raw bytes while retaining SSL/BIO state. |
tlsclientTunnelConsumePostHandshakeRecord() | Consumes exactly one complete TLS 1.3 record, discards cover plaintext, and flushes generated protocol output upstream. |
tlsclientTunnelCompleteTakeover() | Releases retained TLS state after the owner authenticates its handoff boundary and enters raw pass-through. |
The phased TLS 1.3 API allows legal NewSessionTicket and KeyUpdate records to remain genuine TLS until an external
authenticated handoff completes. The record that finishes the handshake must leave both the read BIO and BoringSSL's
internal TLS read buffer empty; otherwise takeover fails. Owner-formatted raw controls and BoringSSL-generated protocol
output share the same upstream transport path and retain callback order.
This is an internal integration path with no JSON setting. In normal configuration usage, TlsClient remains a TLS
wrapper for the whole line.
Finish Behavior
TlsClient destroys its own per-line TLS state before propagating Finish.
For ordinary directional finishes, it propagates finish in the corresponding direction. For fatal BoringSSL read/write
errors, it destroys local state and closes both directions. It does not own normal external lines and does not call
lineDestroy() for them.
TlsClient uses direct transport close semantics:
This normal-close choice is intended to mimic the Chrome behavior targeted by this tunnel: when the user or application
closes the connection in this situation, Chrome closes the transport without first sending TLS close_notify. This is not
a claim that Chrome never sends close_notify in every TLS shutdown context.
- normal upstream
Finishfrees TLS state and forwardsFinishonly to the next node - raw downstream transport
Finishfrees TLS state and forwardsFinishonly to the previous node - no normal close path calls
SSL_shutdown()or emits TLSclose_notify - an authenticated peer
close_notifyis consumed, not answered, and closes both directions immediately - fatal TLS, certificate, record authentication,
SSL_write(), or BIO failures close every initialized direction immediately - the tunnel does not wait for a peer shutdown response, perform a TLS half-close, or run a shutdown timer
There is no JSON setting for this policy. A peer that requires a full TLS shutdown handshake may classify this direct EOF as a truncated TLS shutdown.
In handshake-takeover mode, tlsclientTunnelDeinitAfterHandshake() releases TLS state without closing the WaterWall line
and the line then continues as raw passthrough.
Padding
TlsClient does not prepend WaterWall payload bytes directly, so it advertises:
required_padding_left = 0
TLS record construction happens through BoringSSL memory BIO output buffers.
Node Metadata
| Property | Value |
|---|---|
| Node flag | kNodeFlagChainHead |
| Previous node | Allowed, required in normal use |
| Next node | Allowed, required in normal use |
| Layer group | kNodeLayerAnything |
required_padding_left | 0 |
| Line state | BoringSSL SSL object, memory BIOs, handshake flags, queued upstream payloads |
Unsupported Features
This implementation does not support:
- server-side TLS termination
- dynamic certificate selection
- TLS over packet lines
- non-TLS transport wrapping such as WebSocket or HTTP by itself
Use separate WaterWall nodes for those layers.
Common Mistakes
- Do not expect
TlsClientto connect to the remote server by itself; put a connector after it. - Do not assume WaterWall
Estmeans the TLS handshake has completed. - Do not use the singular
alpnkey; configure the orderedalpnsarray. - When offering multiple application protocols, know the target server's stable selection and configure the preceding
node for it.
TlsClientdoes not communicate the server's ALPN choice back to that node. - Do not disable
verifyfor public connections unless you intentionally want to skip certificate validation. - Do not disable
x25519mlkem768if your goal is the closest current Chrome-like handshake.
Advanced: ECH SNI Trick
This feature is not part of ordinary TLS configuration or the normal responsibility of TlsClient. Use it only when a
deployment deliberately coordinates TLS ClientHello construction with compatible packet-level manipulation.
The optional ech-sni-trick setting accepts a hostname string containing between 1 and 255 bytes:
"ech-sni-trick": "example.net"
When configured, TlsClient creates a second, fake ClientHello using that hostname and embeds its bytes as the GREASE
encrypted_client_hello payload of the real outer ClientHello. The outer cleartext SNI remains settings.sni.
The embedded fake ClientHello uses the configured alpns list in the same order, but disables x25519mlkem768 to keep
the payload smaller. This behavior is intended to coordinate with packet-side mechanisms such as IpManipulator's
packet-splitting trick, keeping the ClientHello bytes hashed by BoringSSL consistent with the bytes placed on the wire.
The value must be a JSON string containing between 1 and 255 bytes; a value outside that range or another JSON type is a
startup error. The generateTlsHello:<sni> tunnel API also applies this setting when it is configured.