TLS and fingerprints
Sail uses BoringSSL, through the btls bindings, for TLS over TCP and QUIC. Outbound TLS over TCP sends a browser-shaped ClientHello by default instead of BoringSSL’s native one.
Basic TLS outbound
Section titled “Basic TLS outbound”{ "type": "trojan", "tag": "edge", "server": "203.0.113.10", "server_port": 443, "password": "replace-me", "tls": { "enabled": true, "server_name": "edge.example.com" }}server_name controls certificate verification and SNI. When it is omitted, Sail uses the server address.
Choose a ClientHello profile
Section titled “Choose a ClientHello profile”Each profile matches the ClientHello of one captured browser or client:
fingerprint |
Sends the ClientHello of |
|---|---|
chrome (default), edge |
Chrome 154 on macOS; Chrome on Android and Edge send the same |
firefox |
Firefox 156 on macOS |
safari, ios |
Safari 26.3 on macOS, the same as URLSession on macOS and iOS 26.4 |
android |
An Android app on OkHttp 4.12 over the platform’s Conscrypt |
random |
One of chrome, firefox, edge, safari and ios, as in sing-box, picked once per process and kept for every connection |
Chrome is the default when utls is omitted, and when utls names no fingerprint. Unlike sing-box, the browser profile is therefore on unless it is turned off. Any other name is a configuration error.
{ "tls": { "enabled": true, "server_name": "edge.example.com", "utls": { "enabled": true, "fingerprint": "firefox" } }}To send BoringSSL’s own ClientHello instead, explicitly disable the profile:
{ "tls": { "enabled": true, "utls": { "enabled": false } }}The profile changes the handshake shape, not the application protocol carried inside TLS. Every profile offers TLS 1.2 and 1.3, as the browsers do: with utls enabled, min_version and max_version are ignored with a warning. Without a utls block, a range set there is applied, and a warning says the ClientHello is then no longer the browser’s.
Over QUIC (Hysteria2, TUIC and the quic transport) the ClientHello is BoringSSL’s own: enabling utls or reality there is a configuration error. ECH works there as over TCP.
Custom trust
Section titled “Custom trust”By default, servers are verified against the system’s root store. The top-level certificate block chooses another (store: system, mozilla, chrome or none) and can add certificates of one’s own, as in sing-box.
In an outbound’s tls, certificate replaces those roots: the server must chain to the given PEM certificate. Give it inline:
{ "tls": { "enabled": true, "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" }}Or load it from a path:
{ "tls": { "enabled": true, "certificate_path": "certs/private-ca.pem" }}Relative certificate paths are resolved against the data directory configured with -D or the executable directory by default.
Pin a certificate
Section titled “Pin a certificate”certificate_sha256 takes a server by the SHA-256 hash of a whole certificate (its DER). Each entry is in hex of either case, with or without colons, as openssl x509 -noout -fingerprint -sha256 prints it and Mihomo’s fingerprint takes it, or in standard padded base64, as sing-box 1.15’s field of the same name takes it (openssl x509 -outform der | openssl dgst -sha256 -binary | openssl enc -base64). It replaces the trusted roots and insecure, which play no part. It cannot be combined with certificate or certificate_path. Set beside sing-box’s certificate_public_key_sha256, which pins public keys instead, a server either list takes is accepted, as in sing-box 1.15.
{ "tls": { "enabled": true, "server_name": "edge.example.com", "certificate_sha256": ["5A:1F:...:C3"] }}- A hash of the server’s own (leaf) certificate accepts it outright: no CA, no expiry and no name are checked. A leaf pin trusts that exact certificate for any server name.
- A hash of an intermediate or root the server sends in its chain makes that certificate the only CA: the leaf must chain to it and must be valid for
server_name, whateverinsecuresays. This is Mihomo’s meaning, and goes beyond sing-box, which compares the leaf alone. - Anything else fails the handshake, and the error names the hash of the certificate the server sent.
The field works over TCP and QUIC, and in the tls of DNS servers. REALITY verifies the server itself and ignores it with a warning.
ALPN and ECH
Section titled “ALPN and ECH”Use alpn when the server requires a specific application protocol. With a browser profile and no alpn, the ClientHello offers the browser’s h2 and http/1.1.
{ "tls": { "enabled": true, "alpn": ["h2", "http/1.1"], "ech": { "enabled": true } }}When config is set, it is the ECHConfigList offered, and no DNS query is made, as in sing-box. When it is omitted, Sail looks the ECHConfigList up in the server name’s HTTPS record, or its SVCB record, and a connection fails if the lookup does or finds none. Set disable_dns_lookup to require an explicit base64 or PEM config: without one, the configuration is refused.
ECH works the same over QUIC (Hysteria2, TUIC and the quic transport), where a configured config that BoringSSL cannot parse is refused when the configuration is read. A QUIC connection carries every session, so its lookup is made for the DNS client’s own queries too. A server that rejects ECH fails the connection, over TCP and QUIC alike: there is no retry with the configs it sends, as in sing-box.
ECH is TLS 1.3 only, so ECH with min_version or max_version below 1.3 is a configuration error. sing-box takes such a configuration, but every connection then fails, as Go’s TLS requires a minimum of 1.3 with ECH; Sail refuses it when the configuration is read.
Client certificates
Section titled “Client certificates”A server that authenticates clients by certificate (mutual TLS) asks for one during the handshake. Give the certificate and its key, inline PEM or by path, as sing-box names them:
{ "tls": { "enabled": true, "server_name": "edge.example.com", "client_certificate_path": "certs/client.crt", "client_key_path": "certs/client.key" }}client_certificate and client_key take the PEM inline instead, as one string or one line per entry. Set both halves or neither, and at most one of each inline and path pair. The certificate file may hold its chain after it. The key may be RSA, ECDSA or Ed25519, in PKCS#8, PKCS#1 or SEC1 PEM, and must match the certificate. It works over TCP and over QUIC (Hysteria2, TUIC, the quic transport); not with REALITY, whose server asks for no certificate, and not on DNS servers yet.
Surge’s client-cert=<item> reads a p12 item of its [Keystore], and Mihomo’s certificate and private-key become these fields.
Self-signed inbound certificates
Section titled “Self-signed inbound certificates”As a sail extension, an inbound’s tls may set "self_signed": true in place of a certificate and key: the inbound serves a self-signed ECDSA P-256 certificate it makes at start for its server_name (or sail.invalid without one), as Mihomo’s Hysteria2 and TUIC listeners do when no certificate is configured. No CA vouches for it, so clients need insecure or a pin (certificate_sha256 or certificate_public_key_sha256), and the pin changes whenever a new certificate is made: at every start, and on a reload that changes the inbound beyond its users and client-certificate checks, as a changed server_name does. With a certificate or key, or with REALITY, self_signed is an error.
Verify clients’ certificates
Section titled “Verify clients’ certificates”An inbound asks clients for certificates and checks them with sing-box’s fields in its tls block:
{ "tls": { "enabled": true, "certificate_path": "certs/server.crt", "key_path": "certs/server.key", "client_authentication": "require-and-verify", "client_certificate_path": ["certs/clients-ca.crt"] }}client_authentication takes sing-box’s five modes:
| Mode | Client without a certificate | Client with one |
|---|---|---|
no (the default) |
taken | none is asked for |
request |
taken | taken unchecked |
require-any |
refused | taken unchecked |
verify-if-given |
taken | checked |
require-and-verify |
refused | checked |
CAs set with no mode, or with no, make it require-and-verify, as in sing-box.
A certificate is checked by CAs, inline PEM in client_certificate or PEM or DER files in client_certificate_path. It must chain to one of them, through the intermediates the client sends, be valid at the instance’s time, and be for client authentication: one whose extended key usage is serverAuth alone is refused. A CA that is itself an intermediate is trusted as it is, as Go trusts it.
Pins check it instead of CAs: client_certificate_sha256, sing-box 1.15’s, the SHA-256 of the whole leaf in standard base64 or hex, and client_certificate_public_key_sha256, the SHA-256 of the leaf’s public key in base64. The certificate pins are looked at first, and either list passing is enough. Pins check the leaf alone: no CA, no validity period.
Where sing-box would take clients it should not, Sail refuses the configuration:
- pins with no
client_authentication, orno: sing-box asks for no certificate and checks none; - CAs or pins with
requestorrequire-any: those modes check no certificate; - any of these fields, but a mode of
no, with REALITY, whose server asks for no certificate.
Where sing-box ignores a field beside one it prefers, Sail ignores it too, with a warning: client_certificate_path beside client_certificate, and the pins beside CAs.
The files of client_certificate_path are followed as the inbound’s certificate files are, as in sing-box: CAs written to them are taken for the connections that come next, with no reload, and the connections open go on.
A client refused gets a bad_certificate alert, and the inbound’s handshake error says why. Client certificates are checked on TLS inbounds over TCP (Trojan, VLESS, VMess, AnyTLS, HTTP) and over QUIC (Hysteria2, TUIC, the quic transport) alike. Over QUIC the check is Sail’s own as well: the system’s roots, which the QUIC library would verify by, play no part.
ClientHello without SNI
Section titled “ClientHello without SNI”disable_sni: true leaves the server name out of the ClientHello, with or without a browser profile. The certificate is still verified against server_name, or the server address, unless insecure is set:
{ "tls": { "enabled": true, "server_name": "edge.example.com", "disable_sni": true }}It cannot be combined with ECH or REALITY, whose ClientHello names a server, and it is not available over QUIC yet. Surge’s sni=off sets it.
REALITY
Section titled “REALITY”REALITY is configured inside the TLS block:
{ "tls": { "enabled": true, "server_name": "www.example.com", "reality": { "enabled": true, "public_key": "server-public-key", "short_id": "0123456789abcdef" } }}Use the public key and short ID issued by the server. REALITY’s ClientHello is always a browser’s: utls cannot be disabled with it, and it works over TCP only. REALITY and ordinary certificate-based TLS have different authentication assumptions; do not copy fields from one deployment without matching the server.
A REALITY server (inbound) accepts the name in its server_name and, as a sail extension, those in reality.server_names, as Xray’s serverNames and Mihomo’s server-names do; either alone is enough.
Diagnose handshake failures
Section titled “Diagnose handshake failures”- Run
sail -c config.json -Tto catch field and combination errors. - Run
sail -c config.json -t edge -d 10to isolate the outbound. - Confirm
server_name, system time and the certificate chain. - Temporarily switch the fingerprint only if the server is known to enforce one.
- Enable debug logs and inspect whether the failure happens during DNS, TCP/UDP connect or TLS.