Skip to content

Support Shadowsocks outbound proxy hops - #2157

Open
missdeer wants to merge 4 commits into
shadowsocks:masterfrom
missdeer:feature/ss-outbound-hop
Open

Support Shadowsocks outbound proxy hops#2157
missdeer wants to merge 4 commits into
shadowsocks:masterfrom
missdeer:feature/ss-outbound-hop

Conversation

@missdeer

Copy link
Copy Markdown
Contributor

Summary

This PR adds TCP-only Shadowsocks (ss://) hops to the existing outbound_proxy chain used by sslocal and ssserver.

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:

  • the first reachable edge must use Shadowsocks encryption or a SIP003 transport plugin;
  • the final landing server should not be dialled directly from the client;
  • the entire route should remain in one sslocal or ssserver process instead of requiring an additional local forwarding process.

What changed

Configuration and URL compatibility

  • OutboundProxy is now an enum with separate plain-proxy and Shadowsocks-hop variants.
  • SOCKS5, HTTP, and HTTPS retain their existing host, port, and optional authentication representation.
  • SIP002 ss:// URLs are parsed through ServerConfig, so cipher, password, remarks, and SIP003 plugin parameters use the existing Shadowsocks parser.
  • to_url() supports round-tripping Shadowsocks hops.
  • Debug output redacts Shadowsocks passwords.
  • Existing single-string and string-array outbound_proxy configuration formats remain supported.
  • A sanitized end-to-end example is provided in examples/chain-ss.json5.

TCP chain composition

  • OutboundProxyKind gains an Ss variant backed by an Arc<ServerConfig>.
  • The chain builder receives the shared Shadowsocks context and wraps the already-established transport in ProxyClientStream for an ss:// hop.
  • OutboundProxyStream gains a boxed Shadowsocks variant. Boxing breaks the recursive stream type while preserving static async I/O dispatch.
  • The same outbound client is used by both local and server TCP relay paths.
  • SOCKS5, HTTP, HTTPS, and Shadowsocks layers can compose without changing the existing first-hop dialer abstraction.

SIP003 plugin lifecycle

  • A SIP003 plugin is supported on the first Shadowsocks outbound hop.
  • The plugin is started once in client mode, and its loopback listener becomes the first physical address dialled by the chain.
  • The plugin handle is retained by the outbound client, so the child process follows the client's lifetime and is terminated through the existing Plugin drop behavior.
  • Plugin readiness probes are serialized to prevent concurrent connections from creating repeated three-second waits.
  • A failed readiness probe is cached for one second and then retried. This avoids a retry storm while still allowing a slow-starting plugin to recover.
  • Plugins on non-first Shadowsocks hops return 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.

  • A chain containing an ss:// hop reports that it does not support UDP.
  • Direct OutboundProxyDatagram association returns io::ErrorKind::Unsupported with a specific diagnostic.
  • Local and server UDP relay paths reject traffic when the configured chain contains a Shadowsocks hop.
  • UDP therefore fails closed instead of silently bypassing the chain and exposing a destination or upstream address through a direct datagram path.
  • The legacy behavior for other non-SOCKS5 hop types is unchanged.

Diagnostics and documentation

  • Startup warnings identify potentially recursive Shadowsocks-hop/server configurations.
  • Startup warnings explain that UDP is rejected for chains containing a Shadowsocks hop.
  • README configuration and CLI documentation now list ss://, first-hop plugin support, TCP-only behavior, and the new example.

Compatibility and limitations

  • Existing SOCKS5/HTTP/HTTPS outbound proxy URLs and JSON configuration remain compatible.
  • No new dependencies are introduced.
  • Shadowsocks outbound hops support TCP only.
  • Only the first Shadowsocks hop may contain a SIP003 plugin.
  • UDP chaining through Shadowsocks is not implemented; it requires a separate datagram design with per-hop encapsulation and NAT state.
  • Static cycle detection is diagnostic only and cannot detect every DNS-based or dynamically resolved loop.

Tests

The new integration suite covers:

  • TCP echo through two encrypted Shadowsocks outbound hops;
  • a real mock SIP003 child process on the first hop;
  • throttled plugin-start failure followed by recovery after a delayed plugin start;
  • rejection of a plugin on a non-first Shadowsocks hop;
  • rejection of UDP association through a chain containing a Shadowsocks hop;
  • Send + Sync compatibility of the cached outbound client;
  • SIP002 URL round-tripping, plugin parameters, and example configuration parsing.

Local validation completed successfully:

  • cargo test -p shadowsocks-service --test outbound_ss --no-default-features --features aead-cipher -- --nocapture (6 passed, 2 ignored helper entry points)
  • cargo check --features full-extra
  • cargo build --features full on Windows with NASM 3.01
  • cargo clippy --features "full-extra local-flow-stat utility-url-outline" -- -A clippy::absurd_extreme_comparisons
  • git diff --check

GitHub Actions completed successfully on the pushed branch:

  • Build & Test, including Linux, macOS, and Windows matrices
  • Build MSRV
  • Clippy Check
  • Cargo Deny Check

Security considerations

  • Example credentials and endpoints are placeholders.
  • Shadowsocks passwords are redacted from debug formatting.
  • Unsupported UDP traffic fails closed for ss:// chains instead of bypassing the configured route.

Add TCP-only encrypted hop chaining with first-hop SIP003 plugins and fail closed for unsupported UDP traffic.
@missdeer
missdeer marked this pull request as ready for review July 20, 2026 05:06
@missdeer missdeer changed the title [codex] support Shadowsocks outbound proxy hops Support Shadowsocks outbound proxy hops Jul 20, 2026
missdeer and others added 3 commits July 22, 2026 21:20
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 zonyitoo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  • OutboundProxy as an enum (Plain / Shadowsocks) and reusing ServerConfig for 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.
  • PluginHandle readiness state machine (serialized probes, 1s throttled failure cache) — reasonable design.
  • hop_from_config rejecting 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 server

Behavior 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: sslocal dials the main SS server directly, bypassing the configured proxy entirely; then the main server is asked to connect to socks5://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):

  1. Inner layer: target_addr + payload encrypted with ssserver2's (main server) key — this is the existing trailing ProxyClientStream::from_stream(context, mon, server.server_config(), target_addr).
  2. Outer layer: the inner ciphertext as payload, target header = ssserver2's address, encrypted with ssserver1's key — this is exactly what negotiate_hop's Shadowsocks branch 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)

  1. ShadowsocksHop::tag is redundantsvr_cfg.remarks() already carries the same information.
  2. 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.
  3. 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.
  4. ShadowsocksHop: PartialEq via to_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.
  5. Pre-existing, worth a warning: a main server configured with a SIP003 plugin and an outbound_proxy chain 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 master and resolved conflicts (README.md, config.rs, plus the try_into_tcp arm for the new Shadowsocks stream variant that the auto-merge missed).
  • Renamed all Ss variants to Shadowsocks (OutboundProxyKind, OutboundProxy, OutboundProxyProtocol, OutboundProxyStreamInner, from_ss -> from_shadowsocks), consistent with Socks5/Http/Https (commit 5f1e7e86).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants