feat(socks): add UDP ASSOCIATE with fixed-port udpsocks remotes (#624)

Co-authored-by: Codex GPT-6 <[email protected]>
This commit is contained in:
Jaime Pillora
2026-10-12 00:00:45 +11:00
committed by GitHub
co-authored by Codex GPT-6
parent 3c00f04ca1
commit a5796f95d2
21 changed files with 3214 additions and 36 deletions
+32 -6
View File
@@ -263,15 +263,18 @@ $ chisel client --help
192.168.0.5:3000:google.com:80
socks
5000:socks
udpsocks
5000:udpsocks
R:2222:localhost:22
R:socks
R:5000:socks
R:5000:udpsocks
stdio:example.com:22
1.1.1.1:53/udp
When the chisel server has --socks5 enabled, remotes can
specify "socks" in place of remote-host and remote-port.
The default local host and port for a "socks" remote is
specify "socks" or "udpsocks" in place of remote-host and
remote-port. Both default to the local host and port
127.0.0.1:1080. Connections to this remote will terminate
at the server's internal SOCKS5 proxy. When the server also
has --authfile set, SOCKS5 access requires an entry matching
@@ -285,6 +288,17 @@ $ chisel client --help
default socks port (1080) and terminate the connection at the
client's internal SOCKS5 proxy.
The "socks" remote supports TCP CONNECT only. Use "udpsocks"
for TCP CONNECT and UDP ASSOCIATE, including "R:udpsocks".
It binds TCP and UDP on the same configured interface and port;
the UDP relay never uses a randomly allocated listening port.
UDP requires support on both Chisel peers. Destination names are
resolved at the exit node. If UDP cannot bind, TCP remains available
and UDP ASSOCIATE fails; no alternate port is used. Clients using
a zero UDP source port allow one pending association per source IP
until the first valid datagram pins it. BIND, UDP fragmentation,
and udpsocks over stdio are not supported.
When stdio is used as local-host, the tunnel will connect standard
input/output of this program with the remote. This is useful when
combined with ssh ProxyCommand. You can use
@@ -452,6 +466,14 @@ For mutual TLS, also pass `--tls-ca` to the server and `--tls-cert`/`--tls-key`
Note: if the server also uses `--authfile`, users need an entry matching the token `socks` to use the proxy (see [Authentication](#authentication)).
For setup commands, reverse mode, and a UDP client example, see the [SOCKS5 UDP user guide](example/udpsocks.md).
Ordinary `socks` remotes support TCP CONNECT only and bind no UDP socket. To enable both TCP CONNECT and UDP ASSOCIATE, use `udpsocks` (or `R:udpsocks` for reverse mode), for example `1080:udpsocks`. Both types default to `127.0.0.1:1080` and use the same SOCKS access permissions. A `udpsocks` listener binds TCP and UDP on the same configured interface and port, sharing one UDP relay socket across associations without allocating random listening ports. UDP clients keep their TCP control connection open and send SOCKS-encapsulated datagrams to that port. Datagram destination names are resolved at the exit node, and the UDP traffic travels through the encrypted SSH tunnel. Upgrade both Chisel peers for UDP; TCP SOCKS remains compatible with older peers.
When exposing a `udpsocks` listener through a firewall or container, allow both TCP and UDP on its configured port. UDP ASSOCIATE returns the local IP accepting the TCP connection, which must be reachable by the SOCKS client. For Linux Docker listeners, use `--network host` with the remote `0.0.0.0:1080:udpsocks` to avoid advertising an isolated container address. Container port mappings need both `-p 1080:1080/tcp` and `-p 1080:1080/udp`, plus reachability to the advertised relay IP. If the UDP port is occupied, Chisel logs that UDP is unavailable, retains TCP service, and rejects UDP ASSOCIATE without using an alternate port; restart the proxy after freeing the port.
An association accepts packets from its TCP peer's IP. A declared UDP source port reserves that endpoint; a zero source port is pinned by its first valid datagram. Only one unpinned association per IP is allowed, so clients sharing an IP should declare their UDP source ports or send the first datagram before opening another association. Duplicate source-port reservations are rejected. Closing the control connection or losing SSH ends the association; reconnecting requires a new UDP ASSOCIATE. BIND, UDP fragmentation, and `stdio:udpsocks` are unsupported.
### Reverse SOCKS with an Authfile
To let a specific client act as a SOCKS exit node, grant it the reverse-socks listener address (`R:socks` listens on the server's `127.0.0.1:1080`):
@@ -469,6 +491,8 @@ chisel client --auth exituser:password <server-address> R:socks
# and their traffic exits via the chisel client's network
```
Use `R:udpsocks` in the client command to also enable UDP ASSOCIATE on the same server port; the authfile grant stays the same.
See also the step-by-step [reverse tunneling example](example/reverse-tunneling-authenticated.md).
### Running behind a CDN (Cloudflare)
@@ -486,16 +510,18 @@ Less common knobs are environment variables, all read with a `CHISEL_` prefix (e
| `CONFIG_TIMEOUT`| server | `10s` | wait for the client's config request |
| `SSH_WAIT` | both | `35s` | how long new tunnels wait for an active connection |
| `PING_TIMEOUT` | both | keepalive interval | keepalive ping reply timeout (no pings if `--keepalive 0`) |
| `DIAL_TIMEOUT` | exit node | `30s` | tcp dial timeout for tunnel targets |
| `DIAL_TIMEOUT` | both | `30s` | tcp target dial, SOCKS handshake, and SOCKS UDP DNS timeout |
| `WS_READ_LIMIT` | both | `524288` | max inbound websocket message bytes (0 = no limit; negative = default) |
| `WS_BUFF_SIZE` | both | go default | websocket read/write buffer sizes |
| `UDP_MAX_SIZE` | both | `9012` | max udp packet bytes |
| `UDP_DEADLINE` | both | `15s` | udp flow read deadline and return-peer expiry |
| `UDP_MAX_CONNS` | both | `100` | max udp flows or return peers per tunnel |
| `UDP_MAX_SIZE` | both | `9012` | max udp packet bytes (includes the SOCKS UDP header) |
| `UDP_DEADLINE` | both | `15s` | udp flow read deadline and return-peer expiry; SOCKS UDP destination idle timeout |
| `UDP_MAX_CONNS` | both | `100` | max udp flows or return peers; SOCKS UDP associations per listener and destinations per association |
| `SHUTDOWN_GRACE`| server | `5s` | http request drain time on shutdown |
`HOST`, `PORT`, `AUTH`, and `CHISEL_KEY`/`CHISEL_KEY_FILE` are documented in the `--help` texts above.
SOCKS UDP uses hard association and destination limits and drops packets beyond them. Each association queues up to 16 outbound datagrams; full queues drop packets without blocking other clients. For SOCKS UDP, nonpositive size/connection limits or idle deadlines use the defaults, and packet size is capped at 65507 bytes. Operating systems can impose a smaller UDP send limit: macOS defaults to 9216 bytes (`net.inet.udp.maxdgram`). The default 9012-byte Chisel limit fits within that bound.
#### Caveats
Since WebSockets support is required:
+99
View File
@@ -0,0 +1,99 @@
# udpsocks performance and limit tests
Measured on 2026-10-11 against the UDP reply-source fix in the working tree (base commit `e7a9879`). Each association now uses unconnected outgoing sockets shared across destinations, with at most one socket per address family.
## Results
With 1,200-byte payloads and sixteen concurrent clients, forward UDP delivered **17.17 MB/s (137.4 Mbps)** and reverse UDP delivered **16.88 MB/s (135.0 Mbps)**. Both measured zero loss. A single client had median round-trip latency of approximately **176–192 microseconds**.
At 8,192 bytes with sixteen clients, delivered throughput reached **40.74 MB/s forward and 43.29 MB/s reverse**, with **0.43–0.50% loss**. Direct UDP also lost packets in this case (**0.059%**). The benchmark records timeouts as lost packets.
The limit tests passed at the configured boundaries, including the default 100-association and 100-destination limits. Overload testing confirmed that unrelated clients still respond and associations recover after the flood.
## Method
- Linux/ARM64, four logical CPUs, Go 1.27.0, `GOMAXPROCS=4`.
- Direct UDP, forward `udpsocks`, and reverse `R:udpsocks`, all over loopback. Both Chisel peers, the independent `txthinking/socks5` client, and the echo endpoint run in the benchmark process.
- Three runs per combination, one-second benchmark target per run; the table shows the median of each metric across those runs.
- Payloads of 1,200 and 8,192 bytes. One or sixteen independent clients, each with one request outstanding. Associations and destinations are warmed before timing.
- Default Chisel limits and socket buffers. This host reports default Linux receive/send buffers of 212,992 bytes; these were not increased for the measurements.
- A request counts as lost if its matching response does not arrive within 100 ms. Sequence numbers prevent late responses being counted against a later request.
- RTT percentiles sample successful responses; lost requests are excluded from those percentiles. Throughput includes the time spent waiting for losses.
- MB/s counts delivered application payload once per echo. It excludes SOCKS/SSH/WebSocket overhead and does not double-count the return direction.
- Allocation figures include both Chisel peers, the independent client, and the endpoint. Race instrumentation was disabled for performance measurements.
These are local measurements of this host and workload. A WAN adds network latency, transport congestion, and TCP head-of-line blocking; these measurements do not establish maximum WAN throughput.
## Echo benchmark results
| Path | Payload | Clients | Payload MB/s | Echoes/s | RTT p50 (us) | RTT p95 (us) | Loss (%) | B/attempt | Allocs/attempt |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| direct | 1200 | 1 | 88.87 | 74056 | 12.6 | 14.6 | 0.0000 | 52 | 2 |
| direct | 1200 | 16 | 137.76 | 114797 | 112.8 | 304.8 | 0.0000 | 52 | 2 |
| direct | 8192 | 1 | 499.26 | 60945 | 15.5 | 19.1 | 0.0000 | 52 | 2 |
| direct | 8192 | 16 | 712.51 | 86976 | 82.4 | 205.7 | 0.0589 | 52 | 1 |
| forward | 1200 | 1 | 6.27 | 5225 | 175.9 | 319.6 | 0.0000 | 16827 | 65 |
| forward | 1200 | 16 | 17.17 | 14310 | 924.6 | 2464.0 | 0.0000 | 16839 | 65 |
| forward | 8192 | 1 | 21.32 | 2602 | 327.4 | 729.8 | 0.0000 | 111496 | 99 |
| forward | 8192 | 16 | 40.74 | 4973 | 2195.0 | 5087.0 | 0.4958 | 111139 | 99 |
| reverse | 1200 | 1 | 6.15 | 5123 | 191.7 | 309.1 | 0.0000 | 16827 | 65 |
| reverse | 1200 | 16 | 16.88 | 14069 | 920.4 | 2458.0 | 0.0000 | 16840 | 65 |
| reverse | 8192 | 1 | 22.36 | 2729 | 325.2 | 648.0 | 0.0000 | 111495 | 99 |
| reverse | 8192 | 16 | 43.29 | 5285 | 2083.0 | 4716.0 | 0.4348 | 111201 | 99 |
The 1,200-byte tunnel paths allocate about 16.8 KB and 65 allocations per attempt in this whole-process benchmark. At 8,192 bytes, that rises to about 111 KB and 99 allocations. Allocation reduction is a useful candidate for subsequent profiling and optimization.
## Unpaced overload
Each path sends 20,000 datagrams of 1,200 bytes as quickly as its client can write them, with a concurrent response reader. Offered rates differ between paths. This measures burst loss and recovery rather than a sustainable constant-rate workload.
| Path | Offered packets/s | Received / 20,000 | Loss (%) | Successful RTT p50 (us) | Successful RTT p95 (us) |
| --- | ---: | ---: | ---: | ---: | ---: |
| direct | 165172 | 3455 | 82.72 | 1630.2 | 10664.1 |
| forward | 88916 | 2184 | 89.08 | 9035.8 | 17683.1 |
| reverse | 98778 | 2103 | 89.48 | 9294.8 | 26451.9 |
All three paths passed 20 probes from an unrelated client during the workload and a recovery echo on the flooded connection afterward. No duplicated or corrupted received packets were detected. UDP socket buffers and the relay queue can both drop packets; this test does not attribute each loss to a specific queue.
## Limit coverage
| Limit or behavior | Verified |
| --- | --- |
| Associations per listener | Configured limit 2 and default 100, forward and reverse. The next association fails; existing UDP and CONNECT continue. Closing a control frees capacity. |
| Destinations per association | Configured limit 2 and default 100, forward and reverse. Over-limit requests never reach the new destination. Other associations have independent capacity. |
| Destination idle expiration | A 500 ms configured timeout frees capacity for a new destination without replacing its TCP control connection. Expiration keeps outgoing sockets and their source ports intact. |
| Changed reply endpoint | Two-block TFTP exchanges switch server ports and preserve the client transfer ID, forward/reverse over IPv4/IPv6. A different reply source IP is also covered when the OS permits binding an alternate loopback address; that variant skips when the address is unavailable. Two simultaneous associations use separate exit sockets. |
| Datagram size | Exact 64-byte and 9,012-byte SOCKS packets succeed. One extra byte is dropped before reaching the endpoint. IPv6 header overhead is included. |
| Upper packet-size bound | Setting 70,000 clamps to 65,507. An exact 65,507-byte SOCKS datagram succeeds on Linux. This boundary case skips if an independent native UDP send reports a smaller OS limit, as macOS does by default. The setting clamp remains covered by unit tests on all platforms. Oversized return packets are tested separately. |
| Oversized replies | Responses whose SOCKS header plus payload exceeds the limit are dropped; subsequent valid traffic still works. |
| Shared-reader overflow | A full 16-packet association queue drops overflow and does not block another association. Covered deterministically by `TestSocksUDPSharedRoutingAndOverflow`. |
| Invalid settings | Zero, negative, and malformed size/connection/deadline settings fall back to defaults; oversized size settings clamp. |
| Existing protocol limits | Malformed/unassociated packets and fragmentation are rejected; source-port conflicts and the one-pending-association-per-IP rule remain covered. |
The association and destination limits are per listener and per association respectively; they are not a process-wide socket or memory limit. Each association retains at most two outgoing sockets (IPv4 and IPv6) until it closes. Destination expiry releases tracking capacity; incoming replies do not create new tracking entries.
Local Linux/ARM64 validation passed: `CHISEL_UDP_STRESS=1 GOMAXPROCS=4 go test -race ./...`, `go vet ./...`, and `git diff --check`.
## Reproduce
Run the benchmark without race instrumentation:
```sh
GOMAXPROCS=4 go test ./test/e2e -run "^$" -bench '^BenchmarkSocksUDP/(direct|forward|reverse)/bytes=(1200|8192)/clients=(1|16)$' \
-benchmem -benchtime=1s -count=3
```
Run the boundary and recovery tests:
```sh
go test -race ./test/e2e -run '^TestSocksUDP(AssociationLimit|DestinationLimit|PacketSizeLimit|TransferPort|SourcePortSurvivesIdle)$'
go test -race ./share/tunnel -run '^TestSocksUDP(LimitSettings|SharedRoutingAndOverflow|ExitLimitsAndRecovery)$'
```
Run the opt-in overload test:
```sh
CHISEL_UDP_STRESS=1 GOMAXPROCS=4 go test -v ./test/e2e -run "^TestSocksUDPOverload$"
```
Ordinary `go test ./...` runs the limit tests and skips the overload workload. Set `CHISEL_UDP_STRESS=1` to include it. Performance numbers above come from runs without the race detector; run the full suite with `-race` to check concurrency correctness.
+177
View File
@@ -0,0 +1,177 @@
# Using SOCKS5 UDP with Chisel
Use `udpsocks` to carry both TCP and UDP traffic through a Chisel SOCKS5 proxy. It supports TCP CONNECT and UDP ASSOCIATE, including reverse tunnels.
Both Chisel peers need builds that include `udpsocks`. To build this working tree, run `go build -o chisel .` and use the resulting binary on each machine (build separately for different operating systems or architectures).
## Choose your listener
| Remote | Listener location | Listening sockets | Supported commands |
| --- | --- | --- | --- |
| `1080:socks` | Chisel client | TCP 1080 | CONNECT |
| `1080:udpsocks` | Chisel client | TCP 1080 and UDP 1080 | CONNECT and UDP ASSOCIATE |
| `R:1080:udpsocks` | Chisel server | TCP 1080 and UDP 1080 | CONNECT and UDP ASSOCIATE |
Bare `udpsocks` defaults to `127.0.0.1:1080`. Use `5000:udpsocks` to change the port, or `192.168.1.10:5000:udpsocks` to choose an interface. IPv6 works too: quote the remote, for example `'[::1]:1080:udpsocks'`.
TCP and UDP always use the same configured interface and port number. One UDP relay socket serves all associations: Chisel does not allocate a random relay port for each client. Port zero is rejected. This applies to the exposed relay; normal outgoing UDP sockets and application source ports can still use operating-system-assigned ports.
## Forward proxy: traffic exits through the server
The path is:
```text
SOCKS application → client TCP/UDP :1080 → Chisel tunnel → server → destination
```
On the server, create `users.json`. Replace `CHANGE_ME` with your own password:
```json
{
"proxyuser:CHANGE_ME": ["^socks$"]
}
```
Generate a persistent server key once, then start the server:
```sh
chisel server --keygen chisel-key.pem
chisel server --keyfile chisel-key.pem --port 9312 --socks5 --authfile users.json
```
Copy the fingerprint printed by the server. On the client machine, replace `SERVER_FINGERPRINT`, `server.example.com`, and the password:
```sh
chisel client --fingerprint 'SERVER_FINGERPRINT' --auth 'proxyuser:CHANGE_ME' \
http://server.example.com:9312 1080:udpsocks
```
Point the SOCKS application at **127.0.0.1:1080 on the client machine**. For example, this checks TCP CONNECT:
```sh
curl --socks5-hostname 127.0.0.1:1080 https://example.com
```
The SOCKS application must support SOCKS5 UDP ASSOCIATE to send UDP. Configuring a browser or an HTTP-only SOCKS client does not automatically tunnel its UDP traffic. Chisel tunnel credentials authenticate the Chisel client; the local SOCKS listener uses no SOCKS username/password authentication.
## Reverse proxy: traffic exits through the client
Use this when applications on the server should access destinations reachable from the client's network:
```text
SOCKS application → server TCP/UDP :1080 → Chisel tunnel → client → destination
```
On the server, create `reverse-users.json`:
```json
{
"exituser:CHANGE_ME": ["^R:127\\.0\\.0\\.1:1080$"]
}
```
Start the server using the persistent key generated above:
```sh
chisel server --keyfile chisel-key.pem --port 9312 --reverse \
--authfile reverse-users.json
```
Connect from the machine that should act as the exit:
```sh
chisel client --fingerprint 'SERVER_FINGERPRINT' --auth 'exituser:CHANGE_ME' \
http://server.example.com:9312 R:1080:udpsocks
```
Point SOCKS applications at **127.0.0.1:1080 on the server machine**. Reverse mode needs `--reverse` on the server; it does not require `--socks5` there. The client supplies the SOCKS exit.
For forward mode, both `socks` and `udpsocks` use the authfile permission `socks`. For reverse mode, permission matches the server listener address, such as `R:127.0.0.1:1080`. Changing the listener interface or port also requires updating that reverse permission.
## Try UDP with an independent client
This example uses [PySocks](https://pypi.org/project/PySocks/), which performs the SOCKS negotiation and UDP encapsulation itself.
First, run a UDP echo endpoint on the **exit machine**: the server for forward mode, or the client for reverse mode. Leave it running:
```sh
python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as echo:
echo.bind(('127.0.0.1', 9999))
print('UDP echo listening on 127.0.0.1:9999', flush=True)
while True:
data, peer = echo.recvfrom(65535)
echo.sendto(data, peer)
PY
```
On the **SOCKS listener machine**, install PySocks in a virtual environment:
This needs Python's virtual environment support (`python3-venv` on Debian/Ubuntu). On Windows, use `.venv\Scripts\python.exe` instead of `.venv/bin/python`.
```sh
python3 -m venv .venv
.venv/bin/python -m pip install PySocks==1.7.1
```
Then send a datagram through the proxy:
```sh
.venv/bin/python - <<'PY'
import socket
import socks
with socks.socksocket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.set_proxy(socks.SOCKS5, '127.0.0.1', 1080)
client.settimeout(5)
client.bind(('127.0.0.1', 0))
payload = b'hello through udpsocks'
client.sendto(payload, ('127.0.0.1', 9999))
response, source = client.recvfrom(65535)
assert response == payload, (response, source)
print('UDP round trip succeeded:', response.decode())
PY
```
The destination `127.0.0.1:9999` is reached from the exit machine. The application's source port may be allocated dynamically; the Chisel relay remains on port 1080.
The repository also includes interoperability tests using the independent Go client [txthinking/socks5](https://github.com/txthinking/socks5):
```sh
go test -v ./test/e2e -run TestSocksUDPThirdPartyClient
```
Those tests cover forward and reverse UDP, IPv4, IPv6, hostname destinations, multiple packet sizes, and the configured relay port.
For measured throughput, latency, overload behavior, and boundary tests, see the [performance and limit test report](udpsocks-performance.md). It includes commands to repeat the benchmarks and tests.
## Interfaces, firewalls, and Docker
Keep the SOCKS listener on loopback when applications run on the same machine. To accept applications on a trusted network, specify a reachable interface, for example `192.168.1.10:1080:udpsocks`. The listener has no SOCKS authentication, so control access to that address and port.
Allow **both TCP and UDP** to the configured SOCKS port when applications connect across a firewall. Between the two Chisel peers, allow the Chisel transport port (9312 in these examples); SOCKS UDP travels inside the tunnel, so no separate UDP tunnel port is required between peers.
UDP ASSOCIATE advertises the concrete local IP accepting the TCP control connection. The application must be able to reach that IP over UDP. Linux Docker listeners can use `--network host` and `0.0.0.0:1080:udpsocks` to avoid advertising an isolated container IP. With bridge networking, mapping both `1080:1080/tcp` and `1080:1080/udp` is necessary, but the advertised container IP must also be reachable; port mappings alone do not guarantee this.
## Troubleshooting and limits
| Symptom | Check |
| --- | --- |
| TCP works, UDP ASSOCIATE is rejected | Use `udpsocks`, upgrade both Chisel peers, and check logs for a UDP bind failure. Forward mode also requires server `--socks5`. |
| UDP port is already occupied | Free the configured UDP port and restart the proxy. Chisel retains TCP service and rejects ASSOCIATE; it never switches to another relay port. |
| Association succeeds but packets time out | Check the destination from the exit machine, UDP firewall rules, and reachability of the advertised relay IP. |
| Another client sharing an IP cannot associate | Only one association with an unknown UDP source port may wait per source IP. Send the first client's first datagram before opening the next association, or use clients that declare distinct source ports. |
| UDP stops after reconnecting | Keep the TCP control connection open. Losing that connection or the SSH tunnel ends the association; create a new UDP ASSOCIATE after reconnecting. |
| Large or fragmented datagrams disappear | Fragmented SOCKS UDP packets are unsupported. The default packet limit is 9012 bytes, including the SOCKS UDP header. |
On the exposed relay, only client datagrams from the TCP peer's IP are accepted. A declared source port reserves that endpoint; otherwise, the first valid datagram pins the association to its source port. DNS names in UDP destination headers are resolved at the exit machine.
Each association uses at most one outgoing socket per address family, retaining its source port across destination changes and idle periods until the association closes. Replies may use a different source address or UDP port, as protocols such as TFTP require; the SOCKS reply header carries the actual source address and port. Incoming replies do not allocate additional destination tracking entries.
Defaults are 100 associations per listener, 100 destinations per association, and a 15-second idle timeout for destination tracking. Expired destinations free capacity and are resolved again on the next packet; the outgoing sockets remain available until the association closes. Each association queues up to 16 outbound datagrams and drops overflow. Tune these with `CHISEL_UDP_MAX_CONNS`, `CHISEL_UDP_DEADLINE`, and `CHISEL_UDP_MAX_SIZE` on the relevant peers.
Chisel caps SOCKS UDP packets at 65507 bytes, including the SOCKS header. The operating system can impose a smaller send limit: macOS defaults to 9216 bytes (`net.inet.udp.maxdgram`), which accommodates Chisel's 9012-byte default. Increasing `CHISEL_UDP_MAX_SIZE` does not increase the operating system's limit.
SOCKS BIND, UDP fragmentation, and `stdio:udpsocks` are unsupported. UDP is best-effort: queue and resource limits can drop packets, and reconnecting does not replay them.
+3
View File
@@ -12,6 +12,7 @@ require (
github.com/jpillora/opts v1.8.1
github.com/jpillora/requestlog v1.0.0
github.com/jpillora/sizestr v1.0.0
github.com/txthinking/socks5 v0.0.0-20260601051520-339b044ab0eb
golang.org/x/crypto v0.55.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
@@ -22,8 +23,10 @@ require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jpillora/ansi v1.0.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/posener/complete v1.2.3 // indirect
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
github.com/txthinking/runnergroup v0.0.0-20210608031112-152c7c4432bf // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+42
View File
@@ -24,6 +24,10 @@ github.com/jpillora/requestlog v1.0.0 h1:bg++eJ74T7DYL3DlIpiwknrtfdUA9oP/M4fL+Pp
github.com/jpillora/requestlog v1.0.0/go.mod h1:HTWQb7QfDc2jtHnWe2XEIEeJB7gJPnVdpNn52HXPvy8=
github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw=
github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0=
github.com/miekg/dns v1.1.51 h1:0+Xg7vObnhrz/4ZCZcZh7zPXlmU0aveS2HDBd0m0qSo=
github.com/miekg/dns v1.1.51/go.mod h1:2Z9d3CP1LQWihRZUf29mQ19yDThaI4DAYzte2CaQW5c=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
@@ -33,18 +37,56 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
github.com/txthinking/runnergroup v0.0.0-20210608031112-152c7c4432bf h1:7PflaKRtU4np/epFxRXlFhlzLXZzKFrH5/I4so5Ove0=
github.com/txthinking/runnergroup v0.0.0-20210608031112-152c7c4432bf/go.mod h1:CLUSJbazqETbaR+i0YAhXBICV9TrKH93pziccMhmhpM=
github.com/txthinking/socks5 v0.0.0-20260601051520-339b044ab0eb h1:cFSmrCbLJPb87QrwDhUTnguVLezlIqDiJuY8xKcFZnw=
github.com/txthinking/socks5 v0.0.0-20260601051520-339b044ab0eb/go.mod h1:ntmMHL/xPq1WLeKiw8p/eRATaae6PiVRNipHFJxI8PM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+16 -2
View File
@@ -265,15 +265,18 @@ from the client to the server's <local-interface>:<local-port>.
192.168.0.5:3000:google.com:80
socks
5000:socks
udpsocks
5000:udpsocks
R:2222:localhost:22
R:socks
R:5000:socks
R:5000:udpsocks
stdio:example.com:22
1.1.1.1:53/udp
When the chisel server has --socks5 enabled, remotes can
specify "socks" in place of remote-host and remote-port.
The default local host and port for a "socks" remote is
specify "socks" or "udpsocks" in place of remote-host and
remote-port. Both default to the local host and port
127.0.0.1:1080. Connections to this remote will terminate
at the server's internal SOCKS5 proxy. When the server also
has --authfile set, SOCKS5 access requires an entry matching
@@ -287,6 +290,17 @@ from the client to the server's <local-interface>:<local-port>.
default socks port (1080) and terminate the connection at the
client's internal SOCKS5 proxy.
The "socks" remote supports TCP CONNECT only. Use "udpsocks"
for TCP CONNECT and UDP ASSOCIATE, including "R:udpsocks".
It binds TCP and UDP on the same configured interface and port;
the UDP relay never uses a randomly allocated listening port.
UDP requires support on both Chisel peers. Destination names are
resolved at the exit node. If UDP cannot bind, TCP remains available
and UDP ASSOCIATE fails; no alternate port is used. Clients using
a zero UDP source port allow one pending association per source IP
until the first valid datagram pins it. BIND, UDP fragmentation,
and udpsocks over stdio are not supported.
When stdio is used as local-host, the tunnel will connect standard
input/output of this program with the remote. This is useful when
combined with ssh ProxyCommand. You can use
+34 -13
View File
@@ -25,6 +25,9 @@ import (
// 127.0.0.1:1080:socks
// local 127.0.0.1:1080
// remote socks
// 127.0.0.1:1080:udpsocks
// local 127.0.0.1:1080 (TCP and UDP)
// remote udpsocks
// stdio:example.com:22
// local stdio
// remote example.com:22
@@ -35,7 +38,7 @@ import (
type Remote struct {
LocalHost, LocalPort, LocalProto string
RemoteHost, RemotePort, RemoteProto string
Socks, Reverse, Stdio bool
Socks, UDPSocks, Reverse, Stdio bool
}
const revPrefix = "R:"
@@ -57,8 +60,9 @@ func DecodeRemote(s string) (*Remote, error) {
for i := len(parts) - 1; i >= 0; i-- {
p := parts[i][1]
//remote portion is socks?
if i == len(parts)-1 && p == "socks" {
if i == len(parts)-1 && (p == "socks" || p == "udpsocks") {
r.Socks = true
r.UDPSocks = p == "udpsocks"
continue
}
//local portion is stdio?
@@ -75,12 +79,23 @@ func DecodeRemote(s string) (*Remote, error) {
}
}
if isPort(p) {
if r.UDPSocks && r.LocalPort != "" {
return nil, errors.New("udpsocks accepts one shared TCP and UDP port")
}
if !r.Socks && r.RemotePort == "" {
r.RemotePort = p
}
r.LocalPort = p
continue
}
if r.UDPSocks {
if _, err := strconv.Atoi(p); err == nil {
return nil, errors.New("udpsocks port must be between 1 and 65535")
}
if r.LocalHost != "" {
return nil, errors.New("udpsocks accepts one local interface")
}
}
if !r.Socks && (r.RemotePort == "" && r.LocalPort == "") {
return nil, errors.New("Missing ports")
}
@@ -124,7 +139,10 @@ func DecodeRemote(s string) (*Remote, error) {
return nil, errors.New("cross-protocol remotes are not supported yet")
}
if r.Socks && r.RemoteProto != "tcp" {
return nil, errors.New("only TCP SOCKS is supported")
return nil, errors.New("SOCKS remotes require TCP; use udpsocks for UDP ASSOCIATE")
}
if r.UDPSocks && r.Stdio {
return nil, errors.New("udpsocks requires a TCP and UDP listener; stdio is unsupported")
}
if r.Stdio && r.Reverse {
return nil, errors.New("stdio cannot be reversed")
@@ -153,7 +171,7 @@ func isHost(s string) bool {
var l4Proto = regexp.MustCompile(`(?i)\/(tcp|udp)$`)
//L4Proto extracts the layer-4 protocol from the given string
// L4Proto extracts the layer-4 protocol from the given string
func L4Proto(s string) (head, proto string) {
if l4Proto.MatchString(s) {
l := len(s)
@@ -164,7 +182,7 @@ func L4Proto(s string) (head, proto string) {
return s, ""
}
//implement Stringer
// implement Stringer
func (r Remote) String() string {
sb := strings.Builder{}
if r.Reverse {
@@ -179,7 +197,7 @@ func (r Remote) String() string {
return sb.String()
}
//Encode remote to a string
// Encode remote to a string
func (r Remote) Encode() string {
if r.LocalPort == "" {
r.LocalPort = r.RemotePort
@@ -195,7 +213,7 @@ func (r Remote) Encode() string {
return local + ":" + remote
}
//Local is the decodable local portion
// Local is the decodable local portion
func (r Remote) Local() string {
if r.Stdio {
return "stdio"
@@ -206,8 +224,11 @@ func (r Remote) Local() string {
return r.LocalHost + ":" + r.LocalPort
}
//Remote is the decodable remote portion
// Remote is the decodable remote portion
func (r Remote) Remote() string {
if r.UDPSocks {
return "udpsocks"
}
if r.Socks {
return "socks"
}
@@ -217,8 +238,8 @@ func (r Remote) Remote() string {
return r.RemoteHost + ":" + r.RemotePort
}
//UserAddr is checked when checking if a
//user has access to a given remote
// UserAddr is checked when checking if a
// user has access to a given remote
func (r Remote) UserAddr() string {
if r.Reverse {
return "R:" + r.LocalHost + ":" + r.LocalPort
@@ -229,7 +250,7 @@ func (r Remote) UserAddr() string {
return r.RemoteHost + ":" + r.RemotePort
}
//CanListen checks if the port can be listened on
// CanListen checks if the port can be listened on
func (r Remote) CanListen() bool {
//valid protocols
switch r.LocalProto {
@@ -258,7 +279,7 @@ func (r Remote) CanListen() bool {
type Remotes []*Remote
//Filter out forward reversed/non-reversed remotes
// Filter out forward reversed/non-reversed remotes
func (rs Remotes) Reversed(reverse bool) Remotes {
subset := Remotes{}
for _, r := range rs {
@@ -270,7 +291,7 @@ func (rs Remotes) Reversed(reverse bool) Remotes {
return subset
}
//Encode back into strings
// Encode back into strings
func (rs Remotes) Encode() []string {
s := make([]string, len(rs))
for i, r := range rs {
+61
View File
@@ -2,6 +2,7 @@ package settings
import (
"reflect"
"strings"
"testing"
)
@@ -148,3 +149,63 @@ func TestRemoteDecode(t *testing.T) {
}
}
}
func TestUDPSocksRemote(t *testing.T) {
for _, test := range []struct {
input, host, port string
reverse bool
}{
{"udpsocks", "127.0.0.1", "1080", false},
{"5000:udpsocks", "127.0.0.1", "5000", false},
{"0.0.0.0:5000:udpsocks", "0.0.0.0", "5000", false},
{"localhost:5000:udpsocks", "localhost", "5000", false},
{"[::1]:5000:udpsocks", "[::1]", "5000", false},
{"R:udpsocks", "127.0.0.1", "1080", true},
{"R:5000:udpsocks", "127.0.0.1", "5000", true},
{"R:[::]:5000:udpsocks", "[::]", "5000", true},
} {
t.Run(test.input, func(t *testing.T) {
r, err := DecodeRemote(test.input)
if err != nil {
t.Fatal(err)
}
want := &Remote{LocalHost: test.host, LocalPort: test.port, LocalProto: "tcp", RemoteProto: "tcp", Socks: true, UDPSocks: true, Reverse: test.reverse}
if !reflect.DeepEqual(r, want) {
t.Fatalf("decoded %#v, want %#v", r, want)
}
encoded := test.host + ":" + test.port + ":udpsocks"
permission := "socks"
if test.reverse {
encoded = "R:" + encoded
permission = "R:" + test.host + ":" + test.port
}
if r.Encode() != encoded || r.Remote() != "udpsocks" || !strings.HasSuffix(r.String(), "=>udpsocks") || r.UserAddr() != permission {
t.Fatalf("encode=%q remote=%q string=%q permission=%q", r.Encode(), r.Remote(), r.String(), r.UserAddr())
}
decoded, err := DecodeRemote(r.Encode())
if err != nil || !reflect.DeepEqual(decoded, r) {
t.Fatalf("remote round trip: %#v, %v", decoded, err)
}
config := Config{Version: "test", Remotes: Remotes{r}}
got, err := DecodeConfig(EncodeConfig(config))
if err != nil || !reflect.DeepEqual(got, &config) {
t.Fatalf("configuration round trip: %#v, %v", got, err)
}
})
}
}
func TestUDPSocksInvalidRemote(t *testing.T) {
for _, input := range []string{
"0:udpsocks", "127.0.0.1:0:udpsocks", "R:0:udpsocks",
"-1:udpsocks", "65536:udpsocks", "1080:1081:udpsocks",
"localhost:localhost:udpsocks", "1080/udp:udpsocks", "1080:udpsocks/udp",
"stdio:udpsocks", "stdio:1080:udpsocks", "R:stdio:udpsocks",
} {
t.Run(input, func(t *testing.T) {
if r, err := DecodeRemote(input); err == nil {
t.Fatalf("invalid remote accepted: %#v", r)
}
})
}
}
+189
View File
@@ -0,0 +1,189 @@
package tunnel
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"runtime"
"sync"
"syscall"
"time"
"github.com/jpillora/chisel/share/settings"
)
// A separate destination keeps legacy peers from accepting a UDP channel as
// an ordinary SOCKS stream. The existing TCP SOCKS protocol stays unchanged.
const socksUDPDestination = "socks-udp-v1"
// Older peers may accept a channel before attempting their TCP dial. Require
// an explicit marker before reporting UDP ASSOCIATE success to the client.
const socksUDPReady = "chisel-socks-udp-v1\n"
const (
socksSuccess = 0
socksFailure = 1
socksDenied = 2
socksUnsupported = 7
socksBadAddress = 8
)
var errSocksAddress = errors.New("unsupported SOCKS address type")
type socksAddress struct {
host string
port uint16
wire []byte
}
func readSocksAddress(r io.Reader) (socksAddress, error) {
a := socksAddress{}
head := make([]byte, 1)
if _, err := io.ReadFull(r, head); err != nil {
return a, err
}
n := 0
switch head[0] {
case 1:
n = 4
case 4:
n = 16
case 3:
length := make([]byte, 1)
if _, err := io.ReadFull(r, length); err != nil {
return a, err
}
if length[0] == 0 {
return a, errors.New("empty SOCKS hostname")
}
head = append(head, length[0])
n = int(length[0])
default:
return a, errSocksAddress
}
body := make([]byte, n+2)
if _, err := io.ReadFull(r, body); err != nil {
return a, err
}
if head[0] == 3 {
a.host = string(body[:n])
} else {
a.host = net.IP(body[:n]).String()
}
a.port = binary.BigEndian.Uint16(body[n:])
a.wire = append(head, body...)
return a, nil
}
func (a socksAddress) destination() string {
return net.JoinHostPort(a.host, fmt.Sprint(a.port))
}
func socksIPPort(ip net.IP, port int) []byte {
atyp := byte(4)
if v4 := ip.To4(); v4 != nil {
ip, atyp = v4, 1
} else {
ip = ip.To16()
}
b := append([]byte{atyp}, ip...)
return binary.BigEndian.AppendUint16(b, uint16(port))
}
func socksReply(w io.Writer, code byte, addr []byte) error {
if addr == nil {
addr = []byte{1, 0, 0, 0, 0, 0, 0}
}
return writeSocksBytes(w, append([]byte{5, code, 0}, addr...))
}
func parseSocksUDP(b []byte) (socksAddress, []byte, error) {
if len(b) < 4 || b[0] != 0 || b[1] != 0 || b[2] != 0 {
return socksAddress{}, nil, errors.New("invalid or fragmented SOCKS UDP packet")
}
a, err := readSocksAddress(bytes.NewReader(b[3:]))
if err != nil {
return a, nil, err
}
if a.port == 0 {
return a, nil, errors.New("zero SOCKS UDP destination port")
}
return a, b[3+len(a.wire):], nil
}
func socksUDPTooLarge(n, limit int, err error) bool {
// Unix truncates UDP reads; Windows may return WSAEMSGSIZE (10040)
// instead. Treat both as dropped datagrams, not listener/flow failures.
return (err == nil && n > limit) || errors.Is(err, syscall.EMSGSIZE) ||
(runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(10040)))
}
func writeSocksBytes(w io.Writer, b []byte) error {
for len(b) > 0 {
n, err := w.Write(b)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
b = b[n:]
}
return nil
}
type socksUDPStream struct {
io.ReadWriteCloser
max int
mu sync.Mutex // IPv4 and IPv6 readers share the reply stream
}
func (s *socksUDPStream) readPacket() ([]byte, error) {
var header [4]byte
if _, err := io.ReadFull(s, header[:]); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(header[:])
if n == 0 || uint64(n) > uint64(s.max) {
return nil, errors.New("invalid SOCKS UDP frame size")
}
b := make([]byte, int(n))
_, err := io.ReadFull(s, b)
return b, err
}
func (s *socksUDPStream) writePacket(b []byte) error {
if len(b) == 0 || len(b) > s.max {
return errors.New("invalid SOCKS UDP frame size")
}
s.mu.Lock()
defer s.mu.Unlock()
var header [4]byte
binary.BigEndian.PutUint32(header[:], uint32(len(b)))
if err := writeSocksBytes(s, header[:]); err != nil {
return err
}
return writeSocksBytes(s, b)
}
func socksUDPLimits() (size, conns int, deadline time.Duration) {
size = settings.EnvInt("UDP_MAX_SIZE", 9012)
if size <= 0 {
size = 9012
}
if size > 65507 {
size = 65507
}
conns = settings.EnvInt("UDP_MAX_CONNS", 100)
if conns <= 0 {
conns = 100
}
deadline = settings.EnvDuration("UDP_DEADLINE", 15*time.Second)
if deadline <= 0 {
deadline = 15 * time.Second
}
return
}
+352
View File
@@ -0,0 +1,352 @@
package tunnel
import (
"context"
"errors"
"io"
"net"
"net/netip"
"sync"
"time"
"github.com/jpillora/chisel/share/cio"
"github.com/jpillora/chisel/share/settings"
"golang.org/x/crypto/ssh"
)
// socksProxy owns one UDP socket per TCP listener. Associations are isolated
// by observed UDP endpoints, never by addresses supplied by the exit node.
type socksProxy struct {
proxy *Proxy
udp *net.UDPConn
maxSize, maxConns int
mu sync.Mutex
associations map[*socksAssociation]bool
peers map[netip.AddrPort]*socksAssociation
pending map[netip.Addr]*socksAssociation
workers sync.WaitGroup
}
type socksAssociation struct {
ip netip.Addr
key netip.AddrPort
peer *net.UDPAddr
packets chan []byte
stream *socksUDPStream
}
func newSocksProxy(p *Proxy) *socksProxy {
size, conns, _ := socksUDPLimits()
s := &socksProxy{
proxy: p, maxSize: size, maxConns: conns,
associations: make(map[*socksAssociation]bool),
peers: make(map[netip.AddrPort]*socksAssociation),
pending: make(map[netip.Addr]*socksAssociation),
}
addr := p.tcp.Addr().(*net.TCPAddr)
var err error
s.udp, err = net.ListenUDP("udp", &net.UDPAddr{IP: addr.IP, Port: addr.Port, Zone: addr.Zone})
if err != nil {
p.Infof("SOCKS UDP unavailable on %s: %s (TCP remains available)", addr, err)
} else {
p.Infof("Listening for SOCKS UDP on %s", s.udp.LocalAddr())
}
return s
}
func (s *socksProxy) run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
stop := context.AfterFunc(ctx, func() { s.proxy.Close() })
var udpWorkers sync.WaitGroup
udpError := make(chan error, 1)
if s.udp != nil {
udpWorkers.Add(1)
go func() {
defer udpWorkers.Done()
if err := s.readUDP(); err != nil && ctx.Err() == nil {
s.proxy.Infof("SOCKS UDP read error: %s", err)
udpError <- err
cancel()
}
}()
}
err := s.proxy.runTCP(ctx)
cancel()
stop()
s.proxy.Close()
udpWorkers.Wait()
s.workers.Wait()
if err == nil {
select {
case err = <-udpError:
default:
}
}
return err
}
func socksPeerIP(ip net.IP, zone string) netip.Addr {
a, _ := netip.AddrFromSlice(ip)
a = a.Unmap()
if zone != "" && a.Is6() {
a = a.WithZone(zone)
}
return a
}
func (s *socksProxy) readUDP() error {
// The extra byte detects oversize packets even when ReadFromUDP truncates.
buf := make([]byte, s.maxSize+1)
for {
n, peer, err := s.udp.ReadFromUDP(buf)
if socksUDPTooLarge(n, s.maxSize, err) {
continue
}
if err != nil {
return err
}
if _, _, err := parseSocksUDP(buf[:n]); err != nil {
continue
}
ip := socksPeerIP(peer.IP, peer.Zone)
key := netip.AddrPortFrom(ip, uint16(peer.Port))
s.mu.Lock()
a := s.peers[key]
if a == nil {
a = s.pending[ip]
if a != nil {
delete(s.pending, ip)
a.key = key
s.peers[key] = a
}
}
if a != nil {
if a.peer == nil {
// ReadFromUDP's address is local evidence; retain our own copy.
copyPeer := *peer
copyPeer.IP = append(net.IP(nil), peer.IP...)
a.peer = &copyPeer
}
select {
case a.packets <- append([]byte(nil), buf[:n]...):
default: // a stalled association cannot block this shared listener
}
}
s.mu.Unlock()
}
}
func (s *socksProxy) register(a *socksAssociation, port uint16) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.associations) >= s.maxConns {
return false
}
if port == 0 {
if s.pending[a.ip] != nil {
return false
}
s.pending[a.ip] = a
} else {
a.key = netip.AddrPortFrom(a.ip, port)
if s.peers[a.key] != nil {
return false
}
s.peers[a.key] = a
}
s.associations[a] = true
return true
}
func (s *socksProxy) unregister(a *socksAssociation) {
s.mu.Lock()
delete(s.associations, a)
if s.pending[a.ip] == a {
delete(s.pending, a.ip)
}
if s.peers[a.key] == a {
delete(s.peers, a.key)
}
s.mu.Unlock()
}
func (s *socksProxy) serve(ctx context.Context, src *net.TCPConn) {
defer s.workers.Done()
defer src.Close()
stop := context.AfterFunc(ctx, func() { src.Close() })
defer stop()
// Bound unfinished handshakes without imposing a TCP control idle timeout.
src.SetDeadline(time.Now().Add(settings.EnvDuration("DIAL_TIMEOUT", 30*time.Second)))
if err := s.handle(ctx, src); err != nil && ctx.Err() == nil {
s.proxy.Debugf("SOCKS connection: %s", err)
}
}
func (s *socksProxy) handle(ctx context.Context, src *net.TCPConn) error {
var greeting [2]byte
if _, err := io.ReadFull(src, greeting[:]); err != nil {
return err
}
if greeting[0] != 5 || greeting[1] == 0 {
return errors.New("invalid SOCKS greeting")
}
methods := make([]byte, int(greeting[1]))
if _, err := io.ReadFull(src, methods); err != nil {
return err
}
noAuth := false
for _, method := range methods {
noAuth = noAuth || method == 0
}
if !noAuth {
writeSocksBytes(src, []byte{5, 255})
return errors.New("no supported SOCKS authentication method")
}
if err := writeSocksBytes(src, []byte{5, 0}); err != nil {
return err
}
var header [3]byte
if _, err := io.ReadFull(src, header[:]); err != nil {
return err
}
if header[0] != 5 || header[2] != 0 {
socksReply(src, socksFailure, nil)
return errors.New("invalid SOCKS request")
}
addr, err := readSocksAddress(src)
if err != nil {
code := byte(socksFailure)
if errors.Is(err, errSocksAddress) {
code = socksBadAddress
}
socksReply(src, code, nil)
return err
}
if header[1] != 1 && header[1] != 3 {
return socksReply(src, socksUnsupported, nil)
}
if header[1] == 3 && s.udp == nil {
return socksReply(src, socksFailure, nil)
}
conn := s.proxy.sshTun.getSSH(ctx)
if conn == nil {
return socksReply(src, socksFailure, nil)
}
destination := "socks"
if header[1] == 3 {
destination = socksUDPDestination
}
ch, reqs, err := conn.OpenChannel("chisel", []byte(destination))
if err != nil {
code := byte(socksFailure)
var channelErr *ssh.OpenChannelError
if errors.As(err, &channelErr) && channelErr.Reason == ssh.Prohibited {
code = socksDenied
} else if header[1] == 3 && errors.As(err, &channelErr) && channelErr.Reason == ssh.ConnectionFailed {
code = socksUnsupported
}
if header[1] == 3 {
s.proxy.Infof("SOCKS UDP channel unavailable: %s (UDP requires support on both Chisel peers)", err)
} else {
s.proxy.Infof("SOCKS TCP channel unavailable: %s", err)
}
socksReply(src, code, nil)
return err
}
defer ch.Close()
go ssh.DiscardRequests(reqs)
stop := context.AfterFunc(ctx, func() { ch.Close() })
defer stop()
if header[1] == 1 {
// Replay a no-auth greeting on the unchanged legacy TCP SOCKS channel.
if err := writeSocksBytes(ch, []byte{5, 1, 0}); err != nil {
return err
}
var reply [2]byte
if _, err := io.ReadFull(ch, reply[:]); err != nil {
return err
}
if reply != [2]byte{5, 0} {
return errors.New("unexpected exit SOCKS authentication reply")
}
if err := writeSocksBytes(ch, append(header[:], addr.wire...)); err != nil {
return err
}
src.SetDeadline(time.Time{})
cio.Pipe(src, ch)
return nil
}
// An old exit may accept the channel then fail its TCP dial. It must
// never cause a successful UDP reply, even briefly. Bound a silent exit.
timer := time.AfterFunc(settings.EnvDuration("DIAL_TIMEOUT", 30*time.Second), func() { ch.Close() })
ready := make([]byte, len(socksUDPReady))
_, err = io.ReadFull(ch, ready)
timedOut := !timer.Stop()
if err != nil || timedOut || string(ready) != socksUDPReady {
s.proxy.Infof("SOCKS UDP unavailable: exit did not confirm support (upgrade both Chisel peers)")
return socksReply(src, socksUnsupported, nil)
}
return s.associate(ctx, src, ch, addr.port)
}
func (s *socksProxy) associate(ctx context.Context, src *net.TCPConn, ch ssh.Channel, port uint16) error {
peer := src.RemoteAddr().(*net.TCPAddr)
a := &socksAssociation{
ip: socksPeerIP(peer.IP, peer.Zone), packets: make(chan []byte, 16),
stream: &socksUDPStream{ReadWriteCloser: ch, max: s.maxSize},
}
if !s.register(a, port) {
return socksReply(src, socksFailure, nil)
}
defer s.unregister(a)
local := src.LocalAddr().(*net.TCPAddr)
relay := s.udp.LocalAddr().(*net.UDPAddr)
if err := socksReply(src, socksSuccess, socksIPPort(local.IP, relay.Port)); err != nil {
return err
}
src.SetDeadline(time.Time{})
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
finish := func() { cancel(); ch.Close(); src.Close() }
// All workers unblock on control EOF, channel EOF, or proxy cancellation.
wg.Add(2)
go func() {
defer wg.Done()
defer finish()
for {
select {
case <-ctx.Done():
return
case b := <-a.packets:
if err := a.stream.writePacket(b); err != nil {
return
}
}
}
}()
go func() {
defer wg.Done()
defer finish()
for {
b, err := a.stream.readPacket()
if err != nil {
return
}
if _, _, err := parseSocksUDP(b); err != nil {
continue
}
s.mu.Lock()
// Never resolve or route using the exit's packet address: it is
// the responder encoded in the SOCKS header, not the local peer.
if s.associations[a] && a.peer != nil && ctx.Err() == nil {
s.udp.WriteToUDP(b, a.peer)
}
s.mu.Unlock()
}
}()
io.Copy(io.Discard, src)
finish()
wg.Wait()
return nil
}
+547
View File
@@ -0,0 +1,547 @@
package tunnel
import (
"bytes"
"context"
"encoding/binary"
"io"
"net"
"net/netip"
"strconv"
"sync"
"testing"
"time"
"github.com/armon/go-socks5"
"github.com/jpillora/chisel/share/cio"
"github.com/jpillora/chisel/share/settings"
"golang.org/x/crypto/ssh"
)
type socksTestRWC struct {
io.Reader
io.Writer
}
func (s socksTestRWC) Close() error { return nil }
func TestSocksUDPCodec(t *testing.T) {
addresses := [][]byte{
socksIPPort(net.ParseIP("127.0.0.1"), 53),
socksIPPort(net.ParseIP("::1"), 53),
append([]byte{3, 9}, append([]byte("localhost"), 0, 53)...),
}
for _, address := range addresses {
packet := append(append([]byte{0, 0, 0}, address...), []byte("data")...)
a, payload, err := parseSocksUDP(packet)
if err != nil || a.port != 53 || string(payload) != "data" || !bytes.Equal(a.wire, address) {
t.Fatalf("decode %v: %v %q %v", packet, a, payload, err)
}
for n := 0; n < 3+len(address); n++ {
if _, _, err := parseSocksUDP(packet[:n]); err == nil {
t.Fatalf("accepted truncated packet of length %d", n)
}
}
}
for _, b := range [][]byte{
{0, 0, 0, 2}, {0, 0, 0, 3, 0, 0, 53},
{0, 0, 1, 1, 127, 0, 0, 1, 0, 53},
{1, 0, 0, 1, 127, 0, 0, 1, 0, 53},
{0, 0, 0, 1, 127, 0, 0, 1, 0, 0},
} {
if _, _, err := parseSocksUDP(b); err == nil {
t.Fatalf("accepted malformed packet %v", b)
}
}
}
func FuzzSocksUDPCodec(f *testing.F) {
f.Add([]byte{0, 0, 0, 1, 127, 0, 0, 1, 0, 53, 'x'})
f.Add([]byte{0, 0, 0, 3, 1, 'a', 0, 53})
f.Fuzz(func(t *testing.T, b []byte) {
a, payload, err := parseSocksUDP(b)
if err == nil && (a.port == 0 || len(a.wire)+3+len(payload) != len(b)) {
t.Fatal("invalid decoded packet")
}
})
}
func TestSocksUDPFrames(t *testing.T) {
// A tiny packet limit can be smaller than an IPv6 reply header. Even
// then, closed sockets and read deadlines must terminate the reader.
if socksUDPTooLarge(0, -1, net.ErrClosed) {
t.Fatal("small packet limit hid a closed socket")
}
for _, size := range []uint32{0, 65, 0xffffffff} {
buf := binary.BigEndian.AppendUint32(nil, size)
s := &socksUDPStream{ReadWriteCloser: socksTestRWC{Reader: bytes.NewReader(buf)}, max: 64}
if _, err := s.readPacket(); err == nil {
t.Fatalf("accepted frame size %d", size)
}
}
var buf bytes.Buffer
s := &socksUDPStream{ReadWriteCloser: socksTestRWC{Reader: &buf, Writer: &buf}, max: 64}
if err := s.writePacket([]byte("one")); err != nil {
t.Fatal(err)
}
if err := s.writePacket([]byte("two")); err != nil {
t.Fatal(err)
}
for _, want := range []string{"one", "two"} {
b, err := s.readPacket()
if err != nil || string(b) != want {
t.Fatalf("read %q, want %q: %v", b, want, err)
}
}
if err := s.writePacket(make([]byte, 65)); err == nil {
t.Fatal("wrote oversized frame")
}
}
func TestSocksUDPLimitSettings(t *testing.T) {
for _, test := range []struct {
name, size, conns, idle string
wantSize, wantConns int
wantIdle time.Duration
}{
{"defaults", "", "", "", 9012, 100, 15 * time.Second},
{"configured", "64", "2", "250ms", 64, 2, 250 * time.Millisecond},
{"zero", "0", "0", "0s", 9012, 100, 15 * time.Second},
{"negative", "-1", "-1", "-1s", 9012, 100, 15 * time.Second},
{"invalid", "invalid", "invalid", "invalid", 9012, 100, 15 * time.Second},
{"size cap", "70000", "2", "1s", 65507, 2, time.Second},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("CHISEL_UDP_MAX_SIZE", test.size)
t.Setenv("CHISEL_UDP_MAX_CONNS", test.conns)
t.Setenv("CHISEL_UDP_DEADLINE", test.idle)
size, conns, idle := socksUDPLimits()
if size != test.wantSize || conns != test.wantConns || idle != test.wantIdle {
t.Fatalf("limits=(%d,%d,%s), want (%d,%d,%s)", size, conns, idle, test.wantSize, test.wantConns, test.wantIdle)
}
})
}
}
func FuzzSocksUDPFrames(f *testing.F) {
f.Add([]byte{0, 0, 0, 1, 'x'})
f.Add([]byte{255, 255, 255, 255})
f.Fuzz(func(t *testing.T, b []byte) {
s := &socksUDPStream{ReadWriteCloser: socksTestRWC{Reader: bytes.NewReader(b)}, max: 9012}
packet, err := s.readPacket()
if err == nil && (len(packet) == 0 || len(packet) > 9012 || len(packet)+4 > len(b)) {
t.Fatal("invalid frame decoded")
}
})
}
func TestSocksUDPSharedRoutingAndOverflow(t *testing.T) {
u, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer u.Close()
s := &socksProxy{udp: u, maxSize: 100, maxConns: 2,
associations: make(map[*socksAssociation]bool), peers: make(map[netip.AddrPort]*socksAssociation), pending: make(map[netip.Addr]*socksAssociation)}
ip := netip.MustParseAddr("127.0.0.1")
a := &socksAssociation{ip: ip, packets: make(chan []byte, 16)}
b := &socksAssociation{ip: ip, packets: make(chan []byte, 16)}
if !s.register(a, 0) || s.register(b, 0) {
t.Fatal("pending association policy failed")
}
peer, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer peer.Close()
readDone := make(chan error, 1)
go func() { readDone <- s.readUDP() }()
packet := append([]byte{0, 0, 0}, socksIPPort(net.IPv4(127, 0, 0, 1), 53)...)
t.Run("different source IP", func(t *testing.T) {
otherIP, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 2)})
if err != nil {
t.Skipf("alternate loopback unavailable: %v", err)
}
defer otherIP.Close()
otherIP.WriteToUDP(packet, u.LocalAddr().(*net.UDPAddr))
select {
case <-a.packets:
t.Fatal("different IP claimed a pending association")
case <-time.After(20 * time.Millisecond):
}
})
peer.WriteToUDP(packet, u.LocalAddr().(*net.UDPAddr))
select {
case <-a.packets:
case <-time.After(time.Second):
t.Fatal("first packet did not pin")
}
if !s.register(b, 0) {
t.Fatal("pinned association still blocks pending association")
}
if s.register(&socksAssociation{ip: ip}, 12345) {
t.Fatal("association limit bypassed")
}
// Fill A's queue, then send enough packets to overflow it. B must still
// receive its own packet, proving the shared reader does not block on A.
for i := 0; i < cap(a.packets); i++ {
a.packets <- packet
}
for i := 0; i < 20; i++ {
peer.WriteToUDP(packet, u.LocalAddr().(*net.UDPAddr))
}
other, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer other.Close()
other.WriteToUDP(packet, u.LocalAddr().(*net.UDPAddr))
select {
case <-b.packets:
case <-time.After(time.Second):
t.Fatal("full queue blocked unrelated peer")
}
s.unregister(a)
s.unregister(b)
if !s.register(a, uint16(peer.LocalAddr().(*net.UDPAddr).Port)) {
t.Fatal("closed association retained reservation")
}
u.Close()
select {
case <-readDone:
case <-time.After(time.Second):
t.Fatal("UDP reader did not stop")
}
}
type socksTestSSH struct {
*mockSSHConn
open func(string, []byte) (ssh.Channel, <-chan *ssh.Request, error)
}
func (s *socksTestSSH) OpenChannel(kind string, extra []byte) (ssh.Channel, <-chan *ssh.Request, error) {
return s.open(kind, extra)
}
type socksTestTunnel struct{ conn ssh.Conn }
func (s socksTestTunnel) getSSH(context.Context) ssh.Conn { return s.conn }
type socksTestChannel struct{ net.Conn }
func (s socksTestChannel) CloseWrite() error { return s.Close() }
func (s socksTestChannel) SendRequest(string, bool, []byte) (bool, error) { return false, nil }
func (s socksTestChannel) Stderr() io.ReadWriter { return s.Conn }
func newSocksTestProxy(t *testing.T, conn ssh.Conn) *Proxy {
t.Helper()
// Reserve a port in the test; udpsocks itself must never bind port zero.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port)
listener.Close()
p, err := NewProxy(cio.NewLogger("test"), socksTestTunnel{conn}, 0, &settings.Remote{LocalHost: "127.0.0.1", LocalPort: port, LocalProto: "tcp", Socks: true, UDPSocks: true})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- p.Run(ctx) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(3 * time.Second):
t.Error("SOCKS proxy did not stop")
}
})
return p
}
func TestUDPSocksRejectsEphemeralListener(t *testing.T) {
r := &settings.Remote{LocalHost: "127.0.0.1", LocalPort: "0", LocalProto: "tcp", Socks: true, UDPSocks: true}
p, err := NewProxy(cio.NewLogger("test"), socksTestTunnel{}, 0, r)
if p != nil {
defer p.Close()
}
if err == nil {
t.Fatal("udpsocks accepted an ephemeral listening port")
}
}
func TestSocksLegacyPeerCompatibility(t *testing.T) {
legacy, err := socks5.New(&socks5.Config{Logger: nil})
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
conn := &socksTestSSH{mockSSHConn: newMockSSHConn(false)}
conn.open = func(_ string, extra []byte) (ssh.Channel, <-chan *ssh.Request, error) {
if string(extra) != "socks" {
// Old Chisel treats unknown destinations as TCP host:port and
// rejects this token because it lacks a port.
return nil, nil, &ssh.OpenChannelError{Reason: ssh.ConnectionFailed, Message: "missing port in address"}
}
local, remote := net.Pipe()
wg.Add(1)
go func() { defer wg.Done(); legacy.ServeConn(remote) }()
reqs := make(chan *ssh.Request)
close(reqs)
return socksTestChannel{local}, reqs, nil
}
p := newSocksTestProxy(t, conn)
endpoint, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer endpoint.Close()
go func() {
c, err := endpoint.Accept()
if err == nil {
c.Write([]byte("legacy TCP"))
c.Close()
}
}()
for _, command := range []byte{1, 3} {
c, err := net.Dial("tcp", p.tcp.Addr().String())
if err != nil {
t.Fatal(err)
}
c.SetDeadline(time.Now().Add(time.Second))
c.Write([]byte{5, 1, 0})
greeting := make([]byte, 2)
if _, err := io.ReadFull(c, greeting); err != nil {
t.Fatal(err)
}
a := endpoint.Addr().(*net.TCPAddr)
c.Write(append([]byte{5, command, 0}, socksIPPort(a.IP, a.Port)...))
header := make([]byte, 3)
if _, err := io.ReadFull(c, header); err != nil {
t.Fatal(err)
}
if _, err := readSocksAddress(c); err != nil {
t.Fatal(err)
}
if command == 1 {
b := make([]byte, len("legacy TCP"))
if _, err := io.ReadFull(c, b); err != nil || string(b) != "legacy TCP" || header[1] != socksSuccess {
t.Fatalf("legacy TCP failed: %q %v %v", b, header, err)
}
} else if header[1] != socksUnsupported {
t.Fatalf("legacy UDP reply %d, want unsupported", header[1])
}
c.Close()
}
wg.Wait()
}
func TestSocksLegacyPeerAcceptsBeforeDial(t *testing.T) {
conn := &socksTestSSH{mockSSHConn: newMockSSHConn(false)}
conn.open = func(_ string, _ []byte) (ssh.Channel, <-chan *ssh.Request, error) {
local, remote := net.Pipe()
// Pre-1.12 Chisel accepted channels before dialing. An unknown
// destination then failed the TCP dial and closed the accepted stream.
remote.Close()
reqs := make(chan *ssh.Request)
close(reqs)
return socksTestChannel{local}, reqs, nil
}
p := newSocksTestProxy(t, conn)
c, err := net.Dial("tcp", p.tcp.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(time.Second))
c.Write([]byte{5, 1, 0})
var greeting [2]byte
if _, err := io.ReadFull(c, greeting[:]); err != nil {
t.Fatal(err)
}
c.Write(append([]byte{5, 3, 0}, socksIPPort(net.IPv4zero, 0)...))
var reply [3]byte
if _, err := io.ReadFull(c, reply[:]); err != nil || reply[1] != socksUnsupported {
t.Fatalf("old peer produced reply %v: %v", reply, err)
}
p.socks.mu.Lock()
defer p.socks.mu.Unlock()
if len(p.socks.associations) != 0 {
t.Fatal("unsupported peer retained an association")
}
}
func TestSocksUDPExitLimitsAndRecovery(t *testing.T) {
t.Setenv("CHISEL_UDP_MAX_CONNS", "1")
t.Setenv("CHISEL_UDP_DEADLINE", "50ms")
echo := func() *net.UDPConn {
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
go func() {
b := make([]byte, 100)
for {
n, a, err := c.ReadFromUDP(b)
if err != nil {
return
}
c.WriteToUDP(b[:n], a)
}
}()
return c
}
one, two := echo(), echo()
client, server := net.Pipe()
defer client.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tun := New(Config{Logger: cio.NewLogger("test")})
done := make(chan error, 1)
go func() { done <- tun.handleSocksUDP(ctx, tun.Logger, server) }()
s := &socksUDPStream{ReadWriteCloser: client, max: 9012}
packet := func(c *net.UDPConn) []byte {
a := c.LocalAddr().(*net.UDPAddr)
return append(append([]byte{0, 0, 0}, socksIPPort(a.IP, a.Port)...), 'x')
}
client.SetDeadline(time.Now().Add(time.Second))
if err := s.writePacket(packet(one)); err != nil {
t.Fatal(err)
}
if _, err := s.readPacket(); err != nil {
t.Fatal(err)
}
if err := s.writePacket(packet(two)); err != nil {
t.Fatal(err)
}
client.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
if _, err := s.readPacket(); err == nil {
t.Fatal("flow cap did not drop new destination")
}
// The flow cap must recover once the existing destination goes idle.
time.Sleep(100 * time.Millisecond)
client.SetDeadline(time.Now().Add(time.Second))
if err := s.writePacket(packet(two)); err != nil {
t.Fatal(err)
}
if _, err := s.readPacket(); err != nil {
t.Fatalf("idle flow retained capacity: %v", err)
}
// Close immediately while a reply reader exists: cancellation must join it.
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("exit retained workers on cancellation")
}
}
func TestSocksUDPReturnsOnlyToObservedAssociation(t *testing.T) {
conn := &socksTestSSH{mockSSHConn: newMockSSHConn(false)}
remoteChannels := make(chan net.Conn, 1)
conn.open = func(_ string, extra []byte) (ssh.Channel, <-chan *ssh.Request, error) {
if string(extra) != socksUDPDestination {
t.Errorf("unexpected destination %q", extra)
}
local, remote := net.Pipe()
remoteChannels <- remote
go writeSocksBytes(remote, []byte(socksUDPReady))
reqs := make(chan *ssh.Request)
close(reqs)
return socksTestChannel{local}, reqs, nil
}
p := newSocksTestProxy(t, conn)
udpClient := func() *net.UDPConn {
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
return c
}
allowed, blocked := udpClient(), udpClient()
control, err := net.Dial("tcp", p.tcp.Addr().String())
if err != nil {
t.Fatal(err)
}
defer control.Close()
control.SetDeadline(time.Now().Add(time.Second))
control.Write([]byte{5, 1, 0})
var greeting [2]byte
if _, err := io.ReadFull(control, greeting[:]); err != nil {
t.Fatal(err)
}
port := allowed.LocalAddr().(*net.UDPAddr).Port
control.Write(append([]byte{5, 3, 0}, socksIPPort(net.IPv4zero, port)...))
var reply [3]byte
if _, err := io.ReadFull(control, reply[:]); err != nil || reply[1] != 0 {
t.Fatalf("association failed: %v %v", reply, err)
}
if _, err := readSocksAddress(control); err != nil {
t.Fatal(err)
}
remote := <-remoteChannels
defer remote.Close()
remote.SetDeadline(time.Now().Add(time.Second))
s := &socksUDPStream{ReadWriteCloser: remote, max: 9012}
blockedAddr := blocked.LocalAddr().(*net.UDPAddr)
packet := append(append([]byte{0, 0, 0}, socksIPPort(blockedAddr.IP, blockedAddr.Port)...), []byte("response")...)
// A declared port alone is insufficient evidence for returning a packet.
if err := s.writePacket(packet); err != nil {
t.Fatal(err)
}
for _, c := range []*net.UDPConn{allowed, blocked} {
c.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
if _, _, err := c.ReadFromUDP(make([]byte, 100)); err == nil {
t.Fatal("unobserved endpoint received a return packet")
}
}
allowed.WriteToUDP(packet, p.socks.udp.LocalAddr().(*net.UDPAddr))
if _, err := s.readPacket(); err != nil {
t.Fatal(err)
}
if err := s.writePacket(packet); err != nil {
t.Fatal(err)
}
allowed.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 100)
n, _, err := allowed.ReadFromUDP(buf)
if err != nil || !bytes.Equal(buf[:n], packet) {
t.Fatalf("observed endpoint did not receive reply: %v", err)
}
blocked.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
if _, _, err := blocked.ReadFromUDP(buf); err == nil {
t.Fatal("exit-controlled address received a return packet")
}
control.Close()
if _, err := remote.Read(make([]byte, 1)); err != io.EOF {
t.Fatalf("control EOF did not close SSH channel: %v", err)
}
}
func TestSocksUDPInvalidFrameClosesAssociation(t *testing.T) {
for _, size := range []uint32{0, 9013, 0xffffffff} {
client, server := net.Pipe()
tun := New(Config{Logger: cio.NewLogger("test")})
done := make(chan error, 1)
go func() { done <- tun.handleSocksUDP(context.Background(), tun.Logger, server) }()
client.SetDeadline(time.Now().Add(time.Second))
if _, err := client.Write(binary.BigEndian.AppendUint32(nil, size)); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err == nil {
t.Fatal("invalid frame succeeded")
}
case <-time.After(time.Second):
t.Fatal("invalid frame retained association")
}
client.Close()
}
}
+200
View File
@@ -0,0 +1,200 @@
package tunnel
import (
"context"
"io"
"net"
"sync"
"time"
"github.com/jpillora/chisel/share/cio"
"github.com/jpillora/chisel/share/settings"
)
type socksUDPFlow struct {
addr *net.UDPAddr
lastSeen time.Time // protected by the handler mutex
}
type socksUDPHandler struct {
stream *socksUDPStream
logger *cio.Logger
ctx context.Context
maxConns int
deadline time.Duration
mu sync.Mutex
flows map[string]*socksUDPFlow
sockets map[string]*net.UDPConn // one unconnected socket per address family
workers sync.WaitGroup
}
func (t *Tunnel) handleSocksUDP(ctx context.Context, logger *cio.Logger, stream io.ReadWriteCloser) error {
size, conns, deadline := socksUDPLimits()
ctx, cancel := context.WithCancel(ctx)
h := &socksUDPHandler{
stream: &socksUDPStream{ReadWriteCloser: stream, max: size},
logger: logger, ctx: ctx, maxConns: conns, deadline: deadline,
flows: make(map[string]*socksUDPFlow), sockets: make(map[string]*net.UDPConn),
}
stop := context.AfterFunc(ctx, func() { stream.Close() })
defer func() {
cancel()
stop()
stream.Close()
h.mu.Lock()
for _, conn := range h.sockets {
conn.Close()
}
h.mu.Unlock()
h.workers.Wait()
}()
for {
b, err := h.stream.readPacket()
if err != nil {
return err
}
addr, payload, err := parseSocksUDP(b)
if err != nil {
continue // malformed datagrams do not end a valid association
}
h.write(addr, payload)
}
}
// Expiration bounds destination state without closing the association's
// sockets: protocols such as TFTP must retain their source port even when
// the remote endpoint changes. Caller holds h.mu.
func (h *socksUDPHandler) expire(now time.Time) {
for key, f := range h.flows {
if now.Sub(f.lastSeen) >= h.deadline {
delete(h.flows, key)
}
}
}
func (h *socksUDPHandler) resolve(addr socksAddress) (*net.UDPAddr, error) {
ip := net.ParseIP(addr.host)
zone := ""
if ip == nil {
ctx, cancel := context.WithTimeout(h.ctx, settings.EnvDuration("DIAL_TIMEOUT", 30*time.Second))
defer cancel()
ips, err := net.DefaultResolver.LookupIPAddr(ctx, addr.host)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, &net.DNSError{Err: "no addresses", Name: addr.host, IsNotFound: true}
}
ip, zone = ips[0].IP, ips[0].Zone
}
return &net.UDPAddr{IP: ip, Port: int(addr.port), Zone: zone}, nil
}
func (h *socksUDPHandler) write(addr socksAddress, payload []byte) {
key := addr.destination()
h.mu.Lock()
h.expire(time.Now())
f := h.flows[key]
full := len(h.flows) >= h.maxConns
h.mu.Unlock()
if f == nil {
if full {
return // hard cap: no unbounded destination state
}
resolved, err := h.resolve(addr)
if err != nil {
h.logger.Debugf("SOCKS UDP resolve %s: %s", key, err)
return
}
f = &socksUDPFlow{addr: resolved}
}
network := "udp6"
if f.addr.IP.To4() != nil {
network = "udp4"
}
h.mu.Lock()
conn := h.sockets[network]
if conn == nil {
var err error
conn, err = net.ListenUDP(network, nil)
if err != nil {
h.mu.Unlock()
h.logger.Debugf("SOCKS UDP listen %s: %s", network, err)
return
}
h.sockets[network] = conn
h.workers.Add(1)
go h.readReplies(network, conn)
}
f.lastSeen = time.Now()
h.flows[key] = f
h.mu.Unlock()
conn.SetWriteDeadline(time.Now().Add(h.deadline))
if _, err := conn.WriteToUDP(payload, f.addr); err != nil {
h.logger.Debugf("SOCKS UDP write %s: %s", key, err)
h.mu.Lock()
delete(h.flows, key)
h.mu.Unlock()
}
}
// Prefer an exact endpoint match when refreshing destination activity. For
// replies from a new port, refresh the latest request to that host without
// allocating additional destination state. Replies from other hosts are also
// relayed but do not refresh destination state. Caller holds h.mu.
func (h *socksUDPHandler) replyFlow(addr *net.UDPAddr) *socksUDPFlow {
var latest *socksUDPFlow
for _, f := range h.flows {
if !f.addr.IP.Equal(addr.IP) || f.addr.Zone != addr.Zone {
continue
}
if f.addr.Port == addr.Port {
return f
}
if latest == nil || f.lastSeen.After(latest.lastSeen) {
latest = f
}
}
return latest
}
func (h *socksUDPHandler) readReplies(network string, conn *net.UDPConn) {
defer h.workers.Done()
defer func() {
h.mu.Lock()
if h.sockets[network] == conn {
delete(h.sockets, network)
}
h.mu.Unlock()
conn.Close()
}()
buf := make([]byte, h.stream.max+1)
for {
n, addr, err := conn.ReadFromUDP(buf)
if socksUDPTooLarge(n, h.stream.max, err) {
continue
}
if err != nil {
return
}
header := append([]byte{0, 0, 0}, socksIPPort(addr.IP, addr.Port)...)
if n > h.stream.max-len(header) {
continue
}
h.mu.Lock()
now := time.Now()
h.expire(now)
f := h.replyFlow(addr)
if f != nil {
f.lastSeen = now
}
h.mu.Unlock()
packet := make([]byte, len(header)+n)
copy(packet, header)
copy(packet[len(header):], buf[:n])
if err := h.stream.writePacket(packet); err != nil {
h.stream.Close() // unblock the association's request reader
return
}
}
}
+31 -10
View File
@@ -12,12 +12,12 @@ import (
"golang.org/x/crypto/ssh"
)
//sshTunnel exposes a subset of Tunnel to subtypes
// sshTunnel exposes a subset of Tunnel to subtypes
type sshTunnel interface {
getSSH(ctx context.Context) ssh.Conn
}
//Proxy is the inbound portion of a Tunnel
// Proxy is the inbound portion of a Tunnel
type Proxy struct {
*cio.Logger
sshTun sshTunnel
@@ -27,10 +27,11 @@ type Proxy struct {
dialer net.Dialer
tcp *net.TCPListener
udp *udpListener
socks *socksProxy
mu sync.Mutex
}
//NewProxy creates a Proxy
// NewProxy creates a Proxy
func NewProxy(logger *cio.Logger, sshTun sshTunnel, index int, remote *settings.Remote) (*Proxy, error) {
id := index + 1
p := &Proxy{
@@ -50,12 +51,18 @@ func (p *Proxy) listen() error {
if err != nil {
return p.Errorf("resolve: %s", err)
}
if p.remote.UDPSocks && addr.Port == 0 {
return p.Errorf("udpsocks requires a nonzero listening port")
}
l, err := net.ListenTCP("tcp", addr)
if err != nil {
return p.Errorf("tcp: %s", err)
}
p.Infof("Listening")
p.tcp = l
if p.remote.UDPSocks {
p.socks = newSocksProxy(p)
}
} else if p.remote.LocalProto == "udp" {
l, err := listenUDP(p.Logger, p.sshTun, p.remote)
if err != nil {
@@ -69,24 +76,33 @@ func (p *Proxy) listen() error {
return nil
}
//Close unbinds the proxy's listeners. proxies which have
//been Run are instead closed by cancelling their context.
// Close unbinds the proxy's listeners. proxies which have
// been Run are instead closed by cancelling their context.
func (p *Proxy) Close() error {
var err error
if p.tcp != nil {
return p.tcp.Close()
err = p.tcp.Close()
}
if p.socks != nil && p.socks.udp != nil {
if e := p.socks.udp.Close(); err == nil {
err = e
}
}
if p.udp != nil {
return p.udp.inbound.Close()
}
return nil
return err
}
//Run enables the proxy and blocks while its active,
//close the proxy by cancelling the context.
// Run enables the proxy and blocks while its active,
// close the proxy by cancelling the context.
func (p *Proxy) Run(ctx context.Context) error {
if p.remote.Stdio {
return p.runStdio(ctx)
} else if p.remote.LocalProto == "tcp" {
if p.socks != nil {
return p.socks.run(ctx)
}
return p.runTCP(ctx)
} else if p.remote.LocalProto == "udp" {
return p.udp.run(ctx)
@@ -130,7 +146,12 @@ func (p *Proxy) runTCP(ctx context.Context) error {
close(done)
return err
}
go p.pipeRemote(ctx, src)
if p.socks != nil {
p.socks.workers.Add(1)
go p.socks.serve(ctx, src.(*net.TCPConn))
} else {
go p.pipeRemote(ctx, src)
}
}
}
+10 -2
View File
@@ -39,10 +39,14 @@ func (t *Tunnel) handleSSHChannel(ctx context.Context, ch ssh.NewChannel) {
return
}
remote := string(ch.ExtraData())
socksUDP := remote == socksUDPDestination
//extract protocol
hostPort, proto := settings.L4Proto(remote)
udp := proto == "udp"
socks := hostPort == "socks"
socks := hostPort == "socks" || socksUDP
if socksUDP {
hostPort = "socks" // UDP associations use the same capability and ACL
}
if socks && t.socksServer == nil {
t.Debugf("Denied socks request, please enable socks")
ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled")
@@ -87,7 +91,11 @@ func (t *Tunnel) handleSSHChannel(ctx context.Context, ch ssh.NewChannel) {
//ready to handle
t.connStats.Open()
l.Debugf("Open %s", t.connStats.String())
if socks {
if socksUDP {
if err = writeSocksBytes(stream, []byte(socksUDPReady)); err == nil {
err = t.handleSocksUDP(ctx, l, stream)
}
} else if socks {
err = t.handleSocks(stream)
} else if udp {
err = t.handleUDP(l, stream, hostPort)
+10 -3
View File
@@ -26,7 +26,7 @@ type testLayout struct {
udpServer bool
}
func (tl *testLayout) setup(t *testing.T) (server *chserver.Server, client *chclient.Client, teardown func()) {
func (tl *testLayout) setup(t testing.TB) (server *chserver.Server, client *chclient.Client, teardown func()) {
//start of the world
// goroutines := runtime.NumGoroutine()
//root cancel
@@ -56,11 +56,15 @@ func (tl *testLayout) setup(t *testing.T) (server *chserver.Server, client *chcl
}()
}
//server
_, benchmarking := t.(*testing.B)
server, err := chserver.NewServer(tl.server)
if err != nil {
t.Fatal(err)
}
server.Debug = debug
server.Debug = debug && !benchmarking
if benchmarking {
server.SetOutput(io.Discard)
}
port := availablePort()
if err := server.StartContext(ctx, "127.0.0.1", port); err != nil {
t.Fatal(err)
@@ -88,7 +92,10 @@ func (tl *testLayout) setup(t *testing.T) (server *chserver.Server, client *chcl
if err != nil {
t.Fatal(err)
}
client.Debug = debug
client.Debug = debug && !benchmarking
if benchmarking {
client.SetOutput(io.Discard)
}
if err := client.Start(ctx); err != nil {
t.Fatal(err)
}
+155
View File
@@ -0,0 +1,155 @@
package e2e_test
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"slices"
"sync"
"testing"
"time"
"github.com/txthinking/socks5"
)
// BenchmarkSocksUDP measures warmed UDP echo round trips over loopback.
// The SOCKS paths use an independent client and the real encrypted tunnel.
// MB/s counts delivered application payload once per echo, not both wire
// directions. Timed-out requests count as loss, with a 100ms client timeout.
func BenchmarkSocksUDP(b *testing.B) {
b.Setenv("CHISEL_UDP_MAX_SIZE", "")
b.Setenv("CHISEL_UDP_MAX_CONNS", "")
b.Setenv("CHISEL_UDP_DEADLINE", "")
for _, path := range []string{"direct", "forward", "reverse"} {
for _, size := range []int{64, 1200, 8192} {
for _, workers := range []int{1, 16} {
b.Run(fmt.Sprintf("%s/bytes=%d/clients=%d", path, size, workers), func(b *testing.B) {
echo := socksUDPEcho(b, "udp4", "127.0.0.1:0")
var addr string
if path != "direct" {
addr, _ = setupSocksUDP(b, path == "reverse", availablePort())
}
payload := bytes.Repeat([]byte{'x'}, size)
conns := make([]net.Conn, workers)
for i := range conns {
var err error
if path == "direct" {
conns[i], err = net.Dial("udp4", echo.String())
} else {
client, e := socks5.NewClient(addr, "", "", 5, 0)
if e != nil {
b.Fatal(e)
}
conns[i], err = client.Dial("udp", echo.String())
}
if err != nil {
b.Fatal(err)
}
c := conns[i]
b.Cleanup(func() { c.Close() })
// Pin each wildcard association before opening the next one.
if err := benchmarkUDPEcho(c, payload, make([]byte, size+64)); err != nil {
b.Fatal(err)
}
}
samples := make([][]int64, workers)
buffers := make([][]byte, workers)
payloads := make([][]byte, workers)
losses := make([]int, workers)
for i := range samples {
samples[i] = make([]int64, 0, 2048)
buffers[i] = make([]byte, size+64)
payloads[i] = bytes.Clone(payload)
}
errors := make(chan error, workers)
var wg sync.WaitGroup
sampleEvery := max(1, b.N/workers/2048)
b.ReportAllocs()
b.ResetTimer()
for worker := range workers {
wg.Add(1)
go func() {
defer wg.Done()
for i, n := worker, 0; i < b.N; i, n = i+workers, n+1 {
binary.BigEndian.PutUint64(payloads[worker][:8], uint64(i))
start := time.Now()
if err := benchmarkUDPMeasured(conns[worker], payloads[worker], buffers[worker]); err != nil {
if e, ok := err.(net.Error); ok && e.Timeout() {
losses[worker]++
continue
}
errors <- err
return
}
if n%sampleEvery == 0 && len(samples[worker]) < cap(samples[worker]) {
samples[worker] = append(samples[worker], time.Since(start).Nanoseconds())
}
}
}()
}
wg.Wait()
b.StopTimer()
close(errors)
for err := range errors {
b.Fatal(err)
}
var all []int64
var lost int
for i, sample := range samples {
all = append(all, sample...)
lost += losses[i]
}
if len(all) == 0 {
b.Fatal("all measured datagrams were lost")
}
slices.Sort(all)
b.ReportMetric(float64(all[len(all)*50/100])/1000, "p50-us")
b.ReportMetric(float64(all[len(all)*95/100])/1000, "p95-us")
b.ReportMetric(float64(b.N-lost)/b.Elapsed().Seconds(), "roundtrips/s")
b.ReportMetric(float64(b.N-lost)*float64(size)/b.Elapsed().Seconds()/1e6, "MB/s")
b.ReportMetric(100*float64(lost)/float64(b.N), "loss-percent")
})
}
}
}
}
func benchmarkUDPMeasured(c net.Conn, payload, buf []byte) error {
if err := c.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
return err
}
if n, err := c.Write(payload); err != nil || n != len(payload) {
return fmt.Errorf("UDP write n=%d: %v", n, err)
}
for {
n, err := c.Read(buf)
if err != nil {
return err
}
if n != len(payload) {
return fmt.Errorf("UDP echo n=%d, want %d", n, len(payload))
}
if !bytes.Equal(buf[:8], payload[:8]) {
continue // late reply to an operation already counted as lost
}
if !bytes.Equal(buf[:n], payload) {
return fmt.Errorf("UDP echo payload corruption")
}
return nil
}
}
func benchmarkUDPEcho(c net.Conn, payload, buf []byte) error {
if err := c.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return err
}
if n, err := c.Write(payload); err != nil || n != len(payload) {
return fmt.Errorf("UDP write n=%d: %v", n, err)
}
n, err := c.Read(buf)
if err != nil || !bytes.Equal(buf[:n], payload) {
return fmt.Errorf("UDP echo n=%d: %v", n, err)
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package e2e_test
import (
"bytes"
"fmt"
"net"
"strconv"
"testing"
"github.com/txthinking/socks5"
)
// Exercise negotiation, UDP ASSOCIATE, and datagram encoding/decoding through
// an independent client, without using our SOCKS wire helpers.
func TestSocksUDPThirdPartyClient(t *testing.T) {
for _, reverse := range []bool{false, true} {
t.Run(fmt.Sprintf("reverse=%t", reverse), func(t *testing.T) {
addr, _ := setupSocksUDP(t, reverse, availablePort())
for _, target := range []struct {
name, network, address, host string
}{
{"IPv4", "udp4", "127.0.0.1:0", "127.0.0.1"},
{"domain", "udp", ":0", "localhost"},
{"IPv6", "udp6", "[::1]:0", "::1"},
} {
t.Run(target.name, func(t *testing.T) {
echo := socksUDPEcho(t, target.network, target.address)
client, err := socks5.NewClient(addr, "", "", 3, 3)
if err != nil {
t.Fatal(err)
}
conn, err := client.Dial("udp", net.JoinHostPort(target.host, strconv.Itoa(echo.Port)))
if err != nil {
t.Fatalf("third-party UDP ASSOCIATE: %v", err)
}
defer conn.Close()
// Inspect the socket the client opened from Chisel's reply.
// Every association must use our configured relay port.
relay := conn.(*socks5.Client).UDPConn.RemoteAddr().String()
if relay != addr {
t.Fatalf("third-party client connected to relay %s, want %s", relay, addr)
}
buf := make([]byte, 65535)
for _, size := range []int{1, 1024, 8192} {
payload := bytes.Repeat([]byte{'x'}, size)
if n, err := conn.Write(payload); err != nil || n != size {
t.Fatalf("third-party UDP write (%d bytes): n=%d err=%v", size, n, err)
}
n, err := conn.Read(buf)
if err != nil || !bytes.Equal(buf[:n], payload) {
t.Fatalf("third-party UDP echo (%d bytes): n=%d err=%v", size, n, err)
}
}
})
}
})
}
}
+230
View File
@@ -0,0 +1,230 @@
package e2e_test
import (
"bytes"
"errors"
"fmt"
"net"
"runtime"
"strconv"
"syscall"
"testing"
"time"
)
func TestSocksUDPAssociationLimit(t *testing.T) {
for _, reverse := range []bool{false, true} {
for _, limit := range []int{2, 100} {
t.Run(fmt.Sprintf("reverse=%t/limit=%d", reverse, limit), func(t *testing.T) {
value := ""
if limit != 100 {
value = strconv.Itoa(limit)
}
t.Setenv("CHISEL_UDP_MAX_CONNS", value)
addr, _ := setupSocksUDP(t, reverse, availablePort())
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
controls := make([]net.Conn, limit)
clients := make([]*net.UDPConn, limit)
var relay *net.UDPAddr
for i := range controls {
clients[i] = socksUDPClient(t, "127.0.0.1")
var code byte
controls[i], relay, code = socksUDPControl(t, addr, clients[i].LocalAddr().(*net.UDPAddr).Port)
if code != 0 || relay.String() != addr {
t.Fatalf("association %d/%d code=%d relay=%s", i+1, limit, code, relay)
}
}
extra := socksUDPClient(t, "127.0.0.1")
_, _, code := socksUDPControl(t, addr, extra.LocalAddr().(*net.UDPAddr).Port)
if code != 1 {
t.Fatalf("association %d exceeded cap with reply %d", limit+1, code)
}
// Exhaustion must preserve existing UDP and ordinary CONNECT.
socksUDPRoundTrip(t, clients[0], relay, echo.IP.String(), echo.Port, "at capacity")
socksTCPRoundTrip(t, addr)
controls[0].Close()
deadline := time.Now().Add(3 * time.Second)
for {
c, nextRelay, code := socksUDPControl(t, addr, extra.LocalAddr().(*net.UDPAddr).Port)
if code == 0 {
socksUDPRoundTrip(t, extra, nextRelay, echo.IP.String(), echo.Port, "recovered capacity")
c.Close()
break
}
c.Close()
if code != 1 || time.Now().After(deadline) {
t.Fatalf("association capacity did not recover: code=%d", code)
}
time.Sleep(10 * time.Millisecond)
}
})
}
}
}
func TestSocksUDPDestinationLimit(t *testing.T) {
for _, reverse := range []bool{false, true} {
for _, limit := range []int{2, 100} {
t.Run(fmt.Sprintf("reverse=%t/limit=%d", reverse, limit), func(t *testing.T) {
value, idle := "", 2*time.Second
if limit != 100 {
value, idle = strconv.Itoa(limit), 500*time.Millisecond
}
t.Setenv("CHISEL_UDP_MAX_CONNS", value)
t.Setenv("CHISEL_UDP_DEADLINE", idle.String())
addr, _ := setupSocksUDP(t, reverse, availablePort())
u := socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, addr, u.LocalAddr().(*net.UDPAddr).Port)
if code != 0 {
t.Fatal(code)
}
var first *net.UDPAddr
for i := range limit {
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
if i == 0 {
first = echo
}
socksUDPRoundTrip(t, u, relay, echo.IP.String(), echo.Port, fmt.Sprintf("destination %d", i))
}
blocked, observed := socksUDPLimitEcho(t, "udp4", "127.0.0.1:0", 0)
packet := append(append([]byte{0, 0, 0}, socksWireAddress(blocked.IP.String(), blocked.Port)...), 'x')
assertSocksUDPDrop(t, u, relay, packet, observed)
// Existing flows continue; another association has its own cap.
socksUDPRoundTrip(t, u, relay, first.IP.String(), first.Port, "existing destination")
other := socksUDPClient(t, "127.0.0.1")
_, otherRelay, code := socksUDPControl(t, addr, other.LocalAddr().(*net.UDPAddr).Port)
if code != 0 {
t.Fatal(code)
}
socksUDPRoundTrip(t, other, otherRelay, blocked.IP.String(), blocked.Port, "independent cap")
<-observed
if limit == 2 {
// Expiration must free capacity without replacing the control.
time.Sleep(idle + 150*time.Millisecond)
socksUDPRoundTrip(t, u, relay, blocked.IP.String(), blocked.Port, "after idle expiry")
}
})
}
}
}
func TestSocksUDPPacketSizeLimit(t *testing.T) {
for _, reverse := range []bool{false, true} {
for _, test := range []struct {
name, setting, network, address string
limit int
}{
{"small", "64", "udp4", "127.0.0.1:0", 64},
{"default", "", "udp4", "127.0.0.1:0", 9012},
{"IPv6", "64", "udp6", "[::1]:0", 64},
{"clamped", "70000", "udp4", "127.0.0.1:0", 65507},
} {
t.Run(fmt.Sprintf("reverse=%t/%s", reverse, test.name), func(t *testing.T) {
if test.limit == 65507 {
// Probe the OS independently of Chisel. macOS defaults to a
// smaller UDP send limit; relay failures must still fail below.
probe := socksUDPClient(t, "127.0.0.1")
_, err := probe.WriteToUDP(make([]byte, test.limit), probe.LocalAddr().(*net.UDPAddr))
probe.Close()
if errors.Is(err, syscall.EMSGSIZE) ||
(runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(10040))) {
t.Skipf("OS cannot send a %d-byte UDP datagram: %v", test.limit, err)
}
if err != nil {
t.Fatal(err)
}
}
t.Setenv("CHISEL_UDP_MAX_SIZE", test.setting)
echo, observed := socksUDPLimitEcho(t, test.network, test.address, test.limit)
addr, _ := setupSocksUDP(t, reverse, availablePort())
u := socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, addr, u.LocalAddr().(*net.UDPAddr).Port)
if code != 0 {
t.Fatal(code)
}
header := append([]byte{0, 0, 0}, socksWireAddress(echo.IP.String(), echo.Port)...)
boundary := bytes.Repeat([]byte{'x'}, test.limit-len(header))
socksUDPRoundTrip(t, u, relay, echo.IP.String(), echo.Port, string(boundary))
<-observed
if test.limit < 65507 {
packet := append(append([]byte{}, header...), append(boundary, 'x')...)
assertSocksUDPDrop(t, u, relay, packet, observed)
}
// This request fits; the endpoint deliberately replies one byte
// above the limit. The exit must drop the oversized response.
packet := append(append([]byte{}, header...), []byte("oversized reply")...)
if _, err := u.WriteToUDP(packet, relay); err != nil {
t.Fatal(err)
}
select {
case <-observed:
case <-time.After(time.Second):
t.Fatal("reply-size probe did not reach endpoint")
}
assertSocksUDPTimeout(t, u)
socksUDPRoundTrip(t, u, relay, echo.IP.String(), echo.Port, "valid after oversized packets")
})
}
}
}
// The endpoint records every request, so a missing reply cannot hide an
// over-limit request that was incorrectly forwarded by the exit.
func socksUDPLimitEcho(t *testing.T, network, address string, maxSize int) (*net.UDPAddr, <-chan []byte) {
t.Helper()
a, err := net.ResolveUDPAddr(network, address)
if err != nil {
t.Fatal(err)
}
c, err := net.ListenUDP(network, a)
if err != nil {
if network == "udp6" {
t.Skipf("IPv6 unavailable: %v", err)
}
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
observed := make(chan []byte, 4)
go func() {
buf := make([]byte, 65535)
for {
n, peer, err := c.ReadFromUDP(buf)
if err != nil {
return
}
payload := append([]byte{}, buf[:n]...)
select {
case observed <- payload:
default:
}
if maxSize > 0 && string(payload) == "oversized reply" {
headerLen := 3 + len(socksWireAddress(c.LocalAddr().(*net.UDPAddr).IP.String(), c.LocalAddr().(*net.UDPAddr).Port))
payload = bytes.Repeat([]byte{'y'}, maxSize-headerLen+1)
}
c.WriteToUDP(payload, peer)
}
}()
return c.LocalAddr().(*net.UDPAddr), observed
}
func assertSocksUDPDrop(t *testing.T, u *net.UDPConn, relay *net.UDPAddr, packet []byte, observed <-chan []byte) {
t.Helper()
if _, err := u.WriteToUDP(packet, relay); err != nil {
t.Fatal(err)
}
select {
case payload := <-observed:
t.Fatalf("over-limit packet reached the destination: %d bytes", len(payload))
case <-time.After(75 * time.Millisecond):
}
assertSocksUDPTimeout(t, u)
}
func assertSocksUDPTimeout(t *testing.T, u *net.UDPConn) {
t.Helper()
u.SetReadDeadline(time.Now().Add(75 * time.Millisecond))
_, _, err := u.ReadFromUDP(make([]byte, 65535))
if e, ok := err.(net.Error); !ok || !e.Timeout() {
t.Fatalf("expected dropped datagram, got %v", err)
}
}
+138
View File
@@ -0,0 +1,138 @@
package e2e_test
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"os"
"slices"
"sync/atomic"
"testing"
"time"
"github.com/txthinking/socks5"
)
// Opt-in because this deliberately overloads UDP sockets and the relay queue.
// Loss is measured, not asserted: it depends on the host and socket buffers.
func TestSocksUDPOverload(t *testing.T) {
if os.Getenv("CHISEL_UDP_STRESS") != "1" {
t.Skip("set CHISEL_UDP_STRESS=1 to run UDP overload measurements")
}
t.Setenv("CHISEL_UDP_MAX_SIZE", "")
t.Setenv("CHISEL_UDP_MAX_CONNS", "")
t.Setenv("CHISEL_UDP_DEADLINE", "")
for _, path := range []string{"direct", "forward", "reverse"} {
t.Run(path, func(t *testing.T) {
const packets, size = 20000, 1200
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
quietEcho := socksUDPEcho(t, "udp4", "127.0.0.1:0")
var addr string
if path != "direct" {
addr, _ = setupSocksUDP(t, path == "reverse", availablePort())
}
dial := func(destination *net.UDPAddr) net.Conn {
var c net.Conn
var err error
if path == "direct" {
c, err = net.Dial("udp4", destination.String())
} else {
client, e := socks5.NewClient(addr, "", "", 5, 0)
if e != nil {
t.Fatal(e)
}
c, err = client.Dial("udp", destination.String())
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
if err := benchmarkUDPEcho(c, []byte("warmup"), make([]byte, 128)); err != nil {
t.Fatal(err)
}
return c
}
c, quiet := dial(echo), dial(quietEcho)
var sendingDone atomic.Bool
type result struct {
latencies []int64
duplicate int
err error
}
results := make(chan result, 1)
go func() {
buf := make([]byte, size+64)
tail := bytes.Repeat([]byte{'x'}, size-16)
seen := make([]bool, packets)
r := result{latencies: make([]int64, 0, packets)}
for {
c.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
n, err := c.Read(buf)
if e, ok := err.(net.Error); ok && e.Timeout() {
if sendingDone.Load() {
results <- r
return
}
continue
}
if err != nil || n != size {
r.err = fmt.Errorf("overload reply n=%d: %v", n, err)
results <- r
return
}
seq := binary.BigEndian.Uint64(buf[:8])
if seq >= packets || !bytes.Equal(buf[16:n], tail) {
r.err = fmt.Errorf("corrupted overload reply seq=%d", seq)
results <- r
return
}
if seen[seq] {
r.duplicate++
continue
}
seen[seq] = true
sent := int64(binary.BigEndian.Uint64(buf[8:16]))
r.latencies = append(r.latencies, time.Now().UnixNano()-sent)
}
}()
quietDone := make(chan error, 1)
go func() {
for range 20 {
if err := benchmarkUDPEcho(quiet, []byte("unrelated client"), make([]byte, 128)); err != nil {
quietDone <- err
return
}
time.Sleep(5 * time.Millisecond)
}
quietDone <- nil
}()
payload := bytes.Repeat([]byte{'x'}, size)
start := time.Now()
c.SetWriteDeadline(start.Add(10 * time.Second))
for seq := range packets {
binary.BigEndian.PutUint64(payload[:8], uint64(seq))
binary.BigEndian.PutUint64(payload[8:16], uint64(time.Now().UnixNano()))
if n, err := c.Write(payload); err != nil || n != size {
t.Fatalf("overload send %d n=%d: %v", seq, n, err)
}
}
elapsed := time.Since(start)
sendingDone.Store(true)
r := <-results
if r.err != nil || r.duplicate != 0 || len(r.latencies) == 0 {
t.Fatalf("invalid overload results: received=%d duplicates=%d err=%v", len(r.latencies), r.duplicate, r.err)
}
if err := <-quietDone; err != nil {
t.Fatalf("overload blocked unrelated association: %v", err)
}
if err := benchmarkUDPEcho(c, []byte("after overload"), make([]byte, 128)); err != nil {
t.Fatalf("association did not recover after overload: %v", err)
}
slices.Sort(r.latencies)
t.Logf("sent=%d received=%d loss=%.2f%% offered=%.0f packets/s p50=%.1f us p95=%.1f us; 20 unrelated-client probes and recovery passed",
packets, len(r.latencies), 100*float64(packets-len(r.latencies))/packets, float64(packets)/elapsed.Seconds(),
float64(r.latencies[len(r.latencies)*50/100])/1000, float64(r.latencies[len(r.latencies)*95/100])/1000)
})
}
}
+669
View File
@@ -0,0 +1,669 @@
package e2e_test
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
"strconv"
"testing"
"time"
chclient "github.com/jpillora/chisel/client"
chserver "github.com/jpillora/chisel/server"
"golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
)
func socksWireAddress(host string, port int) []byte {
var b []byte
if ip := net.ParseIP(host); ip != nil {
if v4 := ip.To4(); v4 != nil {
b = append([]byte{1}, v4...)
} else {
b = append([]byte{4}, ip.To16()...)
}
} else {
b = append([]byte{3, byte(len(host))}, []byte(host)...)
}
return binary.BigEndian.AppendUint16(b, uint16(port))
}
func readSocksWireAddress(r io.Reader) (string, int, error) {
var atyp [1]byte
if _, err := io.ReadFull(r, atyp[:]); err != nil {
return "", 0, err
}
n := 0
switch atyp[0] {
case 1:
n = 4
case 4:
n = 16
case 3:
if _, err := io.ReadFull(r, atyp[:]); err != nil {
return "", 0, err
}
n = int(atyp[0])
default:
return "", 0, fmt.Errorf("unexpected SOCKS address type %d", atyp[0])
}
b := make([]byte, n+2)
_, err := io.ReadFull(r, b)
return net.IP(b[:n]).String(), int(binary.BigEndian.Uint16(b[n:])), err
}
func socksUDPControl(t *testing.T, addr string, sourcePort int) (net.Conn, *net.UDPAddr, byte) {
t.Helper()
c, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
c.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := c.Write([]byte{5, 1, 0}); err != nil {
t.Fatal(err)
}
var greeting [2]byte
if _, err := io.ReadFull(c, greeting[:]); err != nil || greeting != [2]byte{5, 0} {
t.Fatalf("SOCKS greeting %v: %v", greeting, err)
}
if _, err := c.Write(append([]byte{5, 3, 0}, socksWireAddress("0.0.0.0", sourcePort)...)); err != nil {
t.Fatal(err)
}
var reply [3]byte
if _, err := io.ReadFull(c, reply[:]); err != nil {
t.Fatal(err)
}
host, port, err := readSocksWireAddress(c)
if err != nil {
t.Fatal(err)
}
c.SetDeadline(time.Time{})
return c, &net.UDPAddr{IP: net.ParseIP(host), Port: port}, reply[1]
}
func socksUDPClient(t *testing.T, host string) *net.UDPConn {
t.Helper()
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP(host)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
return c
}
// Reserve UDP on a TCP-available port. Choosing a random UDP port can collide
// with an existing TCP socket and make a bind-failure test fail before it
// reaches the UDP behavior it is intended to exercise.
func socksUDPBlocker(t *testing.T) *net.UDPConn {
t.Helper()
for range 10 {
addr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:"+availablePort())
if err != nil {
t.Fatal(err)
}
c, err := net.ListenUDP("udp4", addr)
if err != nil {
continue
}
t.Cleanup(func() { c.Close() })
return c
}
t.Fatal("cannot reserve UDP on a TCP-available port")
return nil
}
func socksUDPEcho(t testing.TB, network, addr string) *net.UDPAddr {
t.Helper()
a, err := net.ResolveUDPAddr(network, addr)
if err != nil {
t.Fatal(err)
}
c, err := net.ListenUDP(network, a)
if err != nil {
if network == "udp6" {
t.Skipf("IPv6 unavailable: %s", err)
}
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
go func() {
buf := make([]byte, 65535)
for {
n, peer, err := c.ReadFromUDP(buf)
if err != nil {
return
}
c.WriteToUDP(buf[:n], peer)
}
}()
return c.LocalAddr().(*net.UDPAddr)
}
func socksUDPRoundTrip(t *testing.T, c *net.UDPConn, relay *net.UDPAddr, host string, port int, payload string) {
t.Helper()
packet := append(append([]byte{0, 0, 0}, socksWireAddress(host, port)...), []byte(payload)...)
if _, err := c.WriteToUDP(packet, relay); err != nil {
t.Fatal(err)
}
c.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 65535)
n, from, err := c.ReadFromUDP(buf)
if err != nil {
t.Fatal(err)
}
if from.Port != relay.Port || len(buf[:n]) < 3 || !bytes.Equal(buf[:3], []byte{0, 0, 0}) {
t.Fatalf("invalid UDP relay response: %v from %s", buf[:n], from)
}
r := bytes.NewReader(buf[3:n])
_, responsePort, err := readSocksWireAddress(r)
if err != nil || responsePort != port {
t.Fatalf("responder port %d, want %d: %v", responsePort, port, err)
}
b, _ := io.ReadAll(r)
if string(b) != payload {
t.Fatalf("UDP response %q, want %q", b, payload)
}
}
func setupSocksUDP(t testing.TB, reverse bool, port string) (string, *chclient.Client) {
t.Helper()
return setupSocksUDPOn(t, reverse, "127.0.0.1", port)
}
func setupSocksUDPOn(t testing.TB, reverse bool, host, port string) (string, *chclient.Client) {
t.Helper()
return setupSocksRemoteOn(t, reverse, host, port, "udpsocks")
}
func setupSocksRemoteOn(t testing.TB, reverse bool, host, port, remoteType string) (string, *chclient.Client) {
t.Helper()
remote := net.JoinHostPort(host, port) + ":" + remoteType
if reverse {
remote = "R:" + remote
}
layout := testLayout{
server: &chserver.Config{Socks5: !reverse, Reverse: reverse},
client: &chclient.Config{Remotes: []string{remote}},
}
_, c, teardown := layout.setup(t)
t.Cleanup(teardown)
if host == "0.0.0.0" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port), c
}
func TestSocksTCPOnly(t *testing.T) {
for _, reverse := range []bool{false, true} {
for _, occupied := range []bool{false, true} {
t.Run(fmt.Sprintf("reverse=%t/udpOccupied=%t", reverse, occupied), func(t *testing.T) {
port := availablePort()
if occupied {
blocker := socksUDPBlocker(t)
port = strconv.Itoa(blocker.LocalAddr().(*net.UDPAddr).Port)
}
addr, _ := setupSocksRemoteOn(t, reverse, "127.0.0.1", port, "socks")
if !occupied {
// A plain SOCKS proxy must leave this UDP port available.
u, err := net.ListenPacket("udp", addr)
if err != nil {
t.Fatalf("socks bound UDP: %v", err)
}
t.Cleanup(func() { u.Close() })
}
_, _, code := socksUDPControl(t, addr, 0)
if code != 7 {
t.Fatalf("plain socks UDP ASSOCIATE code=%d, want unsupported (7)", code)
}
socksTCPRoundTrip(t, addr)
})
}
}
}
func socksTCPRoundTrip(t *testing.T, addr string) {
t.Helper()
endpoint, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer endpoint.Close()
go func() {
c, err := endpoint.Accept()
if err == nil {
c.Write([]byte("TCP"))
c.Close()
}
}()
dialer, _ := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
c, err := dialer.Dial("tcp", endpoint.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(time.Second))
b, err := io.ReadAll(c)
if err != nil || string(b) != "TCP" {
t.Fatalf("TCP response %q: %v", b, err)
}
}
func TestSocksUDP(t *testing.T) {
for _, reverse := range []bool{false, true} {
t.Run(fmt.Sprintf("reverse=%t", reverse), func(t *testing.T) {
addr, _ := setupSocksUDP(t, reverse, availablePort())
udp := socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, addr, 0)
if code != 0 || relay.String() != addr {
t.Fatalf("UDP ASSOCIATE code=%d relay=%s, want %s", code, relay, addr)
}
v4 := socksUDPEcho(t, "udp4", "127.0.0.1:0")
socksUDPRoundTrip(t, udp, relay, v4.IP.String(), v4.Port, "IPv4")
// A dual-stack endpoint permits whichever localhost address the
// exit resolver selects, without needing external DNS/networking.
domain := socksUDPEcho(t, "udp", ":0")
socksUDPRoundTrip(t, udp, relay, "localhost", domain.Port, "domain")
t.Run("IPv6", func(t *testing.T) {
v6 := socksUDPEcho(t, "udp6", "[::1]:0")
socksUDPRoundTrip(t, udp, relay, "::1", v6.Port, "IPv6")
})
// TCP and UDP share the SOCKS port and work concurrently.
endpoint, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer endpoint.Close()
go func() {
c, err := endpoint.Accept()
if err == nil {
c.Write([]byte("TCP"))
c.Close()
}
}()
dialer, _ := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
c, err := dialer.Dial("tcp", endpoint.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(time.Second))
b, err := io.ReadAll(c)
if err != nil || string(b) != "TCP" {
t.Fatalf("TCP response %q: %v", b, err)
}
socksUDPRoundTrip(t, udp, relay, v4.IP.String(), v4.Port, "still UDP")
})
}
}
func TestSocksUDPAssociations(t *testing.T) {
addr, _ := setupSocksUDP(t, false, availablePort())
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
u1, u2 := socksUDPClient(t, "127.0.0.1"), socksUDPClient(t, "127.0.0.1")
c1, relay, code := socksUDPControl(t, addr, 0)
if code != 0 {
t.Fatalf("first pending association rejected: %d", code)
}
_, _, code = socksUDPControl(t, addr, 0)
if code != 1 {
t.Fatalf("ambiguous pending association accepted: %d", code)
}
socksUDPRoundTrip(t, u1, relay, echo.IP.String(), echo.Port, "one")
c2, relay2, code := socksUDPControl(t, addr, 0)
if code != 0 {
t.Fatalf("second association rejected after pinning: %d", code)
}
if relay.String() != addr || relay2.String() != addr {
t.Fatalf("associations must share configured UDP port %s, got %s and %s", addr, relay, relay2)
}
socksUDPRoundTrip(t, u2, relay, echo.IP.String(), echo.Port, "two")
socksUDPRoundTrip(t, u1, relay, echo.IP.String(), echo.Port, "one again")
_, _, code = socksUDPControl(t, addr, u1.LocalAddr().(*net.UDPAddr).Port)
if code != 1 {
t.Fatalf("conflicting reservation accepted: %d", code)
}
c1.Close()
// Wait for the source-port reservation to be released by control EOF.
deadline := time.Now().Add(2 * time.Second)
for {
c, _, result := socksUDPControl(t, addr, u1.LocalAddr().(*net.UDPAddr).Port)
if result == 0 {
c.Close()
break
}
c.Close()
if time.Now().After(deadline) {
t.Fatal("closed control retained its reservation")
}
time.Sleep(10 * time.Millisecond)
}
socksUDPRoundTrip(t, u2, relay, echo.IP.String(), echo.Port, "surviving association")
c2.Close()
}
func TestSocksUDPRejectsUnassociatedAndMalformedPackets(t *testing.T) {
addr, _ := setupSocksUDP(t, false, availablePort())
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
allowed, blocked := socksUDPClient(t, "127.0.0.1"), socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, addr, allowed.LocalAddr().(*net.UDPAddr).Port)
if code != 0 {
t.Fatalf("declared source port rejected: %d", code)
}
packet := append(append([]byte{0, 0, 0}, socksWireAddress(echo.IP.String(), echo.Port)...), 'x')
if _, err := blocked.WriteToUDP(packet, relay); err != nil {
t.Fatal(err)
}
blocked.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if _, _, err := blocked.ReadFromUDP(make([]byte, 100)); err == nil {
t.Fatal("unassociated source received a response")
}
for _, invalid := range [][]byte{{0}, {0, 0, 0, 2}, append([]byte{0, 0, 1}, packet[3:]...), append([]byte{1, 0, 0}, packet[3:]...), make([]byte, 10000)} {
allowed.WriteToUDP(invalid, relay)
}
allowed.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if _, _, err := allowed.ReadFromUDP(make([]byte, 11000)); err == nil {
t.Fatal("malformed packet received a response")
}
socksUDPRoundTrip(t, allowed, relay, echo.IP.String(), echo.Port, "valid after invalid")
}
func TestSocksUDPBindFailureKeepsTCP(t *testing.T) {
blocker := socksUDPBlocker(t)
port := strconv.Itoa(blocker.LocalAddr().(*net.UDPAddr).Port)
addr, _ := setupSocksUDP(t, false, port)
_, _, code := socksUDPControl(t, addr, 0)
if code != 1 {
t.Fatalf("UDP bind failure reply %d, want 1", code)
}
// The greeting and CONNECT must still reach the exit's existing TCP engine.
endpoint, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer endpoint.Close()
go func() {
c, err := endpoint.Accept()
if err == nil {
c.Close()
}
}()
dialer, _ := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
c, err := dialer.Dial("tcp", endpoint.Addr().String())
if err != nil {
t.Fatal(err)
}
c.Close()
}
func TestSocksUDPDisconnectClosesControl(t *testing.T) {
addr, client := setupSocksUDP(t, false, availablePort())
control, _, code := socksUDPControl(t, addr, 0)
if code != 0 {
t.Fatal(code)
}
client.Close()
control.SetReadDeadline(time.Now().Add(time.Second))
_, err := control.Read(make([]byte, 1))
if err != io.EOF {
t.Fatalf("control did not close on shutdown: %v", err)
}
// Both ports must be released after the proxy has stopped.
tcp, err := net.Listen("tcp", addr)
if err != nil {
t.Fatalf("TCP port retained: %v", err)
}
tcp.Close()
udp, err := net.ListenPacket("udp", addr)
if err != nil {
t.Fatalf("UDP port retained: %v", err)
}
udp.Close()
}
func TestSocksUDPChannelACL(t *testing.T) {
for _, grant := range []string{`^socks$`, `^127\.0\.0\.1:80$`} {
t.Run(grant, func(t *testing.T) {
addr := startSocksACLServer(t, "socks-udp-acl", grant)
sc, _, _ := dialChiselSSH(t, addr, "user", "pass")
defer sc.Close()
sendConfig(t, sc, nil)
ch, reqs, err := sc.OpenChannel("chisel", []byte("socks-udp-v1"))
if grant != `^socks$` {
if err == nil {
ch.Close()
t.Fatal("UDP channel bypassed socks ACL")
}
return
}
if err != nil {
t.Fatal(err)
}
go ssh.DiscardRequests(reqs)
defer ch.Close()
})
}
server, err := chserver.NewServer(&chserver.Config{Socks5: true})
if err != nil {
t.Fatal(err)
}
server.AddUser("user", "pass", `^socks$`)
port := availablePort()
if err := server.Start("127.0.0.1", port); err != nil {
t.Fatal(err)
}
defer server.Close()
sc, _, _ := dialChiselSSH(t, "127.0.0.1:"+port, "user", "pass")
defer sc.Close()
sendConfig(t, sc, nil)
ch, reqs, err := sc.OpenChannel("chisel", []byte("socks-udp-v1"))
if err != nil {
t.Fatal(err)
}
go ssh.DiscardRequests(reqs)
defer ch.Close()
ready := make([]byte, len("chisel-socks-udp-v1\n"))
if _, err := io.ReadFull(ch, ready); err != nil || string(ready) != "chisel-socks-udp-v1\n" {
t.Fatalf("exit did not confirm UDP support: %q %v", ready, err)
}
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
roundTripSocksUDPChannel(t, ch, echo, "before removal")
server.DeleteUser("user")
if next, _, err := sc.OpenChannel("chisel", []byte("socks-udp-v1")); err == nil {
next.Close()
t.Fatal("removed user opened a UDP channel")
}
roundTripSocksUDPChannel(t, ch, echo, "existing association after removal")
}
func roundTripSocksUDPChannel(t *testing.T, ch ssh.Channel, echo *net.UDPAddr, payload string) {
t.Helper()
packet := append(append([]byte{0, 0, 0}, socksWireAddress(echo.IP.String(), echo.Port)...), []byte(payload)...)
frame := binary.BigEndian.AppendUint32(nil, uint32(len(packet)))
if _, err := ch.Write(append(frame, packet...)); err != nil {
t.Fatal(err)
}
type result struct {
packet []byte
err error
}
done := make(chan result, 1)
go func() {
var header [4]byte
if _, err := io.ReadFull(ch, header[:]); err != nil {
done <- result{err: err}
return
}
size := binary.BigEndian.Uint32(header[:])
if size > 9012 {
done <- result{err: fmt.Errorf("oversized reply %d", size)}
return
}
b := make([]byte, size)
_, err := io.ReadFull(ch, b)
done <- result{b, err}
}()
select {
case r := <-done:
if r.err != nil || !bytes.Equal(r.packet, packet) {
t.Fatalf("UDP channel response %v: %v", r.packet, r.err)
}
case <-time.After(3 * time.Second):
t.Fatal("UDP channel timed out")
}
}
func TestSocksUDPConcurrentDestinations(t *testing.T) {
addr, _ := setupSocksUDP(t, false, availablePort())
u := socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, addr, 0)
if code != 0 {
t.Fatal(code)
}
echoes := []*net.UDPAddr{socksUDPEcho(t, "udp4", "127.0.0.1:0"), socksUDPEcho(t, "udp4", "127.0.0.1:0")}
const packets = 12 // stay below the deliberately bounded queue
for i := 0; i < packets; i++ {
echo := echoes[i%len(echoes)]
packet := append(append([]byte{0, 0, 0}, socksWireAddress(echo.IP.String(), echo.Port)...), []byte(fmt.Sprint(i))...)
if _, err := u.WriteToUDP(packet, relay); err != nil {
t.Fatal(err)
}
}
u.SetReadDeadline(time.Now().Add(3 * time.Second))
seen := make(map[int]bool)
for i := 0; i < packets; i++ {
b := make([]byte, 100)
n, _, err := u.ReadFromUDP(b)
if err != nil {
t.Fatal(err)
}
r := bytes.NewReader(b[3:n])
_, port, err := readSocksWireAddress(r)
if err != nil {
t.Fatal(err)
}
payload, _ := io.ReadAll(r)
id, err := strconv.Atoi(string(payload))
if err != nil || id < 0 || id >= packets || seen[id] || port != echoes[id%len(echoes)].Port {
t.Fatalf("misrouted response %q from port %d", payload, port)
}
seen[id] = true
}
}
func TestSocksUDPListenerAddresses(t *testing.T) {
for _, host := range []string{"0.0.0.0", "::1"} {
t.Run(host, func(t *testing.T) {
if host == "::1" {
probe, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 unavailable: %v", err)
}
probe.Close()
}
addr, _ := setupSocksUDPOn(t, false, host, availablePort())
controlHost, _, _ := net.SplitHostPort(addr)
u := socksUDPClient(t, controlHost)
_, relay, code := socksUDPControl(t, addr, 0)
if code != 0 || relay.String() != addr {
t.Fatalf("relay %s code %d, want concrete address %s", relay, code, addr)
}
echo := socksUDPEcho(t, "udp4", "127.0.0.1:0")
socksUDPRoundTrip(t, u, relay, echo.IP.String(), echo.Port, "listener address")
})
}
}
func TestSocksUDPDisabledExit(t *testing.T) {
port := availablePort()
layout := testLayout{server: &chserver.Config{}, client: &chclient.Config{Remotes: []string{"127.0.0.1:" + port + ":udpsocks"}}}
_, _, teardown := layout.setup(t)
defer teardown()
_, _, code := socksUDPControl(t, "127.0.0.1:"+port, 0)
if code != 2 {
t.Fatalf("SOCKS-disabled exit reply %d, want ruleset failure", code)
}
}
func TestSocksLegacyTCPChannel(t *testing.T) {
addr := startSocksACLServer(t, "legacy-socks-tcp", `^socks$`)
sc, _, _ := dialChiselSSH(t, addr, "user", "pass")
defer sc.Close()
sendConfig(t, sc, nil)
ch, reqs, err := sc.OpenChannel("chisel", []byte("socks"))
if err != nil {
t.Fatal(err)
}
go ssh.DiscardRequests(reqs)
defer ch.Close()
endpoint, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer endpoint.Close()
go func() {
c, err := endpoint.Accept()
if err == nil {
c.Write([]byte("old client"))
c.Close()
}
}()
ch.Write([]byte{5, 1, 0})
var greeting [2]byte
if _, err := io.ReadFull(ch, greeting[:]); err != nil {
t.Fatal(err)
}
a := endpoint.Addr().(*net.TCPAddr)
ch.Write(append([]byte{5, 1, 0}, socksWireAddress(a.IP.String(), a.Port)...))
var header [3]byte
if _, err := io.ReadFull(ch, header[:]); err != nil || header[1] != 0 {
t.Fatalf("CONNECT reply %v: %v", header, err)
}
if _, _, err := readSocksWireAddress(ch); err != nil {
t.Fatal(err)
}
b := make([]byte, len("old client"))
if _, err := io.ReadFull(ch, b); err != nil || string(b) != "old client" {
t.Fatalf("legacy TCP response %q: %v", b, err)
}
}
func TestSocksTCPHalfClose(t *testing.T) {
addr, _ := setupSocksUDP(t, false, availablePort())
endpoint, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer endpoint.Close()
go func() {
c, err := endpoint.Accept()
if err != nil {
return
}
defer c.Close()
c.SetDeadline(time.Now().Add(3 * time.Second))
b, err := io.ReadAll(c)
if err == nil {
c.Write(append(b, '!'))
}
}()
dialer, _ := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
c, err := dialer.Dial("tcp", endpoint.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := c.Write([]byte("half-close")); err != nil {
t.Fatal(err)
}
if err := c.(*net.TCPConn).CloseWrite(); err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(c)
if err != nil || string(b) != "half-close!" {
t.Fatalf("SOCKS half-close response %q: %v", b, err)
}
}
+161
View File
@@ -0,0 +1,161 @@
package e2e_test
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"runtime"
"syscall"
"testing"
"time"
)
// TFTP starts at one server port, receives DATA from a new transfer port,
// and sends ACKs to that port while retaining its own transfer ID (source
// port). Exercise two associations to ensure their exit sockets stay isolated.
func TestSocksUDPTransferPort(t *testing.T) {
for _, reverse := range []bool{false, true} {
for _, kind := range []string{"udp4", "udp6", "different-IP"} {
t.Run(fmt.Sprintf("reverse=%t/%s", reverse, kind), func(t *testing.T) {
network := kind
if kind == "different-IP" {
network = "udp4"
}
host := "127.0.0.1"
if network == "udp6" {
host = "::1"
}
listen := func() *net.UDPConn {
c, err := net.ListenUDP(network, &net.UDPAddr{IP: net.ParseIP(host)})
if err != nil {
if network == "udp6" {
t.Skipf("IPv6 unavailable: %v", err)
}
if host == "127.0.0.2" && (errors.Is(err, syscall.EADDRNOTAVAIL) ||
(runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(10049)))) {
t.Skipf("alternate loopback unavailable: %v", err)
}
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
return c
}
service := listen()
if kind == "different-IP" {
host = "127.0.0.2"
}
transfer := listen()
serviceAddr := service.LocalAddr().(*net.UDPAddr)
transferAddr := transfer.LocalAddr().(*net.UDPAddr)
proxyAddr, _ := setupSocksUDP(t, reverse, availablePort())
clients := [2]*net.UDPConn{}
relays := [2]*net.UDPAddr{}
peers := [2]*net.UDPAddr{}
read := func(c *net.UDPConn, want []byte) *net.UDPAddr {
t.Helper()
c.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 1024)
n, from, err := c.ReadFromUDP(buf)
if err != nil || !bytes.Equal(buf[:n], want) {
t.Fatalf("datagram %x, want %x: %v", buf[:n], want, err)
}
return from
}
send := func(c *net.UDPConn, to *net.UDPAddr, data []byte) {
t.Helper()
if _, err := c.WriteToUDP(data, to); err != nil {
t.Fatal(err)
}
}
request := func(i int, to *net.UDPAddr, data []byte) {
packet := append(append([]byte{0, 0, 0}, socksWireAddress(to.IP.String(), to.Port)...), data...)
send(clients[i], relays[i], packet)
}
rrq := []byte("\x00\x01test\x00octet\x00")
for i := range clients {
clients[i] = socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, proxyAddr, clients[i].LocalAddr().(*net.UDPAddr).Port)
if code != 0 {
t.Fatalf("ASSOCIATE failed: %d", code)
}
relays[i] = relay
request(i, serviceAddr, rrq)
peers[i] = read(service, rrq)
}
if peers[0].String() == peers[1].String() {
t.Fatal("associations share an exit source socket")
}
for block := uint16(1); block <= 2; block++ {
data := binary.BigEndian.AppendUint16([]byte{0, 3}, block)
if block == 1 {
data = append(data, bytes.Repeat([]byte{'x'}, 512)...)
} else {
data = append(data, 'y') // final short DATA block
}
for i := range clients {
send(transfer, peers[i], data)
clients[i].SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 1024)
n, relay, err := clients[i].ReadFromUDP(buf)
if err != nil {
t.Fatalf("DATA block %d reply from new server port: %v", block, err)
}
if relay.String() != relays[i].String() || n < 3 || !bytes.Equal(buf[:3], []byte{0, 0, 0}) {
t.Fatalf("invalid relay packet from %s: %x", relay, buf[:n])
}
r := bytes.NewReader(buf[3:n])
host, port, err := readSocksWireAddress(r)
if err != nil || host != transferAddr.IP.String() || port != transferAddr.Port {
t.Fatalf("reply source %s:%d, want %s: %v", host, port, transferAddr, err)
}
payload, _ := io.ReadAll(r)
if !bytes.Equal(payload, data) {
t.Fatalf("wrong DATA block: %x", payload)
}
ack := binary.BigEndian.AppendUint16([]byte{0, 4}, block)
request(i, &net.UDPAddr{IP: net.ParseIP(host), Port: port}, ack)
if peer := read(transfer, ack); peer.String() != peers[i].String() {
t.Fatalf("source changed during transfer: %s -> %s", peers[i], peer)
}
}
}
})
}
}
}
func TestSocksUDPSourcePortSurvivesIdle(t *testing.T) {
for _, reverse := range []bool{false, true} {
t.Run(fmt.Sprintf("reverse=%t", reverse), func(t *testing.T) {
t.Setenv("CHISEL_UDP_DEADLINE", "50ms")
addr, _ := setupSocksUDP(t, reverse, availablePort())
client := socksUDPClient(t, "127.0.0.1")
service := socksUDPClient(t, "127.0.0.1")
_, relay, code := socksUDPControl(t, addr, 0)
if code != 0 {
t.Fatal(code)
}
to := service.LocalAddr().(*net.UDPAddr)
packet := append(append([]byte{0, 0, 0}, socksWireAddress(to.IP.String(), to.Port)...), 'x')
probe := func() *net.UDPAddr {
if _, err := client.WriteToUDP(packet, relay); err != nil {
t.Fatal(err)
}
service.SetReadDeadline(time.Now().Add(3 * time.Second))
_, peer, err := service.ReadFromUDP(make([]byte, 32))
if err != nil {
t.Fatal(err)
}
return peer
}
before := probe()
time.Sleep(150 * time.Millisecond)
if after := probe(); after.String() != before.String() {
t.Fatalf("idle expiration changed source: %s -> %s", before, after)
}
})
}
}