This commit is contained in:
2026-08-16 19:02:48 -03:00
parent 96fe00eb2b
commit c8e3011f21
31 changed files with 3457 additions and 351 deletions
+117 -36
View File
@@ -90,10 +90,11 @@ flow control a matter of reporting a number.
**DragonTCP does not provide authenticated encryption. It is not a VPN in the **DragonTCP does not provide authenticated encryption. It is not a VPN in the
security sense.** security sense.**
Payloads are **masked**: XORed with a keystream derived from SHA-256 that varies Legacy payloads are **masked**: XORed with a keystream derived from SHA-256 that
with session ID, mode, sequence, direction, and block number. The same plaintext varies with session ID, mode, sequence, direction, and block number. New clients
therefore does not produce the same ciphertext twice, and no fixed ASCII markers also support a self-described **clear-payload** profile that removes the per-32-byte
appear on the wire. SHA-256 work. Auto tries clear first and falls back to the legacy mask for older
servers or networks that reject the clear profile.
This defeats trivial pattern matching. It does not defeat anyone who can read the This defeats trivial pattern matching. It does not defeat anyone who can read the
traffic: traffic:
@@ -101,7 +102,9 @@ traffic:
* The mask is derived from the **session ID, which is transmitted in cleartext in * The mask is derived from the **session ID, which is transmitted in cleartext in
every request header.** Anyone who sees the header can regenerate the keystream every request header.** Anyone who sees the header can regenerate the keystream
and recover the plaintext. This is obfuscation, not confidentiality. and recover the plaintext. This is obfuscation, not confidentiality.
* Record headers — mode, session, sequence, length — are never masked. * Session, sequence, and length fields remain clear. The mode/status byte may
use a startup-selected header mask, but this is traffic shaping rather than
cryptographic protection.
* `StatusError` bodies are sent **unmasked**, as plain text. * `StatusError` bodies are sent **unmasked**, as plain text.
* There is no integrity check, so payloads can be tampered with undetected. * There is no integrity check, so payloads can be tampered with undetected.
@@ -130,6 +133,7 @@ core/
cmd/dragontcp-server/ cmd/dragontcp-server/
main.go listener, DNS cache, address filtering, CLI flags main.go listener, DNS cache, address filtering, CLI flags
chunk.go session manager, buffering, request dispatch chunk.go session manager, buffering, request dispatch
bhttp.go auto-detected BP/BHP1 transport compatibility
debug.go counters and periodic statistics debug.go counters and periodic statistics
chunk_test.go chunk_test.go
internal/wire/ internal/wire/
@@ -227,7 +231,7 @@ A single request may be answered by **several** responses — see `ModeDownload`
| `StatusWait` | 3 | Nothing available yet; ask again | | `StatusWait` | 3 | Nothing available yet; ask again |
| `StatusEOF` | 4 | Target closed the stream | | `StatusEOF` | 4 | Target closed the stream |
### 4.3 Payload masking ### 4.3 Payload encoding
```text ```text
seed[0:16] = session ID seed[0:16] = session ID
@@ -245,6 +249,12 @@ sequence field is a **byte offset** (§4.5), consecutive records never reuse a
keystream position, and re-sending the same offset reproduces the same bytes — keystream position, and re-sending the same offset reproduces the same bytes —
which is what makes idempotent retries safe. which is what makes idempotent retries safe.
When the cover preface carries the clear-payload flag, request and data bodies are
sent without that transform. Headers, session semantics, retry offsets, and all
payload layouts remain identical. Old clients remain masked and are accepted by
the new server. An old server rejects the new flag, so the new client's next
startup candidate is the corresponding legacy direct profile.
Not everything is masked. `WriteResponse` sends the body as-is and is used for Not everything is masked. `WriteResponse` sends the body as-is and is used for
empty `StatusOK`, `StatusWait`, `StatusEOF`, and every `StatusError`. empty `StatusOK`, `StatusWait`, `StatusEOF`, and every `StatusError`.
`WriteMaskedResponse` is used for `StatusData` and for the `OPEN` result. On the `WriteMaskedResponse` is used for `StatusData` and for the `OPEN` result. On the
@@ -350,6 +360,27 @@ client server
│◀─ OK ────────────────────────────────────────│ │◀─ OK ────────────────────────────────────────│
``` ```
### 4.7 BP transport compatibility
Binary connections are auto-detected as either native Dragon B or the
observable BP protocol used by `bhttp_remote_test.py`. BP support uses the
same 29-byte request header, five-byte response header, and SHA-256 counter mask,
but maps modes as `0=probe`, `1=upload/register`, `2=single download`, `3=batch
download`, and `4=ACK`. It implements `BHP1` version-1 probe integrity, probe
batching, empty mode-1 session registration, upload acknowledgements, mode-2's
header-only size hint, the six-byte batch request, ACK, expiry, and unknown-session
errors. Dragon peers can additionally negotiate the clear-payload encoding via
the cover preface; reference clients continue through the original direct masked
encoding unchanged.
The available reference client does not expose a destination-selection or
authentication exchange. Its observable registration/upload/download/ACK
behavior remains accepted unchanged. Dragon's BP client adds a `DOP1` upload
extension after registration to carry the normal token, target host, and port;
that extension gives the Android app a complete bidirectional stream without
changing the reference client's frames. Native Dragon B remains available and
is not replaced.
--- ---
## 5. The Go client ## 5. The Go client
@@ -404,10 +435,44 @@ keepalives, and optionally explicit buffer sizes via `--tcp-buffer`.
| `--chunk-reconnect-every` | Behaviour | | `--chunk-reconnect-every` | Behaviour |
|---|---| |---|---|
| `0` | Persistent — one connection for the life of the lane | | `0` | Persistent — one connection for the life of the lane |
| `1` (default) | Auto — persistent if the path probe showed reuse works, otherwise one logical request per connection. Resolved silently, since it runs once per flow | | `1` (app default) | Auto — starts persistent, retries once after reconnect, and switches that lane to one request per connection only when reuse fails during real traffic |
| `N ≥ 2` | Rotate — close and redial after N logical requests | | `N ≥ 2` | Rotate — close and redial after N logical requests |
### 5.4 Path probing ### 5.4 Startup profile and path probing
Before normal traffic, the client discovers one fixed wire/header profile. The
original direct range remains intact: B provides 32 masks for its mode byte, X
provides 96 masks for its `UP`/`OK` magic, and BP provides its original direct
profile. The cover range adds both legacy-masked and clear-payload B profiles,
clear-payload BP profiles, and covered X profiles. It varies a masked multi-byte
preface, all 256 header-mask/first-byte values, and a distributed set of padding
lengths: `0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 768, 1024, 1400, 2048,
4096` bytes.
Every candidate is tested with real tunnel traffic: the client opens the tunnel,
sends an HTTP request to `http://ip.dr2.site/` over TCP port 80, and accepts the
profile only after receiving an HTTP status line. Discovery no longer rejects a
profile based only on the synthetic `CPROBE`/`DTP2` exchange.
The safe default is one probe thread. `--wire-probe-threads` can allow 116
in-flight attempts, while `--wire-probe-delay` (default 1 second) is still the
global minimum time between new connection starts. Increasing the thread count
therefore permits slow attempts to overlap; it does not launch all candidates at
once. One thread is recommended on carriers with connection-rate filtering.
The winner is cached for the lifetime of the client process and is used
unchanged for every connection and record. Padding contents may be fresh random
bytes, but the selected length and profile never change until restart; there is
no per-packet profile mutation.
`--wire auto` searches 1,153 distributed B/BP/X profiles. Pinning `--wire b`
searches 544 B profiles, `--wire bp` searches 257 BP profiles, and `--wire x`
searches 352 X profiles. B and BP try a clear-payload candidate first, followed
immediately by a legacy direct fallback. A successful selection is logged as:
```text
wire probe: selected=x/mask-6b/cover-91e7/pad-64 completed=141 launched=142 elapsed=42.8s target=http://ip.dr2.site/ validated=true threads=1 fixed_until_restart=true
```
Before the first real connection, `getPathProfile` measures the path once and Before the first real connection, `getPathProfile` measures the path once and
caches the result for **30 minutes**, keyed by server address, token, and size caches the result for **30 minutes**, keyed by server address, token, and size
@@ -558,10 +623,11 @@ Source: `core/cmd/dragontcp-server/`.
### 6.1 Connection handling ### 6.1 Connection handling
The server listens on `0.0.0.0:53` by default, bounded by `--max-connections` The server listens on `0.0.0.0:53` by default, bounded by `--max-connections`
through a slot channel. Each connection runs a loop: read one request with a through a slot channel. Each connection runs a loop: read one request with an
30-second deadline, dispatch it, repeat. Because session state is keyed by session idle deadline, dispatch it, repeat. The deadline is refreshed halfway through its
ID rather than by connection, requests for one logical stream may arrive over many window rather than issuing a system call for every record. Because session state
connections in whatever pattern the client chooses. is keyed by session ID rather than by connection, requests for one logical stream
may arrive over many connections in whatever pattern the client chooses.
### 6.2 Session state and buffering ### 6.2 Session state and buffering
@@ -574,7 +640,8 @@ target plus a download buffer:
That goroutine **blocks when the buffer is full**, which is the entire flow That goroutine **blocks when the buffer is full**, which is the entire flow
control story described in §4.5. Buffer size is `--chunk-buffered × 65536`, control story described in §4.5. Buffer size is `--chunk-buffered × 65536`,
clamped to 1 MiB…64 MiB (default 25616 MiB per session). clamped to 1 MiB…64 MiB (default 32 → 2 MiB per session). The same byte limit is
enforced for X; it is not multiplied by the negotiated chunk size.
Acknowledgement drops bytes off the front and advances `base`. The buffer is Acknowledgement drops bytes off the front and advances `base`. The buffer is
compacted when its capacity exceeds four times its length and is over 1 MiB, so compacted when its capacity exceeds four times its length and is over 1 MiB, so
@@ -673,9 +740,9 @@ a helper binary in an APK.
`DragonService` launches it with `ProcessBuilder`, merges stderr into stdout, and `DragonService` launches it with `ProcessBuilder`, merges stderr into stdout, and
reads the output on a background thread. Only interesting lines reach the UI log: reads the output on a background thread. Only interesting lines reach the UI log:
those beginning with `adaptive ` or `path probe:`, and anything containing `error` those beginning with `wire`, `adaptive `, or `path probe:`, and anything
or `failed`. A watchdog thread waits on the process; if the core exits while the containing `error` or `failed`. A watchdog thread waits on the process; if the
tunnel is supposed to be up, the whole VPN is torn down. core exits while the tunnel is supposed to be up, the whole VPN is torn down.
### 7.3 Startup and shutdown ### 7.3 Startup and shutdown
@@ -822,12 +889,15 @@ checksum of zero is written as `0xFFFF` per RFC 768.
| Server | — | `--server-host` | | Server | — | `--server-host` |
| Port | 53 | `--server-port` | | Port | 53 | `--server-port` |
| Token | empty | `--token` (omitted entirely when blank) | | Token | empty | `--token` (omitted entirely when blank) |
| Wire | auto | `--wire`; Auto, B, BP, and X are selectable |
| Probe delay (ms) | 1000 | `--wire-probe-delay`; global minimum delay between profile connection starts |
| Probe threads | 1 | `--wire-probe-threads`; maximum concurrent profile attempts, 116 |
| Max chunk | 1048576 | `--chunk-max` **and** `--chunk-start` | | Max chunk | 1048576 | `--chunk-max` **and** `--chunk-start` |
| Min chunk | 32 | `--chunk-min` | | Min chunk | 32 | `--chunk-min` |
| Batch max | 1 | `--chunk-concurrency` | | Batch max | 1 | `--chunk-concurrency` |
| Batch min | 1 | `--chunk-concurrency-min` | | Batch min | 1 | `--chunk-concurrency-min` |
| Reconnect every | 1 (auto) | `--chunk-reconnect-every` | | Reconnect every | 1 (auto) | `--chunk-reconnect-every` |
| Timeout (s) | 2 | `--chunk-timeout` | | Timeout (s) | 5 | `--chunk-timeout` |
Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`, Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`,
`--transport chunk`, `--chunk-grow-after 16`, `--chunk-adapt-log=true`. `--transport chunk`, `--chunk-grow-after 16`, `--chunk-adapt-log=true`.
@@ -853,7 +923,7 @@ Download batch: pinned at 5 records per request (never adapts)
Validation ranges: port 165535, max chunk 321048576, min chunk 32max chunk, Validation ranges: port 165535, max chunk 321048576, min chunk 32max chunk,
batch values 1256 with `min ≤ max`, reconnect 01000000, batch values 1256 with `min ≤ max`, reconnect 01000000,
timeout 1120. timeout 1120, probe delay 20030000 ms, probe threads 116.
### 7.10 Logs ### 7.10 Logs
@@ -989,26 +1059,30 @@ keystore out of version control.
### 9.1 Server ### 9.1 Server
```bash ```bash
sudo ./dragontcp-hybrid-server-linux-amd64 --port 53 --chunk-max 1048576 sudo ./dragontcp-hybrid-server-linux-amd64 \
--port 53 --port-alt 80 --chunk-max 1048576
``` ```
With a token: With a token:
```bash ```bash
sudo ./dragontcp-hybrid-server-linux-amd64 \ sudo ./dragontcp-hybrid-server-linux-amd64 \
--token 'YOUR_SECRET' --port 53 --chunk-max 1048576 --token 'YOUR_SECRET' --port 53 --port-alt 80 --chunk-max 1048576
``` ```
With diagnostics: With diagnostics:
```bash ```bash
sudo ./dragontcp-hybrid-server-linux-amd64 \ sudo ./dragontcp-hybrid-server-linux-amd64 \
--port 53 --chunk-max 1048576 --debug --debug-stats-interval 10s --port 53 --port-alt 80 --chunk-max 1048576 --debug --debug-stats-interval 10s
``` ```
`sudo` is needed only because port 53 is privileged. If `systemd-resolved` or The server listens on TCP ports 53 and 80 simultaneously by default. Set
`dnsmasq` already owns port 53, free it or choose another port. No TUN device, NAT, `--port-alt 0` to disable the second listener. Failure to bind the primary port
or firewall rules are required. is fatal; failure to bind the secondary port prints a warning and leaves the
primary listener running. `sudo` is normally needed because both defaults are
privileged. If another service owns either port, free it or select a different
port. No TUN device or NAT rules are required.
### 9.2 Client CLI ### 9.2 Client CLI
@@ -1023,7 +1097,7 @@ summarises the active configuration:
```text ```text
adaptive_chunk=true start=1048576 min=32 max=1048576 grow_after=16 pollers=1 \ adaptive_chunk=true start=1048576 min=32 max=1048576 grow_after=16 pollers=1 \
batch=5-5(pinned) reconnect_every=0 timeout=2s batch=5-5(pinned) reconnect_every=0 timeout=5s
``` ```
### 9.3 Android ### 9.3 Android
@@ -1041,7 +1115,9 @@ Batch max: 1
Batch min: 1 Batch min: 1
Pollers: 1 Pollers: 1
Reconnect every: 1 Reconnect every: 1
Timeout (s): 2 Timeout (s): 5
Probe delay (ms): 1000
Probe threads: 1
``` ```
Use **OPEN LOGS** to watch the path probe and any adaptation. Use **OPEN LOGS** to watch the path probe and any adaptation.
@@ -1055,7 +1131,8 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
| Flag | Default | Meaning | | Flag | Default | Meaning |
|---|---|---| |---|---|---|
| `--host` | `0.0.0.0` | Listen address | | `--host` | `0.0.0.0` | Listen address |
| `--port` | `53` | Listen port | | `--port` | `53` | Primary listen port |
| `--port-alt` | `80` | Simultaneous secondary listen port; 0 disables it |
| `--token` | empty | Optional shared secret | | `--token` | empty | Optional shared secret |
| `--max-connections` | `20000` | Concurrent TCP connections | | `--max-connections` | `20000` | Concurrent TCP connections |
| `--allow-private` | `false` | Allow private/loopback targets — keep off in public | | `--allow-private` | `false` | Allow private/loopback targets — keep off in public |
@@ -1063,7 +1140,7 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
| `--dns-cache-size` | `4096` | Cached hostnames | | `--dns-cache-size` | `4096` | Cached hostnames |
| `--tcp-buffer` | `0` | Explicit socket buffers; 0 = OS autotuning | | `--tcp-buffer` | `0` | Explicit socket buffers; 0 = OS autotuning |
| `--chunk-max` | `1048576` | Largest record accepted (32 B 1 MiB) | | `--chunk-max` | `1048576` | Largest record accepted (32 B 1 MiB) |
| `--chunk-buffered` | `256` | Per-session buffer in 64 KiB units (≈16 MiB) | | `--chunk-buffered` | `32` | Per-session buffer in 64 KiB units (≈2 MiB) |
| `--chunk-poll-wait` | `200ms` | Long-poll wait for a batch's first record | | `--chunk-poll-wait` | `200ms` | Long-poll wait for a batch's first record |
| `--chunk-session-timeout` | `2m` | Idle session reaping | | `--chunk-session-timeout` | `2m` | Idle session reaping |
| `--debug` | `false` | Session/connect/error logs plus periodic stats | | `--debug` | `false` | Session/connect/error logs plus periodic stats |
@@ -1078,6 +1155,9 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
| `--server-host` / `--server-port` | — / `53` | Remote server (host required) | | `--server-host` / `--server-port` | — / `53` | Remote server (host required) |
| `--token` | empty | Shared secret | | `--token` | empty | Shared secret |
| `--transport` | `chunk` | Must be `chunk` | | `--transport` | `chunk` | Must be `chunk` |
| `--wire` | `auto` | Search B/BP/X; `b`, `bp`, or `x` pins one mode |
| `--wire-probe-delay` | `1s` | Global minimum delay between profile probe starts; range 200ms30s |
| `--wire-probe-threads` | `1` | Maximum concurrent real HTTP profile probes; range 116 |
| `--max-connections` | `20000` | Concurrent proxied connections | | `--max-connections` | `20000` | Concurrent proxied connections |
| `--tcp-buffer` | `0` | Explicit socket buffers | | `--tcp-buffer` | `0` | Explicit socket buffers |
| `--chunk-start` | `1048576` | Initial record size (the probe overrides it) | | `--chunk-start` | `1048576` | Initial record size (the probe overrides it) |
@@ -1091,7 +1171,7 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
| `--chunk-concurrency-min` | `1` | Download batch floor, 1256; equal to the ceiling pins the depth | | `--chunk-concurrency-min` | `1` | Download batch floor, 1256; equal to the ceiling pins the depth |
| `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate | | `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll | | `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
| `--chunk-timeout` | `2s` | Per-record transaction timeout | | `--chunk-timeout` | `5s` | Per-record transaction timeout |
| `--chunk-pollers` | `1` | Accepted for compatibility; validated 1128 but unused | | `--chunk-pollers` | `1` | Accepted for compatibility; validated 1128 but unused |
The `concurrency` flag names are historical. They control the download **batch The `concurrency` flag names are historical. They control the download **batch
@@ -1127,10 +1207,10 @@ manual tuning makes things worse.
high, because each round trip returns more data. If the log repeatedly shows high, because each round trip returns more data. If the log repeatedly shows
`adaptive download batch: N -> N/2`, the path cannot sustain that depth. `adaptive download batch: N -> N/2`, the path cannot sustain that depth.
* **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every` * **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every`
to `1` (auto). `0` forces persistent connections, and many networks silently to `1` (auto). Auto starts persistent and retries an idempotent request once on
kill long-lived port-53 connections; auto probes first and falls back to one a fresh connection. If reuse itself failed, only that lane switches to one
logical request per connection when persistence does not survive. This is the logical request per connection. This avoids turning one imperfect startup
single most important setting on a restrictive path. probe into connection churn for every active tunnel.
* **Logs show the same transition many times over (`128 -> 64` repeatedly).** * **Logs show the same transition many times over (`128 -> 64` repeatedly).**
Each tunnel adapts independently, so a burst of flows produces a burst of Each tunnel adapts independently, so a burst of flows produces a burst of
identical lines. The app collapses consecutive duplicates into a counted line; identical lines. The app collapses consecutive duplicates into a counted line;
@@ -1151,7 +1231,8 @@ manual tuning makes things worse.
a small number of high-BDP connections is `1048576` or `4194304` worth trying; a small number of high-BDP connections is `1048576` or `4194304` worth trying;
across many connections it costs memory for nothing. across many connections it costs memory for nothing.
* **Server memory.** Each session can hold `--chunk-buffered × 64 KiB` (default * **Server memory.** Each session can hold `--chunk-buffered × 64 KiB` (default
16 MiB). Lower it when running many concurrent sessions. 2 MiB). Lower it when running many concurrent sessions; the minimum effective
window is 1 MiB.
--- ---
@@ -1196,8 +1277,8 @@ cd core && go test ./...
Coverage: Coverage:
* the masking round-trip, and that different sequences produce different wire * legacy masking round-trips and different-sequence wire bytes,
bytes, * clear-payload B and BP profiles end to end, including nonzero header masks,
* the adaptive record sizer recovering from the minimum rather than latching * the adaptive record sizer recovering from the minimum rather than latching
there, there,
* `reconnectEvery == 0` meaning persistent, * `reconnectEvery == 0` meaning persistent,
+6 -6
View File
@@ -1,6 +1,6 @@
56707362bae6b388795150a77b27a14de046a56e05037485d5dfb4bb7db0f8b9 *bin/dragontcp-hybrid-server-linux-amd64 969e4a020d5475bfff355f38ca809edb68686b5850af8f9709126ed5bb2d13bb *bin/dragontcp-hybrid-server-linux-amd64
35e7dbbb84bbb76b0eea052eff18d58e24c1fa1c43274aa6c1a5c686a1d38f40 *bin/dragontcp-hybrid-server-linux-arm64 eaf2d07d8edf17840ce7bad4b1c81e8f3ccb1504c18397a773ca0aacb2ee72ef *bin/dragontcp-hybrid-server-linux-arm64
2f820ce82a65c285684c875afd0311f1d62afe856f8b878eab682f9864252a19 *bin/dragontcp-hybrid-client-linux-amd64 b6dda0c38b8472b8e648a212daddca5a737c8e0a3457eb3c4f29833ec3d2e1c4 *bin/dragontcp-hybrid-client-linux-amd64
1b5bcf4d3a446cec397206557fa6f1a4c229b11b8e871a7a1072ac3ae9f81e31 *android/lib/arm64-v8a/libdragontcp_client.so d70e5cb83cb6109cba2a8dab1b232c5b7545abb26bb9ad71b92972b7e8f830d9 *android/lib/arm64-v8a/libdragontcp_client.so
8021680a9cff84b0dcdec3c6a1f38c5b6f9620aba2bed89766920e4b2f514afa *android/lib/armeabi-v7a/libdragontcp_client.so 3c45f678da399ecd20c28b8acc631d8c6f18151415ac63109f838a8c68a00e68 *android/lib/armeabi-v7a/libdragontcp_client.so
baa7e4e304f96edd4d1fe8f22b9f11398e1d8ad00e09454afca8419ae718bdd2 *android/lib/x86_64/libdragontcp_client.so 78806799b1c2949835e854635f6c352b572d3b3b526c4a6de877da9dd4e2d98b *android/lib/x86_64/libdragontcp_client.so
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -38,7 +38,7 @@ public class DragonService extends VpnService {
public static final String EXTRA_SERVER = "server"; public static final String EXTRA_SERVER = "server";
public static final String EXTRA_PORT = "port"; public static final String EXTRA_PORT = "port";
public static final String EXTRA_TOKEN = "token"; public static final String EXTRA_TOKEN = "token";
/** Wire format: "auto", "b" or "x". */ /** Wire format: "auto", "b", "bp" or "x". */
public static final String EXTRA_WIRE = "wire"; public static final String EXTRA_WIRE = "wire";
public static final String EXTRA_CHUNK_MAX = "chunkMax"; public static final String EXTRA_CHUNK_MAX = "chunkMax";
public static final String EXTRA_CHUNK_MIN = "chunkMin"; public static final String EXTRA_CHUNK_MIN = "chunkMin";
@@ -48,6 +48,8 @@ public class DragonService extends VpnService {
public static final String EXTRA_BATCH_MIN = "batchMin"; public static final String EXTRA_BATCH_MIN = "batchMin";
public static final String EXTRA_RECONNECT = "reconnect"; public static final String EXTRA_RECONNECT = "reconnect";
public static final String EXTRA_TIMEOUT = "timeout"; public static final String EXTRA_TIMEOUT = "timeout";
public static final String EXTRA_PROBE_DELAY = "probeDelay";
public static final String EXTRA_PROBE_THREADS = "probeThreads";
private static final int BATCH_LIMIT = 256; private static final int BATCH_LIMIT = 256;
@@ -118,7 +120,9 @@ public class DragonService extends VpnService {
int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1); int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1);
int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1); int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1);
int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1); int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1);
int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2); int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 5);
int probeDelay = intent.getIntExtra(EXTRA_PROBE_DELAY, 1000);
int probeThreads = intent.getIntExtra(EXTRA_PROBE_THREADS, 1);
if (server == null || server.trim().isEmpty()) { if (server == null || server.trim().isEmpty()) {
failStart("Server is required"); failStart("Server is required");
@@ -126,19 +130,22 @@ public class DragonService extends VpnService {
} }
server = server.trim(); server = server.trim();
if (token == null) token = ""; if (token == null) token = "";
if (wire == null || !(wire.equals("b") || wire.equals("x"))) wire = "auto"; if (wire == null || !(wire.equals("b") || wire.equals("bp") || wire.equals("x"))) wire = "auto";
chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax)); chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
chunkMin = Math.max(32, Math.min(chunkMax, chunkMin)); chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax)); batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax));
batchMin = Math.max(1, Math.min(batchMax, batchMin)); batchMin = Math.max(1, Math.min(batchMax, batchMin));
reconnect = Math.max(0, reconnect); reconnect = Math.max(0, reconnect);
timeout = Math.max(1, timeout); timeout = Math.max(1, timeout);
probeDelay = Math.max(200, Math.min(30000, probeDelay));
probeThreads = Math.max(1, Math.min(16, probeThreads));
try { try {
AppLog.append("Starting DragonTCP → " + server + ":" + port); AppLog.append("Starting DragonTCP → " + server + ":" + port);
AppLog.append(describeBatch(batchMin, batchMax)); AppLog.append(describeBatch(batchMin, batchMax));
AppLog.append(describeWire(wire)); AppLog.append(describeWire(wire));
Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, wire, reconnect, timeout); AppLog.append("Wire probe: http://ip.dr2.site/ • delay " + probeDelay + " ms • threads " + probeThreads);
Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, wire, reconnect, timeout, probeDelay, probeThreads);
synchronized (stateLock) { coreProcess = process; } synchronized (stateLock) { coreProcess = process; }
startCoreLogReader(process); startCoreLogReader(process);
@@ -206,7 +213,9 @@ public class DragonService extends VpnService {
int batchMin, int batchMin,
String wire, String wire,
int reconnect, int reconnect,
int timeout int timeout,
int probeDelay,
int probeThreads
) throws Exception { ) throws Exception {
File executable = new File(getApplicationInfo().nativeLibraryDir, "libdragontcp_client.so"); File executable = new File(getApplicationInfo().nativeLibraryDir, "libdragontcp_client.so");
if (!executable.exists()) throw new IllegalStateException("Embedded DragonTCP core is missing"); if (!executable.exists()) throw new IllegalStateException("Embedded DragonTCP core is missing");
@@ -224,6 +233,8 @@ public class DragonService extends VpnService {
cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax)); cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax));
cmd.add("--chunk-pollers"); cmd.add("1"); cmd.add("--chunk-pollers"); cmd.add("1");
cmd.add("--wire"); cmd.add(wire); cmd.add("--wire"); cmd.add(wire);
cmd.add("--wire-probe-delay"); cmd.add(probeDelay + "ms");
cmd.add("--wire-probe-threads"); cmd.add(Integer.toString(probeThreads));
cmd.add("--chunk-concurrency"); cmd.add(Integer.toString(batchMax)); cmd.add("--chunk-concurrency"); cmd.add(Integer.toString(batchMax));
cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin)); cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin));
cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect)); cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect));
@@ -249,9 +260,10 @@ public class DragonService extends VpnService {
/** Human-readable summary of the wire selection, for the log screen. */ /** Human-readable summary of the wire selection, for the log screen. */
private static String describeWire(String wire) { private static String describeWire(String wire) {
if ("b".equals(wire)) return "Wire: B (manual)"; if ("b".equals(wire)) return "Wire: B (discovering header profile)";
if ("x".equals(wire)) return "Wire: X (manual)"; if ("bp".equals(wire)) return "Wire: BP";
return "Wire: auto (probing)"; if ("x".equals(wire)) return "Wire: X (discovering header profile)";
return "Wire: auto (discovering B/BP/X profile)";
} }
private void startCoreLogReader(Process process) { private void startCoreLogReader(Process process) {
@@ -55,8 +55,11 @@ public class MainActivity extends Activity {
private EditText batchMax; private EditText batchMax;
private EditText batchMin; private EditText batchMin;
private EditText reconnect; private EditText reconnect;
private EditText probeDelay;
private EditText probeThreads;
private Button wireAuto; private Button wireAuto;
private Button wireB; private Button wireB;
private Button wireBP;
private Button wireX; private Button wireX;
private String wireMode = "auto"; private String wireMode = "auto";
private EditText timeout; private EditText timeout;
@@ -225,9 +228,11 @@ public class MainActivity extends Activity {
LinearLayout wireRow = row(); LinearLayout wireRow = row();
wireAuto = segmentButton("A"); wireAuto = segmentButton("A");
wireB = segmentButton("B"); wireB = segmentButton("B");
wireBP = segmentButton("BP");
wireX = segmentButton("X"); wireX = segmentButton("X");
addSegment(wireRow, wireAuto); addSegment(wireRow, wireAuto);
addSegment(wireRow, wireB); addSegment(wireRow, wireB);
addSegment(wireRow, wireBP);
addSegment(wireRow, wireX); addSegment(wireRow, wireX);
wireCard.addView(wireRow); wireCard.addView(wireRow);
wireHint = text("", 11, MUTED, true); wireHint = text("", 11, MUTED, true);
@@ -238,6 +243,7 @@ public class MainActivity extends Activity {
wireAuto.setOnClickListener(v -> setWireMode("auto")); wireAuto.setOnClickListener(v -> setWireMode("auto"));
wireB.setOnClickListener(v -> setWireMode("b")); wireB.setOnClickListener(v -> setWireMode("b"));
wireBP.setOnClickListener(v -> setWireMode("bp"));
wireX.setOnClickListener(v -> setWireMode("x")); wireX.setOnClickListener(v -> setWireMode("x"));
// --------------------------------------------------------- record size // --------------------------------------------------------- record size
@@ -282,11 +288,17 @@ public class MainActivity extends Activity {
LinearLayout advancedCard = card("ADVANCED"); LinearLayout advancedCard = card("ADVANCED");
LinearLayout timing = row(); LinearLayout timing = row();
reconnect = addFieldToRow(timing, "Reconnect every", "1 = auto", "1", true, false, 0.58f); reconnect = addFieldToRow(timing, "Reconnect every", "1 = auto", "1", true, false, 0.58f);
timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f); timeout = addFieldToRow(timing, "Timeout (s)", "5", "5", true, false, 0.42f);
advancedCard.addView(timing); advancedCard.addView(timing);
LinearLayout probing = row();
probeDelay = addFieldToRow(probing, "Probe delay (ms)", "1000", "1000", true, false, 0.65f);
probeThreads = addFieldToRow(probing, "Probe threads", "1", "1", true, false, 0.35f);
advancedCard.addView(probing);
advancedCard.addView(hint( advancedCard.addView(hint(
"1 = auto (recommended: probes the path, falls back to one request per " "1 = auto (recommended: starts persistent, then learns one request per "
+ "connection) • 0 = persistent • N = rotate every N requests")); + "connection only if reuse really fails) • 0 = persistent • N = rotate every N requests\n"
+ "Probe delay spaces startup profile connections; 1000 ms is recommended. "
+ "Probe threads defaults to 1; more may trigger carrier limits."));
settings.addView(advancedCard, cardParams()); settings.addView(advancedCard, cardParams());
// ------------------------------------------------------------- buttons // ------------------------------------------------------------- buttons
@@ -469,17 +481,21 @@ public class MainActivity extends Activity {
wireMode = mode; wireMode = mode;
paintSegment(wireAuto, "auto".equals(mode)); paintSegment(wireAuto, "auto".equals(mode));
paintSegment(wireB, "b".equals(mode)); paintSegment(wireB, "b".equals(mode));
paintSegment(wireBP, "bp".equals(mode));
paintSegment(wireX, "x".equals(mode)); paintSegment(wireX, "x".equals(mode));
if (wireHint == null) return; if (wireHint == null) return;
if ("auto".equals(mode)) { if ("auto".equals(mode)) {
wireHint.setTextColor(ACCENT); wireHint.setTextColor(ACCENT);
wireHint.setText("Auto: tries B, then X, keeping the one that connects."); wireHint.setText("Auto: discovers a working B/BP/X profile and keeps it until restart.");
} else if ("b".equals(mode)) { } else if ("b".equals(mode)) {
wireHint.setTextColor(OK); wireHint.setTextColor(OK);
wireHint.setText("Manual: B."); wireHint.setText("B only; header profile is discovered automatically.");
} else if ("bp".equals(mode)) {
wireHint.setTextColor(OK);
wireHint.setText("BP only.");
} else { } else {
wireHint.setTextColor(OK); wireHint.setTextColor(OK);
wireHint.setText("Manual: X."); wireHint.setText("X only; header profile is discovered automatically.");
} }
} }
@@ -578,7 +594,9 @@ public class MainActivity extends Activity {
i.putExtra(DragonService.EXTRA_BATCH_MAX, p.getInt("batchMax", 1)); i.putExtra(DragonService.EXTRA_BATCH_MAX, p.getInt("batchMax", 1));
i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1)); i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1));
i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1)); i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1));
i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2)); i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 5));
i.putExtra(DragonService.EXTRA_PROBE_DELAY, p.getInt("probeDelay", 1000));
i.putExtra(DragonService.EXTRA_PROBE_THREADS, p.getInt("probeThreads", 1));
if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i); if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
} }
@@ -593,6 +611,8 @@ public class MainActivity extends Activity {
if (bMin > bMax) throw new IllegalArgumentException("Batch min must not exceed batch max"); if (bMin > bMax) throw new IllegalArgumentException("Batch min must not exceed batch max");
int rec = parse(reconnect, 0, 1000000, "Reconnect every"); int rec = parse(reconnect, 0, 1000000, "Reconnect every");
int tout = parse(timeout, 1, 120, "Timeout"); int tout = parse(timeout, 1, 120, "Timeout");
int delay = parse(probeDelay, 200, 30000, "Probe delay");
int threads = parse(probeThreads, 1, 16, "Probe threads");
getSharedPreferences(PREFS, MODE_PRIVATE).edit() getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString("server", h) .putString("server", h)
@@ -605,6 +625,8 @@ public class MainActivity extends Activity {
.putInt("batchMin", bMin) .putInt("batchMin", bMin)
.putInt("reconnect", rec) .putInt("reconnect", rec)
.putInt("timeout", tout) .putInt("timeout", tout)
.putInt("probeDelay", delay)
.putInt("probeThreads", threads)
.apply(); .apply();
} }
@@ -627,7 +649,9 @@ public class MainActivity extends Activity {
batchMax.setText(Integer.toString(p.getInt("batchMax", 1))); batchMax.setText(Integer.toString(p.getInt("batchMax", 1)));
batchMin.setText(Integer.toString(p.getInt("batchMin", 1))); batchMin.setText(Integer.toString(p.getInt("batchMin", 1)));
reconnect.setText(Integer.toString(p.getInt("reconnect", 1))); reconnect.setText(Integer.toString(p.getInt("reconnect", 1)));
timeout.setText(Integer.toString(p.getInt("timeout", 2))); timeout.setText(Integer.toString(p.getInt("timeout", 5)));
probeDelay.setText(Integer.toString(p.getInt("probeDelay", 1000)));
probeThreads.setText(Integer.toString(p.getInt("probeThreads", 1)));
} }
private void updateConnectionUi(boolean active, String rawStatus) { private void updateConnectionUi(boolean active, String rawStatus) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Test which TCP ports on an authorized host respond to the DragonTCP BP probe."""
from __future__ import annotations
import argparse
import concurrent.futures
import csv
import hashlib
import socket
import struct
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
MODE_PROBE = 0
STATUS_OK = 0
REQUEST_HEADER_SIZE = 29
RESPONSE_HEADER_SIZE = 5
MAX_RESPONSE_BODY = 2 * 1024 * 1024
BP_PROBE = b"BHP1\x01\x00\x00\x00\x00\x00"
@dataclass(frozen=True)
class Result:
port: int
state: str
elapsed_ms: int
detail: str = ""
def sha256_ctr_mask(
data: bytes,
session_id: bytes,
mode: int,
sequence: int,
is_response: bool,
) -> bytes:
"""Apply the BP payload mask. Calling this twice restores the input."""
if not data:
return b""
sid = session_id[:16].ljust(16, b"\x00")
seed = sid + bytes((mode & 0xFF,)) + struct.pack(">Q", sequence)
seed += bytes((1 if is_response else 0,))
out = bytearray(len(data))
for offset in range(0, len(data), 32):
counter = offset // 32
block = hashlib.sha256(seed + struct.pack(">I", counter)).digest()
count = min(32, len(data) - offset)
for index in range(count):
out[offset + index] = data[offset + index] ^ block[index]
return bytes(out)
def read_exact(sock: socket.socket, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise EOFError(f"EOF after {len(data)}/{size} bytes")
data.extend(chunk)
return bytes(data)
def test_port(host: str, port: int, timeout: float) -> Result:
started = time.monotonic()
session_id = uuid.uuid4().bytes
encrypted = sha256_ctr_mask(BP_PROBE, session_id, MODE_PROBE, 0, False)
request = struct.pack(">B16sQI", MODE_PROBE, session_id, 0, len(encrypted)) + encrypted
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.settimeout(timeout)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.sendall(request)
header = read_exact(sock, RESPONSE_HEADER_SIZE)
status, body_size = struct.unpack(">BI", header)
if body_size > MAX_RESPONSE_BODY:
raise ValueError(f"response body too large: {body_size}")
body = read_exact(sock, body_size) if body_size else b""
decoded = sha256_ctr_mask(body, session_id, MODE_PROBE, 0, True)
elapsed = int((time.monotonic() - started) * 1000)
if status == STATUS_OK and decoded == BP_PROBE:
return Result(port, "bp", elapsed, "valid BP probe echo")
return Result(
port,
"open",
elapsed,
f"non-BP response status={status} body={decoded[:16].hex()}",
)
except (ConnectionRefusedError, TimeoutError, socket.timeout):
return Result(port, "closed", int((time.monotonic() - started) * 1000))
except OSError as exc:
return Result(
port,
"closed",
int((time.monotonic() - started) * 1000),
str(exc),
)
except Exception as exc: # A TCP service answered, but not with a valid BP frame.
return Result(
port,
"open",
int((time.monotonic() - started) * 1000),
str(exc),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Test TCP ports for a valid BP probe response. Only scan hosts you own "
"or have explicit permission to test."
)
)
parser.add_argument("--host", required=True, help="authorized IPv4, IPv6, or hostname")
parser.add_argument("--start-port", type=int, default=1, help="first port (default: 1)")
parser.add_argument("--end-port", type=int, default=65535, help="last port (default: 65535)")
parser.add_argument("--threads", type=int, default=1, help="maximum concurrent probes, 1-64 (default: 1)")
parser.add_argument(
"--delay-ms",
type=int,
default=200,
help="global delay between probe starts, 0-30000 ms (default: 200)",
)
parser.add_argument("--timeout", type=float, default=2.5, help="per-port timeout in seconds (default: 2.5)")
parser.add_argument("--show-open", action="store_true", help="also print open ports that do not speak BP")
parser.add_argument("--progress-every", type=int, default=1000, help="progress interval; 0 disables")
parser.add_argument("--output", type=Path, help="optional CSV output for BP and other open ports")
args = parser.parse_args()
if not 1 <= args.start_port <= 65535:
parser.error("--start-port must be between 1 and 65535")
if not 1 <= args.end_port <= 65535:
parser.error("--end-port must be between 1 and 65535")
if args.start_port > args.end_port:
parser.error("--start-port must not exceed --end-port")
if not 1 <= args.threads <= 64:
parser.error("--threads must be between 1 and 64")
if not 0 <= args.delay_ms <= 30000:
parser.error("--delay-ms must be between 0 and 30000")
if not 0.05 <= args.timeout <= 120:
parser.error("--timeout must be between 0.05 and 120 seconds")
if args.progress_every < 0:
parser.error("--progress-every must be 0 or greater")
return args
def main() -> int:
args = parse_args()
total = args.end_port - args.start_port + 1
delay = args.delay_ms / 1000.0
completed = 0
results: list[Result] = []
started = time.monotonic()
print(
f"BP scan host={args.host} ports={args.start_port}-{args.end_port} "
f"threads={args.threads} delay={args.delay_ms}ms timeout={args.timeout:g}s"
)
print_lock = threading.Lock()
def consume(result: Result) -> None:
nonlocal completed
completed += 1
if result.state != "closed":
results.append(result)
with print_lock:
if result.state == "bp":
print(f"BP {args.host}:{result.port} {result.elapsed_ms}ms")
elif result.state == "open" and args.show_open:
suffix = f" {result.detail}" if result.detail else ""
print(f"OPEN {args.host}:{result.port} {result.elapsed_ms}ms{suffix}")
if args.progress_every and completed % args.progress_every == 0:
elapsed = time.monotonic() - started
print(f"progress {completed}/{total} elapsed={elapsed:.1f}s")
pending: set[concurrent.futures.Future[Result]] = set()
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads) as pool:
for port in range(args.start_port, args.end_port + 1):
while len(pending) >= args.threads:
done, pending = concurrent.futures.wait(
pending,
return_when=concurrent.futures.FIRST_COMPLETED,
)
for future in done:
consume(future.result())
pending.add(pool.submit(test_port, args.host, port, args.timeout))
if delay:
time.sleep(delay)
for future in concurrent.futures.as_completed(pending):
consume(future.result())
except KeyboardInterrupt:
print("\nInterrupted; partial results follow.", file=sys.stderr)
results.sort(key=lambda item: item.port)
bp_ports = [item.port for item in results if item.state == "bp"]
elapsed = time.monotonic() - started
print(f"completed={completed}/{total} elapsed={elapsed:.1f}s")
print("BP ports: " + (", ".join(map(str, bp_ports)) if bp_ports else "none"))
if args.output:
with args.output.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(("host", "port", "state", "elapsed_ms", "detail"))
for result in results:
writer.writerow((args.host, result.port, result.state, result.elapsed_ms, result.detail))
print(f"wrote {args.output}")
return 0 if bp_ports else 1
if __name__ == "__main__":
raise SystemExit(main())
+499
View File
@@ -0,0 +1,499 @@
package main
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
"dragontcp/internal/cover"
"dragontcp/internal/protocol"
"dragontcp/internal/wire"
)
const (
bpModeProbe byte = 0
bpModeUpload byte = 1
bpModeDownload byte = 2
bpModeBatchDownload byte = 3
bpModeACK byte = 4
bpHeaderSize = 29
)
var bpOpenMagic = [4]byte{'D', 'O', 'P', '1'}
var bpCloseMagic = [4]byte{'D', 'C', 'L', '1'}
type bpPhysicalConn struct {
conn net.Conn
requests int
}
type bpLane struct {
mu sync.Mutex
serverAddr string
tcpBuffer int
reconnectEvery int
timeout time.Duration
coverProfile cover.Profile
autoReconnect bool
pc *bpPhysicalConn
closed bool
}
func newBPLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, coverProfile cover.Profile) *bpLane {
autoReconnect := reconnectEvery == 1
if autoReconnect {
reconnectEvery = 0
}
return &bpLane{
serverAddr: serverAddr,
tcpBuffer: tcpBuffer,
reconnectEvery: reconnectEvery,
timeout: timeout,
coverProfile: coverProfile,
autoReconnect: autoReconnect,
}
}
func (l *bpLane) transportFailureLocked(reused bool) {
if l.autoReconnect && reused {
l.reconnectEvery = 1
}
l.discardLocked()
}
func (l *bpLane) discardLocked() {
if l.pc != nil {
_ = l.pc.conn.Close()
l.pc = nil
}
}
func (l *bpLane) closeAfterLocked() {
if l.pc != nil && l.reconnectEvery > 0 && l.pc.requests >= l.reconnectEvery {
l.discardLocked()
}
}
func (l *bpLane) ensureLocked() error {
if l.closed {
return net.ErrClosed
}
if l.pc != nil {
if l.reconnectEvery <= 0 || l.pc.requests < l.reconnectEvery {
return nil
}
l.discardLocked()
}
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
conn, err := d.Dial("tcp", l.serverAddr)
if err != nil {
return err
}
if err := cover.WritePreface(conn, l.coverProfile); err != nil {
_ = conn.Close()
return err
}
protocol.TuneTCP(conn)
protocol.TuneTCPBuffer(conn, l.tcpBuffer)
l.pc = &bpPhysicalConn{conn: conn}
return nil
}
func (l *bpLane) Close() {
l.mu.Lock()
l.closed = true
l.discardLocked()
l.mu.Unlock()
}
func writeBPRequest(w io.Writer, mode byte, sid wire.SessionID, seq uint64, payload []byte, downloadHint uint32, headerMask byte, clear bool) error {
n := uint32(len(payload))
if mode == bpModeDownload {
n = downloadHint
payload = nil
}
if len(payload) > wire.MaxPayload {
return fmt.Errorf("BP payload too large: %d", len(payload))
}
var header [bpHeaderSize]byte
header[0] = mode ^ headerMask
copy(header[1:17], sid[:])
binary.BigEndian.PutUint64(header[17:25], seq)
binary.BigEndian.PutUint32(header[25:29], n)
if clear {
buffers := net.Buffers{header[:], payload}
_, err := buffers.WriteTo(w)
return err
}
packet := make([]byte, bpHeaderSize+len(payload))
copy(packet[:bpHeaderSize], header[:])
copy(packet[bpHeaderSize:], payload)
wire.MaskInPlace(packet[bpHeaderSize:], sid, mode, seq, false)
for len(packet) > 0 {
written, err := w.Write(packet)
if err != nil {
return err
}
if written <= 0 {
return io.ErrShortWrite
}
packet = packet[written:]
}
return nil
}
func readBPResponse(r io.Reader, sid wire.SessionID, mode byte, seq uint64, headerMask byte, clear bool) (byte, []byte, error) {
status, body, err := wire.ReadResponseProfile(r, headerMask)
if err == nil && status != wire.StatusError && len(body) > 0 && !clear {
wire.MaskInPlace(body, sid, mode, seq, true)
}
return status, body, err
}
func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byte, downloadHint uint32) (byte, []byte, error) {
l.mu.Lock()
defer l.mu.Unlock()
timeout := l.timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if err := l.ensureLocked(); err != nil {
lastErr = err
continue
}
reused := l.pc.requests > 0
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
if err := writeBPRequest(l.pc.conn, mode, sid, seq, payload, downloadHint, l.coverProfile.HeaderMask, l.coverProfile.Clear); err != nil {
lastErr = err
l.transportFailureLocked(reused)
continue
}
status, body, err := readBPResponse(l.pc.conn, sid, mode, seq, l.coverProfile.HeaderMask, l.coverProfile.Clear)
if err != nil {
lastErr = err
l.transportFailureLocked(reused)
continue
}
l.pc.requests++
_ = l.pc.conn.SetDeadline(time.Time{})
l.closeAfterLocked()
return status, body, nil
}
return 0, nil, fmt.Errorf("BP request failed after reconnect: %w", lastErr)
}
func decodeBPData(body []byte) ([]byte, error) {
if len(body) < 4 {
return nil, fmt.Errorf("short BP DATA body")
}
n := int(binary.BigEndian.Uint32(body[:4]))
if n < 0 || n > len(body)-4 {
return nil, fmt.Errorf("bad BP DATA length")
}
return append([]byte(nil), body[4:4+n]...), nil
}
func (l *bpLane) download(sid wire.SessionID, offset uint64, maxChunk, count int) ([][]byte, byte, error) {
l.mu.Lock()
defer l.mu.Unlock()
timeout := l.timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
mode := bpModeDownload
payload := []byte(nil)
hint := uint32(maxChunk)
if count > 1 {
mode = bpModeBatchDownload
payload = make([]byte, 6)
binary.BigEndian.PutUint32(payload[:4], uint32(maxChunk))
binary.BigEndian.PutUint16(payload[4:6], uint16(count))
hint = 0
}
responses := 1
if mode == bpModeBatchDownload {
responses = count
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if err := l.ensureLocked(); err != nil {
lastErr = err
continue
}
reused := l.pc.requests > 0
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
if err := writeBPRequest(l.pc.conn, mode, sid, offset, payload, hint, l.coverProfile.HeaderMask, l.coverProfile.Clear); err != nil {
lastErr = err
l.transportFailureLocked(reused)
continue
}
out := make([][]byte, 0, responses)
lastStatus := wire.StatusOK
for i := 0; i < responses; i++ {
status, body, err := readBPResponse(l.pc.conn, sid, mode, offset, l.coverProfile.HeaderMask, l.coverProfile.Clear)
if err != nil {
lastErr = err
l.transportFailureLocked(reused)
goto retry
}
lastStatus = status
switch status {
case wire.StatusData:
data, err := decodeBPData(body)
if err != nil {
l.discardLocked()
return out, status, err
}
if len(data) > 0 {
out = append(out, data)
}
case wire.StatusOK, wire.StatusWait:
case wire.StatusEOF:
case wire.StatusError:
l.discardLocked()
return out, status, fmt.Errorf("%s", string(body))
default:
l.discardLocked()
return out, status, fmt.Errorf("unexpected BP download status %d", status)
}
}
l.pc.requests++
_ = l.pc.conn.SetDeadline(time.Time{})
l.closeAfterLocked()
return out, lastStatus, nil
retry:
}
return nil, 0, fmt.Errorf("BP download request failed after reconnect: %w", lastErr)
}
type bpConn struct {
sid wire.SessionID
opts chunkClientOptions
uploadLane *bpLane
downloadLane *bpLane
upSizer *adaptiveSizer
downSizer *adaptiveSizer
writeMu sync.Mutex
upOffset uint64
readMu sync.Mutex
readBuf []byte
downloadOffset uint64
consumedOffset uint64
lastAck uint64
eof bool
pipeline int
closeOnce sync.Once
}
func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chunkClientOptions) (net.Conn, error) {
if opts.minSize < 32 {
opts.minSize = 32
}
if opts.maxSize < opts.minSize {
opts.maxSize = opts.minSize
}
if opts.maxSize > 1024*1024 {
opts.maxSize = 1024 * 1024
}
if opts.startSize < opts.minSize || opts.startSize > opts.maxSize {
opts.startSize = opts.maxSize
}
if opts.txnTimeout <= 0 {
opts.txnTimeout = 5 * time.Second
}
if opts.maxPipeline < 1 {
opts.maxPipeline = 1
}
if opts.maxPipeline > 256 {
opts.maxPipeline = 256
}
if opts.minPipeline < 1 {
opts.minPipeline = 1
}
if opts.minPipeline > opts.maxPipeline {
opts.minPipeline = opts.maxPipeline
}
reconnect := opts.reconnectEvery
if reconnect < 0 {
reconnect = 0
}
sid, err := randomSessionID()
if err != nil {
return nil, err
}
uploadLane := newBPLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.coverProfile)
status, body, err := uploadLane.single(bpModeUpload, sid, 0, nil, 0)
if err != nil {
uploadLane.Close()
return nil, err
}
if status == wire.StatusError {
uploadLane.Close()
return nil, fmt.Errorf("%s", string(body))
}
if status != wire.StatusOK {
uploadLane.Close()
return nil, fmt.Errorf("bad BP registration response %d", status)
}
openPayload, err := encodeOpen(token, targetHost, targetPort)
if err != nil {
uploadLane.Close()
return nil, err
}
openPayload = append(append([]byte(nil), bpOpenMagic[:]...), openPayload...)
status, body, err = uploadLane.single(bpModeUpload, sid, 1, openPayload, 0)
if err != nil {
uploadLane.Close()
return nil, err
}
if status == wire.StatusError {
uploadLane.Close()
return nil, fmt.Errorf("%s", string(body))
}
if status != wire.StatusOK {
uploadLane.Close()
return nil, fmt.Errorf("bad BP OPEN response %d", status)
}
c := &bpConn{
sid: sid,
opts: opts,
uploadLane: uploadLane,
downloadLane: newBPLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.coverProfile),
pipeline: opts.maxPipeline,
}
c.upSizer = newAdaptiveSizer("BP upload", opts.startSize, opts)
c.downSizer = newAdaptiveSizer("BP download", opts.startSize, opts)
return c, nil
}
func (c *bpConn) fillReadBuffer() error {
if c.eof {
return io.EOF
}
for len(c.readBuf) == 0 && !c.eof {
if c.consumedOffset > c.lastAck {
status, body, err := c.downloadLane.single(bpModeACK, c.sid, c.consumedOffset, nil, 0)
if err != nil {
return err
}
if status == wire.StatusError {
return fmt.Errorf("%s", string(body))
}
c.lastAck = c.consumedOffset
}
chunk := c.downSizer.Current()
count := c.pipeline
if count < c.opts.minPipeline {
count = c.opts.minPipeline
}
if count > c.opts.maxPipeline {
count = c.opts.maxPipeline
}
data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, chunk, count)
if err != nil {
old, next := c.downSizer.FailureReason(chunk, err)
if old == next && next == c.opts.minSize {
return err
}
time.Sleep(30 * time.Millisecond)
continue
}
c.readBuf = appendChunkParts(c.readBuf, data)
for _, part := range data {
c.downloadOffset += uint64(len(part))
}
if len(data) > 0 {
c.downSizer.Success(chunk)
if c.pipeline < c.opts.maxPipeline {
c.pipeline++
}
}
if status == wire.StatusEOF {
c.eof = true
}
if len(c.readBuf) == 0 && !c.eof {
delay := c.opts.pollDelay
if delay <= 0 {
delay = 5 * time.Millisecond
}
time.Sleep(delay)
}
}
if c.eof && len(c.readBuf) == 0 {
return io.EOF
}
return nil
}
func (c *bpConn) Read(p []byte) (int, error) {
c.readMu.Lock()
defer c.readMu.Unlock()
if len(p) == 0 {
return 0, nil
}
if len(c.readBuf) == 0 {
if err := c.fillReadBuffer(); err != nil {
return 0, err
}
}
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
c.consumedOffset += uint64(n)
return n, nil
}
func (c *bpConn) Write(p []byte) (int, error) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
total := 0
for len(p) > 0 {
size := c.upSizer.Current()
n := minInt(size, len(p))
status, body, err := c.uploadLane.single(bpModeUpload, c.sid, c.upOffset+2, p[:n], 0)
if err != nil {
old, next := c.upSizer.FailureReason(size, err)
if old == next && next == c.opts.minSize {
return total, err
}
time.Sleep(30 * time.Millisecond)
continue
}
if status == wire.StatusError {
return total, fmt.Errorf("%s", string(body))
}
if status != wire.StatusOK {
return total, fmt.Errorf("unexpected BP upload status %d", status)
}
c.upOffset += uint64(n)
total += n
p = p[n:]
c.upSizer.Success(size)
}
return total, nil
}
func (c *bpConn) Close() error {
c.closeOnce.Do(func() {
_, _, _ = c.downloadLane.single(bpModeACK, c.sid, c.consumedOffset, bpCloseMagic[:], 0)
c.uploadLane.Close()
c.downloadLane.Close()
})
return nil
}
func (c *bpConn) LocalAddr() net.Addr { return dummyAddr("dragontcp-bp-local") }
func (c *bpConn) RemoteAddr() net.Addr { return dummyAddr("dragontcp-bp-remote") }
func (c *bpConn) SetDeadline(time.Time) error { return nil }
func (c *bpConn) SetReadDeadline(time.Time) error { return nil }
func (c *bpConn) SetWriteDeadline(time.Time) error { return nil }
+122 -38
View File
@@ -11,6 +11,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"dragontcp/internal/cover"
"dragontcp/internal/protocol" "dragontcp/internal/protocol"
"dragontcp/internal/wire" "dragontcp/internal/wire"
) )
@@ -29,6 +30,9 @@ type chunkClientOptions struct {
tcpBuffer int tcpBuffer int
minPipeline int minPipeline int
maxPipeline int maxPipeline int
headerMask byte
coverProfile cover.Profile
skipPathProbe bool
} }
type adaptiveSizer struct { type adaptiveSizer struct {
@@ -120,6 +124,10 @@ func (s *adaptiveSizer) Success(attempted int) {
} }
func (s *adaptiveSizer) Failure(attempted int) (int, int) { func (s *adaptiveSizer) Failure(attempted int) (int, int) {
return s.FailureReason(attempted, nil)
}
func (s *adaptiveSizer) FailureReason(attempted int, cause error) (int, int) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
old := s.current old := s.current
@@ -147,8 +155,12 @@ func (s *adaptiveSizer) Failure(attempted int) (int, int) {
} }
s.current = next s.current = next
if s.logChanges && old != next { if s.logChanges && old != next {
if cause != nil {
fmt.Printf("adaptive %s chunk: %d -> %d after transport failure: %v\n", s.name, old, next, cause)
} else {
fmt.Printf("adaptive %s chunk: %d -> %d after transport failure\n", s.name, old, next) fmt.Printf("adaptive %s chunk: %d -> %d after transport failure\n", s.name, old, next)
} }
}
return old, next return old, next
} }
@@ -163,19 +175,39 @@ type requestLane struct {
tcpBuffer int tcpBuffer int
reconnectEvery int reconnectEvery int
timeout time.Duration timeout time.Duration
headerMask byte
coverProfile cover.Profile
autoReconnect bool
pc *physicalConn pc *physicalConn
closed bool closed bool
} }
func newRequestLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration) *requestLane { func newRequestLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, headerMask byte, coverProfile cover.Profile) *requestLane {
autoReconnect := reconnectEvery == 1
if autoReconnect {
// Auto starts persistent. If a request fails only after this lane has
// already completed traffic on the connection, it learns that reuse is
// unsafe and switches itself to one request per connection.
reconnectEvery = 0
}
return &requestLane{ return &requestLane{
serverAddr: serverAddr, serverAddr: serverAddr,
tcpBuffer: tcpBuffer, tcpBuffer: tcpBuffer,
reconnectEvery: reconnectEvery, reconnectEvery: reconnectEvery,
timeout: timeout, timeout: timeout,
headerMask: headerMask,
coverProfile: coverProfile,
autoReconnect: autoReconnect,
} }
} }
func (l *requestLane) transportFailureLocked(reused bool) {
if l.autoReconnect && reused {
l.reconnectEvery = 1
}
l.discardLocked()
}
func (l *requestLane) discardLocked() { func (l *requestLane) discardLocked() {
if l.pc != nil { if l.pc != nil {
_ = l.pc.conn.Close() _ = l.pc.conn.Close()
@@ -204,6 +236,10 @@ func (l *requestLane) ensureLocked() error {
if err != nil { if err != nil {
return err return err
} }
if err := cover.WritePreface(conn, l.coverProfile); err != nil {
_ = conn.Close()
return err
}
protocol.TuneTCP(conn) protocol.TuneTCP(conn)
protocol.TuneTCPBuffer(conn, l.tcpBuffer) protocol.TuneTCPBuffer(conn, l.tcpBuffer)
l.pc = &physicalConn{conn: conn} l.pc = &physicalConn{conn: conn}
@@ -220,31 +256,39 @@ func (l *requestLane) Close() {
func (l *requestLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byte) (byte, []byte, error) { func (l *requestLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byte) (byte, []byte, error) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
if err := l.ensureLocked(); err != nil {
return 0, nil, err
}
timeout := l.timeout timeout := l.timeout
if timeout <= 0 { if timeout <= 0 {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout)) var lastErr error
if err := wire.WriteRequest(l.pc.conn, mode, sid, seq, payload); err != nil { for attempt := 0; attempt < 2; attempt++ {
l.discardLocked() if err := l.ensureLocked(); err != nil {
return 0, nil, err lastErr = err
continue
} }
status, body, err := wire.ReadResponse(l.pc.conn) reused := l.pc.requests > 0
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
if err := wire.WriteRequestProfileEncoding(l.pc.conn, mode, sid, seq, payload, l.headerMask, l.coverProfile.Clear); err != nil {
lastErr = err
l.transportFailureLocked(reused)
continue
}
status, body, err := wire.ReadResponseProfile(l.pc.conn, l.headerMask)
if err != nil { if err != nil {
l.discardLocked() lastErr = err
return 0, nil, err l.transportFailureLocked(reused)
continue
} }
l.pc.requests++ l.pc.requests++
_ = l.pc.conn.SetDeadline(time.Time{}) _ = l.pc.conn.SetDeadline(time.Time{})
l.closeAfterLocked() l.closeAfterLocked()
if status != wire.StatusError && len(body) > 0 { if status != wire.StatusError && len(body) > 0 && !l.coverProfile.Clear {
body = wire.DecodeMaskedResponse(status, body, sid, mode, seq) body = wire.DecodeMaskedResponse(status, body, sid, mode, seq)
} }
return status, body, nil return status, body, nil
} }
return 0, nil, fmt.Errorf("request failed after reconnect: %w", lastErr)
}
// download sends one compact request and consumes up to count response records. // download sends one compact request and consumes up to count response records.
// startOffset is also the response keystream sequence. Each DATA response advances // startOffset is also the response keystream sequence. Each DATA response advances
@@ -252,37 +296,44 @@ func (l *requestLane) single(mode byte, sid wire.SessionID, seq uint64, payload
func (l *requestLane) download(sid wire.SessionID, startOffset, ackOffset uint64, maxChunk, count int) ([][]byte, byte, error) { func (l *requestLane) download(sid wire.SessionID, startOffset, ackOffset uint64, maxChunk, count int) ([][]byte, byte, error) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
if err := l.ensureLocked(); err != nil {
return nil, 0, err
}
timeout := l.timeout timeout := l.timeout
if timeout <= 0 { if timeout <= 0 {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
payload := make([]byte, 14) payload := make([]byte, 14)
binary.BigEndian.PutUint64(payload[0:8], ackOffset) binary.BigEndian.PutUint64(payload[0:8], ackOffset)
binary.BigEndian.PutUint32(payload[8:12], uint32(maxChunk)) binary.BigEndian.PutUint32(payload[8:12], uint32(maxChunk))
binary.BigEndian.PutUint16(payload[12:14], uint16(count)) binary.BigEndian.PutUint16(payload[12:14], uint16(count))
if err := wire.WriteRequest(l.pc.conn, wire.ModeDownload, sid, startOffset, payload); err != nil { var lastErr error
l.discardLocked() for attempt := 0; attempt < 2; attempt++ {
return nil, 0, err if err := l.ensureLocked(); err != nil {
lastErr = err
continue
}
reused := l.pc.requests > 0
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
if err := wire.WriteRequestProfileEncoding(l.pc.conn, wire.ModeDownload, sid, startOffset, payload, l.headerMask, l.coverProfile.Clear); err != nil {
lastErr = err
l.transportFailureLocked(reused)
continue
} }
out := make([][]byte, 0, count) out := make([][]byte, 0, count)
offset := startOffset offset := startOffset
lastStatus := wire.StatusOK lastStatus := wire.StatusOK
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
status, body, err := wire.ReadResponse(l.pc.conn) status, body, err := wire.ReadResponseProfile(l.pc.conn, l.headerMask)
if err != nil { if err != nil {
l.discardLocked() lastErr = err
return out, lastStatus, err l.transportFailureLocked(reused)
goto retry
} }
lastStatus = status lastStatus = status
switch status { switch status {
case wire.StatusData: case wire.StatusData:
if !l.coverProfile.Clear {
body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeDownload, offset) body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeDownload, offset)
}
if len(body) == 0 { if len(body) == 0 {
l.discardLocked() l.discardLocked()
return out, status, fmt.Errorf("empty DATA response") return out, status, fmt.Errorf("empty DATA response")
@@ -307,6 +358,9 @@ func (l *requestLane) download(sid wire.SessionID, startOffset, ackOffset uint64
_ = l.pc.conn.SetDeadline(time.Time{}) _ = l.pc.conn.SetDeadline(time.Time{})
l.closeAfterLocked() l.closeAfterLocked()
return out, lastStatus, nil return out, lastStatus, nil
retry:
}
return nil, 0, fmt.Errorf("download request failed after reconnect: %w", lastErr)
} }
type pathProfile struct { type pathProfile struct {
@@ -364,7 +418,7 @@ func probeOne(serverAddr, token string, opts chunkClientOptions, kind byte, cand
if timeout <= 0 || timeout > 2500*time.Millisecond { if timeout <= 0 || timeout > 2500*time.Millisecond {
timeout = 2500 * time.Millisecond timeout = 2500 * time.Millisecond
} }
lane := newRequestLane(serverAddr, opts.tcpBuffer, 1, timeout) lane := newRequestLane(serverAddr, opts.tcpBuffer, 1, timeout, opts.headerMask, opts.coverProfile)
defer lane.Close() defer lane.Close()
seq := probeSeq.Add(1) seq := probeSeq.Add(1)
@@ -405,7 +459,7 @@ func probePersistent(serverAddr, token string, opts chunkClientOptions) bool {
if timeout <= 0 || timeout > 2500*time.Millisecond { if timeout <= 0 || timeout > 2500*time.Millisecond {
timeout = 2500 * time.Millisecond timeout = 2500 * time.Millisecond
} }
lane := newRequestLane(serverAddr, opts.tcpBuffer, 0, timeout) lane := newRequestLane(serverAddr, opts.tcpBuffer, 0, timeout, opts.headerMask, opts.coverProfile)
defer lane.Close() defer lane.Close()
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
seq := probeSeq.Add(1) seq := probeSeq.Add(1)
@@ -459,7 +513,7 @@ func probeMaximum(serverAddr, token string, opts chunkClientOptions, kind byte)
} }
func getPathProfile(serverAddr, token string, opts chunkClientOptions) pathProfile { func getPathProfile(serverAddr, token string, opts chunkClientOptions) pathProfile {
key := fmt.Sprintf("%s|%s|%d|%d", serverAddr, token, opts.minSize, opts.maxSize) key := fmt.Sprintf("%s|%s|%d|%d|%02x|%t|%04x|%d|%t", serverAddr, token, opts.minSize, opts.maxSize, opts.headerMask, opts.coverProfile.Enabled, opts.coverProfile.ID, opts.coverProfile.Padding, opts.coverProfile.Clear)
profileState.Lock() profileState.Lock()
if profileState.key == key && time.Since(profileState.p.at) < 30*time.Minute { if profileState.key == key && time.Since(profileState.p.at) < 30*time.Minute {
p := profileState.p p := profileState.p
@@ -533,6 +587,31 @@ type chunkConn struct {
closeOnce sync.Once closeOnce sync.Once
} }
// appendChunkParts keeps the single-response fast path zero-copy. For a batch,
// it reserves the complete size once rather than repeatedly growing and copying
// the aggregate read buffer.
func appendChunkParts(dst []byte, parts [][]byte) []byte {
if len(parts) == 0 {
return dst
}
if len(dst) == 0 && len(parts) == 1 {
return parts[0]
}
total := len(dst)
for _, part := range parts {
total += len(part)
}
if cap(dst) < total {
grown := make([]byte, len(dst), total)
copy(grown, dst)
dst = grown
}
for _, part := range parts {
dst = append(dst, part...)
}
return dst
}
func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts chunkClientOptions) (net.Conn, error) { func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts chunkClientOptions) (net.Conn, error) {
if opts.minSize < 32 { if opts.minSize < 32 {
opts.minSize = 32 opts.minSize = 32
@@ -565,16 +644,21 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
opts.minPipeline = opts.maxPipeline opts.minPipeline = opts.maxPipeline
} }
profile := getPathProfile(serverAddr, token, opts) profile := pathProfile{
upload: opts.minSize,
download: opts.minSize,
persistent: false,
at: time.Now(),
}
if !opts.skipPathProbe {
profile = getPathProfile(serverAddr, token, opts)
}
reconnect := opts.reconnectEvery reconnect := opts.reconnectEvery
// Compatibility-friendly reconnect modes: // Compatibility-friendly reconnect modes:
// 0 = persistent (CLI explicit) // 0 = persistent (CLI explicit)
// 1 = auto: persistent when the path probe succeeds, otherwise one request/connection // 1 = auto: start persistent, then learn one request/connection only if
// reuse fails during real traffic
// N>=2 = force connection rotation after N logical requests // N>=2 = force connection rotation after N logical requests
// Resolved silently: this runs once per proxied flow, so it must never log.
if reconnect == 1 && profile.persistent {
reconnect = 0
}
sid, err := randomSessionID() sid, err := randomSessionID()
if err != nil { if err != nil {
@@ -583,7 +667,7 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
// OPEN rides the upload lane instead of a throwaway connection. A dedicated // OPEN rides the upload lane instead of a throwaway connection. A dedicated
// control connection cost one extra dial per proxied flow, which shows up on // control connection cost one extra dial per proxied flow, which shows up on
// the server as connection churn on top of the steady-state count. // the server as connection churn on top of the steady-state count.
uploadLane := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout) uploadLane := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.headerMask, opts.coverProfile)
payload, err := encodeOpen(token, targetHost, targetPort) payload, err := encodeOpen(token, targetHost, targetPort)
if err != nil { if err != nil {
uploadLane.Close() uploadLane.Close()
@@ -624,7 +708,7 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
opts: opts, opts: opts,
serverMax: serverMax, serverMax: serverMax,
uploadLane: uploadLane, uploadLane: uploadLane,
downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout), downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.headerMask, opts.coverProfile),
// Start at the configured ceiling. On transport failure the batch is // Start at the configured ceiling. On transport failure the batch is
// halved but never below minPipeline; successful data grows it back by // halved but never below minPipeline; successful data grows it back by
// one. When min == max the depth is pinned and never adapts, which is // one. When min == max the depth is pinned and never adapts, which is
@@ -659,8 +743,8 @@ func (c *chunkConn) fillReadBuffer() error {
} }
data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count) data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count)
c.readBuf = appendChunkParts(c.readBuf, data)
for _, part := range data { for _, part := range data {
c.readBuf = append(c.readBuf, part...)
c.downloadOffset += uint64(len(part)) c.downloadOffset += uint64(len(part))
} }
if len(data) > 0 { if len(data) > 0 {
@@ -678,10 +762,10 @@ func (c *chunkConn) fillReadBuffer() error {
c.pipeline = c.minPipeline c.pipeline = c.minPipeline
} }
if c.opts.adaptLog && old != c.pipeline { if c.opts.adaptLog && old != c.pipeline {
fmt.Printf("adaptive download pipeline: %d -> %d after transport failure\n", old, c.pipeline) fmt.Printf("adaptive download pipeline: %d -> %d after transport failure: %v\n", old, c.pipeline, err)
} }
} else { } else {
old, next := c.downSizer.Failure(chunk) old, next := c.downSizer.FailureReason(chunk, err)
if old == next && next == c.opts.minSize { if old == next && next == c.opts.minSize {
minFailures++ minFailures++
if minFailures >= 8 { if minFailures >= 8 {
@@ -745,7 +829,7 @@ func (c *chunkConn) Write(p []byte) (int, error) {
n := minInt(size, len(p)) n := minInt(size, len(p))
status, body, err := c.uploadLane.single(wire.ModeUpload, c.sid, c.upOffset, p[:n]) status, body, err := c.uploadLane.single(wire.ModeUpload, c.sid, c.upOffset, p[:n])
if err != nil { if err != nil {
old, next := c.upSizer.Failure(size) old, next := c.upSizer.FailureReason(size, err)
if old == next && next == c.opts.minSize { if old == next && next == c.opts.minSize {
minFailures++ minFailures++
if minFailures >= 8 { if minFailures >= 8 {
+72 -2
View File
@@ -1,6 +1,13 @@
package main package main
import "testing" import (
"net"
"testing"
"time"
"dragontcp/internal/cover"
"dragontcp/internal/wire"
)
func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) { func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
opts := chunkClientOptions{ opts := chunkClientOptions{
@@ -23,9 +30,72 @@ func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
} }
} }
func TestReconnectAutoLearnsFromRealReuseFailure(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
serverErr := make(chan error, 1)
go func() {
first, err := ln.Accept()
if err != nil {
serverErr <- err
return
}
if _, err := wire.ReadRequest(first); err != nil {
serverErr <- err
return
}
if err := wire.WriteResponse(first, wire.StatusOK, nil); err != nil {
serverErr <- err
return
}
_ = first.Close() // Force the next logical request to reconnect.
second, err := ln.Accept()
if err != nil {
serverErr <- err
return
}
defer second.Close()
if _, err := wire.ReadRequest(second); err != nil {
serverErr <- err
return
}
serverErr <- wire.WriteResponse(second, wire.StatusOK, nil)
}()
lane := newRequestLane(ln.Addr().String(), 0, 1, time.Second, 0, cover.Profile{})
defer lane.Close()
if !lane.autoReconnect || lane.reconnectEvery != 0 {
t.Fatalf("auto lane started auto=%t reconnectEvery=%d", lane.autoReconnect, lane.reconnectEvery)
}
var sid wire.SessionID
if status, _, err := lane.single(wire.ModeProbe, sid, 1, nil); err != nil || status != wire.StatusOK {
t.Fatalf("first request status=%d err=%v", status, err)
}
if status, _, err := lane.single(wire.ModeProbe, sid, 2, nil); err != nil || status != wire.StatusOK {
t.Fatalf("retried request status=%d err=%v", status, err)
}
if lane.reconnectEvery != 1 || lane.pc != nil {
t.Fatalf("auto lane did not learn single-request mode: reconnectEvery=%d pc=%v", lane.reconnectEvery, lane.pc)
}
if err := <-serverErr; err != nil {
t.Fatal(err)
}
}
func TestReconnectZeroMeansPersistent(t *testing.T) { func TestReconnectZeroMeansPersistent(t *testing.T) {
lane := newRequestLane("127.0.0.1:1", 0, 0, 0) lane := newRequestLane("127.0.0.1:1", 0, 0, 0, 0, cover.Profile{})
if lane.reconnectEvery != 0 { if lane.reconnectEvery != 0 {
t.Fatalf("reconnectEvery=%d, want 0", lane.reconnectEvery) t.Fatalf("reconnectEvery=%d, want 0", lane.reconnectEvery)
} }
} }
func TestBPAutoStartsPersistent(t *testing.T) {
lane := newBPLane("127.0.0.1:1", 0, 1, time.Second, cover.Profile{})
if !lane.autoReconnect || lane.reconnectEvery != 0 {
t.Fatalf("BP auto lane started auto=%t reconnectEvery=%d", lane.autoReconnect, lane.reconnectEvery)
}
}
+21 -13
View File
@@ -377,10 +377,12 @@ func main() {
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker") chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)") chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)")
chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth") chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth")
chunkReconnect = flag.Int("chunk-reconnect-every", 0, "force reconnect after N logical requests; 0 = persistent/automatic") chunkReconnect = flag.Int("chunk-reconnect-every", 0, "connection reuse: 0 persistent, 1 auto-learn, N rotate after N requests")
chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll") chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink") chunkTimeout = flag.Duration("chunk-timeout", 5*time.Second, "per-record transaction timeout before adaptive shrink")
wireMode = flag.String("wire", "auto", "wire mode: b, x, or auto (probe and pick)") wireMode = flag.String("wire", "auto", "wire mode: b, bp, x, or auto (probe and pick)")
wireProbeDelay = flag.Duration("wire-probe-delay", time.Second, "minimum delay between wire profile probe starts (200ms-30s)")
wireProbeThreads = flag.Int("wire-probe-threads", 1, "maximum concurrent wire profile probes (1-16)")
) )
flag.Parse() flag.Parse()
@@ -430,19 +432,29 @@ func main() {
} }
*wireMode = strings.ToLower(strings.TrimSpace(*wireMode)) *wireMode = strings.ToLower(strings.TrimSpace(*wireMode))
switch *wireMode { switch *wireMode {
case WireBinary, WireXOR, WireAuto: case WireBinary, WireBP, WireXOR, WireAuto:
case "binary": case "binary":
*wireMode = WireBinary *wireMode = WireBinary
case "bh", "h":
*wireMode = WireBP
case "xor": case "xor":
*wireMode = WireXOR *wireMode = WireXOR
default: default:
fmt.Fprintln(os.Stderr, "--wire must be b, x or auto") fmt.Fprintln(os.Stderr, "--wire must be b, bp, x or auto")
os.Exit(2) os.Exit(2)
} }
if *chunkReconnect < 0 { if *chunkReconnect < 0 {
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater") fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
os.Exit(2) os.Exit(2)
} }
if *wireProbeDelay < 200*time.Millisecond || *wireProbeDelay > 30*time.Second {
fmt.Fprintln(os.Stderr, "--wire-probe-delay must be between 200ms and 30s")
os.Exit(2)
}
if *wireProbeThreads < 1 || *wireProbeThreads > 16 {
fmt.Fprintln(os.Stderr, "--wire-probe-threads must be between 1 and 16")
os.Exit(2)
}
chunkOpts := chunkClientOptions{ chunkOpts := chunkClientOptions{
startSize: *chunkStart, startSize: *chunkStart,
minSize: *chunkMin, minSize: *chunkMin,
@@ -498,15 +510,11 @@ func main() {
) )
} }
wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts) wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts, *wireProbeDelay, *wireProbeThreads)
if *wireMode == WireAuto { fmt.Printf("wire=%s discovering fixed header profile via http://%s/ probe_delay=%s probe_threads=%d\n", *wireMode, probeHost, wireProbeDelay.String(), *wireProbeThreads)
fmt.Printf("wire=auto probing %s\n", probeHost) // Discover in the background so the local listener starts immediately. A
// Resolve in the background so startup is not blocked; a connection that // connection arriving first waits on the same selector lock and result.
// arrives first simply waits for the same result.
go wires.mode() go wires.mode()
} else {
fmt.Printf("wire=%s (manual)\n", *wireMode)
}
slots := make(chan struct{}, *maxConnections) slots := make(chan struct{}, *maxConnections)
+262 -51
View File
@@ -1,18 +1,21 @@
package main package main
import ( import (
"bufio"
"fmt" "fmt"
"net" "net"
"strings" "strings"
"sync" "sync"
"time" "time"
"dragontcp/internal/cover"
"dragontcp/internal/xorchunk" "dragontcp/internal/xorchunk"
) )
// DragonTCP speaks two wires that are not interchangeable: // DragonTCP speaks three wires that are not interchangeable:
// //
// b — compact binary records (29/5-byte headers, SHA-256 keystream mask) // b — compact binary records (29/5-byte headers, clear or SHA-256-compatible payloads)
// bp — compatible registration/upload/download/ACK records, clear or SHA-256-compatible
// x — legacy UP/OK framing with XOR 0xAD over ASCII chunk commands // x — legacy UP/OK framing with XOR 0xAD over ASCII chunk commands
// //
// Networks differ in which they pass, so the client can be pinned to either or // Networks differ in which they pass, so the client can be pinned to either or
@@ -20,6 +23,7 @@ import (
// keeping the first that answers. // keeping the first that answers.
const ( const (
WireBinary = "b" WireBinary = "b"
WireBP = "bp"
WireXOR = "x" WireXOR = "x"
WireAuto = "auto" WireAuto = "auto"
) )
@@ -36,107 +40,314 @@ const (
type wireSelector struct { type wireSelector struct {
mu sync.Mutex mu sync.Mutex
configured string // b, x or auto configured string // b, x or auto
resolved string // b or x once decided resolved wireChoice
hasChoice bool
serverAddr string serverAddr string
token string token string
binOpts chunkClientOptions binOpts chunkClientOptions
xorOpts xorchunk.Options xorOpts xorchunk.Options
probeDelay time.Duration
probeThreads int
// Test hooks are nil in production.
candidateOverride []wireChoice
probeOverride func(wireChoice) bool
} }
func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options) *wireSelector { type wireChoice struct {
mode string
mask byte
cover cover.Profile
}
func (c wireChoice) String() string {
if c.mode == WireBP {
if c.cover.Enabled {
return fmt.Sprintf("bp/%s", c.cover)
}
return "bp/direct"
}
if c.cover.Enabled {
return fmt.Sprintf("%s/mask-%02x/%s", c.mode, c.mask, c.cover)
}
return fmt.Sprintf("%s/mask-%02x/direct", c.mode, c.mask)
}
func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options, probeDelay time.Duration, probeThreads int) *wireSelector {
s := &wireSelector{ s := &wireSelector{
configured: configured, configured: configured,
serverAddr: serverAddr, serverAddr: serverAddr,
token: token, token: token,
binOpts: binOpts, binOpts: binOpts,
xorOpts: xorOpts, xorOpts: xorOpts,
} probeDelay: probeDelay,
if configured != WireAuto { probeThreads: probeThreads,
s.resolved = configured
} }
return s return s
} }
// dial opens a tunnel over the active wire, resolving the wire first if needed. // dial opens a tunnel over the active wire, resolving the wire first if needed.
func (s *wireSelector) dial(host string, port int) (net.Conn, error) { func (s *wireSelector) dial(host string, port int) (net.Conn, error) {
mode := s.mode() choice := s.mode()
if mode == WireXOR { if choice.mode == WireBP {
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts) opts := s.binOpts
opts.headerMask = choice.mask
opts.coverProfile = choice.cover
return openBPTunnel(s.serverAddr, s.token, host, port, opts)
} }
return openChunkTunnel(s.serverAddr, s.token, host, port, s.binOpts) if choice.mode == WireXOR {
if choice.cover.Enabled {
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts.WithCoverProfile(choice.cover))
}
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts.WithHeaderMask(choice.mask))
}
opts := s.binOpts
opts.headerMask = choice.mask
opts.coverProfile = choice.cover
return openChunkTunnel(s.serverAddr, s.token, host, port, opts)
} }
// mode returns the wire to use, running detection once if configured as auto. // mode returns the wire to use, running detection once if configured as auto.
// Detection failure is not cached, so a client that starts before the network // Detection failure is not cached, so a client that starts before the network
// is usable retries on the next connection instead of latching a bad guess. // is usable retries on the next connection instead of latching a bad guess.
func (s *wireSelector) mode() string { func (s *wireSelector) mode() wireChoice {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.resolved != "" { if s.hasChoice {
return s.resolved return s.resolved
} }
if picked, ok := s.detectLocked(); ok { if picked, ok := s.detectLocked(); ok {
s.resolved = picked s.resolved = picked
s.hasChoice = true
return picked return picked
} }
// Undecided: use the binary wire for this attempt without caching it. // Undecided: honor an explicitly pinned family for this attempt without
return WireBinary // caching it. Auto retains the original B fallback and retries discovery on
// the next connection.
switch s.configured {
case WireBP:
return wireChoice{mode: WireBP}
case WireXOR:
return wireChoice{mode: WireXOR}
default:
return wireChoice{mode: WireBinary}
}
} }
func (s *wireSelector) detectLocked() (string, bool) { // profileCandidates covers all compatible B first-byte bases and all X magic
for _, candidate := range []string{WireBinary, WireXOR} { // masks that cannot be confused with B. Profile zero for each wire is first so
if s.probe(candidate) { // existing permissive networks complete discovery quickly.
fmt.Printf("wire probe: %s selected via %s\n", candidate, probeHost) func (s *wireSelector) profileCandidates() []wireChoice {
return candidate, true var binaryProfiles []wireChoice
var xorProfiles []wireChoice
if s.configured == WireAuto || s.configured == WireBinary {
for n := 0; n < 256; n += 8 {
binaryProfiles = append(binaryProfiles, wireChoice{mode: WireBinary, mask: byte(n)})
} }
fmt.Printf("wire probe: %s failed\n", candidate)
} }
fmt.Printf("wire probe: neither wire reached %s; retrying later\n", probeHost) if s.configured == WireAuto || s.configured == WireXOR {
return "", false for n := 0; n < 256; n++ {
mask := byte(n)
if ('U'^mask)&7 >= 5 {
xorProfiles = append(xorProfiles, wireChoice{mode: WireXOR, mask: mask})
}
}
}
paddingRange := []uint16{0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 768, 1024, 1400, 2048, 4096}
makeCovered := func(n int, xor, clear bool) cover.Profile {
first := byte(n)
second := byte(n*197 + 101)
mask := byte(n*149 + 37)
return cover.Profile{
Enabled: true,
ID: uint16(first)<<8 | uint16(second),
Padding: paddingRange[n%len(paddingRange)],
HeaderMask: mask,
XOR: xor,
Clear: clear,
}
}
// New peers try the clear-payload profile first. The next candidates are
// legacy direct profiles, so an older server falls back immediately instead
// of screening the complete expanded profile range.
out := make([]wireChoice, 0, len(binaryProfiles)+len(xorProfiles)+1025)
if s.configured == WireAuto || s.configured == WireBinary {
profile := makeCovered(0, false, true)
out = append(out, wireChoice{mode: WireBinary, mask: profile.HeaderMask, cover: profile})
}
if s.configured == WireAuto || s.configured == WireBP {
profile := makeCovered(0, false, true)
out = append(out, wireChoice{mode: WireBP, mask: profile.HeaderMask, cover: profile})
}
// Interleave formats so neither family can consume the entire discovery
// window before the other one gets a chance.
for i := 0; i < len(binaryProfiles) || i < len(xorProfiles); i++ {
if i < len(binaryProfiles) {
out = append(out, binaryProfiles[i])
}
if i < len(xorProfiles) {
out = append(out, xorProfiles[i])
}
if i == 0 && s.configured == WireAuto {
out = append(out, wireChoice{mode: WireBP})
}
}
if s.configured == WireBP {
out = append(out, wireChoice{mode: WireBP})
}
// Covered profiles expand discovery beyond the one-byte direct formats
// without taking the Cartesian product (which would create thousands of
// connections). Across this distributed range each wire still exercises all
// 256 first bytes, all 256 frame masks, and every padding length repeatedly.
for n := 0; n < 256; n++ {
if s.configured == WireAuto || s.configured == WireBinary {
profile := makeCovered(n, false, false)
out = append(out, wireChoice{mode: WireBinary, mask: profile.HeaderMask, cover: profile})
if n != 0 {
profile = makeCovered(n, false, true)
out = append(out, wireChoice{mode: WireBinary, mask: profile.HeaderMask, cover: profile})
}
}
if s.configured == WireAuto || s.configured == WireBP {
if n != 0 {
profile := makeCovered(n, false, true)
out = append(out, wireChoice{mode: WireBP, mask: profile.HeaderMask, cover: profile})
}
}
if s.configured == WireAuto || s.configured == WireXOR {
profile := makeCovered(n, true, false)
out = append(out, wireChoice{mode: WireXOR, mask: profile.HeaderMask, cover: profile})
}
}
return out
}
// detectLocked validates candidates with real HTTP traffic through ip.dr2.site.
// The default is one worker. Users may explicitly allow more workers, while the
// launch delay still spaces new attempts globally to avoid a connection burst.
func (s *wireSelector) detectLocked() (wireChoice, bool) {
candidates := s.profileCandidates()
if s.candidateOverride != nil {
candidates = s.candidateOverride
}
threads := s.probeThreads
if threads < 1 {
threads = 1
}
if threads > 16 {
threads = 16
}
delay := s.probeDelay
if delay <= 0 {
delay = time.Second
}
type result struct {
choice wireChoice
ok bool
}
results := make(chan result, threads)
next := 0
inflight := 0
completed := 0
started := time.Now()
var lastLaunch time.Time
for next < len(candidates) || inflight > 0 {
canLaunch := next < len(candidates) && inflight < threads
if canLaunch && (lastLaunch.IsZero() || time.Since(lastLaunch) >= delay) {
candidate := candidates[next]
next++
inflight++
lastLaunch = time.Now()
go func(choice wireChoice) {
validated := false
if s.probeOverride != nil {
validated = s.probeOverride(choice)
} else {
validated = s.probe(choice)
}
results <- result{choice: choice, ok: validated}
}(candidate)
continue
}
var got result
if canLaunch {
wait := delay - time.Since(lastLaunch)
timer := time.NewTimer(wait)
select {
case got = <-results:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-timer.C:
continue
}
} else {
got = <-results
}
inflight--
completed++
if got.ok {
fmt.Printf("wire probe: selected=%s completed=%d launched=%d elapsed=%s target=http://%s/ validated=true threads=%d fixed_until_restart=true\n", got.choice, completed, next, time.Since(started).Round(time.Millisecond), probeHost, threads)
return got.choice, true
}
if completed%32 == 0 {
fmt.Printf("wire probe: completed=%d/%d launched=%d elapsed=%s target=http://%s/ no validated profile yet\n", completed, len(candidates), next, time.Since(started).Round(time.Millisecond), probeHost)
}
}
fmt.Printf("wire probe: no profile validated through http://%s/ after %d candidates in %s; retrying later\n", probeHost, completed, time.Since(started).Round(time.Millisecond))
return wireChoice{}, false
} }
// probe fetches probeHost through one wire and reports whether a well-formed // probe fetches probeHost through one wire and reports whether a well-formed
// HTTP status line came back. // HTTP status line came back.
func (s *wireSelector) probe(mode string) bool { func (s *wireSelector) probe(choice wireChoice) bool {
type result struct{ ok bool }
done := make(chan result, 1)
go func() {
var ( var (
conn net.Conn conn net.Conn
err error err error
) )
if mode == WireXOR { if choice.mode == WireXOR {
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts) if choice.cover.Enabled {
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithCoverProfile(choice.cover))
} else { } else {
conn, err = openChunkTunnel(s.serverAddr, s.token, probeHost, probePort, s.binOpts) conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithHeaderMask(choice.mask))
}
} else if choice.mode == WireBP {
opts := s.binOpts
opts.headerMask = choice.mask
opts.coverProfile = choice.cover
opts.skipPathProbe = true
opts.minSize = 32
opts.startSize = 32
opts.maxSize = 32
conn, err = openBPTunnel(s.serverAddr, s.token, probeHost, probePort, opts)
} else {
opts := s.binOpts
opts.headerMask = choice.mask
opts.coverProfile = choice.cover
opts.skipPathProbe = true
opts.minSize = 32
opts.startSize = 32
opts.maxSize = 32
conn, err = openChunkTunnel(s.serverAddr, s.token, probeHost, probePort, opts)
} }
if err != nil { if err != nil {
done <- result{false} return false
return
} }
defer conn.Close() defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(probeTimeout))
request := "GET / HTTP/1.1\r\nHost: " + probeHost + "\r\nUser-Agent: dragontcp\r\nConnection: close\r\n\r\n" request := "GET / HTTP/1.1\r\nHost: " + probeHost + "\r\nUser-Agent: dragontcp\r\nConnection: close\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil { if _, err := conn.Write([]byte(request)); err != nil {
done <- result{false}
return
}
buf := make([]byte, 64)
n, err := conn.Read(buf)
if n <= 0 || (err != nil && n == 0) {
done <- result{false}
return
}
done <- result{strings.HasPrefix(string(buf[:n]), "HTTP/")}
}()
select {
case r := <-done:
return r.ok
case <-time.After(probeTimeout):
// The tunnel goroutine is left to unwind on its own; the wire simply
// did not answer in time, which is all the caller needs to know.
return false return false
} }
statusLine, err := bufio.NewReader(conn).ReadString('\n')
return err == nil && strings.HasPrefix(statusLine, "HTTP/")
} }
@@ -0,0 +1,171 @@
package main
import (
"sync/atomic"
"testing"
"time"
)
func TestProfileCandidatesCoverBothWireFamilies(t *testing.T) {
selector := &wireSelector{configured: WireAuto}
candidates := selector.profileCandidates()
binaryCount, bpCount, xorCount := 0, 0, 0
seen := make(map[wireChoice]bool, len(candidates))
firstBytes := make(map[byte]bool, 256)
coveredMasks := map[string]map[byte]bool{WireBinary: {}, WireBP: {}, WireXOR: {}}
coveredPadding := map[string]map[uint16]bool{WireBinary: {}, WireBP: {}, WireXOR: {}}
for _, candidate := range candidates {
if seen[candidate] {
t.Fatalf("duplicate candidate: %s", candidate)
}
seen[candidate] = true
if candidate.cover.Enabled {
coveredMasks[candidate.mode][candidate.mask] = true
coveredPadding[candidate.mode][candidate.cover.Padding] = true
}
switch candidate.mode {
case WireBinary:
binaryCount++
if !candidate.cover.Enabled && candidate.mask&7 != 0 {
t.Fatalf("ambiguous binary mask: %02x", candidate.mask)
}
if candidate.cover.Enabled {
firstBytes[byte(candidate.cover.ID>>8)] = true
} else {
for mode := byte(0); mode <= 4; mode++ {
firstBytes[mode^candidate.mask] = true
}
}
case WireXOR:
xorCount++
if !candidate.cover.Enabled && ('U'^candidate.mask)&7 < 5 {
t.Fatalf("ambiguous XOR mask: %02x", candidate.mask)
}
if candidate.cover.Enabled {
firstBytes[byte(candidate.cover.ID>>8)] = true
} else {
firstBytes['U'^candidate.mask] = true
}
case WireBP:
bpCount++
if candidate.cover.Enabled && !candidate.cover.Clear {
t.Fatalf("covered BP profile must use clear payloads: %s", candidate)
}
if !candidate.cover.Enabled && candidate.mask != 0 {
t.Fatalf("direct BP profile must keep a clear header: %s", candidate)
}
default:
t.Fatalf("unknown candidate: %s", candidate)
}
}
if binaryCount != 544 || bpCount != 257 || xorCount != 352 {
t.Fatalf("profiles B=%d BP=%d X=%d, want B=544 BP=257 X=352", binaryCount, bpCount, xorCount)
}
if len(firstBytes) != 256 {
t.Fatalf("profiles cover %d first-byte values, want 256", len(firstBytes))
}
for _, mode := range []string{WireBinary, WireBP, WireXOR} {
if len(coveredMasks[mode]) != 256 {
t.Fatalf("mode %s covers %d masks, want 256", mode, len(coveredMasks[mode]))
}
if len(coveredPadding[mode]) != 16 {
t.Fatalf("mode %s covers %d padding lengths, want 16", mode, len(coveredPadding[mode]))
}
}
}
func TestManualWireStillDiscoversAllProfilesForThatFamily(t *testing.T) {
for _, tc := range []struct {
mode string
want int
}{{WireBinary, 544}, {WireBP, 257}, {WireXOR, 352}} {
selector := &wireSelector{configured: tc.mode}
candidates := selector.profileCandidates()
if len(candidates) != tc.want {
t.Fatalf("mode %s profiles=%d, want %d", tc.mode, len(candidates), tc.want)
}
for _, candidate := range candidates {
if candidate.mode != tc.mode {
t.Fatalf("mode %s included %s", tc.mode, candidate)
}
}
}
}
func TestClearProfilesAreTriedBeforeLegacyFallbacks(t *testing.T) {
for _, mode := range []string{WireBinary, WireBP} {
candidates := (&wireSelector{configured: mode}).profileCandidates()
if len(candidates) < 2 || !candidates[0].cover.Clear {
t.Fatalf("mode %s does not prefer a clear profile", mode)
}
if candidates[1].cover.Enabled {
t.Fatalf("mode %s does not fall back immediately to a legacy direct profile", mode)
}
}
}
func measureDiscoveryConcurrency(t *testing.T, threads int) int32 {
t.Helper()
candidates := make([]wireChoice, 24)
for i := range candidates {
candidates[i] = wireChoice{mode: WireBinary, mask: byte(i * 8)}
}
var active atomic.Int32
var maximum atomic.Int32
var calls atomic.Int32
selector := &wireSelector{
configured: WireAuto,
candidateOverride: candidates,
probeThreads: threads,
probeDelay: time.Nanosecond,
probeOverride: func(wireChoice) bool {
current := active.Add(1)
for {
old := maximum.Load()
if current <= old || maximum.CompareAndSwap(old, current) {
break
}
}
calls.Add(1)
// Keep attempts alive long enough for the globally spaced scheduler
// to fill every configured worker reliably on slower CI runners.
time.Sleep(20 * time.Millisecond)
active.Add(-1)
return false
},
}
if _, ok := selector.detectLocked(); ok {
t.Fatal("unexpected working profile")
}
if calls.Load() != int32(len(candidates)) {
t.Fatalf("screened=%d, want %d", calls.Load(), len(candidates))
}
return maximum.Load()
}
func TestDiscoveryDefaultsToOneWorker(t *testing.T) {
if maximum := measureDiscoveryConcurrency(t, 0); maximum != 1 {
t.Fatalf("maximum concurrent probes=%d, want 1", maximum)
}
}
func TestDiscoveryHonorsConfiguredWorkers(t *testing.T) {
if maximum := measureDiscoveryConcurrency(t, 4); maximum != 4 {
t.Fatalf("maximum concurrent probes=%d, want 4", maximum)
}
}
func TestFailedManualDiscoveryKeepsPinnedWire(t *testing.T) {
for _, mode := range []string{WireBinary, WireBP, WireXOR} {
selector := &wireSelector{
configured: mode,
candidateOverride: []wireChoice{{mode: mode}},
probeDelay: time.Nanosecond,
probeOverride: func(wireChoice) bool { return false },
}
if choice := selector.mode(); choice.mode != mode {
t.Fatalf("configured=%s fallback=%s", mode, choice.mode)
}
}
}
+653
View File
@@ -0,0 +1,653 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
"dragontcp/internal/wire"
)
const (
bhttpModeProbe byte = 0
bhttpModeUpload byte = 1
bhttpModeDownload byte = 2
bhttpModeBatchDownload byte = 3
bhttpModeACK byte = 4
bhttpProbeVersion byte = 1
bhttpRequestHeaderSize = 29
)
var bhttpProbeMagic = [4]byte{'B', 'H', 'P', '1'}
var bhttpOpenMagic = [4]byte{'D', 'O', 'P', '1'}
var bpCloseMagic = [4]byte{'D', 'C', 'L', '1'}
// bhttpSession intentionally models only the transport/session behavior that
// is observable in bhttp_remote_test.py. The supplied client test contains no
// destination-selection handshake, so uploads are acknowledged and counted but
// are not forwarded to an invented target.
type bhttpSession struct {
mu sync.Mutex
lastSeen time.Time
uploaded uint64
acked uint64
stream *streamSession
}
func (s *bhttpSession) touch() {
s.mu.Lock()
s.lastSeen = time.Now()
s.mu.Unlock()
}
type bhttpSessionManager struct {
mu sync.RWMutex
sessions map[string]*bhttpSession
timeout time.Duration
max int
}
func newBHTTPSessionManager(timeout time.Duration, max int) *bhttpSessionManager {
if timeout <= 0 {
timeout = 2 * time.Minute
}
if max < 1 {
max = 1
}
m := &bhttpSessionManager{
sessions: make(map[string]*bhttpSession),
timeout: timeout,
max: max,
}
go m.cleanupLoop()
return m
}
func (m *bhttpSessionManager) get(sid wire.SessionID) *bhttpSession {
m.mu.RLock()
s := m.sessions[sidKey(sid)]
m.mu.RUnlock()
if s != nil {
s.touch()
}
return s
}
func (m *bhttpSessionManager) register(sid wire.SessionID) bool {
key := sidKey(sid)
m.mu.Lock()
if old := m.sessions[key]; old != nil {
m.mu.Unlock()
old.touch()
return true
}
if len(m.sessions) >= m.max {
m.mu.Unlock()
return false
}
m.sessions[key] = &bhttpSession{lastSeen: time.Now()}
m.mu.Unlock()
return true
}
func (m *bhttpSessionManager) remove(sid wire.SessionID) bool {
key := sidKey(sid)
m.mu.Lock()
session := m.sessions[key]
delete(m.sessions, key)
m.mu.Unlock()
if session == nil {
return false
}
session.mu.Lock()
stream := session.stream
session.stream = nil
session.mu.Unlock()
if stream != nil {
stream.close()
}
return true
}
func (m *bhttpSessionManager) cleanupLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for now := range ticker.C {
cutoff := now.Add(-m.timeout)
var closing []*streamSession
m.mu.Lock()
for key, session := range m.sessions {
session.mu.Lock()
stale := session.lastSeen.Before(cutoff)
stream := session.stream
session.mu.Unlock()
if stale {
delete(m.sessions, key)
if stream != nil {
closing = append(closing, stream)
}
}
}
m.mu.Unlock()
for _, stream := range closing {
stream.close()
}
}
}
type bhttpRequest struct {
mode byte
session wire.SessionID
seq uint64
value uint32
payload []byte
headerMask byte
clear bool
}
type binaryHeader struct {
mode byte
session wire.SessionID
seq uint64
length uint32
}
func peekBinaryHeader(r *bufio.Reader, headerMask byte) (binaryHeader, error) {
var out binaryHeader
header, err := r.Peek(bhttpRequestHeaderSize)
if err != nil {
return out, err
}
out.mode = header[0] ^ headerMask
copy(out.session[:], header[1:17])
out.seq = binary.BigEndian.Uint64(header[17:25])
out.length = binary.BigEndian.Uint32(header[25:29])
return out, nil
}
func readBHTTPRequest(r *bufio.Reader, headerMask byte, clear bool) (bhttpRequest, error) {
var req bhttpRequest
var header [bhttpRequestHeaderSize]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return req, err
}
req.mode = header[0] ^ headerMask
req.headerMask = headerMask
req.clear = clear
if req.mode > bhttpModeACK {
return req, fmt.Errorf("unknown BP mode")
}
copy(req.session[:], header[1:17])
req.seq = binary.BigEndian.Uint64(header[17:25])
req.value = binary.BigEndian.Uint32(header[25:29])
// BHTTP mode 2 overloads the normal body-length field as a download-size
// hint and sends no payload bytes after the 29-byte header.
if req.mode == bhttpModeDownload {
return req, nil
}
if req.value > wire.MaxPayload {
return req, fmt.Errorf("BP payload too large")
}
if req.value > 0 {
req.payload = make([]byte, int(req.value))
if _, err := io.ReadFull(r, req.payload); err != nil {
return req, err
}
if !clear {
wire.MaskInPlace(req.payload, req.session, req.mode, req.seq, false)
}
}
return req, nil
}
func parseBHTTPProbe(payload []byte) (byte, int, error) {
if len(payload) < 10 || !bytes.Equal(payload[:4], bhttpProbeMagic[:]) || payload[4] != bhttpProbeVersion {
return 0, 0, fmt.Errorf("bad BP probe")
}
submode := payload[5]
if submode > bhttpModeACK {
return 0, 0, fmt.Errorf("unknown BP probe submode")
}
param := int(binary.BigEndian.Uint32(payload[6:10]))
want := 10
if submode == bhttpModeUpload && param >= 10 {
want = param
}
if len(payload) != want {
return 0, 0, fmt.Errorf("bad BP probe length")
}
for i := 10; i < len(payload); i++ {
if payload[i] != byte(i*31) {
return 0, 0, fmt.Errorf("bad BP probe pattern")
}
}
return submode, param, nil
}
func makeBHTTPProbe(submode byte, param int) []byte {
total := 10
if submode == bhttpModeDownload && param > total {
total = param
}
out := make([]byte, total)
copy(out[:4], bhttpProbeMagic[:])
out[4] = bhttpProbeVersion
out[5] = submode
binary.BigEndian.PutUint32(out[6:10], uint32(param))
for i := 10; i < len(out); i++ {
out[i] = byte(i * 31)
}
return out
}
func writeBHTTPError(conn net.Conn, message string) error {
return wire.WriteResponse(conn, wire.StatusError, []byte(message))
}
func writeBHTTPMasked(conn net.Conn, status byte, body []byte, req bhttpRequest) error {
return wire.WriteMaskedResponseProfileEncoding(conn, status, body, req.session, req.mode, req.seq, req.headerMask, req.clear)
}
func writeBHTTPData(conn net.Conn, req bhttpRequest, data []byte) error {
// Build and mask the complete response once. The generic two-step path
// first built a BP body and then copied it into another framed packet,
// temporarily allocating roughly twice the download size.
if req.clear {
var header [wire.ResponseHeaderSize]byte
header[0] = wire.StatusData ^ req.headerMask
binary.BigEndian.PutUint32(header[1:5], uint32(4+len(data)))
var length [4]byte
binary.BigEndian.PutUint32(length[:], uint32(len(data)))
buffers := net.Buffers{header[:], length[:], data}
_, err := buffers.WriteTo(conn)
return err
}
packet := make([]byte, wire.ResponseHeaderSize+4+len(data))
packet[0] = wire.StatusData ^ req.headerMask
binary.BigEndian.PutUint32(packet[1:5], uint32(4+len(data)))
binary.BigEndian.PutUint32(packet[5:9], uint32(len(data)))
copy(packet[9:], data)
wire.MaskInPlace(packet[5:], req.session, req.mode, req.seq, true)
for len(packet) > 0 {
n, err := conn.Write(packet)
if err != nil {
return err
}
if n <= 0 {
return io.ErrShortWrite
}
packet = packet[n:]
}
return nil
}
type bhttpServerContext struct {
sessions *bhttpSessionManager
token string
allowPrivate bool
cache *dnsCache
tcpBuffer int
maxChunk int
maxBuffer int
pollWait time.Duration
debug *serverDebug
}
func processBHTTPRequest(conn net.Conn, req bhttpRequest, ctx *bhttpServerContext) error {
sessions := ctx.sessions
maxChunk := ctx.maxChunk
switch req.mode {
case bhttpModeProbe:
submode, param, err := parseBHTTPProbe(req.payload)
if err != nil {
return writeBHTTPError(conn, err.Error())
}
if submode == bhttpModeUpload && len(req.payload) > maxChunk {
return writeBHTTPError(conn, "probe too large")
}
if submode == bhttpModeDownload && (param < 0 || param > maxChunk) {
return writeBHTTPError(conn, "probe too large")
}
count := 1
if submode == bhttpModeACK {
count = param
if count < 1 {
count = 1
}
if count > 256 {
count = 256
}
}
body := makeBHTTPProbe(submode, param)
for i := 0; i < count; i++ {
// The reference client decrypts every batch echo with the original
// request sequence, rather than incrementing it per response.
if err := writeBHTTPMasked(conn, wire.StatusOK, body, req); err != nil {
return err
}
}
return nil
case bhttpModeUpload:
if req.seq == 0 && len(req.payload) == 0 {
if !sessions.register(req.session) {
return writeBHTTPError(conn, "session limit reached")
}
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
session := sessions.get(req.session)
if session == nil {
return writeBHTTPError(conn, "unknown session")
}
if len(req.payload) > maxChunk {
return writeBHTTPError(conn, "upload too large")
}
if req.seq == 1 && len(req.payload) >= len(bhttpOpenMagic) && bytes.Equal(req.payload[:len(bhttpOpenMagic)], bhttpOpenMagic[:]) {
supplied, host, port, err := parseOpen(req.payload[len(bhttpOpenMagic):])
if err != nil {
return writeBHTTPError(conn, err.Error())
}
if !tokenEqual(supplied, ctx.token) {
return writeBHTTPError(conn, "authentication failed")
}
session.mu.Lock()
alreadyOpen := session.stream != nil
session.mu.Unlock()
if alreadyOpen {
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
dialCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
target, err := dialTarget(dialCtx, host, port, ctx.allowPrivate, ctx.cache, ctx.tcpBuffer)
cancel()
if err != nil {
return writeBHTTPError(conn, err.Error())
}
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, ctx.debug)
session.mu.Lock()
if session.stream == nil {
session.stream = stream
session.lastSeen = time.Now()
stream = nil
}
session.mu.Unlock()
if stream != nil {
stream.close()
}
if ctx.debug != nil && ctx.debug.enabled {
ctx.debug.logf("BP OPEN sid=%x target=%s:%d", req.session[:4], host, port)
}
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
session.mu.Lock()
stream := session.stream
session.mu.Unlock()
if stream != nil {
if req.seq < 2 {
return writeBHTTPError(conn, "bad upload sequence")
}
if err := stream.upload(req.seq-2, req.payload); err != nil {
return writeBHTTPError(conn, err.Error())
}
}
session.mu.Lock()
session.uploaded += uint64(len(req.payload))
session.lastSeen = time.Now()
session.mu.Unlock()
return wire.WriteResponse(conn, wire.StatusOK, nil)
case bhttpModeDownload:
session := sessions.get(req.session)
if session == nil {
return writeBHTTPError(conn, "unknown session")
}
session.mu.Lock()
stream := session.stream
session.mu.Unlock()
if stream == nil {
// The reference transport has no observable downstream producer.
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
limit := int(req.value)
if limit < 1 {
limit = 1
}
if limit > maxChunk {
limit = maxChunk
}
data, status, err := stream.readAt(req.seq, limit, ctx.pollWait)
if err != nil {
return writeBHTTPError(conn, err.Error())
}
switch status {
case wire.StatusData:
return writeBHTTPData(conn, req, data)
case wire.StatusEOF:
return wire.WriteResponse(conn, wire.StatusEOF, nil)
default:
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
case bhttpModeBatchDownload:
session := sessions.get(req.session)
if session == nil {
return writeBHTTPError(conn, "unknown session")
}
if len(req.payload) != 6 {
return writeBHTTPError(conn, "bad batch download request")
}
count := int(binary.BigEndian.Uint16(req.payload[4:6]))
limit := int(binary.BigEndian.Uint32(req.payload[:4]))
if limit < 1 {
limit = 1
}
if limit > maxChunk {
limit = maxChunk
}
if count < 1 {
count = 1
}
if count > 256 {
count = 256
}
session.mu.Lock()
stream := session.stream
session.mu.Unlock()
offset := req.seq
for i := 0; i < count; i++ {
if stream == nil {
if err := wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
return err
}
continue
}
wait := time.Duration(0)
if i == 0 {
wait = ctx.pollWait
}
data, status, err := stream.readAt(offset, limit, wait)
if err != nil {
return writeBHTTPError(conn, err.Error())
}
switch status {
case wire.StatusData:
if err := writeBHTTPData(conn, req, data); err != nil {
return err
}
offset += uint64(len(data))
case wire.StatusEOF:
if err := wire.WriteResponse(conn, wire.StatusEOF, nil); err != nil {
return err
}
default:
if err := wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
return err
}
}
}
return nil
case bhttpModeACK:
session := sessions.get(req.session)
if session == nil {
return writeBHTTPError(conn, "unknown session")
}
// Dragon's BP extension sends an explicit close marker. Reference BP
// clients continue to use an empty ACK, while Dragon clients release the
// target socket and buffered download data immediately instead of waiting
// for the idle-session reaper.
if bytes.Equal(req.payload, bpCloseMagic[:]) {
sessions.remove(req.session)
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
session.mu.Lock()
if req.seq > session.acked {
session.acked = req.seq
}
session.lastSeen = time.Now()
stream := session.stream
session.mu.Unlock()
if stream != nil {
stream.ack(req.seq)
}
return wire.WriteResponse(conn, wire.StatusOK, nil)
}
return writeBHTTPError(conn, "unknown mode")
}
type binaryFlavor byte
const (
binaryFlavorUnknown binaryFlavor = iota
binaryFlavorDragon
binaryFlavorBHTTP
)
func isBHTTPProbe(payload []byte) bool {
return len(payload) >= 4 && bytes.Equal(payload[:4], bhttpProbeMagic[:])
}
// handleBinary auto-detects the two protocols without changing the native B
// header space. BHTTP is clear-header only; Dragon profiles and cover-prefaced
// connections continue through the existing handler unchanged.
func handleBinary(
conn net.Conn,
headerMask byte,
clearPayload bool,
token string,
allowPrivate bool,
cache *dnsCache,
tcpBuffer int,
manager *streamManager,
bhttp *bhttpSessionManager,
chunkMax int,
bufferBytes int,
pollWait time.Duration,
debug *serverDebug,
) {
reader := bufio.NewReader(conn)
bhttpContext := &bhttpServerContext{
sessions: bhttp,
token: token,
allowPrivate: allowPrivate,
cache: cache,
tcpBuffer: tcpBuffer,
maxChunk: chunkMax,
maxBuffer: bufferBytes,
pollWait: pollWait,
debug: debug,
}
flavor := binaryFlavorUnknown
deadline := newIdleDeadline(conn, 30*time.Second)
for {
if deadline.refresh() != nil {
return
}
header, err := peekBinaryHeader(reader, headerMask)
if err != nil {
return
}
if flavor == binaryFlavorUnknown {
switch header.mode {
case bhttpModeProbe:
// Probe framing is shared, so consume it once and use its magic
// to select BHP1 or DTP2 without losing any bytes.
req, err := wire.ReadRequestProfileEncoding(reader, headerMask, clearPayload)
if err != nil {
return
}
if isBHTTPProbe(req.Payload) {
flavor = binaryFlavorBHTTP
breq := bhttpRequest{mode: req.Mode, session: req.Session, seq: req.Seq, value: uint32(len(req.Payload)), payload: req.Payload, headerMask: headerMask, clear: clearPayload}
if processBHTTPRequest(conn, breq, bhttpContext) != nil {
return
}
continue
}
flavor = binaryFlavorDragon
if processWireRequest(conn, req, token, allowPrivate, cache, tcpBuffer, manager, chunkMax, bufferBytes, pollWait, debug) != nil {
return
}
continue
case bhttpModeUpload:
if bhttp.get(header.session) != nil || (header.seq == 0 && header.length == 0) {
flavor = binaryFlavorBHTTP
} else {
flavor = binaryFlavorDragon
}
case bhttpModeDownload:
if bhttp.get(header.session) != nil {
flavor = binaryFlavorBHTTP
} else if manager.get(header.session) != nil {
flavor = binaryFlavorDragon
} else {
// The BHTTP unknown-session test sends only a header whose
// length field is a hint. Consume no nonexistent body.
if _, err := readBHTTPRequest(reader, headerMask, clearPayload); err == nil {
_ = writeBHTTPError(conn, "unknown session")
}
return
}
case bhttpModeBatchDownload:
if bhttp.get(header.session) != nil || header.length == 6 {
flavor = binaryFlavorBHTTP
} else {
flavor = binaryFlavorDragon
}
case bhttpModeACK:
if bhttp.get(header.session) != nil {
flavor = binaryFlavorBHTTP
} else {
flavor = binaryFlavorDragon
}
default:
return
}
}
if flavor == binaryFlavorBHTTP {
req, err := readBHTTPRequest(reader, headerMask, clearPayload)
if err != nil || processBHTTPRequest(conn, req, bhttpContext) != nil {
return
}
continue
}
req, err := wire.ReadRequestProfileEncoding(reader, headerMask, clearPayload)
if err != nil {
return
}
if processWireRequest(conn, req, token, allowPrivate, cache, tcpBuffer, manager, chunkMax, bufferBytes, pollWait, debug) != nil {
return
}
}
}
+301
View File
@@ -0,0 +1,301 @@
package main
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
"dragontcp/internal/cover"
"dragontcp/internal/wire"
)
func writeBHTTPTestRequest(w io.Writer, mode byte, sid wire.SessionID, seq uint64, payload []byte, downloadHint uint32) error {
n := uint32(len(payload))
if mode == bhttpModeDownload {
n = downloadHint
payload = nil
}
packet := make([]byte, bhttpRequestHeaderSize+len(payload))
packet[0] = mode
copy(packet[1:17], sid[:])
binary.BigEndian.PutUint64(packet[17:25], seq)
binary.BigEndian.PutUint32(packet[25:29], n)
copy(packet[29:], payload)
wire.MaskInPlace(packet[29:], sid, mode, seq, false)
_, err := w.Write(packet)
return err
}
func readBHTTPTestResponse(r io.Reader, sid wire.SessionID, mode byte, seq uint64) (byte, []byte, error) {
status, body, err := wire.ReadResponse(r)
if err == nil && status != wire.StatusError {
wire.MaskInPlace(body, sid, mode, seq, true)
}
return status, body, err
}
func startBHTTPTestServer(t *testing.T, sessions *bhttpSessionManager) (net.Conn, <-chan struct{}) {
t.Helper()
server, client := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
defer server.Close()
handleBinary(
server,
0,
false,
"",
false,
newDNSCache(time.Minute, 16),
0,
newStreamManager(time.Minute, nil),
sessions,
1024*1024,
1024*1024,
10*time.Millisecond,
nil,
)
}()
return client, done
}
func TestBHTTPReferenceSessionStack(t *testing.T) {
sessions := newBHTTPSessionManager(time.Minute, 32)
client, done := startBHTTPTestServer(t, sessions)
defer func() {
client.Close()
<-done
}()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
var sid wire.SessionID
for i := range sid {
sid[i] = byte(i + 1)
}
if err := writeBHTTPTestRequest(client, bhttpModeUpload, sid, 0, nil, 0); err != nil {
t.Fatal(err)
}
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeUpload, 0); err != nil || status != wire.StatusOK {
t.Fatalf("registration status=%d err=%v", status, err)
}
if err := writeBHTTPTestRequest(client, bhttpModeUpload, sid, 1, []byte("Hello BHTTP"), 0); err != nil {
t.Fatal(err)
}
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeUpload, 1); err != nil || status != wire.StatusOK {
t.Fatalf("upload status=%d err=%v", status, err)
}
// The size is in the header but no 1,350-byte body follows. This is the
// framing difference that made the native Dragon parser wait forever.
if err := writeBHTTPTestRequest(client, bhttpModeDownload, sid, 0, nil, 1350); err != nil {
t.Fatal(err)
}
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeDownload, 0); err != nil || status != wire.StatusOK {
t.Fatalf("download status=%d err=%v", status, err)
}
batch := make([]byte, 6)
binary.BigEndian.PutUint32(batch[:4], 1350)
binary.BigEndian.PutUint16(batch[4:], 2)
if err := writeBHTTPTestRequest(client, bhttpModeBatchDownload, sid, 0, batch, 0); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeBatchDownload, 0); err != nil || status != wire.StatusOK {
t.Fatalf("batch response %d status=%d err=%v", i, status, err)
}
}
if err := writeBHTTPTestRequest(client, bhttpModeACK, sid, 5, nil, 0); err != nil {
t.Fatal(err)
}
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeACK, 5); err != nil || status != wire.StatusOK {
t.Fatalf("ack status=%d err=%v", status, err)
}
}
func TestBPExplicitCloseRemovesSession(t *testing.T) {
sessions := newBHTTPSessionManager(time.Minute, 32)
client, done := startBHTTPTestServer(t, sessions)
defer func() {
client.Close()
<-done
}()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
var sid wire.SessionID
copy(sid[:], []byte("close-session-01"))
if err := writeBHTTPTestRequest(client, bhttpModeUpload, sid, 0, nil, 0); err != nil {
t.Fatal(err)
}
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeUpload, 0); err != nil || status != wire.StatusOK {
t.Fatalf("registration status=%d err=%v", status, err)
}
if sessions.get(sid) == nil {
t.Fatal("registered session is missing")
}
if err := writeBHTTPTestRequest(client, bhttpModeACK, sid, 0, bpCloseMagic[:], 0); err != nil {
t.Fatal(err)
}
if status, _, err := readBHTTPTestResponse(client, sid, bhttpModeACK, 0); err != nil || status != wire.StatusOK {
t.Fatalf("close status=%d err=%v", status, err)
}
if sessions.get(sid) != nil {
t.Fatal("explicit close retained the session")
}
}
func TestBHTTPReferenceProbeAndBatchEcho(t *testing.T) {
sessions := newBHTTPSessionManager(time.Minute, 32)
client, done := startBHTTPTestServer(t, sessions)
defer func() {
client.Close()
<-done
}()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
var sid wire.SessionID
copy(sid[:], []byte("probe-session-01"))
payload := make([]byte, 10)
copy(payload[:4], []byte("BHP1"))
payload[4] = 1
payload[5] = bhttpModeDownload
binary.BigEndian.PutUint32(payload[6:], 512)
if err := writeBHTTPTestRequest(client, bhttpModeProbe, sid, 0, payload, 0); err != nil {
t.Fatal(err)
}
status, body, err := readBHTTPTestResponse(client, sid, bhttpModeProbe, 0)
if err != nil || status != wire.StatusOK || len(body) != 512 || !bytes.Equal(body[:10], payload) {
t.Fatalf("download probe status=%d len=%d err=%v", status, len(body), err)
}
for i := 10; i < len(body); i++ {
if body[i] != byte(i*31) {
t.Fatalf("probe pattern byte %d=%02x", i, body[i])
}
}
payload[5] = bhttpModeACK
binary.BigEndian.PutUint32(payload[6:], 3)
if err := writeBHTTPTestRequest(client, bhttpModeProbe, sid, 0, payload, 0); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
status, body, err := readBHTTPTestResponse(client, sid, bhttpModeProbe, 0)
if err != nil || status != wire.StatusOK || !bytes.Equal(body, payload) {
t.Fatalf("batch probe %d status=%d body=%x err=%v", i, status, body, err)
}
}
}
func TestBHTTPUnknownSessionDownloadHasNoBody(t *testing.T) {
sessions := newBHTTPSessionManager(time.Minute, 32)
client, done := startBHTTPTestServer(t, sessions)
defer func() {
client.Close()
<-done
}()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
var sid wire.SessionID
copy(sid[:], []byte("unknown-session!"))
if err := writeBHTTPTestRequest(client, bhttpModeDownload, sid, 0, nil, 1350); err != nil {
t.Fatal(err)
}
status, _, err := wire.ReadResponse(client)
if err != nil || status == wire.StatusOK || status == wire.StatusData {
t.Fatalf("unknown session status=%d err=%v", status, err)
}
}
func TestBinaryAutoDetectionKeepsNativeDragonProbe(t *testing.T) {
sessions := newBHTTPSessionManager(time.Minute, 32)
client, done := startBHTTPTestServer(t, sessions)
defer func() {
client.Close()
<-done
}()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
var sid wire.SessionID
payload := make([]byte, 11)
copy(payload[:4], wire.ProbeMagic[:])
payload[4] = wire.ProbeKeepalive
if err := wire.WriteRequest(client, wire.ModeProbe, sid, 1, payload); err != nil {
t.Fatal(err)
}
status, _, err := wire.ReadResponse(client)
if err != nil || status != wire.StatusOK {
t.Fatalf("native probe status=%d err=%v", status, err)
}
}
func TestClearCoveredBinaryAndBPProfiles(t *testing.T) {
for _, bp := range []bool{false, true} {
t.Run(map[bool]string{false: "B", true: "BP"}[bp], func(t *testing.T) {
server, client := net.Pipe()
profile := cover.Profile{Enabled: true, ID: 0x8173, Padding: 32, HeaderMask: 0x9b, Clear: true}
done := make(chan struct{})
go func() {
defer close(done)
defer server.Close()
profiled, isXOR, mask, err := sniffWire(server)
if err != nil || isXOR {
return
}
handleBinary(
profiled, mask, true, "", false,
newDNSCache(time.Minute, 16), 0,
newStreamManager(time.Minute, nil),
newBHTTPSessionManager(time.Minute, 32),
1024*1024, 1024*1024, 10*time.Millisecond, nil,
)
}()
defer func() {
client.Close()
<-done
}()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
if err := cover.WritePreface(client, profile); err != nil {
t.Fatal(err)
}
var sid wire.SessionID
copy(sid[:], []byte("clear-profile-01"))
if bp {
payload := makeBHTTPProbe(bhttpModeDownload, 256)[:10]
packet := make([]byte, bhttpRequestHeaderSize+len(payload))
packet[0] = bhttpModeProbe ^ profile.HeaderMask
copy(packet[1:17], sid[:])
binary.BigEndian.PutUint32(packet[25:29], uint32(len(payload)))
copy(packet[29:], payload)
if _, err := client.Write(packet); err != nil {
t.Fatal(err)
}
status, body, err := wire.ReadResponseProfile(client, profile.HeaderMask)
if err != nil || status != wire.StatusOK || !bytes.Equal(body, makeBHTTPProbe(bhttpModeDownload, 256)) {
t.Fatalf("clear BP status=%d len=%d err=%v", status, len(body), err)
}
return
}
payload := make([]byte, 11)
copy(payload[:4], wire.ProbeMagic[:])
payload[4] = wire.ProbeDownload
binary.BigEndian.PutUint32(payload[7:11], 256)
if err := wire.WriteRequestProfileEncoding(client, wire.ModeProbe, sid, 7, payload, profile.HeaderMask, true); err != nil {
t.Fatal(err)
}
status, body, err := wire.ReadResponseProfile(client, profile.HeaderMask)
if err != nil || status != wire.StatusData || !bytes.Equal(body, probePattern(256)) {
t.Fatalf("clear B status=%d len=%d err=%v", status, len(body), err)
}
})
}
}
+5 -2
View File
@@ -9,6 +9,7 @@ import (
"sync" "sync"
"time" "time"
"dragontcp/internal/protocol"
"dragontcp/internal/wire" "dragontcp/internal/wire"
) )
@@ -55,11 +56,13 @@ func (s *streamSession) signalLocked() {
func (s *streamSession) touchLocked() { s.lastSeen = time.Now() } func (s *streamSession) touchLocked() { s.lastSeen = time.Now() }
func (s *streamSession) readTarget() { func (s *streamSession) readTarget() {
tmp := make([]byte, 64*1024) ptr := protocol.BufferPool.Get().(*[]byte)
tmp := *ptr
defer protocol.BufferPool.Put(ptr)
for { for {
n, err := s.target.Read(tmp) n, err := s.target.Read(tmp)
if n > 0 { if n > 0 {
data := append([]byte(nil), tmp[:n]...) data := tmp[:n]
for len(data) > 0 { for len(data) > 0 {
s.mu.Lock() s.mu.Lock()
for !s.closed && len(s.buf) >= s.maxBuffer { for !s.closed && len(s.buf) >= s.maxBuffer {
+181
View File
@@ -1,8 +1,17 @@
package main package main
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"fmt"
"io"
"net"
"testing" "testing"
"time"
"dragontcp/internal/cover"
"dragontcp/internal/protocol"
"dragontcp/internal/wire"
) )
func TestParseOpenAllowsEmptyToken(t *testing.T) { func TestParseOpenAllowsEmptyToken(t *testing.T) {
@@ -20,3 +29,175 @@ func TestParseOpenAllowsEmptyToken(t *testing.T) {
t.Fatalf("got token=%q host=%q port=%d", token, gotHost, port) t.Fatalf("got token=%q host=%q port=%d", token, gotHost, port)
} }
} }
func TestBinaryProfileProbeEndToEnd(t *testing.T) {
for n := 0; n < 256; n += 8 {
mask := byte(n)
server, client := net.Pipe()
clientResult := make(chan error, 1)
go func() {
defer client.Close()
var sid wire.SessionID
payload := make([]byte, 11)
copy(payload[:4], wire.ProbeMagic[:])
payload[4] = wire.ProbeKeepalive
if err := wire.WriteRequestProfile(client, wire.ModeProbe, sid, 1, payload, mask); err != nil {
clientResult <- err
return
}
status, _, err := wire.ReadResponseProfile(client, mask)
if err == nil && status != wire.StatusOK {
err = fmt.Errorf("status=%d", status)
}
clientResult <- err
}()
profiled, isXOR, gotMask, err := sniffWire(server)
if err != nil || isXOR || gotMask != mask {
t.Fatalf("mask %02x sniff: xor=%t gotMask=%02x err=%v", mask, isXOR, gotMask, err)
}
req, err := wire.ReadRequestProfile(profiled, gotMask)
if err == nil {
err = processWireRequest(profiled, req, "", false, nil, 0, nil, 1024, 0, 0, nil)
}
if err != nil {
t.Fatalf("mask %02x server: %v", mask, err)
}
if err := <-clientResult; err != nil {
t.Fatalf("mask %02x client: %v", mask, err)
}
_ = server.Close()
}
}
func TestXORProfileProbeEndToEnd(t *testing.T) {
for n := 0; n < 256; n++ {
mask := byte(n)
if ('U'^mask)&7 < 5 {
continue
}
server, client := net.Pipe()
clientResult := make(chan error, 1)
go func() {
defer client.Close()
if err := protocol.WriteRequestFrameProfile(client, 7, []byte("CPROBE -"), mask); err != nil {
clientResult <- err
return
}
id, payload, err := protocol.ReadResponseFrameProfile(client, mask)
if err == nil && (id != 7 || string(payload) != "PROBEOK") {
err = fmt.Errorf("id=%d payload=%q", id, payload)
}
clientResult <- err
}()
profiled, isXOR, gotMask, err := sniffWire(server)
if err != nil || !isXOR || gotMask != mask {
t.Fatalf("mask %02x sniff: xor=%t gotMask=%02x err=%v", mask, isXOR, gotMask, err)
}
handleXOR(profiled, gotMask, "", false, nil, 0, nil, 1024, 8, time.Millisecond, nil)
if err := <-clientResult; err != nil {
t.Fatalf("mask %02x client: %v", mask, err)
}
_ = server.Close()
}
}
func TestCoveredProfilesProbeEndToEnd(t *testing.T) {
for _, padding := range []uint16{0, 64, cover.MaxPadding} {
for _, xor := range []bool{false, true} {
profile := cover.Profile{Enabled: true, ID: 0x91e7, Padding: padding, HeaderMask: 0x6b, XOR: xor}
server, client := net.Pipe()
clientResult := make(chan error, 1)
go func() {
defer client.Close()
if err := cover.WritePreface(client, profile); err != nil {
clientResult <- err
return
}
if xor {
if err := protocol.WriteRequestFrameProfile(client, 11, []byte("CPROBE -"), profile.HeaderMask); err != nil {
clientResult <- err
return
}
id, payload, err := protocol.ReadResponseFrameProfile(client, profile.HeaderMask)
if err == nil && (id != 11 || string(payload) != "PROBEOK") {
err = fmt.Errorf("id=%d payload=%q", id, payload)
}
clientResult <- err
return
}
var sid wire.SessionID
payload := make([]byte, 11)
copy(payload[:4], wire.ProbeMagic[:])
payload[4] = wire.ProbeKeepalive
if err := wire.WriteRequestProfile(client, wire.ModeProbe, sid, 3, payload, profile.HeaderMask); err != nil {
clientResult <- err
return
}
status, _, err := wire.ReadResponseProfile(client, profile.HeaderMask)
if err == nil && status != wire.StatusOK {
err = fmt.Errorf("status=%d", status)
}
clientResult <- err
}()
profiled, gotXOR, gotMask, err := sniffWire(server)
if err != nil || gotXOR != xor || gotMask != profile.HeaderMask {
t.Fatalf("padding=%d xor=%t sniff got xor=%t mask=%02x err=%v", padding, xor, gotXOR, gotMask, err)
}
if xor {
handleXOR(profiled, gotMask, "", false, nil, 0, nil, 1024, 8, time.Millisecond, nil)
} else {
req, readErr := wire.ReadRequestProfile(profiled, gotMask)
if readErr == nil {
readErr = processWireRequest(profiled, req, "", false, nil, 0, nil, 1024, 0, 0, nil)
}
if readErr != nil {
t.Fatalf("padding=%d binary server: %v", padding, readErr)
}
}
if err := <-clientResult; err != nil {
t.Fatalf("padding=%d xor=%t client: %v", padding, xor, err)
}
_ = server.Close()
}
}
}
func TestSniffWireRecognizesAllHeaderProfiles(t *testing.T) {
test := func(firstTwo []byte, wantXOR bool, wantMask byte) {
server, client := net.Pipe()
defer server.Close()
go func() {
initial := make([]byte, 12)
copy(initial, firstTwo)
_, _ = client.Write(initial)
_ = client.Close()
}()
profiled, gotXOR, gotMask, err := sniffWire(server)
if err != nil {
t.Fatalf("header=%x: %v", firstTwo, err)
}
if gotXOR != wantXOR || gotMask != wantMask {
t.Fatalf("header=%x got xor=%t mask=%02x, want xor=%t mask=%02x", firstTwo, gotXOR, gotMask, wantXOR, wantMask)
}
replayed := make([]byte, 2)
if _, err := io.ReadFull(profiled, replayed); err != nil || !bytes.Equal(replayed, firstTwo) {
t.Fatalf("header=%x replay=%x err=%v", firstTwo, replayed, err)
}
}
for n := 0; n < 256; n += 8 {
mask := byte(n)
test([]byte{mask, 0xa7}, false, mask)
}
for n := 0; n < 256; n++ {
mask := byte(n)
if ('U'^mask)&7 >= 5 {
test([]byte{'U' ^ mask, 'P' ^ mask}, true, mask)
}
}
}
+101 -66
View File
@@ -15,11 +15,32 @@ import (
"time" "time"
"dragontcp/internal/protocol" "dragontcp/internal/protocol"
"dragontcp/internal/wire"
) )
var active int64 var active int64
// idleDeadline avoids a SetDeadline system call for every small protocol
// record. It refreshes halfway through the idle window, preserving idle-client
// cleanup while making persistent high-throughput lanes substantially cheaper.
type idleDeadline struct {
conn net.Conn
timeout time.Duration
next time.Time
}
func newIdleDeadline(conn net.Conn, timeout time.Duration) *idleDeadline {
return &idleDeadline{conn: conn, timeout: timeout}
}
func (d *idleDeadline) refresh() error {
now := time.Now()
if !d.next.IsZero() && now.Before(d.next.Add(-d.timeout/2)) {
return nil
}
d.next = now.Add(d.timeout)
return d.conn.SetDeadline(d.next)
}
type dnsEntry struct { type dnsEntry struct {
ips []netip.Addr ips []netip.Addr
expires time.Time expires time.Time
@@ -160,10 +181,11 @@ func handle(
tcpBuffer int, tcpBuffer int,
slots chan struct{}, slots chan struct{},
manager *streamManager, manager *streamManager,
bhttpManager *bhttpSessionManager,
xorManager *chunkManager, xorManager *chunkManager,
chunkMax int, chunkMax int,
bufferBytes int, bufferBytes int,
chunkBuffered int, xorBufferBytes int,
chunkPollWait time.Duration, chunkPollWait time.Duration,
debug *serverDebug, debug *serverDebug,
) { ) {
@@ -176,46 +198,71 @@ func handle(
protocol.TuneTCP(conn) protocol.TuneTCP(conn)
protocol.TuneTCPBuffer(conn, tcpBuffer) protocol.TuneTCPBuffer(conn, tcpBuffer)
// One listener serves both wires. The legacy XOR framing starts every // One listener serves both wires and every startup-selected header profile.
// request with the ASCII magic "UP"; the binary framing starts with a mode // sniffWire partitions the full first-byte space so B and X remain
// byte of 0-4, so the two are never ambiguous. // unambiguous even when their legacy mode/UP bytes are masked.
_ = conn.SetDeadline(time.Now().Add(30 * time.Second)) _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
conn, isXOR, err := sniffWire(conn) conn, isXOR, headerMask, err := sniffWire(conn)
if err != nil { if err != nil {
return return
} }
if isXOR { if isXOR {
if debug != nil && debug.enabled { if debug != nil && debug.enabled {
debug.logf("WIRE peer=%v mode=xor", conn.RemoteAddr()) debug.logf("WIRE peer=%v mode=xor header_mask=%02x", conn.RemoteAddr(), headerMask)
} }
handleXOR(conn, token, allowPrivate, cache, tcpBuffer, xorManager, handleXOR(conn, headerMask, token, allowPrivate, cache, tcpBuffer, xorManager,
chunkMax, chunkBuffered, chunkPollWait, debug) chunkMax, xorBufferBytes, chunkPollWait, debug)
return return
} }
clearPayload := false
if profiled, ok := conn.(interface{ ClearPayload() bool }); ok {
clearPayload = profiled.ClearPayload()
}
if debug != nil && debug.enabled { if debug != nil && debug.enabled {
debug.logf("WIRE peer=%v mode=binary", conn.RemoteAddr()) debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t", conn.RemoteAddr(), headerMask, clearPayload)
} }
for { handleBinary(conn, headerMask, clearPayload, token, allowPrivate, cache, tcpBuffer, manager,
_ = conn.SetDeadline(time.Now().Add(30 * time.Second)) bhttpManager, chunkMax, bufferBytes, chunkPollWait, debug)
req, err := wire.ReadRequest(conn)
if err != nil {
return
} }
if err := processWireRequest(
conn, func acceptLoop(
req, ln net.Listener,
token, token string,
allowPrivate, allowPrivate bool,
cache, cache *dnsCache,
tcpBuffer, tcpBuffer int,
manager, slots chan struct{},
chunkMax, manager *streamManager,
bufferBytes, bhttpManager *bhttpSessionManager,
chunkPollWait, xorManager *chunkManager,
debug, chunkMax int,
); err != nil { bufferBytes int,
return xorBufferBytes int,
chunkPollWait time.Duration,
debug *serverDebug,
) {
for {
conn, err := ln.Accept()
if err != nil {
fmt.Fprintln(os.Stderr, "accept:", err)
continue
}
select {
case slots <- struct{}{}:
atomic.AddInt64(&active, 1)
if debug.enabled {
debug.logf("ACCEPT local=%v peer=%v active_connections=%d", conn.LocalAddr(), conn.RemoteAddr(), atomic.LoadInt64(&active))
}
go handle(conn, token, allowPrivate, cache, tcpBuffer, slots, manager,
bhttpManager, xorManager, chunkMax, bufferBytes, xorBufferBytes,
chunkPollWait, debug)
default:
if debug.enabled {
debug.errorf("REJECT peer=%v reason=max-connections", conn.RemoteAddr())
}
_ = conn.Close()
} }
} }
} }
@@ -224,6 +271,7 @@ func main() {
var ( var (
host = flag.String("host", "0.0.0.0", "listen host") host = flag.String("host", "0.0.0.0", "listen host")
port = flag.Int("port", 53, "listen port") port = flag.Int("port", 53, "listen port")
portAlt = flag.Int("port-alt", 80, "second simultaneous listen port; 0 disables")
token = flag.String("token", "", "optional shared token") token = flag.String("token", "", "optional shared token")
maxConnections = flag.Int("max-connections", 20000, "max simultaneous tunnels") maxConnections = flag.Int("max-connections", 20000, "max simultaneous tunnels")
allowPrivate = flag.Bool("allow-private", false, "allow private/loopback targets") allowPrivate = flag.Bool("allow-private", false, "allow private/loopback targets")
@@ -231,7 +279,7 @@ func main() {
dnsCacheSize = flag.Int("dns-cache-size", 4096, "maximum cached DNS hostnames") dnsCacheSize = flag.Int("dns-cache-size", 4096, "maximum cached DNS hostnames")
tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning") tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning")
chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (32 bytes to 1 MiB)") chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (32 bytes to 1 MiB)")
chunkBuffered = flag.Int("chunk-buffered", 256, "compatibility buffer units; 256 = about 16 MiB per active session") chunkBuffered = flag.Int("chunk-buffered", 32, "per-session download buffer in 64 KiB units; 32 = about 2 MiB")
chunkPollWait = flag.Duration("chunk-poll-wait", 200*time.Millisecond, "server long-poll wait for chunk data") chunkPollWait = flag.Duration("chunk-poll-wait", 200*time.Millisecond, "server long-poll wait for chunk data")
sessionTimeout = flag.Duration("chunk-session-timeout", 2*time.Minute, "idle chunk session timeout") sessionTimeout = flag.Duration("chunk-session-timeout", 2*time.Minute, "idle chunk session timeout")
debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics") debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics")
@@ -256,8 +304,23 @@ func main() {
os.Exit(1) os.Exit(1)
} }
defer ln.Close() defer ln.Close()
listeners := []net.Listener{ln}
fmt.Printf("DragonTCP Go server listening on %s\n", listenAddr) fmt.Printf("DragonTCP Go server listening on %s\n", listenAddr)
if *portAlt < 0 || *portAlt > 65535 {
fmt.Fprintln(os.Stderr, "--port-alt must be between 0 and 65535")
os.Exit(2)
}
if *portAlt != 0 && *portAlt != *port {
altAddr := net.JoinHostPort(*host, strconv.Itoa(*portAlt))
alt, altErr := net.Listen("tcp", altAddr)
if altErr != nil {
fmt.Fprintf(os.Stderr, "warning: secondary listener %s unavailable: %v\n", altAddr, altErr)
} else {
defer alt.Close()
listeners = append(listeners, alt)
fmt.Printf("DragonTCP Go server listening on %s\n", altAddr)
}
}
fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer) fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer)
slots := make(chan struct{}, *maxConnections) slots := make(chan struct{}, *maxConnections)
@@ -271,45 +334,17 @@ func main() {
bufferBytes = 64 * 1024 * 1024 bufferBytes = 64 * 1024 * 1024
} }
manager := newStreamManager(*sessionTimeout, debug) manager := newStreamManager(*sessionTimeout, debug)
bhttpManager := newBHTTPSessionManager(*sessionTimeout, *maxConnections)
xorManager := newChunkManager(*sessionTimeout, debug) xorManager := newChunkManager(*sessionTimeout, debug)
fmt.Printf("binary_transport=true chunk_max=%d buffer_bytes=%d poll_wait=%s\n", *chunkMax, bufferBytes, chunkPollWait.String()) fmt.Printf("binary_transport=true bp_compat=true chunk_max=%d buffer_bytes=%d poll_wait=%s\n", *chunkMax, bufferBytes, chunkPollWait.String())
if debug.enabled { if debug.enabled {
fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery) fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery)
} }
for { for _, listener := range listeners {
conn, err := ln.Accept() go acceptLoop(listener, *token, *allowPrivate, cache, *tcpBuffer, slots,
if err != nil { manager, bhttpManager, xorManager, *chunkMax, bufferBytes,
fmt.Fprintln(os.Stderr, "accept:", err) bufferBytes, *chunkPollWait, debug)
continue
}
select {
case slots <- struct{}{}:
atomic.AddInt64(&active, 1)
if debug.enabled {
debug.logf("ACCEPT peer=%v active_connections=%d", conn.RemoteAddr(), atomic.LoadInt64(&active))
}
go handle(
conn,
*token,
*allowPrivate,
cache,
*tcpBuffer,
slots,
manager,
xorManager,
*chunkMax,
bufferBytes,
*chunkBuffered,
*chunkPollWait,
debug,
)
default:
if debug.enabled {
debug.errorf("REJECT peer=%v reason=max-connections", conn.RemoteAddr())
}
_ = conn.Close()
}
} }
select {}
} }
+94 -21
View File
@@ -17,6 +17,7 @@ import (
"sync" "sync"
"time" "time"
"dragontcp/internal/cover"
"dragontcp/internal/protocol" "dragontcp/internal/protocol"
) )
@@ -24,11 +25,12 @@ type chunkSession struct {
id string id string
target net.Conn target net.Conn
maxChunk int maxChunk int
maxChunks int maxBuffer int
mu sync.Mutex mu sync.Mutex
notify chan struct{} notify chan struct{}
chunks map[uint64][]byte chunks map[uint64][]byte
buffered int
nextDown uint64 nextDown uint64
eof bool eof bool
closed bool closed bool
@@ -42,14 +44,28 @@ type chunkSession struct {
haveLastUp bool haveLastUp bool
} }
func newChunkSession(id string, target net.Conn, maxChunk, maxChunks int, debug *serverDebug) *chunkSession { func newChunkSession(id string, target net.Conn, maxChunk, maxBuffer int, debug *serverDebug) *chunkSession {
if maxBuffer < maxChunk {
maxBuffer = maxChunk
}
readSize := maxChunk
if readSize > 64*1024 {
readSize = 64 * 1024
}
mapCapacity := maxBuffer / readSize
if mapCapacity < 1 {
mapCapacity = 1
}
if mapCapacity > 256 {
mapCapacity = 256
}
s := &chunkSession{ s := &chunkSession{
id: id, id: id,
target: target, target: target,
maxChunk: maxChunk, maxChunk: maxChunk,
maxChunks: maxChunks, maxBuffer: maxBuffer,
notify: make(chan struct{}), notify: make(chan struct{}),
chunks: make(map[uint64][]byte, maxChunks), chunks: make(map[uint64][]byte, mapCapacity),
lastSeen: time.Now(), lastSeen: time.Now(),
debug: debug, debug: debug,
} }
@@ -73,7 +89,12 @@ func (s *chunkSession) touch() {
} }
func (s *chunkSession) readTarget() { func (s *chunkSession) readTarget() {
buf := make([]byte, s.maxChunk) ptr := protocol.BufferPool.Get().(*[]byte)
buf := *ptr
defer protocol.BufferPool.Put(ptr)
if s.maxChunk < len(buf) {
buf = buf[:s.maxChunk]
}
for { for {
n, err := s.target.Read(buf) n, err := s.target.Read(buf)
@@ -89,10 +110,11 @@ func (s *chunkSession) readTarget() {
s.mu.Unlock() s.mu.Unlock()
return return
} }
if len(s.chunks) < s.maxChunks { if s.buffered+len(data) <= s.maxBuffer {
seq := s.nextDown seq := s.nextDown
s.nextDown++ s.nextDown++
s.chunks[seq] = data s.chunks[seq] = data
s.buffered += len(data)
s.touchLocked() s.touchLocked()
s.signalLocked() s.signalLocked()
s.mu.Unlock() s.mu.Unlock()
@@ -181,6 +203,7 @@ func (s *chunkSession) pull(want uint64, ack int64, offset, limit int, wait time
removed := false removed := false
for seq := range s.chunks { for seq := range s.chunks {
if seq <= uint64(ack) { if seq <= uint64(ack) {
s.buffered -= len(s.chunks[seq])
delete(s.chunks, seq) delete(s.chunks, seq)
removed = true removed = true
} }
@@ -333,7 +356,8 @@ func decodeWireToken(token string) string {
} }
func isChunkCommand(payload []byte) bool { func isChunkCommand(payload []byte) bool {
return bytes.HasPrefix(payload, []byte("COPEN ")) || return bytes.HasPrefix(payload, []byte("CPROBE ")) ||
bytes.HasPrefix(payload, []byte("COPEN ")) ||
bytes.HasPrefix(payload, []byte("CPUSH ")) || bytes.HasPrefix(payload, []byte("CPUSH ")) ||
bytes.HasPrefix(payload, []byte("CPULL ")) || bytes.HasPrefix(payload, []byte("CPULL ")) ||
bytes.HasPrefix(payload, []byte("CCLOSE ")) bytes.HasPrefix(payload, []byte("CCLOSE "))
@@ -349,10 +373,21 @@ func processChunkCommand(
tcpBuffer int, tcpBuffer int,
manager *chunkManager, manager *chunkManager,
maxChunk int, maxChunk int,
maxBufferedChunks int, maxBufferedBytes int,
pollWait time.Duration, pollWait time.Duration,
debug *serverDebug, debug *serverDebug,
) error { ) error {
if bytes.HasPrefix(payload, []byte("CPROBE ")) {
parts := strings.Fields(string(payload))
if len(parts) != 2 {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPROBE"))
}
if !tokenEqual(decodeWireToken(parts[1]), token) {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
}
return protocol.WriteResponseFrame(conn, requestID, []byte("PROBEOK"))
}
if bytes.HasPrefix(payload, []byte("COPEN ")) { if bytes.HasPrefix(payload, []byte("COPEN ")) {
parts := strings.Fields(string(payload)) parts := strings.Fields(string(payload))
if len(parts) != 5 { if len(parts) != 5 {
@@ -378,7 +413,7 @@ func processChunkCommand(
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error())) return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
} }
session := newChunkSession(sid, target, maxChunk, maxBufferedChunks, debug) session := newChunkSession(sid, target, maxChunk, maxBufferedBytes, debug)
if err := manager.add(sid, session); err != nil { if err := manager.add(sid, session); err != nil {
session.close() session.close()
if debug != nil && debug.enabled { if debug != nil && debug.enabled {
@@ -522,39 +557,77 @@ func processChunkCommand(
type prefixedConn struct { type prefixedConn struct {
net.Conn net.Conn
r io.Reader r io.Reader
headerMask byte
cover cover.Profile
} }
func (p *prefixedConn) Read(b []byte) (int, error) { return p.r.Read(b) } func (p *prefixedConn) Read(b []byte) (int, error) { return p.r.Read(b) }
func (p *prefixedConn) HeaderMask() byte { return p.headerMask }
func (p *prefixedConn) CoverProfile() cover.Profile { return p.cover }
func (p *prefixedConn) ClearPayload() bool { return p.cover.Clear }
// sniffWire reads the two magic bytes and reports whether this connection // sniffWire first checks for the optional self-describing cover preface. If it
// speaks the legacy XOR framing. The returned conn replays them. // is absent, the bytes are replayed and the legacy/direct B/X classifier is
func sniffWire(conn net.Conn) (net.Conn, bool, error) { // used unchanged.
var magic [2]byte func sniffWire(conn net.Conn) (net.Conn, bool, byte, error) {
if _, err := io.ReadFull(conn, magic[:]); err != nil { var initial [cover.PrefaceSize]byte
return conn, false, err if _, err := io.ReadFull(conn, initial[:]); err != nil {
return conn, false, 0, err
} }
replayed := &prefixedConn{Conn: conn, r: io.MultiReader(bytes.NewReader(magic[:]), conn)} if profile, ok := cover.DecodePreface(initial); ok {
return replayed, magic[0] == 'U' && magic[1] == 'P', nil if profile.Padding > 0 {
padding := make([]byte, int(profile.Padding))
if _, err := io.ReadFull(conn, padding); err != nil {
return conn, false, 0, err
}
}
profiled := &prefixedConn{Conn: conn, r: conn, headerMask: profile.HeaderMask, cover: profile}
return profiled, profile.XOR, profile.HeaderMask, nil
}
magic := initial[:2]
replay := io.MultiReader(bytes.NewReader(initial[:]), conn)
if magic[0]&7 >= 5 {
mask := magic[0] ^ 'U'
if magic[1]^mask != 'P' {
return conn, false, 0, fmt.Errorf("unknown wire header")
}
replayed := &prefixedConn{Conn: conn, r: replay, headerMask: mask}
return replayed, true, mask, nil
}
mask := magic[0] & 0xf8
mode := magic[0] ^ mask
if mode > 4 {
return conn, false, 0, fmt.Errorf("unknown binary mode")
}
replayed := &prefixedConn{Conn: conn, r: replay, headerMask: mask}
return replayed, false, mask, nil
} }
// handleXOR serves one connection speaking UP/OK + XOR 0xAD: the v4 chunk // handleXOR serves one connection speaking UP/OK + XOR 0xAD: the v4 chunk
// commands, plus the TUNNEL/TUNNEL2 stream commands. // commands, plus the TUNNEL/TUNNEL2 stream commands.
func handleXOR( func handleXOR(
conn net.Conn, conn net.Conn,
headerMask byte,
token string, token string,
allowPrivate bool, allowPrivate bool,
cache *dnsCache, cache *dnsCache,
tcpBuffer int, tcpBuffer int,
manager *chunkManager, manager *chunkManager,
chunkMax int, chunkMax int,
chunkBuffered int, bufferBytes int,
chunkPollWait time.Duration, chunkPollWait time.Duration,
debug *serverDebug, debug *serverDebug,
) { ) {
deadline := newIdleDeadline(conn, 20*time.Second)
for { for {
_ = conn.SetDeadline(time.Now().Add(20 * time.Second)) if deadline.refresh() != nil {
return
}
requestID, _, payload, err := protocol.ReadRequestFrame(conn) requestID, _, payload, err := protocol.ReadRequestFrameProfile(conn, headerMask)
if err != nil { if err != nil {
if debug != nil && debug.enabled && err != io.EOF { if debug != nil && debug.enabled && err != io.EOF {
debug.errorf("peer=%v read XOR request: %v", conn.RemoteAddr(), err) debug.errorf("peer=%v read XOR request: %v", conn.RemoteAddr(), err)
@@ -565,7 +638,7 @@ func handleXOR(
if isChunkCommand(payload) { if isChunkCommand(payload) {
if err := processChunkCommand( if err := processChunkCommand(
conn, requestID, payload, token, allowPrivate, cache, tcpBuffer, conn, requestID, payload, token, allowPrivate, cache, tcpBuffer,
manager, chunkMax, chunkBuffered, chunkPollWait, debug, manager, chunkMax, bufferBytes, chunkPollWait, debug,
); err != nil { ); err != nil {
return return
} }
+141
View File
@@ -0,0 +1,141 @@
// Package cover implements the optional connection preface used by startup
// profile discovery. Legacy connections have no preface and remain supported.
package cover
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"fmt"
"io"
)
const (
PrefaceSize = 12
MaxPadding = 4096
)
// Profile is selected once during startup and then reused unchanged. Padding
// bytes are freshly random on each physical connection, but their length and
// all header fields remain fixed.
type Profile struct {
Enabled bool
ID uint16
Padding uint16
HeaderMask byte
XOR bool
Clear bool
}
func (p Profile) String() string {
if !p.Enabled {
return "direct"
}
encoding := "masked"
if p.Clear {
encoding = "clear"
}
return fmt.Sprintf("cover-%04x/pad-%d/%s", p.ID, p.Padding, encoding)
}
func key(id uint16) [32]byte {
var seed [16]byte
copy(seed[:12], []byte("DragonTCP-C3"))
binary.BigEndian.PutUint16(seed[12:14], id)
seed[14], seed[15] = byte(id)^0x6d, byte(id>>8)^0xb2
return sha256.Sum256(seed[:])
}
// EncodePreface returns the fixed-size, self-describing portion. The first two
// bytes are the mutable profile ID; all metadata after them is masked.
func EncodePreface(p Profile) ([PrefaceSize]byte, error) {
var out [PrefaceSize]byte
if !p.Enabled {
return out, fmt.Errorf("cover profile is disabled")
}
if p.Padding > MaxPadding {
return out, fmt.Errorf("cover padding too large: %d", p.Padding)
}
binary.BigEndian.PutUint16(out[0:2], p.ID)
var plain [10]byte
copy(plain[0:4], []byte("DTC3"))
if p.XOR {
plain[4] |= 1
}
if p.Clear {
plain[4] |= 2
}
plain[5] = p.HeaderMask
binary.BigEndian.PutUint16(plain[6:8], p.Padding)
plain[8] = plain[4] ^ plain[5] ^ 0xa5
plain[9] = plain[6] ^ plain[7] ^ 0x5a
k := key(p.ID)
for i := range plain {
out[2+i] = plain[i] ^ k[i]
}
return out, nil
}
// DecodePreface recognizes an encoded cover profile. ok=false means the bytes
// belong to a legacy/direct connection and must be replayed unchanged.
func DecodePreface(in [PrefaceSize]byte) (p Profile, ok bool) {
id := binary.BigEndian.Uint16(in[0:2])
k := key(id)
var plain [10]byte
for i := range plain {
plain[i] = in[2+i] ^ k[i]
}
if string(plain[0:4]) != "DTC3" || plain[4]&^byte(3) != 0 {
return Profile{}, false
}
if plain[8] != plain[4]^plain[5]^0xa5 || plain[9] != plain[6]^plain[7]^0x5a {
return Profile{}, false
}
padding := binary.BigEndian.Uint16(plain[6:8])
if padding > MaxPadding {
return Profile{}, false
}
return Profile{
Enabled: true,
ID: id,
Padding: padding,
HeaderMask: plain[5],
XOR: plain[4]&1 != 0,
Clear: plain[4]&2 != 0,
}, true
}
// WritePreface sends the encoded profile followed by its fixed amount of
// random padding.
func WritePreface(w io.Writer, p Profile) error {
if !p.Enabled {
return nil
}
preface, err := EncodePreface(p)
if err != nil {
return err
}
packet := make([]byte, PrefaceSize+int(p.Padding))
copy(packet, preface[:])
if p.Padding > 0 {
if _, err := rand.Read(packet[PrefaceSize:]); err != nil {
return err
}
}
return writeAll(w, packet)
}
func writeAll(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
}
+41
View File
@@ -0,0 +1,41 @@
package cover
import (
"bytes"
"testing"
)
func TestProfileRoundTripAcrossRange(t *testing.T) {
for id := 0; id < 65536; id += 257 {
for _, xor := range []bool{false, true} {
for _, clear := range []bool{false, true} {
want := Profile{Enabled: true, ID: uint16(id), Padding: uint16(id % (MaxPadding + 1)), HeaderMask: byte(id), XOR: xor, Clear: clear}
encoded, err := EncodePreface(want)
if err != nil {
t.Fatal(err)
}
got, ok := DecodePreface(encoded)
if !ok || got != want {
t.Fatalf("id=%04x xor=%t clear=%t got=%+v ok=%t", id, xor, clear, got, ok)
}
}
}
}
}
func TestWritePrefaceIncludesFixedPadding(t *testing.T) {
p := Profile{Enabled: true, ID: 0x1234, Padding: 64, HeaderMask: 0x9a, XOR: true}
var out bytes.Buffer
if err := WritePreface(&out, p); err != nil {
t.Fatal(err)
}
if out.Len() != PrefaceSize+64 {
t.Fatalf("length=%d", out.Len())
}
var encoded [PrefaceSize]byte
copy(encoded[:], out.Bytes())
got, ok := DecodePreface(encoded)
if !ok || got != p {
t.Fatalf("got=%+v ok=%t", got, ok)
}
}
+31 -4
View File
@@ -31,12 +31,17 @@ var BufferPool = sync.Pool{
} }
func ReadRequestFrame(r io.Reader) (uint32, uint32, []byte, error) { func ReadRequestFrame(r io.Reader) (uint32, uint32, []byte, error) {
return ReadRequestFrameProfile(r, 0)
}
// ReadRequestFrameProfile decodes the UP magic after applying headerMask.
func ReadRequestFrameProfile(r io.Reader, headerMask byte) (uint32, uint32, []byte, error) {
var header [14]byte var header [14]byte
if _, err := io.ReadFull(r, header[:]); err != nil { if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, 0, nil, err return 0, 0, nil, err
} }
if header[0] != 'U' || header[1] != 'P' { if header[0]^headerMask != 'U' || header[1]^headerMask != 'P' {
return 0, 0, nil, errors.New("bad request magic") return 0, 0, nil, errors.New("bad request magic")
} }
@@ -58,11 +63,16 @@ func ReadRequestFrame(r io.Reader) (uint32, uint32, []byte, error) {
} }
func WriteRequestFrame(w io.Writer, requestID uint32, payload []byte) error { func WriteRequestFrame(w io.Writer, requestID uint32, payload []byte) error {
return WriteRequestFrameProfile(w, requestID, payload, 0)
}
// WriteRequestFrameProfile masks the two-byte UP magic with headerMask.
func WriteRequestFrameProfile(w io.Writer, requestID uint32, payload []byte, headerMask byte) error {
if len(payload) > MaxHandshake { if len(payload) > MaxHandshake {
return errors.New("request frame payload too large") return errors.New("request frame payload too large")
} }
packet := make([]byte, 14+len(payload)) packet := make([]byte, 14+len(payload))
packet[0], packet[1] = 'U', 'P' packet[0], packet[1] = 'U'^headerMask, 'P'^headerMask
binary.BigEndian.PutUint32(packet[2:6], requestID) binary.BigEndian.PutUint32(packet[2:6], requestID)
binary.BigEndian.PutUint32(packet[6:10], 0) binary.BigEndian.PutUint32(packet[6:10], 0)
binary.BigEndian.PutUint32(packet[10:14], uint32(len(payload))) binary.BigEndian.PutUint32(packet[10:14], uint32(len(payload)))
@@ -72,12 +82,17 @@ func WriteRequestFrame(w io.Writer, requestID uint32, payload []byte) error {
} }
func ReadResponseFrame(r io.Reader) (uint32, []byte, error) { func ReadResponseFrame(r io.Reader) (uint32, []byte, error) {
return ReadResponseFrameProfile(r, 0)
}
// ReadResponseFrameProfile decodes the OK magic after applying headerMask.
func ReadResponseFrameProfile(r io.Reader, headerMask byte) (uint32, []byte, error) {
var header [10]byte var header [10]byte
if _, err := io.ReadFull(r, header[:]); err != nil { if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, nil, err return 0, nil, err
} }
if header[0] != 'O' || header[1] != 'K' { if header[0]^headerMask != 'O' || header[1]^headerMask != 'K' {
return 0, nil, fmt.Errorf("bad response magic: %q", header[:2]) return 0, nil, fmt.Errorf("bad response magic: %q", header[:2])
} }
@@ -98,11 +113,16 @@ func ReadResponseFrame(r io.Reader) (uint32, []byte, error) {
} }
func WriteResponseFrame(w io.Writer, requestID uint32, payload []byte) error { func WriteResponseFrame(w io.Writer, requestID uint32, payload []byte) error {
return WriteResponseFrameProfile(w, requestID, payload, writerHeaderMask(w))
}
// WriteResponseFrameProfile masks the two-byte OK magic with headerMask.
func WriteResponseFrameProfile(w io.Writer, requestID uint32, payload []byte, headerMask byte) error {
if len(payload) > MaxHandshake { if len(payload) > MaxHandshake {
return errors.New("response frame payload too large") return errors.New("response frame payload too large")
} }
packet := make([]byte, 10+len(payload)) packet := make([]byte, 10+len(payload))
packet[0], packet[1] = 'O', 'K' packet[0], packet[1] = 'O'^headerMask, 'K'^headerMask
binary.BigEndian.PutUint32(packet[2:6], requestID) binary.BigEndian.PutUint32(packet[2:6], requestID)
binary.BigEndian.PutUint32(packet[6:10], uint32(len(payload))) binary.BigEndian.PutUint32(packet[6:10], uint32(len(payload)))
copy(packet[10:], payload) copy(packet[10:], payload)
@@ -121,6 +141,13 @@ func writeAll(w io.Writer, b []byte) error {
return nil return nil
} }
func writerHeaderMask(w io.Writer) byte {
if profiled, ok := w.(interface{ HeaderMask() byte }); ok {
return profiled.HeaderMask()
}
return 0
}
func CopyXOR(dst net.Conn, src net.Conn) error { func CopyXOR(dst net.Conn, src net.Conn) error {
ptr := BufferPool.Get().(*[]byte) ptr := BufferPool.Get().(*[]byte)
buf := *ptr buf := *ptr
+50
View File
@@ -0,0 +1,50 @@
package protocol
import (
"bytes"
"testing"
)
func TestXORHeaderProfilesRoundTrip(t *testing.T) {
for n := 0; n < 256; n++ {
mask := byte(n)
if ('U'^mask)&7 < 5 {
continue
}
var request bytes.Buffer
if err := WriteRequestFrameProfile(&request, 7, []byte("CPROBE -"), mask); err != nil {
t.Fatal(err)
}
requestID, _, payload, err := ReadRequestFrameProfile(&request, mask)
if err != nil || requestID != 7 || !bytes.Equal(payload, []byte("CPROBE -")) {
t.Fatalf("mask %02x request did not round-trip: id=%d payload=%q err=%v", mask, requestID, payload, err)
}
var response bytes.Buffer
if err := WriteResponseFrameProfile(&response, 7, []byte("PROBEOK"), mask); err != nil {
t.Fatal(err)
}
responseID, payload, err := ReadResponseFrameProfile(&response, mask)
if err != nil || responseID != 7 || !bytes.Equal(payload, []byte("PROBEOK")) {
t.Fatalf("mask %02x response did not round-trip: id=%d payload=%q err=%v", mask, responseID, payload, err)
}
}
}
type profiledBuffer struct {
bytes.Buffer
mask byte
}
func (b *profiledBuffer) HeaderMask() byte { return b.mask }
func TestServerResponseUsesConnectionProfile(t *testing.T) {
profiled := &profiledBuffer{mask: 0x3a}
if err := WriteResponseFrame(profiled, 9, []byte("ok")); err != nil {
t.Fatal(err)
}
if got := profiled.Bytes()[0]; got != 'O'^profiled.mask {
t.Fatalf("first byte=%02x, want %02x", got, byte('O')^profiled.mask)
}
}
+92 -8
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
) )
const ( const (
@@ -72,12 +73,36 @@ func MaskInPlace(data []byte, sid SessionID, mode byte, seq uint64, response boo
} }
func WriteRequest(w io.Writer, mode byte, sid SessionID, seq uint64, plaintext []byte) error { func WriteRequest(w io.Writer, mode byte, sid SessionID, seq uint64, plaintext []byte) error {
return WriteRequestProfile(w, mode, sid, seq, plaintext, 0)
}
// WriteRequestProfile writes a binary request whose first byte is XORed with
// headerMask. The remaining framing and payload encoding stay unchanged.
// Masks are selected once at client startup and then remain fixed.
func WriteRequestProfile(w io.Writer, mode byte, sid SessionID, seq uint64, plaintext []byte, headerMask byte) error {
return WriteRequestProfileEncoding(w, mode, sid, seq, plaintext, headerMask, false)
}
// WriteRequestProfileEncoding optionally leaves the payload clear. Clear mode
// is signalled by the connection cover preface, so legacy peers continue to use
// the SHA-256 compatibility mask unchanged.
func WriteRequestProfileEncoding(w io.Writer, mode byte, sid SessionID, seq uint64, plaintext []byte, headerMask byte, clear bool) error {
if len(plaintext) > MaxPayload { if len(plaintext) > MaxPayload {
return fmt.Errorf("request payload too large: %d", len(plaintext)) return fmt.Errorf("request payload too large: %d", len(plaintext))
} }
if clear {
var header [RequestHeaderSize]byte
header[0] = mode ^ headerMask
copy(header[1:17], sid[:])
binary.BigEndian.PutUint64(header[17:25], seq)
binary.BigEndian.PutUint32(header[25:29], uint32(len(plaintext)))
buffers := net.Buffers{header[:], plaintext}
_, err := buffers.WriteTo(w)
return err
}
packet := make([]byte, RequestHeaderSize+len(plaintext)) packet := make([]byte, RequestHeaderSize+len(plaintext))
packet[0] = mode packet[0] = mode ^ headerMask
copy(packet[1:17], sid[:]) copy(packet[1:17], sid[:])
binary.BigEndian.PutUint64(packet[17:25], seq) binary.BigEndian.PutUint64(packet[17:25], seq)
binary.BigEndian.PutUint32(packet[25:29], uint32(len(plaintext))) binary.BigEndian.PutUint32(packet[25:29], uint32(len(plaintext)))
@@ -87,13 +112,25 @@ func WriteRequest(w io.Writer, mode byte, sid SessionID, seq uint64, plaintext [
} }
func ReadRequest(r io.Reader) (Request, error) { func ReadRequest(r io.Reader) (Request, error) {
return ReadRequestProfile(r, 0)
}
// ReadRequestProfile decodes a request written with WriteRequestProfile.
func ReadRequestProfile(r io.Reader, headerMask byte) (Request, error) {
return ReadRequestProfileEncoding(r, headerMask, false)
}
func ReadRequestProfileEncoding(r io.Reader, headerMask byte, clear bool) (Request, error) {
var req Request var req Request
var header [RequestHeaderSize]byte var header [RequestHeaderSize]byte
if _, err := io.ReadFull(r, header[:]); err != nil { if _, err := io.ReadFull(r, header[:]); err != nil {
return req, err return req, err
} }
req.Mode = header[0] req.Mode = header[0] ^ headerMask
if req.Mode > ModeClose {
return req, errors.New("unknown request mode")
}
copy(req.Session[:], header[1:17]) copy(req.Session[:], header[1:17])
req.Seq = binary.BigEndian.Uint64(header[17:25]) req.Seq = binary.BigEndian.Uint64(header[17:25])
n := binary.BigEndian.Uint32(header[25:29]) n := binary.BigEndian.Uint32(header[25:29])
@@ -106,28 +143,53 @@ func ReadRequest(r io.Reader) (Request, error) {
if _, err := io.ReadFull(r, req.Payload); err != nil { if _, err := io.ReadFull(r, req.Payload); err != nil {
return req, err return req, err
} }
if !clear {
MaskInPlace(req.Payload, req.Session, req.Mode, req.Seq, false) MaskInPlace(req.Payload, req.Session, req.Mode, req.Seq, false)
} }
}
return req, nil return req, nil
} }
func WriteResponse(w io.Writer, status byte, body []byte) error { func WriteResponse(w io.Writer, status byte, body []byte) error {
return WriteResponseProfile(w, status, body, writerHeaderMask(w))
}
// WriteResponseProfile writes a response using the selected first-byte mask.
func WriteResponseProfile(w io.Writer, status byte, body []byte, headerMask byte) error {
if len(body) > MaxPayload { if len(body) > MaxPayload {
return fmt.Errorf("response body too large: %d", len(body)) return fmt.Errorf("response body too large: %d", len(body))
} }
packet := make([]byte, ResponseHeaderSize+len(body)) packet := make([]byte, ResponseHeaderSize+len(body))
packet[0] = status packet[0] = status ^ headerMask
binary.BigEndian.PutUint32(packet[1:5], uint32(len(body))) binary.BigEndian.PutUint32(packet[1:5], uint32(len(body)))
copy(packet[5:], body) copy(packet[5:], body)
return writeAll(w, packet) return writeAll(w, packet)
} }
func WriteMaskedResponse(w io.Writer, status byte, body []byte, sid SessionID, mode byte, seq uint64) error { func WriteMaskedResponse(w io.Writer, status byte, body []byte, sid SessionID, mode byte, seq uint64) error {
return WriteMaskedResponseProfileEncoding(w, status, body, sid, mode, seq, writerHeaderMask(w), writerClearPayload(w))
}
// WriteMaskedResponseProfile combines the normal payload mask with the
// selected first-byte header mask.
func WriteMaskedResponseProfile(w io.Writer, status byte, body []byte, sid SessionID, mode byte, seq uint64, headerMask byte) error {
return WriteMaskedResponseProfileEncoding(w, status, body, sid, mode, seq, headerMask, false)
}
func WriteMaskedResponseProfileEncoding(w io.Writer, status byte, body []byte, sid SessionID, mode byte, seq uint64, headerMask byte, clear bool) error {
if len(body) > MaxPayload { if len(body) > MaxPayload {
return fmt.Errorf("response body too large: %d", len(body)) return fmt.Errorf("response body too large: %d", len(body))
} }
if clear {
var header [ResponseHeaderSize]byte
header[0] = status ^ headerMask
binary.BigEndian.PutUint32(header[1:5], uint32(len(body)))
buffers := net.Buffers{header[:], body}
_, err := buffers.WriteTo(w)
return err
}
packet := make([]byte, ResponseHeaderSize+len(body)) packet := make([]byte, ResponseHeaderSize+len(body))
packet[0] = status packet[0] = status ^ headerMask
binary.BigEndian.PutUint32(packet[1:5], uint32(len(body))) binary.BigEndian.PutUint32(packet[1:5], uint32(len(body)))
copy(packet[5:], body) copy(packet[5:], body)
MaskInPlace(packet[5:], sid, mode, seq, true) MaskInPlace(packet[5:], sid, mode, seq, true)
@@ -135,6 +197,11 @@ func WriteMaskedResponse(w io.Writer, status byte, body []byte, sid SessionID, m
} }
func ReadResponse(r io.Reader) (byte, []byte, error) { func ReadResponse(r io.Reader) (byte, []byte, error) {
return ReadResponseProfile(r, 0)
}
// ReadResponseProfile decodes a response written with a header profile.
func ReadResponseProfile(r io.Reader, headerMask byte) (byte, []byte, error) {
var header [ResponseHeaderSize]byte var header [ResponseHeaderSize]byte
if _, err := io.ReadFull(r, header[:]); err != nil { if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, nil, err return 0, nil, err
@@ -150,16 +217,19 @@ func ReadResponse(r io.Reader) (byte, []byte, error) {
return 0, nil, err return 0, nil, err
} }
} }
return header[0], body, nil status := header[0] ^ headerMask
if status > StatusEOF {
return 0, nil, errors.New("unknown response status")
}
return status, body, nil
} }
func DecodeMaskedResponse(status byte, body []byte, sid SessionID, mode byte, seq uint64) []byte { func DecodeMaskedResponse(status byte, body []byte, sid SessionID, mode byte, seq uint64) []byte {
if len(body) == 0 || status == StatusError { if len(body) == 0 || status == StatusError {
return body return body
} }
out := append([]byte(nil), body...) MaskInPlace(body, sid, mode, seq, true)
MaskInPlace(out, sid, mode, seq, true) return body
return out
} }
func writeAll(w io.Writer, b []byte) error { func writeAll(w io.Writer, b []byte) error {
@@ -175,3 +245,17 @@ func writeAll(w io.Writer, b []byte) error {
} }
return nil return nil
} }
func writerHeaderMask(w io.Writer) byte {
if profiled, ok := w.(interface{ HeaderMask() byte }); ok {
return profiled.HeaderMask()
}
return 0
}
func writerClearPayload(w io.Writer) bool {
if profiled, ok := w.(interface{ ClearPayload() bool }); ok {
return profiled.ClearPayload()
}
return false
}
+79 -3
View File
@@ -2,20 +2,77 @@ package wire
import ( import (
"bytes" "bytes"
"io"
"testing" "testing"
) )
func TestMaskChangesWithSequenceAndRoundTrips(t *testing.T) { func TestMaskChangesWithSequenceAndRoundTrips(t *testing.T) {
var sid SessionID var sid SessionID
for i := range sid { sid[i] = byte(i+1) } for i := range sid {
sid[i] = byte(i + 1)
}
plain := bytes.Repeat([]byte("DragonTCP"), 100) plain := bytes.Repeat([]byte("DragonTCP"), 100)
a := append([]byte(nil), plain...) a := append([]byte(nil), plain...)
b := append([]byte(nil), plain...) b := append([]byte(nil), plain...)
MaskInPlace(a, sid, ModeUpload, 1, false) MaskInPlace(a, sid, ModeUpload, 1, false)
MaskInPlace(b, sid, ModeUpload, 2, false) MaskInPlace(b, sid, ModeUpload, 2, false)
if bytes.Equal(a, b) { t.Fatal("different sequences produced identical wire bytes") } if bytes.Equal(a, b) {
t.Fatal("different sequences produced identical wire bytes")
}
MaskInPlace(a, sid, ModeUpload, 1, false) MaskInPlace(a, sid, ModeUpload, 1, false)
if !bytes.Equal(a, plain) { t.Fatal("mask did not round-trip") } if !bytes.Equal(a, plain) {
t.Fatal("mask did not round-trip")
}
}
func TestBinaryHeaderProfilesRoundTrip(t *testing.T) {
var sid SessionID
for i := range sid {
sid[i] = byte(i + 1)
}
for n := 0; n < 256; n += 8 {
mask := byte(n)
var request bytes.Buffer
if err := WriteRequestProfile(&request, ModeUpload, sid, 42, []byte("payload"), mask); err != nil {
t.Fatal(err)
}
if got := request.Bytes()[0]; got != ModeUpload^mask {
t.Fatalf("mask %02x first byte=%02x", mask, got)
}
req, err := ReadRequestProfile(&request, mask)
if err != nil {
t.Fatalf("mask %02x: %v", mask, err)
}
if req.Mode != ModeUpload || req.Seq != 42 || !bytes.Equal(req.Payload, []byte("payload")) {
t.Fatalf("mask %02x request did not round-trip", mask)
}
var response bytes.Buffer
if err := WriteResponseProfile(&response, StatusOK, []byte("ok"), mask); err != nil {
t.Fatal(err)
}
status, body, err := ReadResponseProfile(&response, mask)
if err != nil || status != StatusOK || !bytes.Equal(body, []byte("ok")) {
t.Fatalf("mask %02x response did not round-trip: status=%d body=%q err=%v", mask, status, body, err)
}
}
}
type profiledBuffer struct {
bytes.Buffer
mask byte
}
func (b *profiledBuffer) HeaderMask() byte { return b.mask }
func TestServerResponseUsesConnectionProfile(t *testing.T) {
profiled := &profiledBuffer{mask: 0xa0}
if err := WriteResponse(profiled, StatusOK, []byte("ok")); err != nil {
t.Fatal(err)
}
if got := profiled.Bytes()[0]; got != StatusOK^profiled.mask {
t.Fatalf("first byte=%02x, want %02x", got, StatusOK^profiled.mask)
}
} }
func BenchmarkMask1MiB(b *testing.B) { func BenchmarkMask1MiB(b *testing.B) {
@@ -27,3 +84,22 @@ func BenchmarkMask1MiB(b *testing.B) {
MaskInPlace(data, sid, ModeUpload, uint64(i), false) MaskInPlace(data, sid, ModeUpload, uint64(i), false)
} }
} }
func BenchmarkWriteRequest1MiB(b *testing.B) {
var sid SessionID
data := make([]byte, 1024*1024)
for _, tc := range []struct {
name string
clear bool
}{{"sha256-compat", false}, {"clear", true}} {
b.Run(tc.name, func(b *testing.B) {
b.SetBytes(int64(len(data)))
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := WriteRequestProfileEncoding(io.Discard, ModeUpload, sid, uint64(i), data, 0, tc.clear); err != nil {
b.Fatal(err)
}
}
})
}
}
+55 -6
View File
@@ -19,6 +19,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"dragontcp/internal/cover"
"dragontcp/internal/protocol" "dragontcp/internal/protocol"
) )
@@ -57,6 +58,24 @@ type Options struct {
pollDelay time.Duration pollDelay time.Duration
txnTimeout time.Duration txnTimeout time.Duration
tcpBuffer int tcpBuffer int
headerMask byte
coverProfile cover.Profile
}
// WithHeaderMask returns a copy using one fixed frame-magic profile. The mask
// is selected during startup discovery and remains unchanged for normal data.
func (o Options) WithHeaderMask(mask byte) Options {
o.headerMask = mask
o.coverProfile = cover.Profile{}
return o
}
// WithCoverProfile returns a copy using a fixed startup-selected preface,
// padding length, and frame mask.
func (o Options) WithCoverProfile(profile cover.Profile) Options {
o.coverProfile = profile
o.headerMask = profile.HeaderMask
return o
} }
func wireToken(token string) string { func wireToken(token string) string {
@@ -219,17 +238,21 @@ type txnLane struct {
tcpBuffer int tcpBuffer int
reconnectEvery int reconnectEvery int
timeout time.Duration timeout time.Duration
headerMask byte
coverProfile cover.Profile
conn net.Conn conn net.Conn
count int count int
closed bool closed bool
} }
func newTxnLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration) *txnLane { func newTxnLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, headerMask byte, coverProfile cover.Profile) *txnLane {
return &txnLane{ return &txnLane{
serverAddr: serverAddr, serverAddr: serverAddr,
tcpBuffer: tcpBuffer, tcpBuffer: tcpBuffer,
reconnectEvery: reconnectEvery, reconnectEvery: reconnectEvery,
timeout: timeout, timeout: timeout,
headerMask: headerMask,
coverProfile: coverProfile,
} }
} }
@@ -262,6 +285,10 @@ func (l *txnLane) ensureConn() error {
if err != nil { if err != nil {
return err return err
} }
if err := cover.WritePreface(conn, l.coverProfile); err != nil {
_ = conn.Close()
return err
}
protocol.TuneTCP(conn) protocol.TuneTCP(conn)
protocol.TuneTCPBuffer(conn, l.tcpBuffer) protocol.TuneTCPBuffer(conn, l.tcpBuffer)
l.conn = conn l.conn = conn
@@ -285,12 +312,12 @@ func (l *txnLane) Do(payload []byte) ([]byte, error) {
_ = l.conn.SetDeadline(time.Now().Add(timeout)) _ = l.conn.SetDeadline(time.Now().Add(timeout))
requestID := requestCounter.Add(1) requestID := requestCounter.Add(1)
if err := protocol.WriteRequestFrame(l.conn, requestID, payload); err != nil { if err := protocol.WriteRequestFrameProfile(l.conn, requestID, payload, l.headerMask); err != nil {
l.closeLocked() l.closeLocked()
return nil, err return nil, err
} }
responseID, response, err := protocol.ReadResponseFrame(l.conn) responseID, response, err := protocol.ReadResponseFrameProfile(l.conn, l.headerMask)
if err != nil { if err != nil {
l.closeLocked() l.closeLocked()
return nil, err return nil, err
@@ -324,6 +351,28 @@ func doControl(lane *txnLane, payload []byte) ([]byte, error) {
return nil, lastErr return nil, lastErr
} }
// ProbeProfile performs one small authenticated transaction using the selected
// frame-magic mask. It does not create a target session.
func ProbeProfile(serverAddr, token string, opts Options) bool {
timeout := opts.txnTimeout
if timeout <= 0 || timeout > 2*time.Second {
timeout = 2 * time.Second
}
lane := newTxnLane(serverAddr, opts.tcpBuffer, 1, timeout, opts.headerMask, opts.coverProfile)
defer lane.Close()
resp, err := lane.Do([]byte("CPROBE " + wireToken(token)))
if err != nil {
return false
}
if string(resp) == "PROBEOK" {
return true
}
// Servers predating profile discovery do not know CPROBE, but receiving a
// correctly framed error still proves that the legacy mask-zero header
// survived. The subsequent end-to-end probe remains authoritative.
return opts.headerMask == 0 && strings.HasPrefix(string(resp), "ERR expected TUNNEL")
}
type chunkResult struct { type chunkResult struct {
seq uint64 seq uint64
data []byte data []byte
@@ -425,7 +474,7 @@ func Open(serverAddr, token, targetHost string, targetPort int, opts Options) (n
c.upSizer = newAdaptiveSizer("upload", opts) c.upSizer = newAdaptiveSizer("upload", opts)
c.downSizer = newAdaptiveSizer("download", opts) c.downSizer = newAdaptiveSizer("download", opts)
c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout) c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout, opts.headerMask, opts.coverProfile)
openPayload := []byte(fmt.Sprintf( openPayload := []byte(fmt.Sprintf(
"COPEN %s %s %s %d", "COPEN %s %s %s %d",
@@ -464,7 +513,7 @@ func Open(serverAddr, token, targetHost string, targetPort int, opts Options) (n
c.pullLanes = make([]*txnLane, opts.pollers) c.pullLanes = make([]*txnLane, opts.pollers)
for i := 0; i < opts.pollers; i++ { for i := 0; i < opts.pollers; i++ {
lane := newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout) lane := newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout, opts.headerMask, opts.coverProfile)
c.pullLanes[i] = lane c.pullLanes[i] = lane
c.workers.Add(1) c.workers.Add(1)
go c.pullWorker(lane) go c.pullWorker(lane)
@@ -762,7 +811,7 @@ func (c *chunkConn) Close() error {
c.once.Do(func() { c.once.Do(func() {
c.cancel() c.cancel()
lane := newTxnLane(c.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout) lane := newTxnLane(c.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout, c.opts.headerMask, c.opts.coverProfile)
_, _ = doControl(lane, []byte(fmt.Sprintf("CCLOSE %s %s", wireToken(c.token), c.sid))) _, _ = doControl(lane, []byte(fmt.Sprintf("CCLOSE %s %s", wireToken(c.token), c.sid)))
lane.Close() lane.Close()