A SIP003 plugin for shadowsocks that wraps each stream in a WebSocket-over-TLS connection on port 443, with the TLS handshake protected by ECH (Encrypted Client Hello). To passive observers the connection looks like a TLS request to a benign public name; the real tunnel domain is encrypted inside the ECH-protected ClientHelloInner.
- Auto-issues and renews a Let's Encrypt cert via TLS-ALPN-01, on the same port-443 listener — no port 80 needed.
- Stealth: probes that don't hit the secret WebSocket path get a fake nginx 404, indistinguishable from a default nginx install.
- Single config surface: every option lives in
SS_PLUGIN_OPTIONS. No YAML, no separate config file.
src/server.rs + src/client.rs |
event loops |
src/tls_server.rs + src/tls_client.rs |
BoringSSL wrappers |
src/ws.rs |
WebSocket ↔ AsyncRead/Write adapter |
src/acme.rs + src/challenge.rs |
TLS-ALPN-01 issuance + renewal |
src/ech.rs |
HPKE keygen, ECHConfig (un)marshaling, FFI |
src/sip003.rs + src/config.rs |
SIP003 env / plugin options |
tests/sip003_e2e.rs |
full localhost test against shadowsocks-rust |
Install the plugin together with shadowsocks-rust in one command:
# Client: ech-tls-tunnel + sslocal
curl -fsSL https://raw.githubusercontent.com/shadowsocks/ech-tls-tunnel/main/install.sh | sh -s -- client
# Server: ech-tls-tunnel + ssserver
curl -fsSL https://raw.githubusercontent.com/shadowsocks/ech-tls-tunnel/main/install.sh | sh -s -- serverThe installer supports Linux x86_64/ARM64 (static musl builds) and macOS
Apple Silicon. It requires curl, tar, xz, and sha256sum or shasum,
verifies both release SHA-256 checksums, and installs into /usr/local/bin
(using sudo when needed). It installs binaries only; follow
Quick start to configure and run your client or server.
Existing binaries with the same names are replaced.
For a user-owned directory, append --bin-dir "$HOME/.local/bin"
(create its parent first if necessary), and add that directory to PATH.
To pin versions, pass environment variables to sh, for example:
curl -fsSL https://raw.githubusercontent.com/shadowsocks/ech-tls-tunnel/main/install.sh | ECH_VERSION=v0.1.1 SS_VERSION=v1.25.0 sh -s -- client --bin-dir "$HOME/.local/bin"The defaults are the latest plugin release and shadowsocks-rust v1.25.0.
Pick a release binary from the
Releases page,
extract, and put ech-tls-tunnel somewhere on PATH:
# Linux x86_64 (glibc — Ubuntu/Debian/RHEL/etc.)
curl -L https://github.com/shadowsocks/ech-tls-tunnel/releases/latest/download/ech-tls-tunnel-linux-amd64.tar.gz | tar xz
sudo mv ech-tls-tunnel /usr/local/bin/
# Linux x86_64 (musl, fully static — Alpine, OpenWrt, embedded)
curl -L https://github.com/shadowsocks/ech-tls-tunnel/releases/latest/download/ech-tls-tunnel-linux-amd64-musl.tar.gz | tar xz
sudo mv ech-tls-tunnel /usr/local/bin/
# macOS arm64
curl -L https://github.com/shadowsocks/ech-tls-tunnel/releases/latest/download/ech-tls-tunnel-darwin-arm64.tar.gz | tar xz
sudo mv ech-tls-tunnel /usr/local/bin/linux-arm64 and linux-arm64-musl builds are also published.
Or build from source:
cargo install --git https://github.com/shadowsocks/ech-tls-tunnel# Generate the HPKE keypair + ECHConfigList
sudo mkdir -p /var/lib/ech-tls-tunnel
ech-tls-tunnel ech-gen-keys \
--out /var/lib/ech-tls-tunnel/ech
# Run ssserver with the plugin
ssserver \
-s 0.0.0.0:443 \
-k '<password>' \
-m aes-128-gcm \
--plugin ech-tls-tunnel \
--plugin-opts "mode=server;\
domain=tunnel.example.com;\
path=/ws-tunnel-CHANGE-ME;\
acme_email=admin@example.com;\
acme_cache=/var/lib/ech-tls-tunnel/acme;\
ech_key=/var/lib/ech-tls-tunnel/ech/ech.key"The first run blocks on the ACME order; subsequent runs reuse the cached cert and renew transparently.
Copy the base64 ECHConfigList printed by ech-gen-keys (or the
contents of /var/lib/ech-tls-tunnel/ech/ech.config_list after
base64-encoding) and pass it as ech_config=:
sslocal \
-b 127.0.0.1:1080 \
-s tunnel.example.com:443 \
-k '<password>' \
-m aes-128-gcm \
--protocol socks \
--plugin ech-tls-tunnel \
--plugin-opts "mode=client;\
sni=tunnel.example.com;\
path=/ws-tunnel-CHANGE-ME;\
ech_config=<paste base64 ECHConfigList here>"Now 127.0.0.1:1080 is a SOCKS5 proxy whose traffic looks (to anyone
on the wire) like an HTTPS connection to cloudflare-ech.com.
| Key | Default | Notes |
|---|---|---|
mode |
(required) | server or client |
path |
(required) | Secret WS path, must start with /. Anything else gets fake nginx 404. |
fast_open |
false |
Enable TCP Fast Open on listener and outgoing connections. Linux benefits most. |
| Key | Default | Notes |
|---|---|---|
domain |
(required) | Real tunnel domain — inner SNI; appears as a SAN on the production cert. |
cert + key |
— | Static cert/key on disk. Mutually exclusive with acme_email. |
acme_email |
— | Contact email; enables ACME (Let's Encrypt) via TLS-ALPN-01. |
acme_cache |
/var/lib/ech-tls-tunnel/acme |
Where the ACME account + cert live across restarts. |
acme_staging |
false |
Use Let's Encrypt staging — set true while testing to avoid rate limits. |
acme_cover_san |
false for cloudflare-ech.com; true otherwise |
Include ech_public_name as a SAN on the ACME cert. Set false when the cover name is a domain you don't own (e.g. www.baidu.com); the cert then only covers domain. |
ech_public_name |
cloudflare-ech.com |
Outer SNI advertised to public observers. Providing ech_key enables ECH; the name must match the generated key. Owning the name (with a SAN on the cert) holds up under active probing; an unowned cover name only hides the SNI from passive observers. |
reject_non_ech |
true |
Only meaningful when ECH is enabled. TCP-RST any inbound TLS handshake whose ClientHello lacks the encrypted_client_hello extension (and isn't an ACME acme-tls/1 validator), so active probes can't observe the production cert. |
ech_key |
— | Path to the HPKE private key from ech-gen-keys. |
server_name |
nginx/1.24.0 |
Value of the Server header in fake-404 responses. |
| Key | Default | Notes |
|---|---|---|
sni |
(required) | Real upstream hostname — sent as inner SNI inside the ECH-protected ClientHello. |
ech_config |
— | Base64 ECHConfigList from the server. Either this or ech_config_file. |
ech_config_file |
— | Path to a binary ECHConfigList file (alternative to ech_config). |
ca_file |
— | Pin to a specific CA bundle (PEM). Mutually exclusive with insecure. |
insecure |
false |
DEV/TEST ONLY — skip cert verification. |
fingerprint |
— | Browser-fingerprint shaping for the TLS ClientHello. One of chrome, firefox, safari, ios, android, edge, or random. Versioned aliases (chrome120, safari16, …) also accepted. See src/fingerprint.rs for the profile bodies. |
ech-tls-tunnel ech-gen-keys [--public-name <NAME>] --out <DIR>
The public name defaults to cloudflare-ech.com; use --public-name and
ech_public_name to override it. Existing keys retain their original name,
so keep that name explicitly configured or generate and distribute new keys.
Using this name does not route traffic through Cloudflare. ACME excludes
this cover name by default and issues only for your tunnel domain.
Generates an HPKE X25519 keypair, writes ech.key (binary private
key) and ech.config_list (binary ECHConfigList) under <DIR>, and
prints the base64 ConfigList ready to paste into the client's
ech_config= plugin option.
The plugin itself is a child process of ssserver — write the
systemd unit for ssserver, not the plugin:
# /etc/systemd/system/ssserver-ech.service
[Unit]
Description=Shadowsocks server with ech-tls-tunnel plugin
After=network.target
[Service]
ExecStart=/usr/local/bin/ssserver \
-s 0.0.0.0:443 \
-k YOUR_PASSWORD \
-m aes-128-gcm \
--plugin /usr/local/bin/ech-tls-tunnel \
--plugin-opts "mode=server;domain=tunnel.example.com;path=/ws-secret;acme_email=admin@example.com;ech_key=/var/lib/ech-tls-tunnel/ech/ech.key"
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now ssserver-echsslocal ──TCP──▶ ech-tls-tunnel (client mode) ──TLS+ECH+WS──▶ ech-tls-tunnel (server mode) ──TCP──▶ ssserver
│
▼
ACME (TLS-ALPN-01 on the same port 443)
- TLS termination uses BoringSSL via the
boring/tokio-boringcrates from Cloudflare. BoringSSL's mature ECH support (SSL_marshal_ech_config,SSL_ECH_KEYS_*,SSL_set1_ech_config_list) is what makes server-side ECH possible in pure Rust today. - The ACME flow (instant-acme) uses TLS-ALPN-01: when the ACME server
validates a domain, it offers ALPN
acme-tls/1. AChallengeStorekeyed by the SAN being validated lets the ALPN-select callback hot-swap the active SSL_CTX to a self-signed cert carryingSHA-256(keyAuthorization)in theacmeIdentifierextension. After validation, the entry is removed and traffic resumes on the production cert. - Cert renewals hot-swap the production
SslAcceptorviaarc-swap; in-flight connections keep the old cert, new ones get the renewed.
In scope: passive DPI, SNI-based blocking, basic active probing (connecting to your IP and inspecting the response).
Out of scope: traffic-analysis attacks (packet sizes, timing), GFW-style replay-and-correlate, host compromise.
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --lib --tests # 58 tests, end-to-end against shadowsocks-rustThe full e2e test (tests/sip003_e2e.rs) requires ssserver,
sslocal, and curl on PATH; it skips with a clear message
otherwise.
See docs/PRD.md, docs/ROADMAP.md,
and docs/TODO.md for the design.
MIT.