New
This commit is contained in:
@@ -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 1–16
|
||||||
|
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 256 → 16 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, 1–16 |
|
||||||
| 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 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
|
Validation ranges: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
|
||||||
batch values 1–256 with `min ≤ max`, reconnect 0–1000000,
|
batch values 1–256 with `min ≤ max`, reconnect 0–1000000,
|
||||||
timeout 1–120.
|
timeout 1–120, probe delay 200–30000 ms, probe threads 1–16.
|
||||||
|
|
||||||
### 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 200ms–30s |
|
||||||
|
| `--wire-probe-threads` | `1` | Maximum concurrent real HTTP profile probes; range 1–16 |
|
||||||
| `--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, 1–256; equal to the ceiling pins the depth |
|
| `--chunk-concurrency-min` | `1` | Download batch floor, 1–256; 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 1–128 but unused |
|
| `--chunk-pollers` | `1` | Accepted for compatibility; validated 1–128 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
@@ -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
|
||||||
|
|||||||
Generated
+13
@@ -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
@@ -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())
|
||||||
@@ -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 }
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user