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 |
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 \
--public-name front.example.com \
--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_public_name=front.example.com;\
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 front.example.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 |
true |
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 |
— | Outer SNI advertised to public observers. Required (with ech_key) to enable ECH. 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>
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_public_name=front.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.