Support Shadowsocks outbound proxy hops - #2157
Conversation
Add TCP-only encrypted hop chaining with first-hop SIP003 plugins and fail closed for unsupported UDP traffic.
Rename the abbreviated `Ss` enum variants to the full `Shadowsocks` name, consistent with the existing `Socks5`, `Http`, and `Https` variants: - `OutboundProxyKind::Ss` -> `OutboundProxyKind::Shadowsocks` - `OutboundProxy::Ss` -> `OutboundProxy::Shadowsocks` - `OutboundProxyProtocol::Ss` -> `OutboundProxyProtocol::Shadowsocks` - `OutboundProxyStreamInner::Ss` -> `OutboundProxyStreamInner::Shadowsocks` - `OutboundProxyStream::from_ss` -> `OutboundProxyStream::from_shadowsocks`
zonyitoo
left a comment
There was a problem hiding this comment.
Summary
Thanks for the PR — the underlying primitives are well built, but there is one blocking design problem: the PR inverts the meaning of outbound_proxy for sslocal, which is a silent breaking change for every existing user. Below is the full analysis, the expected semantics, and a concrete fix plan.
What's good (please keep)
OutboundProxyas an enum (Plain/Shadowsocks) and reusingServerConfigfor SIP002 URL parsing (cipher, password, remarks, SIP003 plugin params) — clean and correct.OutboundProxyStream::Shadowsocks(Box<ProxyClientStream<OutboundProxyStream>>)— the boxed variant breaks the recursive type while keeping static dispatch. This type works for both chain directions.PluginHandlereadiness state machine (serialized probes, 1s throttled failure cache) — reasonable design.hop_from_configrejecting plugins on non-first hops at configuration time — correct, since a SIP003 plugin is a local process and can only serve the physically-first hop.- Test coverage, including the mock SIP003 subprocess tests and the throttled-retry test.
BLOCKING: sslocal's outbound_proxy semantics are inverted (breaking change)
Current behavior on master
sslocal ──> outbound chain (socks5/http/https) ──> main SS server ──(ss)──> target
The chain is a set of forward proxies in front of the main server. Evidence in master's connect_proxied_with_opts_inner:
let ss_addr: Address = server.server_config().tcp_external_addr().into();
...
client.connect_tcp(&dialer, &ss_addr) // <- chain's final target IS the main serverBehavior after this PR
sslocal ──(ss)──> main SS server ──> outbound chain ──> target
connect_tcp_with_initial_shadowsocks makes the main server the physical first hop and the outbound_proxy entries become trailing hops whose last entry dials the destination directly.
Why this is a breaking change
For every existing user with e.g. "outbound_proxy": "socks5://127.0.0.1:1080":
- master: the local SOCKS5 proxy forwards the connection to the main SS server. The server is hidden behind the proxy.
- after this PR:
sslocaldials the main SS server directly, bypassing the configured proxy entirely; then the main server is asked to connect tosocks5://127.0.0.1:1080, which now resolves on the server's loopback and fails.
The traffic path silently changes direction, with no config migration path and no error at startup.
Expected semantics
ss:// should be introduced as one more hop type of the existing forward chain, keeping the master direction:
sslocal ──TCP──> ssserver1 (outbound ss hop) ──TCP──> ssserver2 (main server) ──TCP──> target
with nested encryption built in this order (exactly as the outermost ProxyClientStream on master already does):
- Inner layer:
target_addr + payloadencrypted with ssserver2's (main server) key — this is the existing trailingProxyClientStream::from_stream(context, mon, server.server_config(), target_addr). - Outer layer: the inner ciphertext as payload, target header = ssserver2's address, encrypted with ssserver1's key — this is exactly what
negotiate_hop'sShadowsocksbranch already does:
ProxyClientStream::from_stream(context.clone(), stream, svr_cfg, next_target.clone())So ssserver1 decrypts the outer layer and sees [ssserver2_addr | C2], forwards C2 to ssserver2; ssserver2 decrypts and sees [target | plaintext].
Key point: the hard part is already implemented correctly in this PR. Combining master's auto_proxy_stream.rs structure (chain target = main server + trailing ProxyClientStream) with this PR's negotiate_hop Shadowsocks variant produces exactly the required semantics. No new nesting mechanism is needed.
Concrete fix plan
| File | Action |
|---|---|
local/net/tcp/auto_proxy_stream.rs |
Restore master's OutboundTransport::{Direct(TcpStream), Chained(OutboundProxyStream)} and AutoProxyClientStream::Proxied(ProxyClientStream<MonProxyStream<OutboundTransport>>); restore connect_proxied_with_opts_inner so the chain target is ss_addr again, with the trailing inner ProxyClientStream (main server, target). |
net/outbound/chain.rs |
Delete connect_tcp_with_initial_shadowsocks / connect_chain_with_initial_shadowsocks. Keep connect_tcp (with the new context param), negotiate_hop's Shadowsocks arm, and wait_plugin_started. |
net/outbound/mod.rs |
Delete try_from_config_after_main_server and the hop-offset mechanism; a plain 0-based enumerate() in try_from_config already distinguishes the (plugin-eligible) first hop. Keep PluginHandle, config_supports_udp. |
local/context.rs |
Use try_from_config again (keep the io::Result signature — plugin startup failures must propagate). |
local/mod.rs |
Restore master's warning ("non-SOCKS5 hop(s); UDP traffic will bypass the chain"). The recursion warning (ss hop addr == main server addr) is still worth keeping. |
local/net/udp/association.rs |
Fully restore master's chained UDP path (use_chain, LocalTcpDialer, ProxiedSocket::Chained, associate_udp multi-hop relay). |
local/loadbalancing/ping_balancer.rs |
Restore master's health checks: TCP via ProxyClientStream::connect_with_opts directly to the main server; UDP check without the chain rejection. |
server/udprelay.rs, server/mod.rs |
Restore master's behavior for non-SOCKS5 chains (bypass + warn) instead of rejecting UDP when the chain contains an ss hop. The ssserver side had no direction change, so its UDP policy should stay consistent with http/https hops. |
examples/chain-ss.json5 |
Invert: outbound_proxy: ["ss://edge...?plugin=..."] (first hop, plugin allowed) and server = the landing server (final hop, no plugin). |
tests/outbound_ss.rs |
Rewrite sslocal_main_server_is_first_hop_and_outbound_proxy_is_landing to assert the forward semantics (outer hop receives the main server's address as its decrypted target; main server receives the real target). Drop sslocal_outbound_proxy_plugin_is_rejected_as_non_first_hop — under the forward semantics the plugin on outbound_proxy[0] is legitimate (it is the physical first hop); the non-first-hop rejection is already covered by plugin_on_non_first_shadowsocks_hop_is_rejected. |
README.md |
Restore the "outbound_proxy sits in front of the main server" description, extended with ss://. |
Regression: UDP relay through SOCKS5-only chains is removed for sslocal
On master, an all-SOCKS5 chain supports UDP relay via multi-hop UDP ASSOCIATE (association.rs's Chained path — deleted in this PR). Under the forward semantics this feature is fully valid (the UDP chain terminates at the main server's UDP address), so it must be restored.
For chains containing ss:///http:///https:// hops, please restore master's documented behavior: UDP bypasses the chain (direct to the main server) with the existing warning, instead of rejecting. The same applies to the ssserver side (server/udprelay.rs's new rejection should be removed) so that ss hops behave exactly like http/https hops.
Minor issues (non-blocking)
ShadowsocksHop::tagis redundant —svr_cfg.remarks()already carries the same information.- Recursive chain detection only warns — when an
ss://hop's address equals the main server's address, the chain loops (first hop == final target). Consider making it a hard config error. - Confusing error message:
"plugin on non-first ss outbound proxy hop requires rendezvous support"— "requires rendezvous support" is cryptic; something like "SIP003 plugins are only supported on the first outbound proxy hop" would be clearer. ShadowsocksHop: PartialEqviato_url()string comparison — fields not represented in the URL (e.g.identity_keys) don't participate in equality. Acceptable for config comparison, but worth a comment.- Pre-existing, worth a warning: a main server configured with a SIP003 plugin and an
outbound_proxychain cannot work — the chain's final target becomes the plugin's loopback address (tcp_external_addr()), which the last hop cannot dial. This is silently broken on master too; since this PR touches the area, please consider adding a startup warning so users aren't left guessing.
Housekeeping already pushed to this branch
- Merged latest
masterand resolved conflicts (README.md,config.rs, plus thetry_into_tcparm for the newShadowsocksstream variant that the auto-merge missed). - Renamed all
Ssvariants toShadowsocks(OutboundProxyKind,OutboundProxy,OutboundProxyProtocol,OutboundProxyStreamInner,from_ss->from_shadowsocks), consistent withSocks5/Http/Https(commit5f1e7e86).
Once the chain direction is restored to the forward semantics above (which mostly means deleting the inversion code and reinstating master's assembly), the rest of this PR is in good shape.
Summary
This PR adds TCP-only Shadowsocks (
ss://) hops to the existingoutbound_proxychain used bysslocalandssserver.It allows an outbound connection to traverse an encrypted Shadowsocks edge hop before reaching the normal Shadowsocks server or another supported proxy hop. Existing SOCKS5, HTTP, and HTTPS outbound-proxy configurations continue to use the same configuration forms and connection paths.
Motivation
The existing outbound chain can hide the final Shadowsocks server behind SOCKS5/HTTP CONNECT proxies, but it cannot use a Shadowsocks server itself as an encrypted intermediate hop. This is useful for deployments where:
sslocalorssserverprocess instead of requiring an additional local forwarding process.What changed
Configuration and URL compatibility
OutboundProxyis now an enum with separate plain-proxy and Shadowsocks-hop variants.ss://URLs are parsed throughServerConfig, so cipher, password, remarks, and SIP003 plugin parameters use the existing Shadowsocks parser.to_url()supports round-tripping Shadowsocks hops.outbound_proxyconfiguration formats remain supported.examples/chain-ss.json5.TCP chain composition
OutboundProxyKindgains anSsvariant backed by anArc<ServerConfig>.ProxyClientStreamfor anss://hop.OutboundProxyStreamgains a boxed Shadowsocks variant. Boxing breaks the recursive stream type while preserving static async I/O dispatch.SIP003 plugin lifecycle
Plugindrop behavior.Unsupported; supporting those requires per-connection rendezvous plumbing and is intentionally outside this PR.UDP behavior
Shadowsocks outbound hops are TCP-only in this PR.
ss://hop reports that it does not support UDP.OutboundProxyDatagramassociation returnsio::ErrorKind::Unsupportedwith a specific diagnostic.Diagnostics and documentation
ss://, first-hop plugin support, TCP-only behavior, and the new example.Compatibility and limitations
Tests
The new integration suite covers:
Send + Synccompatibility of the cached outbound client;Local validation completed successfully:
cargo test -p shadowsocks-service --test outbound_ss --no-default-features --features aead-cipher -- --nocapture(6 passed,2 ignoredhelper entry points)cargo check --features full-extracargo build --features fullon Windows with NASM 3.01cargo clippy --features "full-extra local-flow-stat utility-url-outline" -- -A clippy::absurd_extreme_comparisonsgit diff --checkGitHub Actions completed successfully on the pushed branch:
Security considerations
ss://chains instead of bypassing the configured route.