Skip to content

feat(local-tunnel): support dynamic addressing mode (0-RTT in-band relay)#2117

Open
wyzhou-com wants to merge 6 commits intoshadowsocks:masterfrom
wyzhou-com:master
Open

feat(local-tunnel): support dynamic addressing mode (0-RTT in-band relay)#2117
wyzhou-com wants to merge 6 commits intoshadowsocks:masterfrom
wyzhou-com:master

Conversation

@wyzhou-com
Copy link
Copy Markdown

No description provided.

wyzhou-com and others added 5 commits April 17, 2026 07:35
…lay)

The tunnel protocol previously required a fixed forward_addr at startup,
limiting it to a single static destination.  This commit makes
forward_addr optional: when omitted, the tunnel reads the target address
from the head of each client stream/packet using the SOCKS5 address
encoding (ATYP + ADDR + PORT), then forwards the remaining payload
accordingly.

In essence, this is an unencrypted shadowsocks relay running locally.
The dynamic tunnel speaks the same wire format as the shadowsocks
protocol — ATYP+ADDR+PORT prefix followed by payload — but without the
encryption layer.

Wire format per connection/packet (no negotiation, no response):

  TCP:  [ATYP(1) ADDR(variable) PORT(2)] [payload stream...]
  UDP:  [ATYP(1) ADDR(variable) PORT(2)] [payload]
        (response packets are prefixed with the same header)

Protocol overhead is exactly the target address itself — typically 7
bytes for IPv4 or 19 bytes for IPv6 — with zero round-trip handshake.
By comparison, a SOCKS5 session costs 2+ RTT (4 messages for
auth-request, auth-response, proxy-request, proxy-response) before the
first byte of payload can flow.

This 0-RTT property makes the tunnel ideal as a lightweight glue layer
between components that already know the destination address:

  - Custom transparent proxy adapters: ss-local already ships a built-in
    redir module for standard tproxy/redirect use cases, but third-party
    adapters with custom functionality need a way to feed resolved
    destinations into ss-local.  The dynamic tunnel serves as a minimal
    IPC protocol for these implementations — they prepend the address
    header and connect, replacing the full SOCKS5 handshake that would
    otherwise be wasted between two localhost processes.

  - Chained relay topologies (relay₁ → relay₂ → ... → relayₙ → target):
    each hop reads the address header once and forwards; no per-hop
    handshake amplification.  N hops cost O(N) address-header bytes
    total, versus O(N) round-trip latencies with SOCKS5.

  - forwarders: minimal code surface — a connect() plus a
    single writev() of header + payload is the complete client
    implementation; no state machine for multi-message negotiation.

  - Programmatic proxies and service meshes: any process that can
    prepend a 7-byte header to its outbound stream gains transparent
    proxy-chain access, making this a drop-in building block for custom
    routing layers.

Static mode (forward_addr present) is completely unchanged; the dynamic
path is only entered when forward_addr is None.

Changes:
  - config.rs:      relax forward_addr validation for tunnel protocol
  - local/mod.rs:   pass Option<Address> instead of .expect()
  - tunnel/server.rs:   propagate Option<Address> through builder
  - tunnel/tcprelay.rs: read ATYP+ADDR+PORT from stream when dynamic
  - tunnel/udprelay.rs: split into run_static/run_dynamic with
                         per-mode InboundWriter (response header
                         prepend in dynamic mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove redundant "read target address" suffix from TCP trace log and
add matching trace log to UDP dynamic mode for consistency.
Malformed ATYP+ADDR+PORT from a dynamic TCP tunnel client was mapped
to io::Error and returned, but the spawned task result is not joined,
so the failure was silently dropped. Emit an error! on the failing
match arm, matching the UDP dynamic path which already logs invalid
packets.
The DOMAIN branch of `Address::read_cursor()` only checked
`remaining() < domain_len`, then unconditionally called `cur.get_u16()`
to read the 2-byte PORT. When the input ends exactly after the domain
bytes (e.g. `03 03 61 62 63` — ATYP=DOMAIN, LEN=3, "abc", no PORT),
`get_u16()` panics on buffer underflow instead of returning `Err`.

Extend the check to `domain_len + 2` so PORT is validated before it is
read, matching the IPv4 / IPv6 branches that already check `4 + 2` and
`16 + 2` respectively.

This is not limited to the dynamic UDP tunnel that surfaced the bug:
`Address::read_cursor()` is also called on every inbound UDP packet
from the core relay decoders — AEAD (`udprelay/aead.rs`), AEAD-2022
(`udprelay/aead_2022.rs`), stream ciphers (`udprelay/stream.rs`), and
`udprelay/crypto_io.rs`. A crafted packet with a truncated DOMAIN
header in any of those paths would abort the UDP task instead of
being rejected as malformed.

Add an integration test `udp_dynamic_tunnel` in `tests/tunnel.rs` that
first sends the truncated `03 03 61 62 63` packet and then a valid
IPv4-targeted dynamic packet, asserting the echo reply still comes
back — i.e. the listener survived the bad packet. Also add
`tcp_dynamic_tunnel` as a happy-path counterpart, styled consistently
with the existing `tcp_tunnel` / `udp_tunnel` tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@wyzhou-com wyzhou-com marked this pull request as draft April 21, 2026 13:33
Restore the public TunnelBuilder static constructors to accept Address
instead of Option<Address>, and add explicit dynamic constructors for the
new in-band addressing mode. This keeps existing callers source-compatible
while still allowing dynamic tunnels to be created intentionally.

Also clean up the tunnel relay implementation:

- route config construction through static or dynamic builder entry points
- keep Option<Address> as an internal implementation detail
- normalize IPv4-mapped IPv6 addresses in dynamic UDP responses, matching
  the SOCKS5 UDP writer behavior
- share the UDP receive/association loop between static and dynamic modes
  while keeping mode-specific packet parsing and response writers separate
- remove redundant explanatory comments added around dynamic mode

This avoids an API break from the dynamic tunnel feature, keeps UDP response
address encoding consistent with existing relay behavior, and reduces
duplicate UDP loop logic without changing static tunnel semantics.
@wyzhou-com wyzhou-com marked this pull request as ready for review April 22, 2026 01:20
@wyzhou-com
Copy link
Copy Markdown
Author

@zonyitoo

Requesting review for the latest commit 089798b:

API Compatibility & Refactoring
API Restoration: Reverted TunnelBuilder::new and with_context to their original signatures to maintain backward compatibility. Dynamic mode is now handled through explicit new_dynamic and with_context_dynamic entry points.

Logic Consolidation: Unified UDP inbound logic using a generic run_with_packet_parser function. This eliminates code duplication between static and dynamic forwarding modes.

Address Standardisation: Implemented IPv4-mapped IPv6 normalization for UDP responses to ensure consistency with existing SOCKS5 logic.

Status
The previous API breaking behavior has been resolved. If this implementation or the relay feature does not align with the project's maintenance goals, please advise, and I will close this PR.

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.

1 participant