Skip to main content

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:

FieldTypeDescription
namestringUser-chosen node name. Must be unique inside the config file.
typestringMust be exactly "TlsClient".
settingsobjectTLS client settings.
nextstringStream-facing node that carries encrypted TLS records. Required in normal use.

Required settings field:

FieldTypeDescription
snistringTLS 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

FieldDefaultDescription
alpns["h2", "http/1.1"]Ordered ALPN protocol offer. The JSON order is preserved in the ClientHello.
verifytrueEnables BoringSSL peer certificate verification.
x25519mlkem768trueAdvertises the X25519MLKEM768 hybrid group for Chrome-like behavior.
tls13-record-shapingabsentExperimental sender-side TLS 1.3 padding and delay configuration.
verbosefalseEnables 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 HttpClient for HTTP/1.1 and configure TlsClient with only "alpns": ["http/1.1"].
  • For WebSocket over HTTP/2, configure HttpClient for HTTP/2 so it uses the HTTP/2 CONNECT path, and configure TlsClient with only "alpns": ["h2"].
  • Offering both h2 and http/1.1 is valid, commonly used, and closer to Chrome's default ClientHello. When using that offer with a fixed preceding HttpClient, the user must know which protocol the target server will select and configure HttpClient for that version. If the target's choice is unknown or can vary, offer only the protocol that matches HttpClient.
caution

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:

BehaviorCurrent value
Minimum TLS versionTLS 1.2
Maximum TLS versionTLS 1.3
Session cacheclient session cache enabled
Session timeout7200 seconds
GREASEenabled
Extension permutationenabled
Certificate compressionBrotli decompression support
Signed certificate timestampsenabled
OCSP stapling requestenabled 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:

ProtocolALPS payload
h2fixed Chrome payload 02 68 32
http/1.1empty 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:

  1. initializes per-line BoringSSL state
  2. creates memory BIOs
  3. switches the SSL object into client mode
  4. sets the configured SNI
  5. forwards upstream Init to the next tunnel
  6. calls SSL_connect() to generate the first ClientHello flight
  7. 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:

APIPurpose
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 Finish frees TLS state and forwards Finish only to the next node
  • raw downstream transport Finish frees TLS state and forwards Finish only to the previous node
  • no normal close path calls SSL_shutdown() or emits TLS close_notify
  • an authenticated peer close_notify is 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

PropertyValue
Node flagkNodeFlagChainHead
Previous nodeAllowed, required in normal use
Next nodeAllowed, required in normal use
Layer groupkNodeLayerAnything
required_padding_left0
Line stateBoringSSL 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 TlsClient to connect to the remote server by itself; put a connector after it.
  • Do not assume WaterWall Est means the TLS handshake has completed.
  • Do not use the singular alpn key; configure the ordered alpns array.
  • When offering multiple application protocols, know the target server's stable selection and configure the preceding node for it. TlsClient does not communicate the server's ALPN choice back to that node.
  • Do not disable verify for public connections unless you intentionally want to skip certificate validation.
  • Do not disable x25519mlkem768 if your goal is the closest current Chrome-like handshake.

Advanced: ECH SNI Trick

Advanced, specialized behavior

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.