Mult Port + TCP Calibration (SSH DEAD)
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
# Runtime SSH secrets/state (never commit these).
|
||||
/dragontcp_ssh_host_key
|
||||
/dragontcp-users.json
|
||||
/dragontcp-users.json.tmp
|
||||
|
||||
# Generated build outputs.
|
||||
/SHA256SUMS
|
||||
/bin/dragontcp-hybrid-client-*
|
||||
/bin/dragontcp-hybrid-server-*
|
||||
/android/lib/*/libdragontcp_client.so
|
||||
/core/dragontcp-client
|
||||
/core/dragontcp-server
|
||||
/core/dragontcp-server.exe
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Build and validation status
|
||||
|
||||
This source bundle contains the DragonTCP + tunnel-only SSH + embedded UDPGW + Android SOCKS5 integration.
|
||||
|
||||
## Validated in the integration environment
|
||||
|
||||
- `gofmt` clean for the modified Go sources.
|
||||
- `go test ./...` passes for all DragonTCP packages using local API-compatible compile/test stubs for `golang.org/x/crypto/ssh` and `bcrypt` because this sandbox cannot reach the public Go module mirror.
|
||||
- `go vet ./...` passes under the same compile-only dependency stub setup.
|
||||
- Android `ProxyClient.kt`, `SocketProtector.kt`, and `ProxyProfile.kt` compile with the installed Kotlin compiler.
|
||||
- `build_core.sh`, `build_all.sh`, and `android/build_apk.sh` pass `bash -n` syntax validation.
|
||||
- UDPGW has a local UDP echo relay test.
|
||||
- SOCKS5 tests cover IPv4 UDP framing, BadVPN/UDPGW framing, domain rejection for UDP leak prevention, and `UDP ASSOCIATE 0.0.0.0:0`.
|
||||
- Internal-target tests verify the UDPGW alias is reachable only from an authenticated SSH session, not directly from a raw DragonTCP stream.
|
||||
- The native `--ssh-menu` user manager was exercised end-to-end with scripted create/list/renew/reset/delete operations; user passwords are auto-generated and only bcrypt hashes are persisted.
|
||||
|
||||
## Not produced in this sandbox
|
||||
|
||||
No prebuilt DragonTCP binaries, `.so`, APK, generated SSH host key, user database, or signing keystore are included. This is intentional so an old pre-SSH binary cannot be mistaken for the integrated build.
|
||||
|
||||
A real link against `golang.org/x/crypto v0.31.0` could not be performed here because outbound DNS/module fetching is blocked. The full Android APK also could not be built because an Android SDK/platform is not installed in this sandbox.
|
||||
|
||||
## Build on a normal development machine
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
./build_all.sh
|
||||
```
|
||||
|
||||
`build_core.sh` first builds the Go client/server and Android arm64 native core. `android/build_apk.sh` then builds and signs the APK. The first Go build needs normal network access if `golang.org/x/crypto v0.31.0` is not already present in the module cache.
|
||||
|
||||
For server-only/client-only binaries:
|
||||
|
||||
```bash
|
||||
./build_core.sh
|
||||
```
|
||||
|
||||
The generated artifacts go under `bin/` and `android/lib/arm64-v8a/`; `SHA256SUMS` is regenerated when `sha256sum` is available.
|
||||
|
||||
## SSH throughput/logging fix validation
|
||||
|
||||
- Added a bounded SSH carrier write combiner: up to 1 MiB per DragonTCP write with a 4 MiB queue and 2 ms combine window.
|
||||
- Added server-side bulk downstream coalescing only for the reserved internal SSH carrier: target goal 512 KiB, maximum coalescing window 25 ms.
|
||||
- SSH handshake/authentication readiness remains implemented in the Go client for CLI use; the current Android app does not enable SSH mode.
|
||||
- Server logs successful SSH connect/disconnect and rejected/auth-failed sessions.
|
||||
- Unit tests verify ordered SSH write combining and 512 KiB downstream coalescing from 32 KiB SSH-like bursts.
|
||||
- `go test ./...`, `go vet ./...`, and `go test -race ./cmd/dragontcp-client ./cmd/dragontcp-server` pass using the same local API-compatible x/crypto stubs described above.
|
||||
|
||||
|
||||
## Staged startup / fake-iperf calibration validation
|
||||
|
||||
- Normal Android B/BP startup uses SHA-256-masked payload framing; clear-payload mode is not exposed by the app.
|
||||
- Startup has an explicit `AUTH -> CALIBRATION -> ACTIVE` boundary; CLI SSH starts only after ACTIVE, while Android stays direct-only.
|
||||
- Added server-backed synthetic throughput probes: upload pipelines full candidate records to the server; download streams full candidate records from the server. No external test file is required.
|
||||
- Legacy CLI MAX FIRST remains available, but Android now uses ascending fake-iperf calibration and does not expose the MAX FIRST option.
|
||||
- A socket I/O timeout is treated as a dead physical connection and does not trigger the chunk-size sweep.
|
||||
- The validated upload/download sizes become the runtime adaptation ceilings for the VPN session.
|
||||
- Tests cover full UP/DW burst validation, masked-profile discovery, max-first compatibility, Go race checks, and server-side probe handling.
|
||||
|
||||
|
||||
## Ascending Android calibration / direct-only app update
|
||||
|
||||
- Android no longer exposes or starts SSH mode. SSH, SOCKS5 and UDPGW source remains in the Go project/server for future use.
|
||||
- Android no longer exposes MAX FIRST. It always requests ascending startup calibration.
|
||||
- Fake-iperf calibration is sequential UP then DW to avoid self-induced probe bursts.
|
||||
- Each direction proves the 32-byte minimum, grows by 4x toward the fixed 1 MiB maximum, then binary-refines the first failed boundary to 32-byte precision.
|
||||
- Runtime recoverable transfer failures reduce the active chunk immediately by 200 bytes; connection-level I/O timeouts still kill the physical tunnel and are not treated as size failures.
|
||||
- The Android main screen contains persistent LIVE CHUNK SIZE indicators for UP and DW. They update from calibration candidates, the selected ACTIVE profile, and later adaptive chunk changes without opening the log screen.
|
||||
- Android does not expose clear payload; normal B/BP discovery is masked-only. The Go CLI force-clear path is retained for compatibility/testing.
|
||||
- `go test ./...` and `go vet ./...` pass with the local API-compatible x/crypto stubs. `go test -race ./cmd/dragontcp-client ./cmd/dragontcp-server` also passes after widening the retained SSH-only 512 KiB coalescing window to 25 ms for race-instrumented scheduling.
|
||||
|
||||
|
||||
## X calibration / 32-byte refinement / Android masked-only cleanup
|
||||
|
||||
- X now runs the same staged UP/DW fake-iperf calibration as B/BP using native X `CIPERFUP`/`CIPERFDW` commands on the real UP/OK + XOR wire.
|
||||
- Ascending calibration starts at 32 bytes, grows by 4x toward 1 MiB, then refines the first good/bad boundary to **32-byte precision** independently for UP and DW.
|
||||
- Calibration now confirms boundary decisions with a **2-of-3** majority. The first failed ascent candidate is retried on fresh connections, and all fine-refinement candidates are confirmed. This prevents a single transient failure from producing unstable 1 KiB/2 KiB results. I/O timeouts remain inconclusive connection failures rather than chunk-size failures.
|
||||
- Android no longer exposes the CLEAR PAYLOAD control. Normal B/BP discovery uses SHA-256-masked payload profiles only; the explicit Go CLI force-clear flag is retained solely for compatibility/testing.
|
||||
- Android no longer exposes the RECORD SIZE (BYTES) Max/Min block. Its calibration range is fixed internally to 32 bytes through 1 MiB and the live UP/DW indicators show the calibrated/runtime sizes.
|
||||
- `go test ./...`, `go vet ./...`, and race tests for client/server/X calibration pass using the local API-compatible x/crypto stubs.
|
||||
|
||||
### Single-poller calibration fix
|
||||
- B/BP fake-iperf UP: exactly one outstanding calibration record per candidate.
|
||||
- B/BP fake-iperf DW: server returns exactly one calibration record per candidate.
|
||||
- X UP/DW: exactly one transaction per candidate.
|
||||
- 2-of-3 confirmation remains sequential on fresh connections.
|
||||
- Runtime poller/concurrency settings are unchanged and do not participate in calibration.
|
||||
|
||||
Validation for single-poller calibration: `go test ./...`, `go vet ./...`, targeted `go test -race`, and client/server build checks passed using the local API-compatible x/crypto stub because this environment cannot fetch the upstream module.
|
||||
|
||||
## X calibrated-chunk runtime lock
|
||||
|
||||
- After X UP/DW fake-iperf calibration completes, the selected upload and download chunk sizes are fixed for that X tunnel.
|
||||
- X runtime `Success()` cannot grow a calibrated chunk and runtime `Failure()` cannot shrink it. This removes the old `adaptive ... chunk` oscillation after calibration.
|
||||
- Failed X physical transactions reconnect/retry at the same calibrated chunk size; repeated failures are bounded so a dead path cannot loop forever.
|
||||
- The X ACTIVE log now includes `calibrated_locked=true runtime_adaptive=false`.
|
||||
- B/BP runtime behavior is unchanged by this fix.
|
||||
- `go test ./...`, `go vet ./...`, and `go test -race ./internal/xorchunk ./cmd/dragontcp-client` pass using the local API-compatible x/crypto stub.
|
||||
|
||||
|
||||
|
||||
## Full-range header scan (superseded)
|
||||
|
||||
The earlier `0x00`-`0xFF` covered scan has been removed from normal Android discovery. See **Valid direct-mask scan update** below. The server keeps cover-profile decoding only for backward compatibility and CLI-only compatibility modes.
|
||||
|
||||
## Android port-range / working-port source update
|
||||
|
||||
- Android CONNECTION now has `Port start` and `Port end`; existing single-port installs migrate the old Port value into both fields.
|
||||
- The Go client accepts `--server-port-start` / `--server-port-end` while retaining `--server-port` as the single-port compatibility fallback.
|
||||
- Port discovery is ascending and validates one port candidate at a time. TCP-open alone is not enough: the candidate must pass the real DragonTCP wire/header probe before selection.
|
||||
- The selected endpoint is locked before UP/DW fake-iperf calibration begins. Reconnects in that client process continue using the selected endpoint.
|
||||
- Android's `LIVE TRANSPORT` card now shows `WORKING PORT`, `UP CHUNK`, and `DW CHUNK` independently of the log screen.
|
||||
- Port-scan telemetry uses `[D-TCP] phase=PORT_SCAN ...`; Android shows `TESTING PORTS` until a DragonTCP port is selected.
|
||||
- This package is source-only per request; no APK/native binaries were rebuilt for this update.
|
||||
|
||||
|
||||
|
||||
## Valid direct-mask scan update
|
||||
|
||||
- Normal Android masked discovery no longer scans arbitrary covered `0x00`-`0xFF` profiles.
|
||||
- B and BP test exactly 32 direct-valid masks: `00,08,10,...,F8` (`mask & 7 == 0`).
|
||||
- X tests exactly the 96 direct-valid masks for which `('U' ^ mask) & 7 >= 5`.
|
||||
- Auto mode interleaves those valid sets in numeric order: 32 B + 32 BP + 96 X = 160 candidates maximum.
|
||||
- BP direct framing now carries its selected non-zero header mask without requiring the cover preface, matching B's direct-mask behavior.
|
||||
- Normal Android discovery remains SHA-256 payload-masked; the CLI-only forced-clear compatibility profiles are unchanged.
|
||||
- Client tests verify that no normal candidate uses an invalid mask or a cover profile.
|
||||
- Validation for this update: `go test ./cmd/dragontcp-client ./internal/xorchunk ./internal/wire ./internal/protocol ./internal/cover`, `go vet` for the same client/internal packages, and `go test -race ./cmd/dragontcp-client ./internal/xorchunk` pass using the local API-compatible x/crypto test stub. The production `core/go.mod` is unchanged and still points to the real `golang.org/x/crypto` module.
|
||||
- Source-only package; no APK was built for this update.
|
||||
@@ -4,12 +4,23 @@ DragonTCP carries ordinary TCP traffic inside a compact binary record protocol,
|
||||
normally over TCP port 53. It has three parts:
|
||||
|
||||
* a **Go server** on a Linux host that relays streams to their real destinations,
|
||||
* a **Go client** that exposes a local HTTP/HTTPS proxy,
|
||||
* a **Go client** that exposes a local HTTP/HTTPS proxy and, optionally, an
|
||||
authenticated tunnel-only SSH/SOCKS5 VPN path,
|
||||
* an **Android app** that captures all device traffic with `VpnService`, runs a
|
||||
userspace TCP/IP stack, and feeds everything into that local proxy.
|
||||
|
||||
This document is a complete reference for how all of it works.
|
||||
|
||||
> **Current Android build:** SSH support is retained in the Go client/server source,
|
||||
> but the Android app intentionally runs direct DragonTCP only. Startup performs
|
||||
> server-backed fake-iperf calibration for B, BP, and X: it starts at 32 bytes, grows
|
||||
> candidate UP/DW chunks by 4x toward the fixed 1 MiB ceiling, then refines the first
|
||||
> good/bad boundary to 32-byte precision. For X, the calibrated UP/DW values are
|
||||
> locked for the lifetime of the tunnel: runtime successes do not grow them and
|
||||
> transport failures do not shrink them. The Android main screen shows live UP and
|
||||
> DW chunk candidates/active sizes without requiring the log screen. Android always
|
||||
> uses SHA-256-masked payload framing; the clear-payload control is not exposed.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
@@ -59,11 +70,37 @@ This document is a complete reference for how all of it works.
|
||||
destination server
|
||||
```
|
||||
|
||||
When SSH mode is enabled, the path becomes:
|
||||
|
||||
```text
|
||||
Android apps
|
||||
│ IP packets
|
||||
▼
|
||||
VpnService + TunnelEngine
|
||||
│ SOCKS5 TCP / UDP ASSOCIATE
|
||||
▼
|
||||
127.0.0.1:1080 (dragontcp-client)
|
||||
│
|
||||
│ one persistent SSH connection carried by one DragonTCP stream
|
||||
▼
|
||||
dragontcp-server
|
||||
│
|
||||
├─ SSH direct-tcpip channels ───────────────► TCP destinations
|
||||
│
|
||||
└─ SSH-only dragontcp-udpgw.internal:7400 ─► embedded UDPGW ─► UDP
|
||||
```
|
||||
|
||||
The SSH service is deliberately **tunnel-only**. It supports password
|
||||
authentication and `direct-tcpip`; a `session` channel is only a dummy channel
|
||||
whose shell/exec/PTY requests are refused. There is no usable interactive shell,
|
||||
SCP, or command execution path.
|
||||
|
||||
### 1.2 Design decisions
|
||||
|
||||
**The server is only a relay.** It creates no TUN device, performs no NAT, and
|
||||
needs no `iptables` rules. All packet-level work happens on the phone in
|
||||
userspace. The server is a single static binary with no dependencies.
|
||||
userspace. SSH and UDPGW are embedded in the same Go server binary; no separate
|
||||
OpenSSH or BadVPN daemon is required.
|
||||
|
||||
**Each direction is split into records, not a byte stream.** Every record is
|
||||
carried by its own request/response exchange. That costs some efficiency and buys
|
||||
@@ -87,8 +124,9 @@ flow control a matter of reporting a number.
|
||||
|
||||
## 2. Security model
|
||||
|
||||
**DragonTCP does not provide authenticated encryption. It is not a VPN in the
|
||||
security sense.**
|
||||
**Raw DragonTCP does not provide authenticated encryption.** In the legacy/direct
|
||||
HTTP-proxy mode, DragonTCP is transport obfuscation rather than a cryptographic
|
||||
VPN.
|
||||
|
||||
Legacy payloads are **masked**: XORed with a keystream derived from SHA-256 that
|
||||
varies with session ID, mode, sequence, direction, and block number. New clients
|
||||
@@ -115,6 +153,13 @@ may open sessions. It is not a key and does not affect the mask.
|
||||
HTTPS, not by DragonTCP. Do not assume anything sent over plain HTTP through this
|
||||
tunnel is private.
|
||||
|
||||
**SSH mode changes the client-to-server security layer.** The persistent SSH
|
||||
session provides SSH confidentiality, integrity, server host-key verification
|
||||
(TOFU pinning when configured), and password user authentication inside the
|
||||
DragonTCP carrier. Traffic leaving the DragonTCP server toward its final
|
||||
destination is still protected only by the destination protocol, so HTTPS/TLS
|
||||
remains the correct end-to-end security boundary.
|
||||
|
||||
On the server side, the relay refuses private and special-use destinations by
|
||||
default (§6.5). That restriction is what stops the tunnel being used to reach the
|
||||
host's own localhost services or cloud metadata endpoints.
|
||||
@@ -125,15 +170,20 @@ host's own localhost services or cloud metadata endpoints.
|
||||
|
||||
```text
|
||||
core/
|
||||
go.mod module "dragontcp", Go 1.22, no dependencies
|
||||
go.mod module "dragontcp", Go 1.22; x/crypto for SSH/bcrypt
|
||||
cmd/dragontcp-client/
|
||||
main.go local HTTP/HTTPS proxy, CLI flags
|
||||
chunk.go transport: lanes, probing, adaptation, batching
|
||||
ssh_tunnel.go persistent SSH-over-DragonTCP carrier
|
||||
socks5.go local SOCKS5 TCP + UDP ASSOCIATE front end
|
||||
chunk_test.go
|
||||
cmd/dragontcp-server/
|
||||
main.go listener, DNS cache, address filtering, CLI flags
|
||||
chunk.go session manager, buffering, request dispatch
|
||||
bhttp.go auto-detected BP/BHP1 transport compatibility
|
||||
fake_ssh.go tunnel-only SSH + JSON user CLI
|
||||
udpgw.go embedded BadVPN-compatible UDP gateway
|
||||
internal_targets.go reserved internal-service routing
|
||||
debug.go counters and periodic statistics
|
||||
chunk_test.go
|
||||
internal/wire/
|
||||
@@ -155,18 +205,18 @@ android/
|
||||
vpn/TcpSession.kt userspace TCP endpoint
|
||||
vpn/UdpSession.kt UDP/DNS handling
|
||||
vpn/net/ IP/TCP/UDP headers, checksums, packet builder, DNS
|
||||
vpn/proxy/ProxyClient.kt HTTP CONNECT client
|
||||
vpn/proxy/ProxyClient.kt HTTP CONNECT + SOCKS5/UDP client
|
||||
res/ icon and theme
|
||||
assets/ license texts shipped inside the APK
|
||||
lib/arm64-v8a/libdragontcp_client.so the Go client, packaged as a native lib
|
||||
lib/arm64-v8a/libdragontcp_client.so generated Go client, packaged as a native lib
|
||||
build_apk.ps1 / build_apk.cmd Windows build
|
||||
build_apk.sh Linux/macOS build
|
||||
|
||||
build_core.ps1 / build_core.sh Go builds (Windows / Unix)
|
||||
build_all.sh core + APK in one step (Unix)
|
||||
bin/ built server and client binaries
|
||||
build_all.cmd / build_all.sh core + APK in one step (Windows / Unix)
|
||||
bin/ generated server and client binaries
|
||||
licenses/ full Apache 2.0 text
|
||||
SHA256SUMS digests for the built binaries
|
||||
SHA256SUMS generated digests after Unix core build
|
||||
THIRD_PARTY_NOTICES.md upstream attribution (a license condition)
|
||||
```
|
||||
|
||||
@@ -404,6 +454,29 @@ connection carries a 15-second deadline during the handshake, cleared once
|
||||
relaying starts. Accepts are bounded by `--max-connections` through a slot
|
||||
channel; over the limit the client returns `503`.
|
||||
|
||||
If `--ssh-user` is set, the client also starts a no-auth SOCKS5 listener on
|
||||
`127.0.0.1:1080` by default. That listener is local-only; SSH credentials are
|
||||
used between the DragonTCP client and the embedded SSH service, not by local
|
||||
SOCKS clients.
|
||||
|
||||
* SOCKS5 `CONNECT` opens an SSH `direct-tcpip` channel.
|
||||
* SOCKS5 `UDP ASSOCIATE` opens one SSH `direct-tcpip` channel to the embedded
|
||||
UDPGW and translates SOCKS UDP datagrams to/from BadVPN UDPGW frames.
|
||||
* UDPGW is IPv4-only. Domain-form SOCKS UDP destinations are rejected instead of
|
||||
being resolved locally, preventing an accidental local DNS leak.
|
||||
|
||||
`ssh_tunnel.go` maintains one persistent authenticated SSH connection over one
|
||||
DragonTCP stream and multiplexes the per-flow SSH channels inside it. The SSH
|
||||
packet layer normally emits writes in the tens-of-KiB range; the client therefore
|
||||
uses a bounded 4 MiB carrier send queue and combines consecutive SSH packets into
|
||||
DragonTCP writes of up to 1 MiB (2 ms combine window). On the server, the reserved
|
||||
internal SSH target gets a 25 ms / 512 KiB downstream coalescing policy before a
|
||||
DragonTCP pull response is emitted. This prevents one SSH packet from costing one
|
||||
full DragonTCP WAN round trip while leaving ordinary non-SSH sessions on the
|
||||
original low-latency coalescing behavior. A failed SSH connection is discarded
|
||||
and redialled; individual app flows do not each
|
||||
create their own DragonTCP carrier connection.
|
||||
|
||||
Relaying uses `io.Copy` in both directions and waits for **both** to finish, so
|
||||
TCP half-close is preserved and slow or large responses are not truncated. On
|
||||
Linux this lets the kernel use `splice`.
|
||||
@@ -438,69 +511,55 @@ keepalives, and optionally explicit buffer sizes via `--tcp-buffer`.
|
||||
| `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 |
|
||||
|
||||
### 5.4 Startup profile and path probing
|
||||
### 5.4 Startup header scan and path calibration
|
||||
|
||||
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.
|
||||
Before normal traffic, the client selects one fixed wire/header profile. Normal
|
||||
Android discovery always keeps SHA-256 payload masking enabled.
|
||||
|
||||
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.
|
||||
Normal masked discovery now tests **only masks that are mathematically valid for
|
||||
the server's direct first-byte classifier**. It does not run the old covered
|
||||
`0x00`-`0xFF` sweep.
|
||||
|
||||
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:
|
||||
For B and BP the low three bits carry the request mode (`0..4`), so the mask's
|
||||
low three bits must be zero. That produces exactly 32 candidates:
|
||||
|
||||
```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
|
||||
00 08 10 18 20 28 30 38 ... E8 F0 F8
|
||||
```
|
||||
|
||||
Before the first real connection, `getPathProfile` measures the path once and
|
||||
caches the result for **30 minutes**, keyed by server address, token, and size
|
||||
bounds.
|
||||
For X the first byte is `'U' ^ mask`; the server classifies X only when its low
|
||||
three bits are 5, 6, or 7. That produces exactly 96 valid direct X masks. Auto
|
||||
interleaves only these valid B/BP/X candidates in numeric mask order, for 160
|
||||
candidates total instead of roughly 900 probes.
|
||||
|
||||
Upload and download are probed **independently and concurrently**, each by binary
|
||||
search over a fixed ladder:
|
||||
Header discovery uses one tiny **server-local protocol probe**. It does not open
|
||||
`ip.dr2.site` or another Internet target. This keeps the test focused on whether
|
||||
the DragonTCP framing survives the carrier and avoids mixing server-outbound
|
||||
Internet conditions into header selection.
|
||||
|
||||
The Android default is one probe worker and a 100 ms launch spacing. The winner
|
||||
is cached for the lifetime of the client process and reused by every reconnect.
|
||||
A successful selection is logged as:
|
||||
|
||||
```text
|
||||
32, 64, 128, 256, 512, 1024, 1200, 1280, 1320, 1350, 1360, 1380, 1400,
|
||||
1450, 1600, 2048, 3205, 4096, 8192, 16384, 32768, 65536, 98304,
|
||||
131072, 262144, 524288, 786432, 1048576
|
||||
wire probe: selected=x/mask-37/cover-3792/pad-0/masked header_mask=37 completed=57 launched=57 elapsed=... protocol_probe=true threads=1 fixed_until_restart=true
|
||||
[D-TCP] phase=AUTH state=success wire=x header_mask=37 clear_payload=false ...
|
||||
```
|
||||
|
||||
The ladder is filtered to `[--chunk-min, --chunk-max]`, with the configured bounds
|
||||
added if missing. Binary search costs roughly five probes instead of twenty-eight,
|
||||
and avoids having to *fail* at every size on the way down during real traffic.
|
||||
Candidate counts remain bounded: pinned B has 288 candidates, BP 257, X 352,
|
||||
and Auto 897. The extra direct nonzero B/X profiles are retained after the
|
||||
full-range covered scan for compatibility with older servers.
|
||||
|
||||
A third probe sends eight keepalives on one connection to decide whether request
|
||||
reuse survives. Each probe uses a fresh random session ID with a timeout capped at
|
||||
2.5 s. If the search does not finish within 20 s, the client falls back to
|
||||
32 768 up / 1 350 down.
|
||||
After the header is fixed, UP and DW chunk calibration runs separately. It
|
||||
starts at 32 bytes, grows by 4x toward 1 MiB, and after the first failed size
|
||||
refines the last good/bad interval to **32-byte precision**. Boundary decisions
|
||||
use 2-of-3 confirmation on fresh sequential connections. Calibration is always
|
||||
single-poller/single-outstanding-request so the measured ceiling is not inflated
|
||||
by aggregate parallel traffic.
|
||||
|
||||
The outcome is logged once:
|
||||
|
||||
```text
|
||||
path probe: upload=32768 download=1400 persistent=true
|
||||
```
|
||||
Connection-level I/O timeout is not treated as proof that the candidate chunk
|
||||
is too large. A timeout kills/replaces the physical connection instead of
|
||||
forcing a size downgrade.
|
||||
|
||||
### 5.5 Adaptive record sizing
|
||||
|
||||
@@ -704,6 +763,16 @@ these special-use prefixes:
|
||||
|
||||
`--allow-private` disables the whole check. Leave it off on a public server.
|
||||
|
||||
The SSH tunnel uses the **same** destination policy for `direct-tcpip`, so adding
|
||||
SSH does not automatically expose localhost, RFC1918 services, or cloud metadata
|
||||
addresses. Two reserved names are exceptions with intentionally narrow scope:
|
||||
|
||||
* `dragontcp-ssh.internal:2222` is reachable by raw DragonTCP so the client can
|
||||
perform the SSH handshake against the loopback-only embedded SSH listener.
|
||||
* `dragontcp-udpgw.internal:7400` is in a separate SSH-only registry. It is
|
||||
reachable only after SSH authentication and cannot be opened directly by a
|
||||
raw DragonTCP stream.
|
||||
|
||||
If every resolved address is blocked, the error names them. Dial timeout is 10 s,
|
||||
with 30-second keepalives on the resulting connection.
|
||||
|
||||
@@ -717,6 +786,21 @@ A sweep every 30 s closes sessions idle longer than `--chunk-session-timeout`
|
||||
requests, data and wait records, sessions opened and closed, active sessions.
|
||||
`--debug-chunks` logs every record and is very verbose.
|
||||
|
||||
### 6.7 Tunnel-only SSH and user database
|
||||
|
||||
The embedded SSH listener defaults to `127.0.0.1:2222`. It is not published as a
|
||||
normal Internet-facing SSH port; DragonTCP reaches it through the reserved
|
||||
internal target above. Host keys are Ed25519 and are generated automatically on
|
||||
first start with mode `0600`.
|
||||
|
||||
SSH users are stored in `dragontcp-users.json`. Passwords are bcrypt hashes; the
|
||||
database can set an expiry and a maximum number of simultaneous persistent SSH
|
||||
connections per user. The same server binary acts as the account-management CLI
|
||||
with `--ssh-user-add`, `--ssh-user-delete`, and `--ssh-user-list`.
|
||||
|
||||
The integrated UDPGW listens on `127.0.0.1:7400` by default. It implements the
|
||||
BadVPN UDPGW framing used by the SSH VPN path and is not a public UDP service.
|
||||
|
||||
---
|
||||
|
||||
## 7. The Android app
|
||||
@@ -732,9 +816,9 @@ Package `com.dragontcp.client`, `minSdk 29`, `targetSdk 29`, arm64 only.
|
||||
| `LogActivity` / `AppLog` | Java | Live log viewer over a 600-line in-memory ring |
|
||||
| `TunnelEngine` | Kotlin | TUN read/write loops, session tables, housekeeping |
|
||||
| `TcpSession` | Kotlin | Userspace TCP endpoint, one per 4-tuple |
|
||||
| `UdpSession` | Kotlin | UDP handling; in practice DNS only |
|
||||
| `UdpSession` | Kotlin | DNS/direct-mode UDP handling and SOCKS5 UDP in SSH mode |
|
||||
| `net/*` | Kotlin | Header parsing, checksums, packet construction, DNS parsing |
|
||||
| `ProxyClient` | Kotlin | HTTP CONNECT client against the local Go proxy |
|
||||
| `ProxyClient` | Kotlin | HTTP CONNECT or SOCKS5 CONNECT/UDP ASSOCIATE against the local Go core |
|
||||
|
||||
### 7.2 Process model
|
||||
|
||||
@@ -759,8 +843,9 @@ Startup:
|
||||
3. `DragonService` starts in the foreground with a notification carrying a STOP
|
||||
action.
|
||||
4. The Go core is spawned with flags built from the saved settings.
|
||||
5. The service polls `127.0.0.1:8080` for up to 10 s (150 ms connect timeout,
|
||||
100 ms between attempts) until the proxy accepts.
|
||||
5. The service polls the selected local proxy for up to 10 s (150 ms connect
|
||||
timeout, 100 ms between attempts): `127.0.0.1:8080` in direct mode or
|
||||
`127.0.0.1:1080` in SSH mode.
|
||||
6. The TUN interface is established.
|
||||
7. `TunnelEngine` starts; state broadcasts flip to `CONNECTED`.
|
||||
|
||||
@@ -863,22 +948,27 @@ holds the shared output buffer.
|
||||
|
||||
### 7.7 UDP and DNS
|
||||
|
||||
With an HTTP CONNECT upstream, general UDP cannot be carried. `UdpSession`
|
||||
therefore handles **DNS only**; every other UDP flow is dropped, which makes QUIC
|
||||
fail and pushes apps back to TCP.
|
||||
Direct mode still uses an HTTP CONNECT upstream, so general UDP cannot be
|
||||
carried there. `UdpSession` handles DNS and drops other UDP flows, which makes
|
||||
QUIC fail and pushes apps back to TCP.
|
||||
|
||||
DNS queries are converted to **DNS-over-TCP** (RFC 7766: a 2-byte big-endian
|
||||
length prefix followed by the message) and sent to **1.1.1.1:53** through the
|
||||
tunnel. One tunnel is opened per query with a 10-second timeout, and the session
|
||||
ends after the answer is written back to the TUN as a synthesised UDP datagram.
|
||||
SSH mode enables `ProxyProfile.udpOverSocks`. `UdpSession` opens a SOCKS5 UDP
|
||||
association to `127.0.0.1:1080`; the Go client converts those local SOCKS UDP
|
||||
datagrams into BadVPN-compatible UDPGW frames and sends them through an SSH
|
||||
`direct-tcpip` channel. This gives the Android VPN general IPv4 UDP support in
|
||||
SSH mode without running the external `badvpn-udpgw` executable.
|
||||
|
||||
DNS is forced to **1.1.1.1** by the TUN configuration and follows the selected
|
||||
mode. In direct mode the adapter can use DNS-over-TCP through HTTP CONNECT; in
|
||||
SSH mode DNS UDP traffic can use the SOCKS5/UDPGW path.
|
||||
|
||||
Responses are also parsed to populate `HostRegistry`, a 512-entry access-ordered
|
||||
LRU mapping IP → hostname. It exists so diagnostics can say `github.com:443`
|
||||
rather than `140.82.121.4:443`.
|
||||
|
||||
The stack retains hooks for direct (non-tunnelled) DNS, SOCKS5 UDP association,
|
||||
DNS blocking, and per-app attribution. In this build `AppResolver`, `DnsBlocker`,
|
||||
and `TunnelLog` are deliberate no-op stubs that preserve the upstream API.
|
||||
The stack retains hooks for direct (non-tunnelled) DNS, DNS blocking, and
|
||||
per-app attribution. `AppResolver`, `DnsBlocker`, and `TunnelLog` remain
|
||||
deliberate no-op stubs that preserve the upstream API.
|
||||
|
||||
### 7.8 Packet construction
|
||||
|
||||
@@ -893,23 +983,27 @@ checksum of zero is written as `0xFFFF` per RFC 768.
|
||||
| UI field | Default | Flag passed to the core |
|
||||
|---|---|---|
|
||||
| Server | — | `--server-host` |
|
||||
| Port | 53 | `--server-port` |
|
||||
| Port start | 53 | `--server-port-start` |
|
||||
| Port end | 53 | `--server-port-end` |
|
||||
| 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 delay (ms) | 100 | `--wire-probe-delay`; spacing between sequential valid-mask probes |
|
||||
| Probe threads | 1 | `--wire-probe-threads`; maximum concurrent profile attempts, 1–16 |
|
||||
| Max chunk | 1048576 | `--chunk-max` **and** `--chunk-start` |
|
||||
| Min chunk | 32 | `--chunk-min` |
|
||||
| Batch max | 1 | `--chunk-concurrency` |
|
||||
| Batch min | 1 | `--chunk-concurrency-min` |
|
||||
| Reconnect every | 1 (auto) | `--chunk-reconnect-every` |
|
||||
| Timeout (s) | 5 | `--chunk-timeout` |
|
||||
|
||||
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-min 32`, `--chunk-max 1048576`,
|
||||
`--chunk-start 1048576`, `--chunk-grow-after 16`, and `--chunk-adapt-log=true`.
|
||||
The Android app does not expose manual record-size or clear-payload controls; startup
|
||||
calibration determines independent UP/DW ceilings automatically.
|
||||
|
||||
Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH,
|
||||
and ADVANCED. The batch card carries a live hint that restates the current setting in
|
||||
Settings are laid out in CONNECTION, WIRE, LIVE TRANSPORT, DOWNLOAD BATCH,
|
||||
and ADVANCED cards. The client scans Port start → Port end in ascending order. A port is
|
||||
shown as WORKING PORT only after a TCP connection and a real DragonTCP wire/header
|
||||
probe both succeed; a merely open TCP port is not accepted. The batch card carries a live hint that restates the current setting in
|
||||
words as you type, so the mode is never ambiguous:
|
||||
|
||||
```text
|
||||
@@ -927,9 +1021,8 @@ before spawning the core, and records the resulting mode in the log:
|
||||
Download batch: pinned at 5 records per request (never adapts)
|
||||
```
|
||||
|
||||
Validation ranges: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
|
||||
batch values 1–256 with `min ≤ max`, reconnect 0–1000000,
|
||||
timeout 1–120, probe delay 200–30000 ms, probe threads 1–16.
|
||||
Validation ranges: both ports 1–65535 with `start ≤ end`, batch values 1–256 with `min ≤ max`,
|
||||
reconnect 0–1000000, timeout 1–120, probe delay 50–30000 ms, probe threads 1–16.
|
||||
|
||||
### 7.10 Logs
|
||||
|
||||
@@ -944,6 +1037,15 @@ is written to disk.
|
||||
|
||||
### 8.1 Windows
|
||||
|
||||
For a complete build, double-click `build_all.cmd` at the repository root, or run:
|
||||
|
||||
```cmd
|
||||
build_all.cmd
|
||||
```
|
||||
|
||||
It builds the Go core first and then the signed Android APK. For CI/non-interactive
|
||||
use, `build_all.cmd --no-pause` suppresses the final pause.
|
||||
|
||||
No Gradle and no Android Studio required; `android\build_apk.ps1` drives the SDK
|
||||
command-line tools directly.
|
||||
|
||||
@@ -982,7 +1084,7 @@ Go binaries alone:
|
||||
```powershell
|
||||
.\build_core.ps1 # android .so + linux client + linux amd64/arm64 servers
|
||||
.\build_core.ps1 -ClientOnly # just the .so
|
||||
.\build_core.ps1 -AndroidLibDir <dir>
|
||||
.\build_core.ps1 -AndroidLibRoot <dir>
|
||||
```
|
||||
|
||||
### 8.2 Linux and macOS
|
||||
@@ -998,13 +1100,15 @@ pointing at a Kotlin distribution bundling `lib/kotlinx-coroutines-core-jvm.jar`
|
||||
it defaults to `~/.sdkman/candidates/kotlin/current`. Unlike the PowerShell script
|
||||
it downloads nothing and does not build the Go core for you.
|
||||
|
||||
`build_core.sh` does not build the Linux **client**; `build_core.ps1` does. Build
|
||||
it by hand if you need it.
|
||||
`build_core.sh` builds the Android arm64 client plus Linux amd64 client and
|
||||
amd64/arm64 servers. The PowerShell build can additionally build other Android
|
||||
ABIs when an NDK is available.
|
||||
|
||||
Go builds by hand:
|
||||
|
||||
```bash
|
||||
cd core
|
||||
go mod download
|
||||
go test ./...
|
||||
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=0 \
|
||||
@@ -1016,13 +1120,17 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
|
||||
-o ../bin/dragontcp-hybrid-server-linux-amd64 ./cmd/dragontcp-server
|
||||
```
|
||||
|
||||
CGO is off for every target, so no NDK and no C toolchain are needed.
|
||||
`golang.org/x/crypto` is the only additional Go module used by the fake-SSH
|
||||
feature (SSH protocol and bcrypt password hashes). CGO is off for every target,
|
||||
so no NDK and no C toolchain are needed for arm64. The first Go build needs
|
||||
network access to download the module unless it is already present in the Go
|
||||
module cache.
|
||||
|
||||
### 8.3 What the build does
|
||||
|
||||
1. **Resolve the toolchain and repo root**, and print what was chosen.
|
||||
2. **Build the native core** if the `.so` is missing or `-BuildCore` was passed,
|
||||
by calling `<repo>\build_core.ps1 -ClientOnly -AndroidLibDir <app>\lib\arm64-v8a`.
|
||||
by calling `<repo>\build_core.ps1 -ClientOnly -AndroidLibRoot <app>\lib`.
|
||||
3. **`aapt package`** — compile `res/`, pack `assets/`, bind the manifest against
|
||||
`android.jar`, producing `resources.ap_`.
|
||||
4. **Kotlin** — compile every `.kt` under `src\` to `build\kclasses`, JVM target
|
||||
@@ -1064,6 +1172,52 @@ keystore out of version control.
|
||||
|
||||
### 9.1 Server
|
||||
|
||||
The easiest way to manage tunnel users is the interactive menu:
|
||||
|
||||
```bash
|
||||
./dragontcp-menu.sh
|
||||
```
|
||||
|
||||
or directly:
|
||||
|
||||
```bash
|
||||
./bin/dragontcp-hybrid-server-linux-amd64 --ssh-menu
|
||||
```
|
||||
|
||||
The menu can create, delete, list, reset, and renew users. **Creating or resetting
|
||||
a user automatically generates a strong password**, so there is no password
|
||||
prompt and no environment variable to export. The generated password is shown
|
||||
once; only its bcrypt hash is stored in `dragontcp-users.json`. Renewing expiry
|
||||
or changing the connection limit does not change the existing password.
|
||||
|
||||
The menu defaults are 30 days and 1 simultaneous SSH connection. Enter `0` for
|
||||
no expiry or unlimited connections. `dragontcp-menu.sh` automatically selects
|
||||
the AMD64 or ARM64 Linux server binary and uses the project-root
|
||||
`dragontcp-users.json`. Set `DRAGONTCP_USERS_FILE=/path/to/users.json` if your
|
||||
running server uses a different account database.
|
||||
|
||||
The original non-interactive CLI is still available for automation. Using an
|
||||
environment variable avoids putting a chosen password directly in shell history:
|
||||
|
||||
```bash
|
||||
export DRAGONTCP_SSH_PASS='CHANGE_ME'
|
||||
./bin/dragontcp-hybrid-server-linux-amd64 \
|
||||
--ssh-user-add alice \
|
||||
--ssh-user-password-env DRAGONTCP_SSH_PASS \
|
||||
--ssh-user-days 30 \
|
||||
--ssh-user-max-connections 1
|
||||
unset DRAGONTCP_SSH_PASS
|
||||
```
|
||||
|
||||
List or delete accounts non-interactively:
|
||||
|
||||
```bash
|
||||
./bin/dragontcp-hybrid-server-linux-amd64 --ssh-user-list
|
||||
./bin/dragontcp-hybrid-server-linux-amd64 --ssh-user-delete alice
|
||||
```
|
||||
|
||||
Then run the server normally:
|
||||
|
||||
```bash
|
||||
sudo ./dragontcp-hybrid-server-linux-amd64 \
|
||||
--port 53 --port-alt 80 --chunk-max 1048576
|
||||
@@ -1090,6 +1244,11 @@ 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.
|
||||
|
||||
The embedded SSH listener and UDPGW bind loopback by default (`127.0.0.1:2222`
|
||||
and `127.0.0.1:7400`). Do **not** expose those ports publicly just to make the
|
||||
Android mode work: the intended path is through DragonTCP's reserved internal
|
||||
SSH target.
|
||||
|
||||
### 9.2 Client CLI
|
||||
|
||||
```bash
|
||||
@@ -1106,27 +1265,49 @@ adaptive_chunk=true start=1048576 min=32 max=1048576 grow_after=16 pollers=1 \
|
||||
batch=5-5(pinned) reconnect_every=0 timeout=5s
|
||||
```
|
||||
|
||||
For tunnel-only SSH mode:
|
||||
|
||||
```bash
|
||||
export DRAGONTCP_SSH_PASS='CHANGE_ME'
|
||||
./dragontcp-hybrid-client-linux-amd64 \
|
||||
--server-host YOUR_SERVER_IP --server-port 53 \
|
||||
--ssh-user alice \
|
||||
--ssh-password-env DRAGONTCP_SSH_PASS \
|
||||
--ssh-hostkey-pin-file "$HOME/.dragontcp-ssh-hostkey.pin"
|
||||
```
|
||||
|
||||
The first successful connection writes the SSH host-key SHA-256 fingerprint to
|
||||
the pin file. Later connections fail if that key changes. Point applications at
|
||||
`socks5://127.0.0.1:1080`; TCP is multiplexed as SSH `direct-tcpip` and UDP uses
|
||||
SOCKS5 UDP ASSOCIATE -> SSH -> UDPGW.
|
||||
|
||||
### 9.3 Android
|
||||
|
||||
Install the APK, enter the server address, grant the VPN prompt, connect.
|
||||
A reasonable starting point:
|
||||
Install the APK, enter the server address, grant the VPN prompt, and connect.
|
||||
The Android build is direct DragonTCP only; SSH fields and manual record-size
|
||||
controls are intentionally absent. A reasonable starting point is:
|
||||
|
||||
```text
|
||||
Server: YOUR_SERVER_IP
|
||||
Port: 53
|
||||
Port start: 53
|
||||
Port end: 53
|
||||
Token: (match the server, or leave blank)
|
||||
Max chunk: 1048576
|
||||
Min chunk: 32
|
||||
Wire: Auto
|
||||
Batch max: 1
|
||||
Batch min: 1
|
||||
Pollers: 1
|
||||
Reconnect every: 1
|
||||
Timeout (s): 5
|
||||
Probe delay (ms): 1000
|
||||
Probe delay (ms): 100
|
||||
Probe threads: 1
|
||||
|
||||
Automatic chunk calibration: 32 B -> 1 MiB, 4x ascent, 32 B refinement
|
||||
Payload framing: SHA-256 masked
|
||||
```
|
||||
|
||||
Use **OPEN LOGS** to watch the path probe and any adaptation.
|
||||
Use the **LIVE TRANSPORT** card to watch the selected WORKING PORT plus UP/DW
|
||||
calibration/runtime sizes without opening the log screen. **OPEN LOGS** still shows the detailed
|
||||
AUTH/CALIBRATION/ACTIVE state transitions and fake-iperf results. The app package
|
||||
is excluded from its own VPN so the DragonTCP core does not loop back into the TUN.
|
||||
|
||||
---
|
||||
|
||||
@@ -1152,18 +1333,37 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
|
||||
| `--debug` | `false` | Session/connect/error logs plus periodic stats |
|
||||
| `--debug-chunks` | `false` | Log every record — very verbose |
|
||||
| `--debug-stats-interval` | `5s` | Statistics period; 0 disables |
|
||||
| `--ssh-enable` | `true` | Enable embedded tunnel-only SSH |
|
||||
| `--ssh-listen` | `127.0.0.1:2222` | Embedded SSH listener; keep loopback for normal use |
|
||||
| `--ssh-internal-host` | `dragontcp-ssh.internal` | Reserved DragonTCP name for the embedded SSH service |
|
||||
| `--ssh-host-key` | `dragontcp_ssh_host_key` | Ed25519 host-key file, generated if absent |
|
||||
| `--ssh-users` | `dragontcp-users.json` | JSON SSH user database |
|
||||
| `--ssh-user-add` | empty | Create/update a tunnel user and exit |
|
||||
| `--ssh-user-delete` | empty | Delete a tunnel user and exit |
|
||||
| `--ssh-user-list` | `false` | List tunnel users and exit |
|
||||
| `--ssh-menu` | `false` | Interactive create/delete/list/reset/renew user menu and exit |
|
||||
| `--ssh-user-password-env` | empty | Env var holding password for `--ssh-user-add` |
|
||||
| `--ssh-user-days` | `0` | Account lifetime in days; 0 = no expiry |
|
||||
| `--ssh-user-max-connections` | `1` | Persistent SSH sessions allowed per user; 0 = unlimited |
|
||||
| `--udpgw-enable` | `true` | Enable embedded BadVPN-compatible UDPGW |
|
||||
| `--udpgw-listen` | `127.0.0.1:7400` | UDPGW TCP listener; keep loopback |
|
||||
| `--udpgw-internal-host` | `dragontcp-udpgw.internal` | SSH-only reserved UDPGW destination |
|
||||
| `--udpgw-max-clients` | `10000` | Concurrent UDPGW TCP clients |
|
||||
| `--udpgw-debug` | `false` | Verbose UDPGW errors |
|
||||
|
||||
### 10.2 Client flags
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--listen-host` / `--listen-port` | `127.0.0.1` / `8080` | Local proxy bind |
|
||||
| `--server-host` / `--server-port` | — / `53` | Remote server (host required) |
|
||||
| `--server-host` / `--server-port` | — / `53` | Remote server; `--server-port` remains the single-port compatibility default |
|
||||
| `--server-port-start` | `0` | First port in ascending scan; 0 uses `--server-port` |
|
||||
| `--server-port-end` | `0` | Last port in ascending scan; 0 uses the resolved start port |
|
||||
| `--token` | empty | Shared secret |
|
||||
| `--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 |
|
||||
| `--wire-probe-delay` | `100ms` | Spacing between header-profile probe starts; range 50ms–30s |
|
||||
| `--wire-probe-threads` | `1` | Maximum concurrent server-local header probes; range 1–16 |
|
||||
| `--max-connections` | `20000` | Concurrent proxied connections |
|
||||
| `--tcp-buffer` | `0` | Explicit socket buffers |
|
||||
| `--chunk-start` | `1048576` | Initial record size (the probe overrides it) |
|
||||
@@ -1179,6 +1379,14 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
|
||||
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
|
||||
| `--chunk-timeout` | `5s` | Per-record transaction timeout |
|
||||
| `--chunk-pollers` | `1` | Accepted for compatibility; validated 1–128 but unused |
|
||||
| `--ssh-user` | empty | Enables tunnel-only SSH/SOCKS mode when non-empty |
|
||||
| `--ssh-password` | empty | SSH password; prefer `--ssh-password-env` on CLI |
|
||||
| `--ssh-password-env` | empty | Environment variable containing the SSH password |
|
||||
| `--ssh-internal-host` | `dragontcp-ssh.internal` | Reserved DragonTCP SSH target |
|
||||
| `--ssh-internal-port` | `2222` | Embedded SSH target port |
|
||||
| `--ssh-hostkey-pin-file` | empty | TOFU SHA-256 host-key fingerprint file |
|
||||
| `--ssh-socks-host` / `--ssh-socks-port` | `127.0.0.1` / `1080` | Local SOCKS5 listener in SSH mode |
|
||||
| `--ssh-udpgw-host` / `--ssh-udpgw-port` | `dragontcp-udpgw.internal` / `7400` | SSH-only embedded UDPGW target |
|
||||
|
||||
The `concurrency` flag names are historical. They control the download **batch
|
||||
depth** described in §5.6, not any form of threading.
|
||||
@@ -1189,7 +1397,8 @@ depth** described in §5.6, not any form of threading.
|
||||
|---|---|---|
|
||||
| Client | 15 s | Local-connection handshake deadline |
|
||||
| Client | 10 s | Dial timeout to the server |
|
||||
| Client | 2.5 s | Per-probe timeout cap |
|
||||
| Client | 2.5 s | Quick single-record probe timeout cap |
|
||||
| Client | 8-20 s | Synthetic UP/DW calibration connection deadline |
|
||||
| Client | 20 s | Whole-probe timeout per direction |
|
||||
| Client | 30 min | Path profile cache lifetime |
|
||||
| Client | 30 ms | Pause between failed record retries |
|
||||
@@ -1203,12 +1412,71 @@ depth** described in §5.6, not any form of threading.
|
||||
|
||||
---
|
||||
|
||||
## 10.4 Staged startup and fake-iperf calibration
|
||||
|
||||
The client uses an explicit startup boundary before it exposes the local proxy:
|
||||
|
||||
```text
|
||||
AUTH / wire validation
|
||||
-> UP calibration
|
||||
-> DW calibration
|
||||
-> lock the validated per-direction chunk ceilings
|
||||
-> DragonTCP ACTIVE
|
||||
-> local proxy / VPN ready
|
||||
```
|
||||
|
||||
Calibration is server-backed and is implemented for **B, BP, and X**. It does not
|
||||
download an external file. Each direction starts at 32 bytes, grows aggressively by
|
||||
4x while candidates succeed, and stops the coarse ascent at the first failed size or
|
||||
at the fixed 1 MiB ceiling. It then binary-refines the last good/failed interval until
|
||||
the boundary is within **32 bytes**. UP and DW are calibrated sequentially to avoid
|
||||
self-induced startup bursts.
|
||||
|
||||
Carrier decisions near the boundary use confirmation retries so one transient loss or
|
||||
short-lived network hiccup does not collapse the selected size. The fast 4x ascent is
|
||||
still single-shot while it succeeds. When the first candidate fails, DragonTCP retries
|
||||
that exact size on fresh connections and requires a **2-of-3** success/failure majority.
|
||||
Every 32-byte refinement candidate uses the same majority rule. Socket timeouts are
|
||||
inconclusive and are not counted as evidence that a size is too large.
|
||||
|
||||
For B/BP, normal Android discovery uses SHA-256-masked payload framing only. Clear
|
||||
payload support remains in the Go CLI for compatibility/testing, but the Android app
|
||||
does not expose or request it. X uses its native UP/OK + XOR framing and has dedicated
|
||||
`CIPERFUP`/`CIPERFDW` server calibration commands, so X is measured on the real X
|
||||
wire rather than through the binary protocol.
|
||||
|
||||
A real socket I/O timeout is treated as a dead physical connection, not evidence that
|
||||
the candidate size is too large. The last proven size is retained and the connection is
|
||||
replaced instead of walking the chunk size downward. The validated UP/DW sizes become
|
||||
the runtime ceilings for that VPN session.
|
||||
|
||||
Typical client logs are:
|
||||
|
||||
```text
|
||||
[D-TCP] phase=AUTH state=success wire=x ...
|
||||
[D-TCP] phase=CALIBRATION state=starting wire=x strategy=ascending min=32 max=1048576 growth=4x fine_resolution=32 up_down=sequential
|
||||
[D-TCP] phase=CALIBRATION fake_iperf=upload wire=x stage=ascend chunk=524288 ... result=success
|
||||
[D-TCP] phase=CALIBRATION fake_iperf=upload wire=x stage=refine chunk=... result=...
|
||||
[D-TCP] phase=CALIBRATION state=success wire=x upload=... download=...
|
||||
[D-TCP] phase=ACTIVE wire=x upload_chunk=... download_chunk=...
|
||||
```
|
||||
|
||||
Server debug logs show matching `CALIBRATION fake_iperf=upload` and
|
||||
`CALIBRATION fake_iperf=download` events for B/BP and X.
|
||||
|
||||
## 11. Tuning
|
||||
|
||||
Start with the defaults. Path probing already picks sensible sizes, and most
|
||||
manual tuning makes things worse.
|
||||
|
||||
* **Throughput feels capped.** Raise `Batch max` to 4–16 and leave `Batch min` at
|
||||
* **SSH mode is much slower than direct mode.** Current builds combine small SSH
|
||||
packets before they enter DragonTCP and coalesce the internal SSH downstream
|
||||
stream on the server. You should see `ssh carrier: DragonTCP stream connected`
|
||||
followed by `ssh authenticated:` in the Android log. If those lines are absent,
|
||||
rebuild both the server and the Android native client so they run the same
|
||||
source version. This SSH-specific combining does **not** override `Batch min` /
|
||||
`Batch max`; a user-pinned value of 1 remains pinned at 1.
|
||||
* **Throughput feels capped in direct/non-SSH mode.** Raise `Batch max` to 4–16 and leave `Batch min` at
|
||||
1. More records per request is the main lever when latency to the server is
|
||||
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.
|
||||
@@ -1230,8 +1498,10 @@ manual tuning makes things worse.
|
||||
depth the path tolerates and `Batch max` higher; adaptation then works inside
|
||||
that window instead of falling all the way to 1.
|
||||
* **Frequent `after transport failure` lines.** The network is dropping large
|
||||
records. Lower `Max chunk` to a size the probe already found safe — 1400 is
|
||||
common — so the client stops rediscovering the limit.
|
||||
records. Android startup already runs ascending UP/DW calibration and locks the
|
||||
highest validated ceiling. Runtime recovery reduces the active chunk by 200 B
|
||||
immediately for recoverable transfer failures; an actual I/O timeout kills the
|
||||
physical tunnel instead of changing the chunk size.
|
||||
* **`i/o timeout`.** The active physical connection is closed and the tunnel
|
||||
exits immediately. It no longer redials that session or repeats the request at
|
||||
512 KiB, 256 KiB, and every smaller size.
|
||||
@@ -1271,10 +1541,13 @@ manual tuning makes things worse.
|
||||
| `target resolves only to blocked addresses` | The destination is private or special-use. Intentional; see §6.5. |
|
||||
| `download offset N was already acknowledged` | Client and server disagree on stream position, almost always a stale session after a restart. Reconnect. |
|
||||
| `upload gap: got N expected M` | The same, in the upload direction. Reconnect. |
|
||||
| `server maximum chunk N is below client minimum M` | Raise the server's `--chunk-max` or lower the client's `Min chunk`. |
|
||||
| `server maximum chunk N is below client minimum M` | Android uses a fixed 32-byte minimum; raise the server's `--chunk-max` to at least 32. CLI users may also lower `--chunk-min`. |
|
||||
| `download failed at minimum chunk` | Eight consecutive failures at the smallest record size — the path is not passing traffic at all. |
|
||||
| `i/o timeout` | The physical connection timed out; its tunnel was terminated immediately without replay or a chunk-size retry loop. |
|
||||
| DNS works, QUIC/UDP apps do not | By design: only DNS is carried over UDP (§7.7). |
|
||||
| DNS works, QUIC/UDP apps do not | The current Android build is direct DragonTCP/HTTP only, so general UDP is not tunneled. SSH/UDPGW code remains available in the Go project but is disabled in the app. |
|
||||
| `SSH handshake/auth failed` | Check the SSH username/password, account expiry/connection limit, and that the server was rebuilt with the new core. |
|
||||
| Android says SSH mode but no `ssh authenticated:` line | The old app filtered SSH core logs or the native core is stale. Current startup blocks until authentication succeeds and exposes all `ssh ...` state lines. Rebuild the native core/APK. |
|
||||
| `SSH host key changed` | The saved TOFU pin no longer matches. Verify that the server key was intentionally replaced before deleting the pin. |
|
||||
| No IPv6 anywhere | By design: IPv6 is captured and blackholed (§7.4). |
|
||||
| Connections hang instead of failing fast | Expected only for non-SYN packets to unknown flows, which receive an RST. Anything else warrants the logs. |
|
||||
|
||||
@@ -1298,7 +1571,10 @@ Coverage:
|
||||
* a pinned batch surviving the ~1 MiB per-batch cap while an unpinned one is still
|
||||
trimmed by it,
|
||||
* an adaptive batch halving down to its floor and growing back to its ceiling,
|
||||
* `1..1` being treated as pinned.
|
||||
* `1..1` being treated as pinned,
|
||||
* SOCKS5 IPv4 UDP parsing and UDPGW frame layout,
|
||||
* embedded UDPGW IPv4 relay against a real local UDP echo socket,
|
||||
* separation of raw-DragonTCP internal targets from SSH-only UDPGW targets.
|
||||
|
||||
Beyond unit tests, the transport has been exercised with an 8 MiB HTTP download
|
||||
verified by SHA-256, an HTTPS `CONNECT` download verified byte for byte,
|
||||
@@ -1306,9 +1582,8 @@ persistent and forced-rotation connection modes, and a server restricted to
|
||||
1400-byte records where probing selected 1400 automatically and the download still
|
||||
completed correctly.
|
||||
|
||||
`SHA256SUMS` records digests for the built binaries; the digest for
|
||||
`android/lib/arm64-v8a/libdragontcp_client.so` matches the copy packaged inside
|
||||
the APK.
|
||||
After a successful Unix `build_core.sh`, `SHA256SUMS` is regenerated for the
|
||||
new client/server binaries and Android core.
|
||||
|
||||
---
|
||||
|
||||
@@ -1321,6 +1596,8 @@ marker comment at the top.
|
||||
|
||||
* Attribution: [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)
|
||||
* License text: [`licenses/FreeProxy-APACHE-2.0.txt`](licenses/FreeProxy-APACHE-2.0.txt)
|
||||
* Go SSH/bcrypt dependency license:
|
||||
[`licenses/golang-x-crypto-BSD-3-Clause.txt`](licenses/golang-x-crypto-BSD-3-Clause.txt)
|
||||
|
||||
Both are also shipped inside the APK under `assets/`. They are a license
|
||||
condition rather than documentation, and must not be folded into this file or
|
||||
@@ -1328,3 +1605,8 @@ removed.
|
||||
|
||||
The remaining DragonTCP glue, UI, Go transport, and server code is provided as
|
||||
part of this project.
|
||||
|
||||
## Calibration single-poller rule
|
||||
|
||||
Startup UP/DW calibration is strictly single-lane: each candidate size sends exactly one outstanding record, waits for its response/ACK, and only then continues. Boundary stability retries (2-of-3) are sequential and use fresh connections; they never overlap. Runtime concurrency is unchanged and starts only after calibration. Server debug lines include `pollers=1 outstanding=1` so this is directly visible.
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
969e4a020d5475bfff355f38ca809edb68686b5850af8f9709126ed5bb2d13bb *bin/dragontcp-hybrid-server-linux-amd64
|
||||
eaf2d07d8edf17840ce7bad4b1c81e8f3ccb1504c18397a773ca0aacb2ee72ef *bin/dragontcp-hybrid-server-linux-arm64
|
||||
a56e8c1bbdee5b81cd4882bc67d97c61e11513c928ff67983c9111478b846a82 *bin/dragontcp-hybrid-client-linux-amd64
|
||||
5aed020f3c8d29dc7f9aeaa9546be653de537569dce4e57f6c22b9536d409967 *android/lib/arm64-v8a/libdragontcp_client.so
|
||||
e1252f0501da62d1daf9eb4624b1eec54adb0380b76b037224dc604d2b95ebf2 *android/lib/armeabi-v7a/libdragontcp_client.so
|
||||
cdda348cfe7aad548d9a6799594b077f555b2d8acf2364d4109a37ea6a03db46 *android/lib/x86_64/libdragontcp_client.so
|
||||
@@ -18,3 +18,10 @@ Apache 2.0 license is included at `licenses/FreeProxy-APACHE-2.0.txt`.
|
||||
|
||||
The rest of the DragonTCP-specific glue, UI, Go transport, and server code in
|
||||
this bundle is provided as part of this generated project.
|
||||
|
||||
## golang.org/x/crypto
|
||||
|
||||
The Go core uses **golang.org/x/crypto** for the SSH protocol implementation and
|
||||
bcrypt password hashing. It is distributed under the Go project's BSD-style
|
||||
license. The license text is included at
|
||||
`licenses/golang-x-crypto-BSD-3-Clause.txt`.
|
||||
|
||||
@@ -18,3 +18,10 @@ Apache 2.0 license is included at `licenses/FreeProxy-APACHE-2.0.txt`.
|
||||
|
||||
The rest of the DragonTCP-specific glue, UI, Go transport, and server code in
|
||||
this bundle is provided as part of this generated project.
|
||||
|
||||
## golang.org/x/crypto
|
||||
|
||||
The embedded DragonTCP Go client uses **golang.org/x/crypto** for the SSH
|
||||
protocol implementation. It is distributed under the Go project's BSD-style
|
||||
license. The license text is included in this APK as
|
||||
`golang-x-crypto-BSD-3-Clause.txt`.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,14 +34,16 @@ public class DragonService extends VpnService {
|
||||
public static final String ACTION_STATE = "com.dragontcp.client.STATE";
|
||||
public static final String EXTRA_ACTIVE = "active";
|
||||
public static final String EXTRA_STATUS = "status";
|
||||
public static final String EXTRA_UP_CHUNK = "upChunk";
|
||||
public static final String EXTRA_DOWN_CHUNK = "downChunk";
|
||||
|
||||
public static final String EXTRA_SERVER = "server";
|
||||
public static final String EXTRA_PORT = "port";
|
||||
public static final String EXTRA_PORT_START = "portStart";
|
||||
public static final String EXTRA_PORT_END = "portEnd";
|
||||
public static final String EXTRA_WORKING_PORT = "workingPort";
|
||||
public static final String EXTRA_TOKEN = "token";
|
||||
/** Wire format: "auto", "b", "bp" or "x". */
|
||||
public static final String EXTRA_WIRE = "wire";
|
||||
public static final String EXTRA_CHUNK_MAX = "chunkMax";
|
||||
public static final String EXTRA_CHUNK_MIN = "chunkMin";
|
||||
/** Maximum download records per request. A transport setting, not a thread count. */
|
||||
public static final String EXTRA_BATCH_MAX = "batchMax";
|
||||
/** Minimum download records per request; equal to the maximum pins the depth. */
|
||||
@@ -52,17 +54,27 @@ public class DragonService extends VpnService {
|
||||
public static final String EXTRA_PROBE_THREADS = "probeThreads";
|
||||
|
||||
private static final int BATCH_LIMIT = 256;
|
||||
private static final int CHUNK_MIN = 32;
|
||||
private static final int CHUNK_MAX = 1024 * 1024;
|
||||
|
||||
private static final int NOTIFICATION_ID = 53;
|
||||
private static final String CHANNEL_ID = "dragontcp-lite";
|
||||
private static final int LOCAL_PROXY_PORT = 8080;
|
||||
private static final int TUN_MTU = 1400;
|
||||
private static final int RUNTIME_SHRINK_AFTER = 1;
|
||||
private static final int RUNTIME_SHRINK_STEP = 200;
|
||||
|
||||
private static volatile boolean appActive;
|
||||
private static volatile String appStatus = "DISCONNECTED";
|
||||
private static volatile int appUpChunk;
|
||||
private static volatile int appDownChunk;
|
||||
private static volatile int appWorkingPort;
|
||||
|
||||
public static boolean isActive() { return appActive; }
|
||||
public static String currentStatus() { return appStatus; }
|
||||
public static int currentUpChunk() { return appUpChunk; }
|
||||
public static int currentDownChunk() { return appDownChunk; }
|
||||
public static int currentWorkingPort() { return appWorkingPort; }
|
||||
|
||||
private final Object stateLock = new Object();
|
||||
private volatile Process coreProcess;
|
||||
@@ -96,6 +108,9 @@ public class DragonService extends VpnService {
|
||||
return START_STICKY;
|
||||
}
|
||||
cancelRequested = false;
|
||||
appUpChunk = 0;
|
||||
appDownChunk = 0;
|
||||
appWorkingPort = 0;
|
||||
publishState("CONNECTING", true);
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Starting..."));
|
||||
@@ -112,16 +127,17 @@ public class DragonService extends VpnService {
|
||||
}
|
||||
|
||||
String server = intent.getStringExtra(EXTRA_SERVER);
|
||||
int port = intent.getIntExtra(EXTRA_PORT, 53);
|
||||
int portStart = intent.getIntExtra(EXTRA_PORT_START, 53);
|
||||
int portEnd = intent.getIntExtra(EXTRA_PORT_END, portStart);
|
||||
String token = intent.getStringExtra(EXTRA_TOKEN);
|
||||
String wire = intent.getStringExtra(EXTRA_WIRE);
|
||||
int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024);
|
||||
int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32);
|
||||
int chunkMax = CHUNK_MAX;
|
||||
int chunkMin = CHUNK_MIN;
|
||||
int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1);
|
||||
int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1);
|
||||
int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1);
|
||||
int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 5);
|
||||
int probeDelay = intent.getIntExtra(EXTRA_PROBE_DELAY, 1000);
|
||||
int probeDelay = intent.getIntExtra(EXTRA_PROBE_DELAY, 100);
|
||||
int probeThreads = intent.getIntExtra(EXTRA_PROBE_THREADS, 1);
|
||||
|
||||
if (server == null || server.trim().isEmpty()) {
|
||||
@@ -131,27 +147,36 @@ public class DragonService extends VpnService {
|
||||
server = server.trim();
|
||||
if (token == null) token = "";
|
||||
if (wire == null || !(wire.equals("b") || wire.equals("bp") || wire.equals("x"))) wire = "auto";
|
||||
chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
|
||||
chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
|
||||
batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax));
|
||||
batchMin = Math.max(1, Math.min(batchMax, batchMin));
|
||||
reconnect = Math.max(0, reconnect);
|
||||
timeout = Math.max(1, timeout);
|
||||
probeDelay = Math.max(200, Math.min(30000, probeDelay));
|
||||
probeDelay = Math.max(50, Math.min(30000, probeDelay));
|
||||
probeThreads = Math.max(1, Math.min(16, probeThreads));
|
||||
|
||||
if (portStart < 1 || portStart > 65535 || portEnd < 1 || portEnd > 65535 || portStart > portEnd) {
|
||||
failStart("Invalid server port range");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
AppLog.append("Starting DragonTCP → " + server + ":" + port);
|
||||
AppLog.append("Starting DragonTCP → " + server + " ports " + portStart + "-" + portEnd);
|
||||
AppLog.append("Port scan: ascending " + portStart + "-" + portEnd + " • one candidate at a time • real DragonTCP validation");
|
||||
AppLog.append(describeBatch(batchMin, batchMax));
|
||||
AppLog.append(describeWire(wire));
|
||||
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);
|
||||
AppLog.append("Payload: SHA-256 masked framing (clear-payload mode disabled in Android)");
|
||||
AppLog.append("Chunk startup: ASCENDING calibration from " + chunkMin + " to " + chunkMax
|
||||
+ " bytes; 4x growth then ~32-byte boundary refinement");
|
||||
AppLog.append("Runtime chunk recovery: reduce immediately by " + RUNTIME_SHRINK_STEP
|
||||
+ " bytes after a recoverable transfer failure; I/O timeout kills the connection instead");
|
||||
AppLog.append("Header scan: valid masks only • server-local probe • delay " + probeDelay + " ms • threads " + probeThreads);
|
||||
AppLog.append("SSH: disabled in Android app (server/core SSH support retained)");
|
||||
Process process = startDragonCore(server, portStart, portEnd, token, chunkMax, chunkMin, batchMax, batchMin, wire,
|
||||
reconnect, timeout, probeDelay, probeThreads);
|
||||
synchronized (stateLock) { coreProcess = process; }
|
||||
|
||||
startCoreLogReader(process);
|
||||
waitForLocalProxy(process);
|
||||
waitForLocalProxy(process, LOCAL_PROXY_PORT);
|
||||
if (cancelRequested) throw new InterruptedException("Stop requested");
|
||||
AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:8080");
|
||||
AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:" + LOCAL_PROXY_PORT);
|
||||
|
||||
ParcelFileDescriptor pfd = establishVpn();
|
||||
if (pfd == null) throw new IllegalStateException("Android refused to establish the VPN interface");
|
||||
@@ -205,7 +230,8 @@ public class DragonService extends VpnService {
|
||||
|
||||
private Process startDragonCore(
|
||||
String server,
|
||||
int port,
|
||||
int portStart,
|
||||
int portEnd,
|
||||
String token,
|
||||
int chunkMax,
|
||||
int chunkMin,
|
||||
@@ -225,7 +251,8 @@ public class DragonService extends VpnService {
|
||||
cmd.add("--listen-host"); cmd.add("127.0.0.1");
|
||||
cmd.add("--listen-port"); cmd.add(Integer.toString(LOCAL_PROXY_PORT));
|
||||
cmd.add("--server-host"); cmd.add(server);
|
||||
cmd.add("--server-port"); cmd.add(Integer.toString(port));
|
||||
cmd.add("--server-port-start"); cmd.add(Integer.toString(portStart));
|
||||
cmd.add("--server-port-end"); cmd.add(Integer.toString(portEnd));
|
||||
if (!token.isEmpty()) { cmd.add("--token"); cmd.add(token); }
|
||||
cmd.add("--transport"); cmd.add("chunk");
|
||||
cmd.add("--chunk-start"); cmd.add(Integer.toString(chunkMax));
|
||||
@@ -241,6 +268,12 @@ public class DragonService extends VpnService {
|
||||
cmd.add("--chunk-timeout"); cmd.add(timeout + "s");
|
||||
cmd.add("--chunk-grow-after"); cmd.add("16");
|
||||
cmd.add("--chunk-adapt-log=true");
|
||||
// Android always uses ascending startup calibration. MAX-FIRST remains
|
||||
// available in the Go CLI for compatibility but is intentionally never
|
||||
// enabled by the app. Runtime recovery is immediate and fine-grained.
|
||||
cmd.add("--chunk-max-first=false");
|
||||
cmd.add("--chunk-shrink-after"); cmd.add(Integer.toString(RUNTIME_SHRINK_AFTER));
|
||||
cmd.add("--chunk-shrink-step"); cmd.add(Integer.toString(RUNTIME_SHRINK_STEP));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.redirectErrorStream(true);
|
||||
@@ -271,11 +304,22 @@ public class DragonService extends VpnService {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
// Keep the UI useful: adaptation changes and real errors only.
|
||||
handleCoreTelemetry(line);
|
||||
String lower = line.toLowerCase();
|
||||
if (line.startsWith("adaptive ") || line.startsWith("path probe:") || line.startsWith("wire") || lower.contains("error") || lower.contains("failed")) {
|
||||
if (line.startsWith("[D-TCP]") || line.startsWith("adaptive ")
|
||||
|| line.startsWith("path probe:") || line.startsWith("wire")
|
||||
|| lower.contains("error") || lower.contains("failed")) {
|
||||
AppLog.append(line);
|
||||
}
|
||||
if (line.startsWith("[D-TCP] phase=PORT_SCAN state=starting")) {
|
||||
publishState("TESTING PORTS", true);
|
||||
} else if (line.startsWith("[D-TCP] phase=AUTH state=starting")) {
|
||||
publishState("AUTHENTICATING", true);
|
||||
} else if (line.startsWith("[D-TCP] phase=CALIBRATION state=starting")) {
|
||||
publishState("TESTING CARRIER", true);
|
||||
} else if (line.startsWith("[D-TCP] phase=ACTIVE")) {
|
||||
publishState("TRANSPORT READY", true);
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
@@ -302,13 +346,92 @@ public class DragonService extends VpnService {
|
||||
watcher.start();
|
||||
}
|
||||
|
||||
private void waitForLocalProxy(Process process) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + 10_000;
|
||||
private void handleCoreTelemetry(String line) {
|
||||
if (line == null || line.isEmpty()) return;
|
||||
String lower = line.toLowerCase();
|
||||
|
||||
if (line.startsWith("[D-TCP] phase=PORT_SCAN") && line.contains("state=success")) {
|
||||
int port = parseNamedInt(line, "port=");
|
||||
if (port > 0) updateWorkingPort(port);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[D-TCP] phase=ACTIVE")) {
|
||||
int up = parseNamedInt(line, "upload_chunk=");
|
||||
int down = parseNamedInt(line, "download_chunk=");
|
||||
updateLiveChunks(up, down);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[D-TCP] phase=CALIBRATION fake_iperf=")) {
|
||||
int chunk = parseNamedInt(line, "chunk=");
|
||||
if (chunk > 0) {
|
||||
if (lower.contains("fake_iperf=upload")) updateLiveChunks(chunk, 0);
|
||||
else if (lower.contains("fake_iperf=download")) updateLiveChunks(0, chunk);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("adaptive ") && line.contains(" chunk:")) {
|
||||
int arrow = line.indexOf("->");
|
||||
if (arrow >= 0) {
|
||||
int value = parseLeadingInt(line, arrow + 2);
|
||||
if (value > 0) {
|
||||
if (lower.contains("upload chunk:")) updateLiveChunks(value, 0);
|
||||
else if (lower.contains("download chunk:")) updateLiveChunks(0, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseNamedInt(String line, String key) {
|
||||
int at = line.indexOf(key);
|
||||
if (at < 0) return 0;
|
||||
return parseLeadingInt(line, at + key.length());
|
||||
}
|
||||
|
||||
private static int parseLeadingInt(String line, int start) {
|
||||
int i = start;
|
||||
while (i < line.length() && Character.isWhitespace(line.charAt(i))) i++;
|
||||
int value = 0;
|
||||
boolean found = false;
|
||||
while (i < line.length()) {
|
||||
char c = line.charAt(i);
|
||||
if (c < '0' || c > '9') break;
|
||||
found = true;
|
||||
value = value * 10 + (c - '0');
|
||||
i++;
|
||||
}
|
||||
return found ? value : 0;
|
||||
}
|
||||
|
||||
private void updateLiveChunks(int up, int down) {
|
||||
boolean changed = false;
|
||||
if (up > 0 && up != appUpChunk) {
|
||||
appUpChunk = up;
|
||||
changed = true;
|
||||
}
|
||||
if (down > 0 && down != appDownChunk) {
|
||||
appDownChunk = down;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) publishState(appStatus, appActive);
|
||||
}
|
||||
|
||||
private void updateWorkingPort(int port) {
|
||||
if (port > 0 && port != appWorkingPort) {
|
||||
appWorkingPort = port;
|
||||
publishState(appStatus, appActive);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForLocalProxy(Process process, int localPort) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + 300_000;
|
||||
Throwable last = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (!process.isAlive()) throw new IllegalStateException("DragonTCP core exited before proxy startup");
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress("127.0.0.1", LOCAL_PROXY_PORT), 150);
|
||||
socket.connect(new InetSocketAddress("127.0.0.1", localPort), 150);
|
||||
return;
|
||||
} catch (Throwable t) {
|
||||
last = t;
|
||||
@@ -408,6 +531,9 @@ public class DragonService extends VpnService {
|
||||
stopping = true;
|
||||
cleanupComponentsLocked();
|
||||
if (logMessage != null) AppLog.append(logMessage);
|
||||
appUpChunk = 0;
|
||||
appDownChunk = 0;
|
||||
appWorkingPort = 0;
|
||||
publishState("DISCONNECTED", false);
|
||||
try { stopForeground(true); } catch (Throwable ignored) {}
|
||||
stopSelf();
|
||||
@@ -438,6 +564,9 @@ public class DragonService extends VpnService {
|
||||
state.setPackage(getPackageName());
|
||||
state.putExtra(EXTRA_ACTIVE, active);
|
||||
state.putExtra(EXTRA_STATUS, appStatus);
|
||||
state.putExtra(EXTRA_UP_CHUNK, appUpChunk);
|
||||
state.putExtra(EXTRA_DOWN_CHUNK, appDownChunk);
|
||||
state.putExtra(EXTRA_WORKING_PORT, appWorkingPort);
|
||||
try { sendBroadcast(state); } catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,9 @@ public class MainActivity extends Activity {
|
||||
private static final int BAD = Color.rgb(232, 116, 116);
|
||||
|
||||
private EditText server;
|
||||
private EditText port;
|
||||
private EditText portStart;
|
||||
private EditText portEnd;
|
||||
private EditText token;
|
||||
private EditText chunkMax;
|
||||
private EditText chunkMin;
|
||||
private EditText batchMax;
|
||||
private EditText batchMin;
|
||||
private EditText reconnect;
|
||||
@@ -70,6 +69,9 @@ public class MainActivity extends Activity {
|
||||
private Button stopButton;
|
||||
private Button logsButton;
|
||||
private TextView statusChip;
|
||||
private TextView upChunkValue;
|
||||
private TextView downChunkValue;
|
||||
private TextView workingPortValue;
|
||||
|
||||
private boolean receiverRegistered;
|
||||
private boolean connectPending;
|
||||
@@ -80,8 +82,13 @@ public class MainActivity extends Activity {
|
||||
if (!DragonService.ACTION_STATE.equals(intent.getAction())) return;
|
||||
boolean active = intent.getBooleanExtra(DragonService.EXTRA_ACTIVE, false);
|
||||
String status = intent.getStringExtra(DragonService.EXTRA_STATUS);
|
||||
int upChunk = intent.getIntExtra(DragonService.EXTRA_UP_CHUNK, DragonService.currentUpChunk());
|
||||
int downChunk = intent.getIntExtra(DragonService.EXTRA_DOWN_CHUNK, DragonService.currentDownChunk());
|
||||
int workingPort = intent.getIntExtra(DragonService.EXTRA_WORKING_PORT, DragonService.currentWorkingPort());
|
||||
connectPending = false;
|
||||
updateConnectionUi(active, status == null ? (active ? "CONNECTED" : "DISCONNECTED") : status);
|
||||
updateLiveChunkUi(upChunk, downChunk);
|
||||
updateWorkingPortUi(workingPort);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,6 +118,8 @@ public class MainActivity extends Activity {
|
||||
receiverRegistered = true;
|
||||
}
|
||||
updateConnectionUi(DragonService.isActive(), DragonService.currentStatus());
|
||||
updateLiveChunkUi(DragonService.currentUpChunk(), DragonService.currentDownChunk());
|
||||
updateWorkingPortUi(DragonService.currentWorkingPort());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -216,10 +225,12 @@ public class MainActivity extends Activity {
|
||||
// ---------------------------------------------------------- connection
|
||||
LinearLayout connectionCard = card("CONNECTION");
|
||||
server = addField(connectionCard, "Server", "Server IP or hostname", "", false, false);
|
||||
LinearLayout connectionRow = row();
|
||||
port = addFieldToRow(connectionRow, "Port", "53", "53", true, false, 0.34f);
|
||||
token = addFieldToRow(connectionRow, "Token", "Optional", "", false, true, 0.66f);
|
||||
connectionCard.addView(connectionRow);
|
||||
LinearLayout portRow = row();
|
||||
portStart = addFieldToRow(portRow, "Port start", "53", "53", true, false, 0.5f);
|
||||
portEnd = addFieldToRow(portRow, "Port end", "53", "53", true, false, 0.5f);
|
||||
connectionCard.addView(portRow);
|
||||
token = addField(connectionCard, "Token", "Optional", "", false, true);
|
||||
connectionCard.addView(hint("Ports are tested from start to end. A port is accepted only after a real DragonTCP wire/header probe succeeds."));
|
||||
settings.addView(connectionCard, cardParams());
|
||||
|
||||
// ---------------------------------------------------------------- wire
|
||||
@@ -246,14 +257,65 @@ public class MainActivity extends Activity {
|
||||
wireBP.setOnClickListener(v -> setWireMode("bp"));
|
||||
wireX.setOnClickListener(v -> setWireMode("x"));
|
||||
|
||||
// --------------------------------------------------------- record size
|
||||
LinearLayout sizeCard = card("RECORD SIZE (BYTES)");
|
||||
LinearLayout chunks = row();
|
||||
chunkMax = addFieldToRow(chunks, "Max chunk", "1048576", "1048576", true, false, 0.58f);
|
||||
chunkMin = addFieldToRow(chunks, "Min chunk", "32", "32", true, false, 0.42f);
|
||||
sizeCard.addView(chunks);
|
||||
sizeCard.addView(hint("Probed automatically on connect, then adapted if the path changes."));
|
||||
settings.addView(sizeCard, cardParams());
|
||||
// ------------------------------------------------------ live chunk size
|
||||
LinearLayout liveCard = card("LIVE TRANSPORT");
|
||||
liveCard.addView(hint(
|
||||
"Working port and UP/DW chunk sizes update directly from the DragonTCP core, even when the log screen is closed."
|
||||
));
|
||||
LinearLayout liveRow = row();
|
||||
|
||||
LinearLayout upBox = new LinearLayout(this);
|
||||
upBox.setOrientation(LinearLayout.VERTICAL);
|
||||
upBox.setGravity(Gravity.CENTER);
|
||||
upBox.setPadding(dp(8), dp(10), dp(8), dp(10));
|
||||
upBox.setBackground(roundRect(FIELD, 12, BORDER, 1));
|
||||
TextView upLabel = text("UP CHUNK", 11, MUTED, true);
|
||||
upLabel.setGravity(Gravity.CENTER);
|
||||
upChunkValue = text("--", 18, OK, true);
|
||||
upChunkValue.setGravity(Gravity.CENTER);
|
||||
upChunkValue.setPadding(0, dp(4), 0, 0);
|
||||
upBox.addView(upLabel);
|
||||
upBox.addView(upChunkValue);
|
||||
|
||||
LinearLayout downBox = new LinearLayout(this);
|
||||
downBox.setOrientation(LinearLayout.VERTICAL);
|
||||
downBox.setGravity(Gravity.CENTER);
|
||||
downBox.setPadding(dp(8), dp(10), dp(8), dp(10));
|
||||
downBox.setBackground(roundRect(FIELD, 12, BORDER, 1));
|
||||
TextView downLabel = text("DW CHUNK", 11, MUTED, true);
|
||||
downLabel.setGravity(Gravity.CENTER);
|
||||
downChunkValue = text("--", 18, ACCENT, true);
|
||||
downChunkValue.setGravity(Gravity.CENTER);
|
||||
downChunkValue.setPadding(0, dp(4), 0, 0);
|
||||
downBox.addView(downLabel);
|
||||
downBox.addView(downChunkValue);
|
||||
|
||||
LinearLayout portBox = new LinearLayout(this);
|
||||
portBox.setOrientation(LinearLayout.VERTICAL);
|
||||
portBox.setGravity(Gravity.CENTER);
|
||||
portBox.setPadding(dp(8), dp(10), dp(8), dp(10));
|
||||
portBox.setBackground(roundRect(FIELD, 12, BORDER, 1));
|
||||
TextView portLabel = text("WORKING PORT", 11, MUTED, true);
|
||||
portLabel.setGravity(Gravity.CENTER);
|
||||
workingPortValue = text("--", 18, WARN, true);
|
||||
workingPortValue.setGravity(Gravity.CENTER);
|
||||
workingPortValue.setPadding(0, dp(4), 0, 0);
|
||||
portBox.addView(portLabel);
|
||||
portBox.addView(workingPortValue);
|
||||
|
||||
LinearLayout.LayoutParams upParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
upParams.setMarginEnd(dp(4));
|
||||
LinearLayout.LayoutParams downParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
downParams.setMarginStart(dp(4));
|
||||
downParams.setMarginEnd(dp(4));
|
||||
LinearLayout.LayoutParams portParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||
portParams.setMarginStart(dp(4));
|
||||
liveRow.addView(upBox, upParams);
|
||||
liveRow.addView(downBox, downParams);
|
||||
liveRow.addView(portBox, portParams);
|
||||
liveRow.setPadding(0, dp(8), 0, 0);
|
||||
liveCard.addView(liveRow);
|
||||
settings.addView(liveCard, cardParams());
|
||||
|
||||
// ------------------------------------------------------- download batch
|
||||
LinearLayout batchCard = card("DOWNLOAD BATCH");
|
||||
@@ -291,13 +353,13 @@ public class MainActivity extends Activity {
|
||||
timeout = addFieldToRow(timing, "Timeout (s)", "5", "5", true, false, 0.42f);
|
||||
advancedCard.addView(timing);
|
||||
LinearLayout probing = row();
|
||||
probeDelay = addFieldToRow(probing, "Probe delay (ms)", "1000", "1000", true, false, 0.65f);
|
||||
probeDelay = addFieldToRow(probing, "Probe delay (ms)", "100", "100", true, false, 0.65f);
|
||||
probeThreads = addFieldToRow(probing, "Probe threads", "1", "1", true, false, 0.35f);
|
||||
advancedCard.addView(probing);
|
||||
advancedCard.addView(hint(
|
||||
"1 = auto (recommended: starts persistent, then learns one request per "
|
||||
+ "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 delay spaces the single-poller valid-mask scan; 100 ms is recommended. "
|
||||
+ "Probe threads defaults to 1; more may trigger carrier limits."));
|
||||
settings.addView(advancedCard, cardParams());
|
||||
|
||||
@@ -323,7 +385,8 @@ public class MainActivity extends Activity {
|
||||
settings.addView(logsButton, logsParams);
|
||||
|
||||
TextView note = text(
|
||||
"IPv4 uses the Android VPN adapter. IPv6 is captured and blocked to prevent bypass. DNS is tunneled to 1.1.1.1 over DragonTCP.",
|
||||
"IPv4 uses the Android VPN adapter. IPv6 is captured and blocked to prevent bypass. "
|
||||
+ "Android uses direct DragonTCP mode; SSH remains in the project/server but is disabled in this app build.",
|
||||
11, MUTED, false
|
||||
);
|
||||
note.setLineSpacing(0, 1.08f);
|
||||
@@ -345,6 +408,23 @@ public class MainActivity extends Activity {
|
||||
updateConnectionUi(false, "DISCONNECTED");
|
||||
}
|
||||
|
||||
private void updateLiveChunkUi(int up, int down) {
|
||||
if (upChunkValue != null) upChunkValue.setText(formatChunk(up));
|
||||
if (downChunkValue != null) downChunkValue.setText(formatChunk(down));
|
||||
}
|
||||
|
||||
private void updateWorkingPortUi(int value) {
|
||||
if (workingPortValue != null) workingPortValue.setText(value > 0 ? Integer.toString(value) : "--");
|
||||
}
|
||||
|
||||
private String formatChunk(int bytes) {
|
||||
if (bytes <= 0) return "--";
|
||||
if (bytes == 1024 * 1024) return "1 MiB\n1048576 B";
|
||||
if (bytes >= 1024) return (bytes / 1024) + " KiB\n" + bytes + " B";
|
||||
return bytes + " B";
|
||||
}
|
||||
|
||||
|
||||
/** Describes the batch configuration in words, live, as the user types. */
|
||||
private void updateBatchHint() {
|
||||
if (batchHint == null) return;
|
||||
@@ -486,16 +566,16 @@ public class MainActivity extends Activity {
|
||||
if (wireHint == null) return;
|
||||
if ("auto".equals(mode)) {
|
||||
wireHint.setTextColor(ACCENT);
|
||||
wireHint.setText("Auto: discovers a working B/BP/X profile and keeps it until restart.");
|
||||
wireHint.setText("Auto: scans only direct-valid masked headers (B/BP: 32, X: 96) and keeps the first working profile.");
|
||||
} else if ("b".equals(mode)) {
|
||||
wireHint.setTextColor(OK);
|
||||
wireHint.setText("B only; header profile is discovered automatically.");
|
||||
wireHint.setText("B only; scans the 32 valid masks: 00,08,10,...,F8.");
|
||||
} else if ("bp".equals(mode)) {
|
||||
wireHint.setTextColor(OK);
|
||||
wireHint.setText("BP only.");
|
||||
wireHint.setText("BP only; scans the 32 valid masks: 00,08,10,...,F8.");
|
||||
} else {
|
||||
wireHint.setTextColor(OK);
|
||||
wireHint.setText("X only; header profile is discovered automatically.");
|
||||
wireHint.setText("X only; scans only masks valid for the direct UP classifier (96 values).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,16 +666,16 @@ public class MainActivity extends Activity {
|
||||
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_CONNECT);
|
||||
i.putExtra(DragonService.EXTRA_SERVER, p.getString("server", ""));
|
||||
i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53));
|
||||
int legacyPort = p.getInt("port", 53);
|
||||
i.putExtra(DragonService.EXTRA_PORT_START, p.getInt("portStart", legacyPort));
|
||||
i.putExtra(DragonService.EXTRA_PORT_END, p.getInt("portEnd", legacyPort));
|
||||
i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", ""));
|
||||
i.putExtra(DragonService.EXTRA_WIRE, p.getString("wire", "auto"));
|
||||
i.putExtra(DragonService.EXTRA_CHUNK_MAX, p.getInt("max", 1048576));
|
||||
i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32));
|
||||
i.putExtra(DragonService.EXTRA_BATCH_MAX, p.getInt("batchMax", 1));
|
||||
i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1));
|
||||
i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1));
|
||||
i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 5));
|
||||
i.putExtra(DragonService.EXTRA_PROBE_DELAY, p.getInt("probeDelay", 1000));
|
||||
i.putExtra(DragonService.EXTRA_PROBE_DELAY, p.getInt("probeDelay", 100));
|
||||
i.putExtra(DragonService.EXTRA_PROBE_THREADS, p.getInt("probeThreads", 1));
|
||||
if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
|
||||
}
|
||||
@@ -603,30 +683,35 @@ public class MainActivity extends Activity {
|
||||
private void validateAndSave() {
|
||||
String h = server.getText().toString().trim();
|
||||
if (h.isEmpty()) throw new IllegalArgumentException("Server is required");
|
||||
int p = parse(port, 1, 65535, "Port");
|
||||
int max = parse(chunkMax, 32, 1048576, "Max chunk");
|
||||
int min = parse(chunkMin, 32, max, "Min chunk");
|
||||
int pStart = parse(portStart, 1, 65535, "Port start");
|
||||
int pEnd = parse(portEnd, 1, 65535, "Port end");
|
||||
if (pStart > pEnd) throw new IllegalArgumentException("Port start must not exceed port end");
|
||||
int bMax = parse(batchMax, 1, BATCH_LIMIT, "Batch max");
|
||||
int bMin = parse(batchMin, 1, BATCH_LIMIT, "Batch min");
|
||||
if (bMin > bMax) throw new IllegalArgumentException("Batch min must not exceed batch max");
|
||||
int rec = parse(reconnect, 0, 1000000, "Reconnect every");
|
||||
int tout = parse(timeout, 1, 120, "Timeout");
|
||||
int delay = parse(probeDelay, 200, 30000, "Probe delay");
|
||||
int delay = parse(probeDelay, 50, 30000, "Probe delay");
|
||||
int threads = parse(probeThreads, 1, 16, "Probe threads");
|
||||
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
|
||||
.putString("server", h)
|
||||
.putInt("port", p)
|
||||
.putInt("portStart", pStart)
|
||||
.putInt("portEnd", pEnd)
|
||||
.putInt("port", pStart)
|
||||
.putString("token", token.getText().toString())
|
||||
.putString("wire", wireMode)
|
||||
.putInt("max", max)
|
||||
.putInt("min", min)
|
||||
.putInt("batchMax", bMax)
|
||||
.putInt("batchMin", bMin)
|
||||
.putInt("reconnect", rec)
|
||||
.putInt("timeout", tout)
|
||||
.putInt("probeDelay", delay)
|
||||
.putInt("probeThreads", threads)
|
||||
.remove("sshUser")
|
||||
.remove("sshPassword")
|
||||
.remove("forceMaxFirst")
|
||||
.remove("forceClearPayload")
|
||||
.remove("max")
|
||||
.remove("min")
|
||||
.apply();
|
||||
}
|
||||
|
||||
@@ -641,17 +726,29 @@ public class MainActivity extends Activity {
|
||||
private void loadSettings() {
|
||||
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
server.setText(p.getString("server", ""));
|
||||
port.setText(Integer.toString(p.getInt("port", 53)));
|
||||
int legacyPort = p.getInt("port", 53);
|
||||
portStart.setText(Integer.toString(p.getInt("portStart", legacyPort)));
|
||||
portEnd.setText(Integer.toString(p.getInt("portEnd", legacyPort)));
|
||||
token.setText(p.getString("token", ""));
|
||||
setWireMode(p.getString("wire", "auto"));
|
||||
chunkMax.setText(Integer.toString(p.getInt("max", 1048576)));
|
||||
chunkMin.setText(Integer.toString(p.getInt("min", 32)));
|
||||
batchMax.setText(Integer.toString(p.getInt("batchMax", 1)));
|
||||
batchMin.setText(Integer.toString(p.getInt("batchMin", 1)));
|
||||
reconnect.setText(Integer.toString(p.getInt("reconnect", 1)));
|
||||
timeout.setText(Integer.toString(p.getInt("timeout", 5)));
|
||||
probeDelay.setText(Integer.toString(p.getInt("probeDelay", 1000)));
|
||||
probeDelay.setText(Integer.toString(p.getInt("probeDelay", 100)));
|
||||
probeThreads.setText(Integer.toString(p.getInt("probeThreads", 1)));
|
||||
// Remove legacy app-only SSH/MAX-FIRST preferences. SSH support remains
|
||||
// in the Go project, but this Android UI intentionally never enables it.
|
||||
p.edit()
|
||||
.remove("sshUser")
|
||||
.remove("sshPassword")
|
||||
.remove("forceMaxFirst")
|
||||
.remove("forceClearPayload")
|
||||
.remove("max")
|
||||
.remove("min")
|
||||
.apply();
|
||||
updateLiveChunkUi(DragonService.currentUpChunk(), DragonService.currentDownChunk());
|
||||
updateWorkingPortUi(DragonService.currentWorkingPort());
|
||||
}
|
||||
|
||||
private void updateConnectionUi(boolean active, String rawStatus) {
|
||||
@@ -660,7 +757,7 @@ public class MainActivity extends Activity {
|
||||
boolean connected = status.contains("CONNECTED") && !status.contains("DISCONNECTED");
|
||||
boolean starting = active && !connected && !stopping;
|
||||
|
||||
statusChip.setText(connected ? "CONNECTED" : starting ? "CONNECTING" : stopping ? "STOPPING" : "DISCONNECTED");
|
||||
statusChip.setText(connected ? "CONNECTED" : stopping ? "STOPPING" : starting ? status : "DISCONNECTED");
|
||||
statusChip.setTextColor(connected ? OK : starting || stopping ? WARN : MUTED);
|
||||
statusChip.setBackground(roundRect(
|
||||
connected ? Color.rgb(20, 54, 42) : starting || stopping ? Color.rgb(58, 43, 22) : CARD,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tech.xvanturing.freeproxy.vpn.proxy
|
||||
|
||||
import tech.xvanturing.freeproxy.data.model.ProxyProfile
|
||||
import tech.xvanturing.freeproxy.data.model.ProxyType
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.DatagramSocket
|
||||
@@ -9,12 +10,14 @@ import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
private fun Byte.u8(): Int = toInt() and 0xFF
|
||||
|
||||
/**
|
||||
* Minimal HTTP CONNECT upstream client used by DragonTCP Lite.
|
||||
* Upstream proxy client used by the userspace VPN engine.
|
||||
*
|
||||
* The upstream proxy is always the local DragonTCP Go process on
|
||||
* 127.0.0.1:8080. DragonTCP then carries the stream over adaptive XOR-framed
|
||||
* TCP/53 to the remote server.
|
||||
* Direct DragonTCP mode talks HTTP CONNECT to the local Go core. SSH mode uses
|
||||
* the Go core's local SOCKS5 listener; TCP CONNECT becomes SSH direct-tcpip and
|
||||
* UDP ASSOCIATE is bridged to the server's BadVPN-compatible UDPGW.
|
||||
*/
|
||||
class ProxyClient(
|
||||
private val profile: ProxyProfile,
|
||||
@@ -23,16 +26,30 @@ class ProxyClient(
|
||||
) {
|
||||
@Throws(IOException::class)
|
||||
fun connectTcp(destination: InetAddress, destinationPort: Int): Socket {
|
||||
val socket = Socket()
|
||||
try {
|
||||
socket.bind(InetSocketAddress(0))
|
||||
if (!protector.protect(socket)) {
|
||||
throw IOException("Unable to protect local proxy socket from VPN")
|
||||
}
|
||||
socket.connect(proxyAddress, CONNECT_TIMEOUT_MS)
|
||||
socket.soTimeout = HANDSHAKE_TIMEOUT_MS
|
||||
socket.tcpNoDelay = true
|
||||
return when (profile.type) {
|
||||
ProxyType.HTTP -> connectHttp(destination, destinationPort)
|
||||
ProxyType.SOCKS5 -> connectSocks(destination, destinationPort)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun newProtectedSocket(): Socket {
|
||||
val socket = Socket()
|
||||
socket.bind(InetSocketAddress(0))
|
||||
if (!protector.protect(socket)) {
|
||||
socket.close()
|
||||
throw IOException("Unable to protect local proxy socket from VPN")
|
||||
}
|
||||
socket.connect(proxyAddress, CONNECT_TIMEOUT_MS)
|
||||
socket.soTimeout = HANDSHAKE_TIMEOUT_MS
|
||||
socket.tcpNoDelay = true
|
||||
return socket
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun connectHttp(destination: InetAddress, destinationPort: Int): Socket {
|
||||
val socket = newProtectedSocket()
|
||||
try {
|
||||
val literal = if (destination is Inet6Address) {
|
||||
"[${destination.hostAddress}]:$destinationPort"
|
||||
} else {
|
||||
@@ -60,7 +77,6 @@ class ProxyClient(
|
||||
if (code !in 200..299) {
|
||||
throw IOException("Local DragonTCP CONNECT failed: $status")
|
||||
}
|
||||
|
||||
socket.soTimeout = 0
|
||||
return socket
|
||||
} catch (t: Throwable) {
|
||||
@@ -69,16 +85,113 @@ class ProxyClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP CONNECT does not support SOCKS5 UDP ASSOCIATE. */
|
||||
@Throws(IOException::class)
|
||||
private fun connectSocks(destination: InetAddress, destinationPort: Int): Socket {
|
||||
val socket = newProtectedSocket()
|
||||
try {
|
||||
socksGreeting(socket)
|
||||
val out = socket.getOutputStream()
|
||||
out.write(byteArrayOf(SOCKS_VERSION.toByte(), CMD_CONNECT.toByte(), 0))
|
||||
writeSocksAddress(out = out, address = destination, port = destinationPort)
|
||||
out.flush()
|
||||
readSocksReply(socket.getInputStream())
|
||||
socket.soTimeout = 0
|
||||
return socket
|
||||
} catch (t: Throwable) {
|
||||
runCatching { socket.close() }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a SOCKS5 UDP ASSOCIATE control connection and returns its relay endpoint. */
|
||||
@Throws(IOException::class)
|
||||
fun openUdpAssociate(): UdpAssociation {
|
||||
throw IOException("UDP ASSOCIATE unavailable with local HTTP CONNECT proxy")
|
||||
if (profile.type != ProxyType.SOCKS5) {
|
||||
throw IOException("UDP ASSOCIATE requires the local SOCKS5 proxy")
|
||||
}
|
||||
val socket = newProtectedSocket()
|
||||
try {
|
||||
socksGreeting(socket)
|
||||
socket.getOutputStream().apply {
|
||||
write(byteArrayOf(SOCKS_VERSION.toByte(), CMD_UDP_ASSOCIATE.toByte(), 0, ATYP_IPV4.toByte()))
|
||||
write(byteArrayOf(0, 0, 0, 0, 0, 0))
|
||||
flush()
|
||||
}
|
||||
val relay = readSocksReply(socket.getInputStream())
|
||||
socket.soTimeout = 0
|
||||
return UdpAssociation(socket, relay)
|
||||
} catch (t: Throwable) {
|
||||
runCatching { socket.close() }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun socksGreeting(socket: Socket) {
|
||||
socket.getOutputStream().apply {
|
||||
write(byteArrayOf(SOCKS_VERSION.toByte(), 1, METHOD_NO_AUTH.toByte()))
|
||||
flush()
|
||||
}
|
||||
val response = socket.getInputStream().readExactly(2)
|
||||
if (response[0].u8() != SOCKS_VERSION || response[1].u8() != METHOD_NO_AUTH) {
|
||||
throw IOException("Local SOCKS5 proxy rejected no-auth negotiation")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun readSocksReply(input: InputStream): InetSocketAddress {
|
||||
val header = input.readExactly(4)
|
||||
if (header[0].u8() != SOCKS_VERSION) throw IOException("Invalid SOCKS5 reply version")
|
||||
if (header[1].u8() != 0) throw IOException("SOCKS5 request failed: reply=${header[1].u8()}")
|
||||
if (header[2].u8() != 0) throw IOException("Invalid SOCKS5 reserved byte")
|
||||
|
||||
val host = when (header[3].u8()) {
|
||||
ATYP_IPV4 -> InetAddress.getByAddress(input.readExactly(4)).hostAddress
|
||||
ATYP_IPV6 -> InetAddress.getByAddress(input.readExactly(16)).hostAddress
|
||||
ATYP_DOMAIN -> {
|
||||
val length = input.readExactly(1)[0].u8()
|
||||
if (length == 0) throw IOException("Empty SOCKS5 relay domain")
|
||||
String(input.readExactly(length), Charsets.ISO_8859_1)
|
||||
}
|
||||
else -> throw IOException("Unsupported SOCKS5 relay address type")
|
||||
}
|
||||
val portBytes = input.readExactly(2)
|
||||
val port = (portBytes[0].u8() shl 8) or portBytes[1].u8()
|
||||
if (port == 0) throw IOException("SOCKS5 relay returned port 0")
|
||||
|
||||
// RFC 1928 permits 0.0.0.0 as BND.ADDR. For a local SOCKS listener,
|
||||
// the usable endpoint is then the same host we connected to.
|
||||
val relayHost = if (host == "0.0.0.0" || host == "::" || host == "0:0:0:0:0:0:0:0") {
|
||||
proxyAddress.address?.hostAddress ?: proxyAddress.hostString
|
||||
} else host
|
||||
return InetSocketAddress(relayHost, port)
|
||||
}
|
||||
|
||||
private fun writeSocksAddress(out: java.io.OutputStream, address: InetAddress, port: Int) {
|
||||
val raw = address.address
|
||||
when (raw.size) {
|
||||
4 -> {
|
||||
out.write(ATYP_IPV4)
|
||||
out.write(raw)
|
||||
}
|
||||
16 -> {
|
||||
out.write(ATYP_IPV6)
|
||||
out.write(raw)
|
||||
}
|
||||
else -> throw IOException("Unsupported IP address length ${raw.size}")
|
||||
}
|
||||
out.write((port ushr 8) and 0xFF)
|
||||
out.write(port and 0xFF)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ATYP_IPV4 = 0x01
|
||||
const val ATYP_DOMAIN = 0x03
|
||||
const val ATYP_IPV6 = 0x04
|
||||
private const val SOCKS_VERSION = 0x05
|
||||
private const val METHOD_NO_AUTH = 0x00
|
||||
private const val CMD_CONNECT = 0x01
|
||||
private const val CMD_UDP_ASSOCIATE = 0x03
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 15_000
|
||||
private const val MAX_HEADER_LINE = 8192
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+101
@@ -0,0 +1,101 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
rem DragonTCP complete Windows build wrapper.
|
||||
rem Builds:
|
||||
rem 1. Go core / Android native libraries + Linux deployable binaries
|
||||
rem 2. Android APK
|
||||
rem
|
||||
rem Usage:
|
||||
rem build_all.cmd
|
||||
rem build_all.cmd --no-pause
|
||||
|
||||
cd /d "%~dp0"
|
||||
set "ROOT=%CD%"
|
||||
set "NO_PAUSE=0"
|
||||
if /I "%~1"=="--no-pause" set "NO_PAUSE=1"
|
||||
|
||||
cls
|
||||
echo ========================================
|
||||
echo DragonTCP - BUILD ALL
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Project: %ROOT%
|
||||
echo.
|
||||
|
||||
where powershell.exe >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] powershell.exe was not found.
|
||||
echo Windows PowerShell 5.1 or newer is required.
|
||||
goto :failed
|
||||
)
|
||||
|
||||
where go.exe >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] go.exe was not found in PATH.
|
||||
echo Install Go and reopen this command window.
|
||||
goto :failed
|
||||
)
|
||||
|
||||
echo [1/2] Building DragonTCP core...
|
||||
echo Android native libraries + Linux client/server binaries
|
||||
echo.
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\build_core.ps1"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] Core build failed.
|
||||
goto :failed
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/2] Building and signing Android APK...
|
||||
echo.
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\android\build_apk.ps1"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] APK build failed.
|
||||
goto :failed
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo BUILD COMPLETE
|
||||
echo ========================================
|
||||
echo.
|
||||
if exist "%ROOT%\android\build\DragonTCP-Hybrid-arm64.apk" (
|
||||
echo APK:
|
||||
echo %ROOT%\android\build\DragonTCP-Hybrid-arm64.apk
|
||||
echo.
|
||||
)
|
||||
if exist "%ROOT%\bin\dragontcp-hybrid-server-linux-amd64" (
|
||||
echo Linux server AMD64:
|
||||
echo %ROOT%\bin\dragontcp-hybrid-server-linux-amd64
|
||||
)
|
||||
if exist "%ROOT%\bin\dragontcp-hybrid-server-linux-arm64" (
|
||||
echo Linux server ARM64:
|
||||
echo %ROOT%\bin\dragontcp-hybrid-server-linux-arm64
|
||||
)
|
||||
if exist "%ROOT%\bin\dragontcp-hybrid-client-linux-amd64" (
|
||||
echo Linux client AMD64:
|
||||
echo %ROOT%\bin\dragontcp-hybrid-client-linux-amd64
|
||||
)
|
||||
|
||||
echo.
|
||||
if "%NO_PAUSE%"=="0" pause
|
||||
exit /b 0
|
||||
|
||||
:failed
|
||||
echo.
|
||||
echo ========================================
|
||||
echo BUILD FAILED
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Check the error above. Common requirements:
|
||||
echo - Go installed and available in PATH
|
||||
echo - Android SDK installed
|
||||
echo - Android build-tools and platform installed
|
||||
echo - JDK 17 or 21 installed / JAVA_HOME configured
|
||||
echo - Internet access on first Kotlin/Go dependency download
|
||||
echo.
|
||||
if "%NO_PAUSE%"=="0" pause
|
||||
exit /b 1
|
||||
@@ -6,10 +6,19 @@ command -v "$GO_BIN" >/dev/null 2>&1 || { echo "Go compiler not found" >&2; exit
|
||||
mkdir -p "$ROOT/bin" "$ROOT/android/lib/arm64-v8a"
|
||||
cd "$ROOT/core"
|
||||
|
||||
# This Unix build intentionally ships only arm64-v8a for Android. Remove any
|
||||
# previously bundled ABI binaries so an APK cannot accidentally contain an old
|
||||
# DragonTCP core that predates the SSH feature.
|
||||
rm -rf "$ROOT/android/lib/armeabi-v7a" "$ROOT/android/lib/x86_64" "$ROOT/android/lib/x86"
|
||||
|
||||
echo "[core] Android ARM64 client..."
|
||||
CGO_ENABLED=0 GOOS=android GOARCH=arm64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
|
||||
-o "$ROOT/android/lib/arm64-v8a/libdragontcp_client.so" ./cmd/dragontcp-client
|
||||
|
||||
echo "[core] Linux AMD64 client..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
|
||||
-o "$ROOT/bin/dragontcp-hybrid-client-linux-amd64" ./cmd/dragontcp-client
|
||||
|
||||
echo "[core] Linux AMD64 server..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
|
||||
-o "$ROOT/bin/dragontcp-hybrid-server-linux-amd64" ./cmd/dragontcp-server
|
||||
@@ -18,4 +27,13 @@ echo "[core] Linux ARM64 server..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
|
||||
-o "$ROOT/bin/dragontcp-hybrid-server-linux-arm64" ./cmd/dragontcp-server
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
cd "$ROOT"
|
||||
sha256sum \
|
||||
bin/dragontcp-hybrid-client-linux-amd64 \
|
||||
bin/dragontcp-hybrid-server-linux-amd64 \
|
||||
bin/dragontcp-hybrid-server-linux-arm64 \
|
||||
android/lib/arm64-v8a/libdragontcp_client.so > SHA256SUMS
|
||||
fi
|
||||
|
||||
echo "Core build complete."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -37,6 +38,7 @@ type bpLane struct {
|
||||
tcpBuffer int
|
||||
reconnectEvery int
|
||||
timeout time.Duration
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
autoReconnect bool
|
||||
pc *bpPhysicalConn
|
||||
@@ -44,6 +46,10 @@ type bpLane struct {
|
||||
}
|
||||
|
||||
func newBPLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, coverProfile cover.Profile) *bpLane {
|
||||
return newBPLaneWithMask(serverAddr, tcpBuffer, reconnectEvery, timeout, coverProfile.HeaderMask, coverProfile)
|
||||
}
|
||||
|
||||
func newBPLaneWithMask(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, headerMask byte, coverProfile cover.Profile) *bpLane {
|
||||
autoReconnect := reconnectEvery == 1
|
||||
if autoReconnect {
|
||||
reconnectEvery = 0
|
||||
@@ -53,6 +59,7 @@ func newBPLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Du
|
||||
tcpBuffer: tcpBuffer,
|
||||
reconnectEvery: reconnectEvery,
|
||||
timeout: timeout,
|
||||
headerMask: headerMask,
|
||||
coverProfile: coverProfile,
|
||||
autoReconnect: autoReconnect,
|
||||
}
|
||||
@@ -172,7 +179,7 @@ func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byt
|
||||
}
|
||||
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 {
|
||||
if err := writeBPRequest(l.pc.conn, mode, sid, seq, payload, downloadHint, l.headerMask, l.coverProfile.Clear); err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
return 0, nil, err
|
||||
@@ -180,7 +187,7 @@ func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byt
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, seq, l.coverProfile.HeaderMask, l.coverProfile.Clear)
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, seq, l.headerMask, l.coverProfile.Clear)
|
||||
if err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
@@ -197,6 +204,31 @@ func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byt
|
||||
return 0, nil, fmt.Errorf("BP request failed after reconnect: %w", lastErr)
|
||||
}
|
||||
|
||||
// probeBPProfile validates one BP header/cover profile with a single small
|
||||
// BHP1 echo transaction. It deliberately does not register/open a target
|
||||
// session; authenticated DTP2 calibration runs immediately after profile
|
||||
// selection and remains authoritative for the configured server token.
|
||||
func probeBPProfile(serverAddr string, opts chunkClientOptions) bool {
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
timeout := opts.txnTimeout
|
||||
if timeout <= 0 || timeout > profileProbeTimeout {
|
||||
timeout = profileProbeTimeout
|
||||
}
|
||||
lane := newBPLaneWithMask(serverAddr, opts.tcpBuffer, 1, timeout, opts.headerMask, opts.coverProfile)
|
||||
defer lane.Close()
|
||||
|
||||
payload := make([]byte, 10)
|
||||
copy(payload[:4], []byte("BHP1"))
|
||||
payload[4] = 1
|
||||
payload[5] = bpModeUpload
|
||||
binary.BigEndian.PutUint32(payload[6:10], 0)
|
||||
status, body, err := lane.single(bpModeProbe, sid, 0, payload, 0)
|
||||
return err == nil && status == wire.StatusOK && bytes.Equal(body, payload)
|
||||
}
|
||||
|
||||
func decodeBPData(body []byte) ([]byte, error) {
|
||||
if len(body) < 4 {
|
||||
return nil, fmt.Errorf("short BP DATA body")
|
||||
@@ -240,7 +272,7 @@ func (l *bpLane) download(sid wire.SessionID, offset uint64, maxChunk, count int
|
||||
}
|
||||
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 {
|
||||
if err := writeBPRequest(l.pc.conn, mode, sid, offset, payload, hint, l.headerMask, l.coverProfile.Clear); err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
return nil, 0, err
|
||||
@@ -252,7 +284,7 @@ func (l *bpLane) download(sid wire.SessionID, offset uint64, maxChunk, count int
|
||||
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)
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, offset, l.headerMask, l.coverProfile.Clear)
|
||||
if err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
@@ -327,9 +359,15 @@ func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chu
|
||||
if opts.startSize < opts.minSize || opts.startSize > opts.maxSize {
|
||||
opts.startSize = opts.maxSize
|
||||
}
|
||||
if opts.forceMaxStart {
|
||||
opts.startSize = opts.maxSize
|
||||
}
|
||||
if opts.txnTimeout <= 0 {
|
||||
opts.txnTimeout = 5 * time.Second
|
||||
}
|
||||
if opts.shrinkAfter < 1 {
|
||||
opts.shrinkAfter = 1
|
||||
}
|
||||
if opts.maxPipeline < 1 {
|
||||
opts.maxPipeline = 1
|
||||
}
|
||||
@@ -347,11 +385,24 @@ func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chu
|
||||
reconnect = 0
|
||||
}
|
||||
|
||||
profile := pathProfile{
|
||||
upload: opts.startSize,
|
||||
download: opts.startSize,
|
||||
persistent: false,
|
||||
at: time.Now(),
|
||||
}
|
||||
if !opts.skipPathProbe {
|
||||
// BP uses the same binary framing sizes and cover preface as the native B
|
||||
// transport. Reuse the authenticated DTP2 probe machinery to calibrate
|
||||
// the carrier before BP starts moving SSH/application data.
|
||||
profile = getPathProfile(serverAddr, token, opts)
|
||||
}
|
||||
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadLane := newBPLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.coverProfile)
|
||||
uploadLane := newBPLaneWithMask(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.headerMask, opts.coverProfile)
|
||||
status, body, err := uploadLane.single(bpModeUpload, sid, 0, nil, 0)
|
||||
if err != nil {
|
||||
uploadLane.Close()
|
||||
@@ -389,11 +440,25 @@ func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chu
|
||||
sid: sid,
|
||||
opts: opts,
|
||||
uploadLane: uploadLane,
|
||||
downloadLane: newBPLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.coverProfile),
|
||||
downloadLane: newBPLaneWithMask(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.headerMask, opts.coverProfile),
|
||||
pipeline: opts.maxPipeline,
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("BP upload", opts.startSize, opts)
|
||||
c.downSizer = newAdaptiveSizer("BP download", opts.startSize, opts)
|
||||
upStart := minInt(profile.upload, opts.maxSize)
|
||||
downStart := minInt(profile.download, opts.maxSize)
|
||||
if upStart < opts.minSize {
|
||||
upStart = opts.minSize
|
||||
}
|
||||
if downStart < opts.minSize {
|
||||
downStart = opts.minSize
|
||||
}
|
||||
upSizerOpts := opts
|
||||
downSizerOpts := opts
|
||||
if !opts.skipPathProbe {
|
||||
upSizerOpts.maxSize = upStart
|
||||
downSizerOpts.maxSize = downStart
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("BP upload", upStart, upSizerOpts)
|
||||
c.downSizer = newAdaptiveSizer("BP download", downStart, downSizerOpts)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
@@ -22,6 +23,8 @@ type chunkClientOptions struct {
|
||||
maxSize int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
shrinkStep int
|
||||
adaptLog bool
|
||||
pollers int
|
||||
reconnectEvery int
|
||||
@@ -33,6 +36,7 @@ type chunkClientOptions struct {
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
skipPathProbe bool
|
||||
forceMaxStart bool
|
||||
}
|
||||
|
||||
type adaptiveSizer struct {
|
||||
@@ -43,6 +47,9 @@ type adaptiveSizer struct {
|
||||
max int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
shrinkStep int
|
||||
failures int
|
||||
successes int
|
||||
good int
|
||||
bad int
|
||||
@@ -68,6 +75,13 @@ func newAdaptiveSizer(name string, start int, opts chunkClientOptions) *adaptive
|
||||
}
|
||||
return 64
|
||||
}(),
|
||||
shrinkAfter: func() int {
|
||||
if opts.shrinkAfter > 0 {
|
||||
return opts.shrinkAfter
|
||||
}
|
||||
return 1
|
||||
}(),
|
||||
shrinkStep: opts.shrinkStep,
|
||||
logChanges: opts.adaptLog,
|
||||
}
|
||||
}
|
||||
@@ -81,7 +95,14 @@ func (s *adaptiveSizer) Current() int {
|
||||
func (s *adaptiveSizer) Success(attempted int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.adaptive || attempted != s.current || s.current >= s.max {
|
||||
if !s.adaptive || attempted != s.current {
|
||||
return
|
||||
}
|
||||
// A real success proves the current size still works. In max-first mode this
|
||||
// resets the consecutive-failure budget so isolated carrier errors never
|
||||
// cause a downshift.
|
||||
s.failures = 0
|
||||
if s.current >= s.max {
|
||||
return
|
||||
}
|
||||
if attempted > s.good {
|
||||
@@ -135,11 +156,25 @@ func (s *adaptiveSizer) FailureReason(attempted int, cause error) (int, int) {
|
||||
return old, old
|
||||
}
|
||||
s.successes = 0
|
||||
s.failures++
|
||||
if s.failures < s.shrinkAfter {
|
||||
if s.logChanges && s.shrinkAfter > 1 {
|
||||
fmt.Printf("adaptive %s chunk: holding %d after failure %d/%d: %v\n", s.name, old, s.failures, s.shrinkAfter, cause)
|
||||
}
|
||||
return old, old
|
||||
}
|
||||
s.failures = 0
|
||||
if s.bad == 0 || attempted < s.bad {
|
||||
s.bad = attempted
|
||||
}
|
||||
next := attempted / 2
|
||||
if s.good > 0 && s.good < attempted {
|
||||
if s.shrinkStep > 0 {
|
||||
next = attempted - s.shrinkStep
|
||||
// MAX-FIRST linear downgrade intentionally ignores an older known-good
|
||||
// point: the goal is to walk down in small deterministic steps instead
|
||||
// of making a large jump after a transient carrier rejection.
|
||||
s.good = 0
|
||||
} else if s.good > 0 && s.good < attempted {
|
||||
next = s.good
|
||||
} else {
|
||||
s.good = 0
|
||||
@@ -470,6 +505,123 @@ func probeOne(serverAddr, token string, opts chunkClientOptions, kind byte, cand
|
||||
return status == wire.StatusOK
|
||||
}
|
||||
|
||||
type iperfProbeResult struct {
|
||||
ok bool
|
||||
bytes int
|
||||
elapsed time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
func (r iperfProbeResult) mbps() float64 {
|
||||
if r.elapsed <= 0 || r.bytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (float64(r.bytes) * 8) / r.elapsed.Seconds() / 1_000_000
|
||||
}
|
||||
|
||||
func calibrationDeadline(opts chunkClientOptions) time.Duration {
|
||||
t := opts.txnTimeout
|
||||
if t < 8*time.Second {
|
||||
t = 8 * time.Second
|
||||
}
|
||||
if t > 20*time.Second {
|
||||
t = 20 * time.Second
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func dialProbeConn(serverAddr string, opts chunkClientOptions) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: minDuration(calibrationDeadline(opts), 10*time.Second), KeepAlive: 30 * time.Second}
|
||||
conn, err := d.Dial("tcp", serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cover.WritePreface(conn, opts.coverProfile); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
protocol.TuneTCP(conn)
|
||||
protocol.TuneTCPBuffer(conn, opts.tcpBuffer)
|
||||
_ = conn.SetDeadline(time.Now().Add(calibrationDeadline(opts)))
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// probeIperfOne validates one UP or DW record at a time against the DragonTCP
|
||||
// server. Calibration is intentionally single-poller and never pipelines
|
||||
// multiple outstanding records: it measures a single lane's safe record size,
|
||||
// not aggregate throughput. Boundary confirmation retries happen sequentially
|
||||
// on fresh connections.
|
||||
func probeIperfOne(serverAddr, token string, opts chunkClientOptions, kind byte, candidate int) iperfProbeResult {
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
totalBytes := candidate * count
|
||||
conn, err := dialProbeConn(serverAddr, opts)
|
||||
if err != nil {
|
||||
return iperfProbeResult{err: err}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
return iperfProbeResult{err: err}
|
||||
}
|
||||
startSeq := probeSeq.Add(uint64(count)) - uint64(count) + 1
|
||||
started := time.Now()
|
||||
|
||||
switch kind {
|
||||
case wire.ProbeUpload:
|
||||
payload := makeProbePayload(wire.ProbeIperfUpload, candidate, candidate, token)
|
||||
totalBytes = len(payload) * count
|
||||
for i := 0; i < count; i++ {
|
||||
seq := startSeq + uint64(i)
|
||||
if err := wire.WriteRequestProfileEncoding(conn, wire.ModeProbe, sid, seq, payload, opts.headerMask, opts.coverProfile.Clear); err != nil {
|
||||
return iperfProbeResult{bytes: i * len(payload), elapsed: time.Since(started), err: err}
|
||||
}
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
status, body, err := wire.ReadResponseProfile(conn, opts.headerMask)
|
||||
if err != nil {
|
||||
return iperfProbeResult{bytes: totalBytes, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
if status != wire.StatusOK {
|
||||
return iperfProbeResult{bytes: totalBytes, elapsed: time.Since(started), err: fmt.Errorf("upload status=%d body=%s", status, string(body))}
|
||||
}
|
||||
}
|
||||
return iperfProbeResult{ok: true, bytes: totalBytes, elapsed: time.Since(started)}
|
||||
|
||||
case wire.ProbeDownload:
|
||||
payload := makeProbePayload(wire.ProbeIperfDownload, candidate, 0, token)
|
||||
if err := wire.WriteRequestProfileEncoding(conn, wire.ModeProbe, sid, startSeq, payload, opts.headerMask, opts.coverProfile.Clear); err != nil {
|
||||
return iperfProbeResult{elapsed: time.Since(started), err: err}
|
||||
}
|
||||
want := probePattern(candidate)
|
||||
gotBytes := 0
|
||||
for i := 0; i < count; i++ {
|
||||
status, body, err := wire.ReadResponseProfile(conn, opts.headerMask)
|
||||
if err != nil {
|
||||
return iperfProbeResult{bytes: gotBytes, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
seq := startSeq + uint64(i)
|
||||
if status != wire.StatusError && len(body) > 0 && !opts.coverProfile.Clear {
|
||||
body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeProbe, seq)
|
||||
}
|
||||
if status != wire.StatusData || !bytes.Equal(body, want) {
|
||||
return iperfProbeResult{bytes: gotBytes, elapsed: time.Since(started), err: fmt.Errorf("download validation failed status=%d len=%d want=%d", status, len(body), candidate)}
|
||||
}
|
||||
gotBytes += len(body)
|
||||
}
|
||||
return iperfProbeResult{ok: true, bytes: gotBytes, elapsed: time.Since(started)}
|
||||
default:
|
||||
return iperfProbeResult{err: fmt.Errorf("unknown iperf probe kind %d", kind)}
|
||||
}
|
||||
}
|
||||
|
||||
func minDuration(a, b time.Duration) time.Duration {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func probePersistent(serverAddr, token string, opts chunkClientOptions) bool {
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
@@ -512,28 +664,268 @@ func probeCandidates(minSize, maxSize int) []int {
|
||||
return out
|
||||
}
|
||||
|
||||
func probeMaximum(serverAddr, token string, opts chunkClientOptions, kind byte) int {
|
||||
candidates := probeCandidates(opts.minSize, opts.maxSize)
|
||||
lo, hi := 0, len(candidates)-1
|
||||
best := opts.minSize
|
||||
for lo <= hi {
|
||||
mid := lo + (hi-lo)/2
|
||||
candidate := candidates[mid]
|
||||
if probeOne(serverAddr, token, opts, kind, candidate) {
|
||||
best = candidate
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
const calibrationFineResolution = 32
|
||||
|
||||
const calibrationDecisionAttempts = 3
|
||||
|
||||
// confirmIperfResult makes boundary decisions resistant to one transient
|
||||
// carrier hiccup. The caller provides the first observation; this function
|
||||
// opens fresh probe connections until either success or non-timeout failure has
|
||||
// a 2-of-3 majority. Connection-level timeouts are inconclusive and never count
|
||||
// as proof that a chunk size is too large.
|
||||
func confirmIperfResult(serverAddr, token string, opts chunkClientOptions, kind byte, candidate int, first iperfProbeResult, stage string) iperfProbeResult {
|
||||
name := probeKindName(kind)
|
||||
successes, failures := 0, 0
|
||||
var lastSuccess, lastFailure, lastTimeout iperfProbeResult
|
||||
|
||||
observe := func(r iperfProbeResult) {
|
||||
if r.ok {
|
||||
successes++
|
||||
lastSuccess = r
|
||||
return
|
||||
}
|
||||
if isTransportTimeout(r.err) {
|
||||
lastTimeout = r
|
||||
return
|
||||
}
|
||||
failures++
|
||||
lastFailure = r
|
||||
}
|
||||
if best < opts.minSize {
|
||||
best = opts.minSize
|
||||
|
||||
observe(first)
|
||||
for attempt := 2; attempt <= calibrationDecisionAttempts && successes < 2 && failures < 2; attempt++ {
|
||||
r := probeIperfOne(serverAddr, token, opts, kind, candidate)
|
||||
observe(r)
|
||||
result := "failure"
|
||||
if r.ok {
|
||||
result = "success"
|
||||
} else if isTransportTimeout(r.err) {
|
||||
result = "connection_timeout"
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=%s chunk=%d confirmation=%d/%d result=%s\n", name, stage, candidate, attempt, calibrationDecisionAttempts, result)
|
||||
}
|
||||
return best
|
||||
|
||||
if successes >= 2 {
|
||||
return lastSuccess
|
||||
}
|
||||
if failures >= 2 {
|
||||
return lastFailure
|
||||
}
|
||||
// No majority means connection health was too unstable to classify the
|
||||
// candidate. Preserve the old timeout semantics by reporting the timeout and
|
||||
// keeping the last known-good size.
|
||||
if lastTimeout.err != nil {
|
||||
return lastTimeout
|
||||
}
|
||||
if successes > failures && lastSuccess.ok {
|
||||
return lastSuccess
|
||||
}
|
||||
return lastFailure
|
||||
}
|
||||
|
||||
func probeMaximum(serverAddr, token string, opts chunkClientOptions, kind byte) int {
|
||||
name := probeKindName(kind)
|
||||
// Startup calibration precision is independent from the runtime shrink step.
|
||||
// Grow quickly, then resolve only the final good/bad boundary to 32 bytes.
|
||||
fine := calibrationFineResolution
|
||||
|
||||
// Ascending calibration deliberately starts small and grows geometrically in large steps.
|
||||
// This avoids hammering a constrained carrier with 1 MiB records before we
|
||||
// know they are viable, while still reaching the ceiling in O(log N) probes.
|
||||
// Once the first failure is found, binary refinement resolves the highest
|
||||
// known-good size to roughly `fine` bytes (32 B in the Android build).
|
||||
good := 0
|
||||
bad := 0
|
||||
candidate := opts.minSize
|
||||
if candidate < 32 {
|
||||
candidate = 32
|
||||
}
|
||||
if candidate > opts.maxSize {
|
||||
candidate = opts.maxSize
|
||||
}
|
||||
|
||||
for {
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, candidate)
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps())
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
// Do not waste many tiny probes when the configured floor is very
|
||||
// small. After proving the floor, jump to at least 512 B and then
|
||||
// continue growing by 4x.
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
|
||||
// A first failure may be transient. Re-test this exact size on fresh
|
||||
// connections before declaring the ascending ceiling.
|
||||
result = confirmIperfResult(serverAddr, token, opts, kind, candidate, result, "ascend-confirm")
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d result=recovered_after_retry\n", name, candidate)
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
|
||||
if isTransportTimeout(result.err) {
|
||||
// A real I/O timeout means the physical connection is dead. It is not
|
||||
// evidence that this record size is invalid, so stop calibration at
|
||||
// the last proven size instead of walking the size ladder.
|
||||
selected := good
|
||||
if selected == 0 {
|
||||
selected = opts.minSize
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, candidate, selected, result.err)
|
||||
return selected
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps(), result.err)
|
||||
bad = candidate
|
||||
if good == 0 {
|
||||
return opts.minSize
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for bad-good > fine {
|
||||
next := good + (bad-good)/2
|
||||
if next <= good || next >= bad {
|
||||
break
|
||||
}
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, next)
|
||||
result = confirmIperfResult(serverAddr, token, opts, kind, next, result, "refine-confirm")
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps())
|
||||
good = next
|
||||
continue
|
||||
}
|
||||
if isTransportTimeout(result.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=refine chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, next, good, result.err)
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps(), result.err)
|
||||
bad = next
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=refine selected=%d failed_above=%d resolution=%d\n", name, good, bad, fine)
|
||||
return good
|
||||
}
|
||||
|
||||
func probeKindName(kind byte) string {
|
||||
if kind == wire.ProbeDownload {
|
||||
return "download"
|
||||
}
|
||||
return "upload"
|
||||
}
|
||||
|
||||
const (
|
||||
// A literal 200-byte descent from 1 MiB can require more than five thousand
|
||||
// carrier probes per direction. MAX-FIRST therefore finds a working region
|
||||
// with a bounded coarse descent, then resolves the final good/bad boundary to
|
||||
// approximately 32 bytes. This preserves fine sizing without probe floods.
|
||||
maxFirstCoarseStep = 128 * 1024
|
||||
maxFirstFineResolution = calibrationFineResolution
|
||||
)
|
||||
|
||||
// probeMaximumMaxFirst always validates the configured ceiling first. On a
|
||||
// recoverable size rejection it descends by 128 KiB until it finds a known-good
|
||||
// size, then binary-refines the interval between that good size and the nearest
|
||||
// failed size to <= 32 bytes. A connection-level timeout is never interpreted
|
||||
// as evidence that the chunk is too large, so it does not trigger a size sweep.
|
||||
func probeMaximumMaxFirst(serverAddr, token string, opts chunkClientOptions, kind byte) int {
|
||||
fine := maxFirstFineResolution
|
||||
candidate := opts.maxSize
|
||||
if candidate < opts.minSize {
|
||||
candidate = opts.minSize
|
||||
}
|
||||
name := probeKindName(kind)
|
||||
failedHigh := 0
|
||||
|
||||
// Coarse descent: at most about eight probes from 1 MiB to the bottom of
|
||||
// the normal range, instead of thousands of 200-byte requests.
|
||||
for {
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, candidate)
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=coarse chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps())
|
||||
if failedHigh == 0 {
|
||||
return candidate
|
||||
}
|
||||
break
|
||||
}
|
||||
if isTransportTimeout(result.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=coarse chunk=%d result=connection_timeout action=keep_size_and_replace_connection err=%v\n", name, candidate, result.err)
|
||||
return candidate
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=coarse chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps(), result.err)
|
||||
failedHigh = candidate
|
||||
if candidate <= opts.minSize {
|
||||
return opts.minSize
|
||||
}
|
||||
next := candidate - maxFirstCoarseStep
|
||||
if next < opts.minSize {
|
||||
next = opts.minSize
|
||||
}
|
||||
if next >= candidate {
|
||||
next = opts.minSize
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=coarse chunk_downgrade=%d->%d step=%d\n", name, candidate, next, maxFirstCoarseStep)
|
||||
candidate = next
|
||||
}
|
||||
|
||||
// Fine boundary search. candidate is known-good and failedHigh is known-bad.
|
||||
good := candidate
|
||||
bad := failedHigh
|
||||
for bad-good > fine {
|
||||
next := good + (bad-good)/2
|
||||
if next <= good {
|
||||
break
|
||||
}
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, next)
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=fine chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps())
|
||||
good = next
|
||||
continue
|
||||
}
|
||||
if isTransportTimeout(result.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=fine chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, next, good, result.err)
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=fine chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps(), result.err)
|
||||
bad = next
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=fine selected=%d failed_above=%d resolution=%d\n", name, good, bad, fine)
|
||||
return good
|
||||
}
|
||||
|
||||
func getPathProfile(serverAddr, token string, opts chunkClientOptions) pathProfile {
|
||||
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)
|
||||
key := fmt.Sprintf("%s|%s|%d|%d|%02x|%t|%04x|%d|%t|maxfirst=%t|shrink=%d|step=%d", serverAddr, token, opts.minSize, opts.maxSize, opts.headerMask, opts.coverProfile.Enabled, opts.coverProfile.ID, opts.coverProfile.Padding, opts.coverProfile.Clear, opts.forceMaxStart, opts.shrinkAfter, opts.shrinkStep)
|
||||
profileState.Lock()
|
||||
if profileState.key == key && time.Since(profileState.p.at) < 30*time.Minute {
|
||||
p := profileState.p
|
||||
@@ -545,23 +937,27 @@ func getPathProfile(serverAddr, token string, opts chunkClientOptions) pathProfi
|
||||
fallbackUp := minInt(opts.maxSize, maxInt(opts.minSize, 32768))
|
||||
fallbackDown := minInt(opts.maxSize, maxInt(opts.minSize, 1350))
|
||||
|
||||
upCh := make(chan int, 1)
|
||||
downCh := make(chan int, 1)
|
||||
go func() { upCh <- probeMaximum(serverAddr, token, opts, wire.ProbeUpload) }()
|
||||
go func() { downCh <- probeMaximum(serverAddr, token, opts, wire.ProbeDownload) }()
|
||||
|
||||
p := pathProfile{upload: fallbackUp, download: fallbackDown, persistent: false, at: time.Now()}
|
||||
select {
|
||||
case p.upload = <-upCh:
|
||||
case <-time.After(20 * time.Second):
|
||||
}
|
||||
select {
|
||||
case p.download = <-downCh:
|
||||
case <-time.After(20 * time.Second):
|
||||
if opts.forceMaxStart {
|
||||
// Legacy CLI-only mode retained for compatibility. The Android app no
|
||||
// longer exposes MAX FIRST and uses the ascending path below.
|
||||
p.upload = probeMaximumMaxFirst(serverAddr, token, opts, wire.ProbeUpload)
|
||||
p.download = probeMaximumMaxFirst(serverAddr, token, opts, wire.ProbeDownload)
|
||||
} else {
|
||||
// Keep UP and DW calibration sequential. Running both fake-iperf probes
|
||||
// together can look like a traffic burst and distort the carrier limit we
|
||||
// are trying to measure.
|
||||
p.upload = probeMaximum(serverAddr, token, opts, wire.ProbeUpload)
|
||||
p.download = probeMaximum(serverAddr, token, opts, wire.ProbeDownload)
|
||||
}
|
||||
p.persistent = probePersistent(serverAddr, token, opts)
|
||||
|
||||
fmt.Printf("path probe: upload=%d download=%d persistent=%t\n", p.upload, p.download, p.persistent)
|
||||
fmt.Printf("path probe: strategy=%s upload=%d download=%d persistent=%t\n", func() string {
|
||||
if opts.forceMaxStart {
|
||||
return "max-first"
|
||||
}
|
||||
return "ascending"
|
||||
}(), p.upload, p.download, p.persistent)
|
||||
|
||||
profileState.Lock()
|
||||
profileState.key = key
|
||||
@@ -649,6 +1045,12 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
||||
if opts.adaptSuccesses < 1 {
|
||||
opts.adaptSuccesses = 64
|
||||
}
|
||||
if opts.shrinkAfter < 1 {
|
||||
opts.shrinkAfter = 1
|
||||
}
|
||||
if opts.forceMaxStart {
|
||||
opts.startSize = opts.maxSize
|
||||
}
|
||||
if opts.reconnectEvery < 0 {
|
||||
opts.reconnectEvery = 0
|
||||
}
|
||||
@@ -665,9 +1067,13 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
||||
opts.minPipeline = opts.maxPipeline
|
||||
}
|
||||
|
||||
defaultStart := opts.minSize
|
||||
if opts.forceMaxStart {
|
||||
defaultStart = opts.maxSize
|
||||
}
|
||||
profile := pathProfile{
|
||||
upload: opts.minSize,
|
||||
download: opts.minSize,
|
||||
upload: defaultStart,
|
||||
download: defaultStart,
|
||||
persistent: false,
|
||||
at: time.Now(),
|
||||
}
|
||||
@@ -738,8 +1144,17 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
||||
minPipeline: opts.minPipeline,
|
||||
maxPipeline: opts.maxPipeline,
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("upload", upStart, opts)
|
||||
c.downSizer = newAdaptiveSizer("download", downStart, opts)
|
||||
upSizerOpts := opts
|
||||
downSizerOpts := opts
|
||||
if !opts.skipPathProbe {
|
||||
// Calibration is the path ceiling for this VPN session. Runtime
|
||||
// adaptation may shrink after repeated failures and recover to this
|
||||
// value, but it must not grow above a size the UP/DW test did not prove.
|
||||
upSizerOpts.maxSize = upStart
|
||||
downSizerOpts.maxSize = downStart
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("upload", upStart, upSizerOpts)
|
||||
c.downSizer = newAdaptiveSizer("download", downStart, downSizerOpts)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -30,6 +31,165 @@ func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSizerWaitsForFailureBudgetBeforeShrinking(t *testing.T) {
|
||||
opts := chunkClientOptions{
|
||||
startSize: 1024,
|
||||
minSize: 32,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 3,
|
||||
}
|
||||
s := newAdaptiveSizer("test", 1024, opts)
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
old, next := s.Failure(1024)
|
||||
if old != 1024 || next != 1024 || s.Current() != 1024 {
|
||||
t.Fatalf("failure %d reduced early: old=%d next=%d current=%d", i, old, next, s.Current())
|
||||
}
|
||||
}
|
||||
|
||||
// Any successful record resets the consecutive-failure budget.
|
||||
s.Success(1024)
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, next := s.Failure(1024)
|
||||
if next != 1024 {
|
||||
t.Fatalf("post-success failure %d reduced early to %d", i, next)
|
||||
}
|
||||
}
|
||||
_, next := s.Failure(1024)
|
||||
if next != 512 || s.Current() != 512 {
|
||||
t.Fatalf("third consecutive failure should reduce 1024 -> 512, next=%d current=%d", next, s.Current())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceMaxStartCalibratesAtCeiling(t *testing.T) {
|
||||
profileState.Lock()
|
||||
profileState.key = ""
|
||||
profileState.p = pathProfile{}
|
||||
profileState.Unlock()
|
||||
|
||||
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() {
|
||||
defer close(serverErr)
|
||||
for connection := 0; connection < 4; connection++ {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
|
||||
if req.Mode == wire.ModeOpen {
|
||||
body := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(body, 1024)
|
||||
err = wire.WriteMaskedResponse(conn, wire.StatusOK, body, req.Session, req.Mode, req.Seq)
|
||||
_ = conn.Close()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.Mode != wire.ModeProbe || len(req.Payload) < 11 {
|
||||
_ = conn.Close()
|
||||
serverErr <- &testError{"expected calibration probe"}
|
||||
return
|
||||
}
|
||||
kind := req.Payload[4]
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
switch kind {
|
||||
case wire.ProbeIperfUpload:
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(req.Payload) != candidate {
|
||||
err = &testError{"wrong upload calibration size"}
|
||||
break
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
case wire.ProbeIperfDownload:
|
||||
for i := 0; i < wire.ProbeBurstCount(candidate); i++ {
|
||||
if err = wire.WriteMaskedResponse(conn, wire.StatusData, probePattern(candidate), req.Session, wire.ModeProbe, req.Seq+uint64(i)); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
case wire.ProbeKeepalive:
|
||||
for i := 0; i < 8; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = &testError{"unexpected calibration kind"}
|
||||
}
|
||||
_ = conn.Close()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
startSize: 32,
|
||||
minSize: 32,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 3,
|
||||
minPipeline: 1,
|
||||
maxPipeline: 1,
|
||||
txnTimeout: time.Second,
|
||||
forceMaxStart: true,
|
||||
skipPathProbe: false,
|
||||
}
|
||||
conn, err := openChunkTunnel(ln.Addr().String(), "", "example.com", 443, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := conn.(*chunkConn)
|
||||
if got := c.upSizer.Current(); got != 1024 {
|
||||
t.Fatalf("upload start=%d, want calibrated max 1024", got)
|
||||
}
|
||||
if got := c.downSizer.Current(); got != 1024 {
|
||||
t.Fatalf("download start=%d, want calibrated max 1024", got)
|
||||
}
|
||||
c.uploadLane.Close()
|
||||
c.downloadLane.Close()
|
||||
for err := range serverErr {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testError struct{ message string }
|
||||
|
||||
func (e *testError) Error() string { return e.message }
|
||||
|
||||
func TestReconnectAutoLearnsFromRealReuseFailure(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -99,3 +259,260 @@ func TestBPAutoStartsPersistent(t *testing.T) {
|
||||
t.Fatalf("BP auto lane started auto=%t reconnectEvery=%d", lane.autoReconnect, lane.reconnectEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAscendingIperfFindsBoundaryWithoutFlood(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const threshold = 900000
|
||||
attempts := make(chan int, 64)
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
defer close(attempts)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
attempts <- candidate
|
||||
if candidate > threshold {
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic carrier rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
minSize: 32,
|
||||
maxSize: 1024 * 1024,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
txnTimeout: time.Second,
|
||||
coverProfile: cover.Profile{},
|
||||
}
|
||||
got := probeMaximum(ln.Addr().String(), "", opts, wire.ProbeUpload)
|
||||
_ = ln.Close()
|
||||
<-serverDone
|
||||
|
||||
if got > threshold {
|
||||
t.Fatalf("calibrated size=%d exceeds threshold=%d", got, threshold)
|
||||
}
|
||||
if threshold-got > calibrationFineResolution {
|
||||
t.Fatalf("calibrated size=%d is more than %d bytes below threshold=%d", got, calibrationFineResolution, threshold)
|
||||
}
|
||||
var seen []int
|
||||
for candidate := range attempts {
|
||||
seen = append(seen, candidate)
|
||||
}
|
||||
if len(seen) > 40 {
|
||||
t.Fatalf("ascending calibration used %d probes, want <=40 with boundary confirmation; attempts=%v", len(seen), seen)
|
||||
}
|
||||
if len(seen) == 0 || seen[0] != 32 {
|
||||
t.Fatalf("first ascending probe=%v, want minimum 32", seen)
|
||||
}
|
||||
if len(seen) < 2 || seen[1] != 512 {
|
||||
t.Fatalf("second ascending probe=%v, want fast jump to 512", seen)
|
||||
}
|
||||
for i := 1; i < len(seen); i++ {
|
||||
if seen[i] > threshold {
|
||||
break
|
||||
}
|
||||
if seen[i] < seen[i-1] {
|
||||
t.Fatalf("coarse ascending phase moved backward: %v", seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAscendingIperfRetriesTransientFailureBeforeLoweringCeiling(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
const (
|
||||
threshold = 4096
|
||||
transientChunk = 2048
|
||||
)
|
||||
var transientAttempts int
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
if candidate == transientChunk && transientAttempts == 0 {
|
||||
transientAttempts++
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic transient rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
if candidate > threshold {
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic carrier rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
minSize: 32,
|
||||
maxSize: 16 * 1024,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
txnTimeout: time.Second,
|
||||
coverProfile: cover.Profile{},
|
||||
}
|
||||
got := probeMaximum(ln.Addr().String(), "", opts, wire.ProbeUpload)
|
||||
_ = ln.Close()
|
||||
<-serverDone
|
||||
|
||||
if transientAttempts != 1 {
|
||||
t.Fatalf("transient failure count=%d, want 1", transientAttempts)
|
||||
}
|
||||
if got < transientChunk {
|
||||
t.Fatalf("calibration collapsed below transiently failed %d-byte probe: got %d", transientChunk, got)
|
||||
}
|
||||
if got > threshold || threshold-got > calibrationFineResolution {
|
||||
t.Fatalf("calibrated size=%d, want within %d bytes below threshold=%d", got, calibrationFineResolution, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxFirstIperfUsesBoundedCoarseFineSearch(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const threshold = 900000
|
||||
attempts := make(chan int, 64)
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
defer close(attempts)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
attempts <- candidate
|
||||
if candidate > threshold {
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic carrier rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
minSize: 32,
|
||||
maxSize: 1024 * 1024,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
txnTimeout: time.Second,
|
||||
coverProfile: cover.Profile{},
|
||||
}
|
||||
got := probeMaximumMaxFirst(ln.Addr().String(), "", opts, wire.ProbeUpload)
|
||||
_ = ln.Close()
|
||||
<-serverDone
|
||||
|
||||
if got > threshold {
|
||||
t.Fatalf("calibrated size=%d exceeds threshold=%d", got, threshold)
|
||||
}
|
||||
if threshold-got > calibrationFineResolution {
|
||||
t.Fatalf("calibrated size=%d is more than %d bytes below threshold=%d", got, calibrationFineResolution, threshold)
|
||||
}
|
||||
var seen []int
|
||||
for candidate := range attempts {
|
||||
seen = append(seen, candidate)
|
||||
}
|
||||
if len(seen) > 24 {
|
||||
t.Fatalf("calibration used %d probes, want <=24; attempts=%v", len(seen), seen)
|
||||
}
|
||||
if len(seen) == 0 || seen[0] != 1024*1024 {
|
||||
t.Fatalf("first probe=%v, want maximum %d", seen, 1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSizerLinearShrinkStep(t *testing.T) {
|
||||
opts := chunkClientOptions{
|
||||
startSize: 1024,
|
||||
minSize: 256,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
}
|
||||
s := newAdaptiveSizer("test", 1024, opts)
|
||||
_, next := s.Failure(1024)
|
||||
if next != 824 {
|
||||
t.Fatalf("linear failure should reduce 1024 -> 824, got %d", next)
|
||||
}
|
||||
_, next = s.Failure(824)
|
||||
if next != 624 {
|
||||
t.Fatalf("linear failure should reduce 824 -> 624, got %d", next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,12 +357,77 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
|
||||
}
|
||||
}
|
||||
|
||||
const serverPortReachabilityTimeout = 900 * time.Millisecond
|
||||
|
||||
func serverPortReachable(host string, port int, tcpBuffer int) bool {
|
||||
d := net.Dialer{Timeout: serverPortReachabilityTimeout, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
protocol.TuneTCP(conn)
|
||||
protocol.TuneTCPBuffer(conn, tcpBuffer)
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// selectServerEndpoint walks the user-configured port range in ascending order.
|
||||
// A port is not considered working merely because the TCP handshake succeeds:
|
||||
// the normal DragonTCP wire/header probe must also validate on that endpoint.
|
||||
// Only one port candidate is protocol-tested at a time; UP/DW calibration starts
|
||||
// only after a single endpoint has been locked.
|
||||
func selectServerEndpoint(
|
||||
host string,
|
||||
portStart int,
|
||||
portEnd int,
|
||||
token string,
|
||||
configuredWire string,
|
||||
binOpts chunkClientOptions,
|
||||
xorOpts xorchunk.Options,
|
||||
probeDelay time.Duration,
|
||||
probeThreads int,
|
||||
forceClear bool,
|
||||
tcpBuffer int,
|
||||
) (*wireSelector, string, int, error) {
|
||||
total := portEnd - portStart + 1
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=starting host=%s start=%d end=%d total=%d protocol_validation=true\n", host, portStart, portEnd, total)
|
||||
|
||||
for port := portStart; port <= portEnd; port++ {
|
||||
index := port - portStart + 1
|
||||
// Keep logs readable for wide ranges: always show the first candidate,
|
||||
// every 16th candidate, and every TCP-reachable candidate.
|
||||
if index == 1 || index%16 == 0 || port == portEnd {
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=testing candidate=%d progress=%d/%d\n", port, index, total)
|
||||
}
|
||||
if !serverPortReachable(host, port, tcpBuffer) {
|
||||
continue
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=tcp_reachable candidate=%d progress=%d/%d\n", port, index, total)
|
||||
selector := newWireSelector(configuredWire, addr, token, binOpts, xorOpts, probeDelay, probeThreads, forceClear)
|
||||
choice, err := selector.resolveOnly()
|
||||
if err != nil {
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=rejected candidate=%d reason=no_validated_wire\n", port)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=success port=%d wire=%s header_mask=%02x clear_payload=%t\n", port, choice.mode, choice.mask, choice.cover.Clear)
|
||||
return selector, addr, port, nil
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=failed start=%d end=%d reason=no_working_dragontcp_port\n", portStart, portEnd)
|
||||
return nil, "", 0, fmt.Errorf("no working DragonTCP port found in %d-%d", portStart, portEnd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host")
|
||||
listenPort = flag.Int("listen-port", 8080, "local proxy listen port")
|
||||
serverHost = flag.String("server-host", "", "remote DragonTCP server host")
|
||||
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port")
|
||||
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port; used when no range is supplied")
|
||||
serverPortStart = flag.Int("server-port-start", 0, "first remote DragonTCP port to scan; 0 uses --server-port")
|
||||
serverPortEnd = flag.Int("server-port-end", 0, "last remote DragonTCP port to scan; 0 uses the resolved start port")
|
||||
token = flag.String("token", "", "optional shared token")
|
||||
maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections")
|
||||
transport = flag.String("transport", "chunk", "transport: chunk (DragonTCP binary adaptive transport)")
|
||||
@@ -372,6 +437,9 @@ func main() {
|
||||
chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (up to 1 MiB)")
|
||||
chunkAdaptive = flag.Bool("chunk-adaptive", true, "automatically shrink on failures and grow after stable success")
|
||||
chunkSuccesses = flag.Int("chunk-grow-after", 16, "successful data records required before increasing chunk size")
|
||||
chunkShrinkAfter = flag.Int("chunk-shrink-after", 1, "consecutive recoverable transfer failures required before reducing chunk size")
|
||||
chunkShrinkStep = flag.Int("chunk-shrink-step", 200, "bytes to subtract on each recoverable runtime chunk failure; Android uses 200")
|
||||
chunkMaxFirst = flag.Bool("chunk-max-first", false, "legacy CLI-only max-first calibration; Android uses ascending calibration")
|
||||
chunkAdaptLog = flag.Bool("chunk-adapt-log", true, "print adaptive chunk size changes")
|
||||
chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
|
||||
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
|
||||
@@ -379,17 +447,48 @@ func main() {
|
||||
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, "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")
|
||||
chunkTimeout = flag.Duration("chunk-timeout", 5*time.Second, "per-record transaction timeout before adaptive shrink")
|
||||
chunkTimeout = flag.Duration("chunk-timeout", 5*time.Second, "per-record transaction timeout; a real timeout terminates the tunnel")
|
||||
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)")
|
||||
forceClearPayload = flag.Bool("force-clear-payload", false, "force B/BP clear payloads and disable the SHA-256 payload mask; no masked fallback")
|
||||
wireProbeDelay = flag.Duration("wire-probe-delay", 100*time.Millisecond, "minimum delay between header/profile probe starts (50ms-30s)")
|
||||
wireProbeThreads = flag.Int("wire-probe-threads", 1, "maximum concurrent wire profile probes (1-16)")
|
||||
|
||||
sshUser = flag.String("ssh-user", "", "SSH tunnel username; enables tunnel-only SSH/SOCKS mode")
|
||||
sshPassword = flag.String("ssh-password", "", "SSH tunnel password")
|
||||
sshPasswordEnv = flag.String("ssh-password-env", "", "environment variable containing the SSH tunnel password")
|
||||
sshInternalHost = flag.String("ssh-internal-host", defaultSSHInternalHostClient, "reserved DragonTCP target for internal SSH")
|
||||
sshInternalPort = flag.Int("ssh-internal-port", 2222, "internal fake SSH port on the DragonTCP server")
|
||||
sshHostKeyPin = flag.String("ssh-hostkey-pin-file", "", "TOFU SSH host-key fingerprint file")
|
||||
sshSocksHost = flag.String("ssh-socks-host", "127.0.0.1", "local SOCKS5 listen host when SSH mode is enabled")
|
||||
sshSocksPort = flag.Int("ssh-socks-port", 1080, "local SOCKS5 listen port when SSH mode is enabled")
|
||||
sshUDPGWHost = flag.String("ssh-udpgw-host", "dragontcp-udpgw.internal", "reserved UDPGW target as seen by the SSH server")
|
||||
sshUDPGWPort = flag.Int("ssh-udpgw-port", 7400, "UDPGW port as seen by the SSH server")
|
||||
)
|
||||
flag.Parse()
|
||||
if strings.TrimSpace(*sshPasswordEnv) != "" {
|
||||
*sshPassword = os.Getenv(strings.TrimSpace(*sshPasswordEnv))
|
||||
}
|
||||
|
||||
if *serverHost == "" {
|
||||
fmt.Fprintln(os.Stderr, "--server-host is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
resolvedPortStart := *serverPortStart
|
||||
resolvedPortEnd := *serverPortEnd
|
||||
if resolvedPortStart == 0 {
|
||||
resolvedPortStart = *serverPort
|
||||
}
|
||||
if resolvedPortEnd == 0 {
|
||||
resolvedPortEnd = resolvedPortStart
|
||||
}
|
||||
if resolvedPortStart < 1 || resolvedPortStart > 65535 || resolvedPortEnd < 1 || resolvedPortEnd > 65535 {
|
||||
fmt.Fprintln(os.Stderr, "server ports must be between 1 and 65535")
|
||||
os.Exit(2)
|
||||
}
|
||||
if resolvedPortStart > resolvedPortEnd {
|
||||
fmt.Fprintln(os.Stderr, "--server-port-start must not exceed --server-port-end")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
*transport = strings.ToLower(*transport)
|
||||
if *transport != "chunk" {
|
||||
@@ -406,6 +505,11 @@ func main() {
|
||||
*chunkMax = *chunkSizeLegacy
|
||||
*chunkAdaptive = false
|
||||
}
|
||||
if *chunkMaxFirst {
|
||||
// Retained for CLI compatibility only. The Android build never enables
|
||||
// this flag; its startup calibration is always ascending.
|
||||
*chunkStart = *chunkMax
|
||||
}
|
||||
if *chunkMin < 32 || *chunkMax > protocol.MaxChunkPayload || *chunkMin > *chunkStart || *chunkStart > *chunkMax {
|
||||
fmt.Fprintf(os.Stderr, "require 32 <= --chunk-min <= --chunk-start <= --chunk-max <= %d\n", protocol.MaxChunkPayload)
|
||||
os.Exit(2)
|
||||
@@ -414,6 +518,23 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-grow-after must be at least 1")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkShrinkAfter < 1 || *chunkShrinkAfter > 32 {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-shrink-after must be between 1 and 32")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkShrinkStep < 0 || *chunkShrinkStep > protocol.MaxChunkPayload {
|
||||
fmt.Fprintf(os.Stderr, "--chunk-shrink-step must be between 0 and %d bytes\n", protocol.MaxChunkPayload)
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkMaxFirst {
|
||||
// MAX-FIRST always uses one recoverable failure as a signal to move
|
||||
// to the next calibration candidate. shrinkStep is the final boundary
|
||||
// resolution and the runtime linear recovery step.
|
||||
if *chunkShrinkStep == 0 {
|
||||
*chunkShrinkStep = maxFirstFineResolution
|
||||
}
|
||||
*chunkShrinkAfter = 1
|
||||
}
|
||||
if *chunkPollers < 1 || *chunkPollers > 128 {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-pollers must be between 1 and 128")
|
||||
os.Exit(2)
|
||||
@@ -443,24 +564,42 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "--wire must be b, bp, x or auto")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *forceClearPayload && *wireMode == WireXOR {
|
||||
fmt.Fprintln(os.Stderr, "--force-clear-payload cannot be used with --wire x; use auto, b, or bp")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkReconnect < 0 {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
|
||||
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")
|
||||
if *wireProbeDelay < 50*time.Millisecond || *wireProbeDelay > 30*time.Second {
|
||||
fmt.Fprintln(os.Stderr, "--wire-probe-delay must be between 50ms 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)
|
||||
}
|
||||
sshEnabled := strings.TrimSpace(*sshUser) != ""
|
||||
var err error
|
||||
if sshEnabled {
|
||||
if *sshPassword == "" {
|
||||
fmt.Fprintln(os.Stderr, "--ssh-password is required when --ssh-user is set")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *sshInternalPort < 1 || *sshInternalPort > 65535 || *sshSocksPort < 1 || *sshSocksPort > 65535 || *sshUDPGWPort < 1 || *sshUDPGWPort > 65535 {
|
||||
fmt.Fprintln(os.Stderr, "SSH/SOCKS/UDPGW ports must be between 1 and 65535")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
chunkOpts := chunkClientOptions{
|
||||
startSize: *chunkStart,
|
||||
minSize: *chunkMin,
|
||||
maxSize: *chunkMax,
|
||||
adaptive: *chunkAdaptive,
|
||||
adaptSuccesses: *chunkSuccesses,
|
||||
shrinkAfter: *chunkShrinkAfter,
|
||||
shrinkStep: *chunkShrinkStep,
|
||||
adaptLog: *chunkAdaptLog,
|
||||
pollers: *chunkPollers,
|
||||
minPipeline: *chunkConcurrencyMin,
|
||||
@@ -469,25 +608,17 @@ func main() {
|
||||
pollDelay: *chunkPollDelay,
|
||||
txnTimeout: *chunkTimeout,
|
||||
tcpBuffer: *tcpBuffer,
|
||||
forceMaxStart: *chunkMaxFirst,
|
||||
}
|
||||
|
||||
xorOpts := xorchunk.NewOptions(
|
||||
*chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkAdaptLog,
|
||||
*chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkShrinkAfter, *chunkAdaptLog,
|
||||
*chunkPollers, *chunkReconnect, *chunkPollDelay, *chunkTimeout, *tcpBuffer,
|
||||
)
|
||||
|
||||
listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
|
||||
serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
|
||||
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
fmt.Printf("local Go HTTP proxy listening on %s\n", listenAddr)
|
||||
fmt.Printf("remote DragonTCP endpoint=%s\n", serverAddr)
|
||||
fmt.Printf("remote DragonTCP host=%s port_range=%d-%d\n", *serverHost, resolvedPortStart, resolvedPortEnd)
|
||||
fmt.Printf("max_connections=%d transport=%s tcp_buffer=%d\n", *maxConnections, *transport, *tcpBuffer)
|
||||
if *transport == "chunk" {
|
||||
batchMode := "adaptive"
|
||||
@@ -495,12 +626,15 @@ func main() {
|
||||
batchMode = "pinned"
|
||||
}
|
||||
fmt.Printf(
|
||||
"adaptive_chunk=%v start=%d min=%d max=%d grow_after=%d pollers=%d batch=%d-%d(%s) reconnect_every=%d timeout=%s\n",
|
||||
"adaptive_chunk=%v start=%d min=%d max=%d grow_after=%d shrink_after=%d shrink_step=%d max_first=%v pollers=%d batch=%d-%d(%s) reconnect_every=%d timeout=%s\n",
|
||||
*chunkAdaptive,
|
||||
*chunkStart,
|
||||
*chunkMin,
|
||||
*chunkMax,
|
||||
*chunkSuccesses,
|
||||
*chunkShrinkAfter,
|
||||
*chunkShrinkStep,
|
||||
*chunkMaxFirst,
|
||||
*chunkPollers,
|
||||
*chunkConcurrencyMin,
|
||||
*chunkConcurrency,
|
||||
@@ -510,11 +644,65 @@ func main() {
|
||||
)
|
||||
}
|
||||
|
||||
wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts, *wireProbeDelay, *wireProbeThreads)
|
||||
fmt.Printf("wire=%s discovering fixed header profile via http://%s/ probe_delay=%s probe_threads=%d\n", *wireMode, probeHost, wireProbeDelay.String(), *wireProbeThreads)
|
||||
// Discover in the background so the local listener starts immediately. A
|
||||
// connection arriving first waits on the same selector lock and result.
|
||||
go wires.mode()
|
||||
if *forceClearPayload {
|
||||
fmt.Printf("wire=%s force_clear_payload=true payload_sha256=false clear_header_order=00,25 protocol_probe=server-local\n", *wireMode)
|
||||
} else {
|
||||
fmt.Printf("wire=%s discovering fixed header profile full_range=00-ff protocol_probe=server-local probe_delay=%s probe_threads=%d force_clear_payload=false\n", *wireMode, wireProbeDelay.String(), *wireProbeThreads)
|
||||
}
|
||||
|
||||
// Port discovery happens before chunk calibration. A TCP-open port must also
|
||||
// pass the DragonTCP wire/header probe before it becomes the WORKING PORT.
|
||||
wires, serverAddr, selectedPort, err := selectServerEndpoint(
|
||||
*serverHost, resolvedPortStart, resolvedPortEnd, *token, *wireMode,
|
||||
chunkOpts, xorOpts, *wireProbeDelay, *wireProbeThreads, *forceClearPayload, *tcpBuffer,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "DragonTCP port discovery failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("remote DragonTCP endpoint=%s working_port=%d\n", serverAddr, selectedPort)
|
||||
|
||||
// Resolve/authenticate the already-selected wire and calibrate UP/DW before
|
||||
// exposing the local proxy. resolveOnly() cached the profile, so prepare()
|
||||
// performs no second header scan.
|
||||
if _, err := wires.prepare(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "DragonTCP startup preflight failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var sshManager *sshTunnelManager
|
||||
var socksListener net.Listener
|
||||
if strings.TrimSpace(*sshUser) != "" {
|
||||
sshManager, err = newSSHTunnelManager(wires, *sshUser, *sshPassword, *sshInternalHost, *sshInternalPort, *sshHostKeyPin, *sshUDPGWHost, *sshUDPGWPort)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
defer sshManager.Close()
|
||||
// Validate the complete DragonTCP -> SSH path before advertising the
|
||||
// local SOCKS endpoint. Previously the Android UI could say "SSH tunnel
|
||||
// ready" even though no SSH handshake had happened yet.
|
||||
if err = sshManager.Warmup(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SSH startup failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
socksAddr := net.JoinHostPort(*sshSocksHost, strconv.Itoa(*sshSocksPort))
|
||||
socksListener, err = startSOCKS5Proxy(socksAddr, sshManager, *maxConnections, *tcpBuffer)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SOCKS5 listen failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer socksListener.Close()
|
||||
fmt.Printf("ssh_mode=true local_socks5=%s internal_ssh=%s:%d udpgw=%s:%d user=%s\n", socksAddr, *sshInternalHost, *sshInternalPort, *sshUDPGWHost, *sshUDPGWPort, *sshUser)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ln.Close()
|
||||
fmt.Printf("local Go HTTP proxy listening on %s\n", listenAddr)
|
||||
|
||||
slots := make(chan struct{}, *maxConnections)
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
socksVersion5 = 5
|
||||
socksCmdConnect = 1
|
||||
socksCmdUDPAssociate = 3
|
||||
socksAtypIPv4 = 1
|
||||
socksAtypDomain = 3
|
||||
socksAtypIPv6 = 4
|
||||
)
|
||||
|
||||
func startSOCKS5Proxy(listenAddr string, manager *sshTunnelManager, maxConnections int, tcpBuffer int) (net.Listener, error) {
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxConnections < 1 {
|
||||
maxConnections = 1
|
||||
}
|
||||
slots := make(chan struct{}, maxConnections)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
go func(c net.Conn) {
|
||||
defer func() { <-slots; _ = c.Close() }()
|
||||
protocol.TuneTCP(c)
|
||||
protocol.TuneTCPBuffer(c, tcpBuffer)
|
||||
_ = handleSOCKS5(c, manager)
|
||||
}(conn)
|
||||
default:
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func handleSOCKS5(conn net.Conn, manager *sshTunnelManager) error {
|
||||
br := bufio.NewReaderSize(conn, 4096)
|
||||
if err := socksNegotiate(br, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd, host, port, err := socksReadRequest(br)
|
||||
if err != nil {
|
||||
_ = socksReply(conn, 1, nil)
|
||||
return err
|
||||
}
|
||||
switch cmd {
|
||||
case socksCmdConnect:
|
||||
remote, err := manager.DialTCP(host, port)
|
||||
if err != nil {
|
||||
_ = socksReply(conn, 5, nil)
|
||||
return err
|
||||
}
|
||||
defer remote.Close()
|
||||
if err := socksReply(conn, 0, remote.LocalAddr()); err != nil {
|
||||
return err
|
||||
}
|
||||
protocol.RelayRaw(conn, remote)
|
||||
return nil
|
||||
case socksCmdUDPAssociate:
|
||||
return handleSOCKSUDPAssociate(conn, br, manager)
|
||||
default:
|
||||
_ = socksReply(conn, 7, nil)
|
||||
return fmt.Errorf("SOCKS command %d unsupported", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func socksNegotiate(br *bufio.Reader, w io.Writer) error {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(br, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[0] != socksVersion5 || header[1] == 0 {
|
||||
return errors.New("invalid SOCKS5 greeting")
|
||||
}
|
||||
methods := make([]byte, int(header[1]))
|
||||
if _, err := io.ReadFull(br, methods); err != nil {
|
||||
return err
|
||||
}
|
||||
noAuth := false
|
||||
for _, method := range methods {
|
||||
if method == 0 {
|
||||
noAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !noAuth {
|
||||
_, _ = w.Write([]byte{5, 0xff})
|
||||
return errors.New("SOCKS5 client does not support no-auth")
|
||||
}
|
||||
_, err := w.Write([]byte{5, 0})
|
||||
return err
|
||||
}
|
||||
|
||||
func socksReadRequest(br *bufio.Reader) (cmd byte, host string, port int, err error) {
|
||||
header := make([]byte, 4)
|
||||
if _, err = io.ReadFull(br, header); err != nil {
|
||||
return
|
||||
}
|
||||
if header[0] != 5 || header[2] != 0 {
|
||||
err = errors.New("invalid SOCKS5 request")
|
||||
return
|
||||
}
|
||||
cmd = header[1]
|
||||
host, err = socksReadHost(br, header[3])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var portBuf [2]byte
|
||||
if _, err = io.ReadFull(br, portBuf[:]); err != nil {
|
||||
return
|
||||
}
|
||||
port = int(binary.BigEndian.Uint16(portBuf[:]))
|
||||
// CONNECT requires a real destination port. UDP ASSOCIATE commonly uses
|
||||
// 0.0.0.0:0 to ask the proxy to choose the relay endpoint, which is exactly
|
||||
// what the Android VPN adapter sends.
|
||||
if cmd == socksCmdConnect && port < 1 {
|
||||
err = errors.New("invalid SOCKS5 port")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func socksReadHost(r io.Reader, atyp byte) (string, error) {
|
||||
switch atyp {
|
||||
case socksAtypIPv4:
|
||||
b := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.IP(b).String(), nil
|
||||
case socksAtypIPv6:
|
||||
b := make([]byte, 16)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.IP(b).String(), nil
|
||||
case socksAtypDomain:
|
||||
var n [1]byte
|
||||
if _, err := io.ReadFull(r, n[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n[0] == 0 {
|
||||
return "", errors.New("empty SOCKS domain")
|
||||
}
|
||||
b := make([]byte, int(n[0]))
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported SOCKS address type %d", atyp)
|
||||
}
|
||||
}
|
||||
|
||||
func socksReply(w io.Writer, rep byte, addr net.Addr) error {
|
||||
ip := net.IPv4zero
|
||||
port := 0
|
||||
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
|
||||
if v4 := tcpAddr.IP.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
port = tcpAddr.Port
|
||||
} else if udpAddr, ok := addr.(*net.UDPAddr); ok {
|
||||
if v4 := udpAddr.IP.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
port = udpAddr.Port
|
||||
}
|
||||
out := []byte{5, rep, 0, socksAtypIPv4, 0, 0, 0, 0, 0, 0}
|
||||
copy(out[4:8], ip.To4())
|
||||
binary.BigEndian.PutUint16(out[8:10], uint16(port))
|
||||
_, err := w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleSOCKSUDPAssociate(control net.Conn, br *bufio.Reader, manager *sshTunnelManager) error {
|
||||
udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer udp.Close()
|
||||
if err := socksReply(control, 0, udp.LocalAddr()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gw, err := manager.DialUDPGW()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gw.Close()
|
||||
_ = gw.SetDeadline(time.Time{})
|
||||
|
||||
done := make(chan struct{})
|
||||
var closeOnce sync.Once
|
||||
closeAll := func() { closeOnce.Do(func() { close(done); _ = udp.Close(); _ = gw.Close() }) }
|
||||
defer closeAll()
|
||||
|
||||
var clientMu sync.RWMutex
|
||||
var clientAddr *net.UDPAddr
|
||||
writeMu := sync.Mutex{}
|
||||
|
||||
go func() {
|
||||
defer closeAll()
|
||||
reader := bufio.NewReaderSize(gw, 32*1024)
|
||||
for {
|
||||
payload, err := readUDPGWFrame(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(payload) < 9 {
|
||||
continue
|
||||
}
|
||||
srcIP := payload[3:7]
|
||||
srcPort := binary.BigEndian.Uint16(payload[7:9])
|
||||
data := payload[9:]
|
||||
packet := make([]byte, 10+len(data))
|
||||
packet[0], packet[1], packet[2], packet[3] = 0, 0, 0, socksAtypIPv4
|
||||
copy(packet[4:8], srcIP)
|
||||
binary.BigEndian.PutUint16(packet[8:10], srcPort)
|
||||
copy(packet[10:], data)
|
||||
clientMu.RLock()
|
||||
to := clientAddr
|
||||
clientMu.RUnlock()
|
||||
if to != nil {
|
||||
_, _ = udp.WriteToUDP(packet, to)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer closeAll()
|
||||
// The UDP association lifetime is the TCP control connection lifetime.
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
if _, err := br.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := udp.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
clientMu.Lock()
|
||||
clientAddr = from
|
||||
clientMu.Unlock()
|
||||
ip, port, payload, err := parseSOCKSUDPDatagram(buf[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
frame := buildUDPGWRequest(1, 0, ip, uint16(port), payload)
|
||||
writeMu.Lock()
|
||||
_, err = gw.Write(frame)
|
||||
writeMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseSOCKSUDPDatagram(packet []byte) ([4]byte, int, []byte, error) {
|
||||
var out [4]byte
|
||||
if len(packet) < 10 || packet[0] != 0 || packet[1] != 0 || packet[2] != 0 {
|
||||
return out, 0, nil, errors.New("invalid SOCKS5 UDP packet")
|
||||
}
|
||||
pos := 3
|
||||
atyp := packet[pos]
|
||||
pos++
|
||||
switch atyp {
|
||||
case socksAtypIPv4:
|
||||
if len(packet) < pos+4+2 {
|
||||
return out, 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
copy(out[:], packet[pos:pos+4])
|
||||
pos += 4
|
||||
case socksAtypDomain:
|
||||
if len(packet) <= pos {
|
||||
return out, 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
n := int(packet[pos])
|
||||
pos++
|
||||
if len(packet) < pos+n+2 {
|
||||
return out, 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return out, 0, nil, errors.New("SOCKS UDP domain destinations are disabled to avoid local DNS leakage; use an IPv4 destination")
|
||||
case socksAtypIPv6:
|
||||
return out, 0, nil, errors.New("UDPGW supports IPv4 only")
|
||||
default:
|
||||
return out, 0, nil, errors.New("unsupported SOCKS UDP address type")
|
||||
}
|
||||
port := int(binary.BigEndian.Uint16(packet[pos : pos+2]))
|
||||
pos += 2
|
||||
return out, port, packet[pos:], nil
|
||||
}
|
||||
|
||||
func buildUDPGWRequest(connID uint16, x byte, ip [4]byte, port uint16, data []byte) []byte {
|
||||
payloadLen := 9 + len(data)
|
||||
frame := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(frame[0:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(frame[2:4], connID)
|
||||
frame[4] = x
|
||||
copy(frame[5:9], ip[:])
|
||||
binary.BigEndian.PutUint16(frame[9:11], port)
|
||||
copy(frame[11:], data)
|
||||
return frame
|
||||
}
|
||||
|
||||
func readUDPGWFrame(r *bufio.Reader) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lenBuf[:]))
|
||||
if n < 9 || n > 65535 {
|
||||
return nil, fmt.Errorf("invalid UDPGW frame %d", n)
|
||||
}
|
||||
payload := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUDPGWFrameRoundTripShape(t *testing.T) {
|
||||
ip := [4]byte{1, 2, 3, 4}
|
||||
data := []byte("dragon")
|
||||
frame := buildUDPGWRequest(7, 0, ip, 5353, data)
|
||||
payload, err := readUDPGWFrame(bufio.NewReader(bytes.NewReader(frame)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(payload[0:2]); got != 7 {
|
||||
t.Fatalf("conn id=%d", got)
|
||||
}
|
||||
if !bytes.Equal(payload[3:7], ip[:]) {
|
||||
t.Fatalf("ip=%v", payload[3:7])
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(payload[7:9]); got != 5353 {
|
||||
t.Fatalf("port=%d", got)
|
||||
}
|
||||
if !bytes.Equal(payload[9:], data) {
|
||||
t.Fatalf("data=%q", payload[9:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSOCKSUDPDatagramIPv4(t *testing.T) {
|
||||
packet := []byte{0, 0, 0, socksAtypIPv4, 8, 8, 8, 8, 0, 53, 1, 2, 3}
|
||||
ip, port, data, err := parseSOCKSUDPDatagram(packet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip != [4]byte{8, 8, 8, 8} {
|
||||
t.Fatalf("ip=%v", ip)
|
||||
}
|
||||
if port != 53 {
|
||||
t.Fatalf("port=%d", port)
|
||||
}
|
||||
if !bytes.Equal(data, []byte{1, 2, 3}) {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSOCKSUDPDatagramRejectsDomainToAvoidDNSLeak(t *testing.T) {
|
||||
packet := []byte{0, 0, 0, socksAtypDomain, 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0, 53, 1}
|
||||
_, _, _, err := parseSOCKSUDPDatagram(packet)
|
||||
if err == nil {
|
||||
t.Fatal("expected domain-form UDP destination to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKSReadRequestAllowsZeroPortForUDPAssociate(t *testing.T) {
|
||||
request := []byte{5, socksCmdUDPAssociate, 0, socksAtypIPv4, 0, 0, 0, 0, 0, 0}
|
||||
cmd, host, port, err := socksReadRequest(bufio.NewReader(bytes.NewReader(request)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != socksCmdUDPAssociate || host != "0.0.0.0" || port != 0 {
|
||||
t.Fatalf("got cmd=%d host=%q port=%d", cmd, host, port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sshCarrierConn absorbs the relatively small encrypted writes produced by the
|
||||
// SSH packet layer and combines them before handing them to DragonTCP.
|
||||
//
|
||||
// This matters because a DragonTCP upload is a request/ack transaction. Without
|
||||
// write combining, one SSH packet can become one full network round trip even
|
||||
// when the discovered DragonTCP path supports much larger chunks. The wrapper
|
||||
// deliberately behaves like a kernel socket send buffer: Write returns after
|
||||
// the bytes have been copied into a bounded queue, while a single ordered
|
||||
// writer drains that queue to the underlying DragonTCP stream.
|
||||
type sshCarrierConn struct {
|
||||
raw net.Conn
|
||||
flushBytes int
|
||||
maxBuffered int
|
||||
flushDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
buf []byte
|
||||
closing bool
|
||||
writeErr error
|
||||
coalesceLogged bool
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newSSHCarrierConn(raw net.Conn, flushBytes, maxBuffered int, flushDelay time.Duration) net.Conn {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
if flushBytes < 32*1024 {
|
||||
flushBytes = 32 * 1024
|
||||
}
|
||||
if maxBuffered < flushBytes*2 {
|
||||
maxBuffered = flushBytes * 2
|
||||
}
|
||||
if flushDelay <= 0 {
|
||||
flushDelay = time.Millisecond
|
||||
}
|
||||
c := &sshCarrierConn{
|
||||
raw: raw,
|
||||
flushBytes: flushBytes,
|
||||
maxBuffered: maxBuffered,
|
||||
flushDelay: flushDelay,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
go c.writeLoop()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) writeLoop() {
|
||||
defer close(c.done)
|
||||
defer c.raw.Close()
|
||||
|
||||
for {
|
||||
c.mu.Lock()
|
||||
for len(c.buf) == 0 && !c.closing && c.writeErr == nil {
|
||||
c.cond.Wait()
|
||||
}
|
||||
if c.writeErr != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(c.buf) == 0 && c.closing {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
shouldDelay := len(c.buf) < c.flushBytes && !c.closing
|
||||
c.mu.Unlock()
|
||||
|
||||
// Give consecutive SSH packets a very small window to accumulate. The
|
||||
// delay is tiny compared with a WAN RTT, but it lets 32 KiB SSH packets
|
||||
// become a 256 KiB-1 MiB DragonTCP write on a busy stream.
|
||||
if shouldDelay {
|
||||
time.Sleep(c.flushDelay)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.writeErr != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
n := len(c.buf)
|
||||
if n > c.flushBytes {
|
||||
n = c.flushBytes
|
||||
}
|
||||
batch := make([]byte, n)
|
||||
copy(batch, c.buf[:n])
|
||||
if n == len(c.buf) {
|
||||
c.buf = c.buf[:0]
|
||||
} else {
|
||||
copy(c.buf, c.buf[n:])
|
||||
c.buf = c.buf[:len(c.buf)-n]
|
||||
}
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
|
||||
if !c.coalesceLogged && len(batch) >= 128*1024 {
|
||||
c.coalesceLogged = true
|
||||
fmt.Printf("ssh carrier: packet coalescing active batch=%d\n", len(batch))
|
||||
}
|
||||
|
||||
if err := writeCarrierFull(c.raw, batch); err != nil {
|
||||
c.mu.Lock()
|
||||
if c.writeErr == nil {
|
||||
c.writeErr = err
|
||||
}
|
||||
c.closing = true
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeCarrierFull(w io.Writer, p []byte) error {
|
||||
for len(p) > 0 {
|
||||
n, err := w.Write(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
p = p[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) Read(p []byte) (int, error) {
|
||||
n, err := c.raw.Read(p)
|
||||
if n > 0 || err == nil {
|
||||
return n, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
writeErr := c.writeErr
|
||||
c.mu.Unlock()
|
||||
if writeErr != nil {
|
||||
return 0, writeErr
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
written := 0
|
||||
for written < len(p) {
|
||||
c.mu.Lock()
|
||||
for len(c.buf) >= c.maxBuffered && !c.closing && c.writeErr == nil {
|
||||
c.cond.Wait()
|
||||
}
|
||||
if c.writeErr != nil {
|
||||
err := c.writeErr
|
||||
c.mu.Unlock()
|
||||
return written, err
|
||||
}
|
||||
if c.closing {
|
||||
c.mu.Unlock()
|
||||
if written > 0 {
|
||||
return written, net.ErrClosed
|
||||
}
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
room := c.maxBuffered - len(c.buf)
|
||||
if room < 1 {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
take := len(p) - written
|
||||
if take > room {
|
||||
take = room
|
||||
}
|
||||
c.buf = append(c.buf, p[written:written+take]...)
|
||||
written += take
|
||||
c.cond.Signal()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
c.closing = true
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
<-c.done
|
||||
})
|
||||
c.mu.Lock()
|
||||
err := c.writeErr
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) LocalAddr() net.Addr { return c.raw.LocalAddr() }
|
||||
func (c *sshCarrierConn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
|
||||
func (c *sshCarrierConn) SetDeadline(t time.Time) error { return c.raw.SetDeadline(t) }
|
||||
func (c *sshCarrierConn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
|
||||
func (c *sshCarrierConn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) }
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type carrierTestConn struct {
|
||||
mu sync.Mutex
|
||||
writes int
|
||||
buf bytes.Buffer
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *carrierTestConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (c *carrierTestConn) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
c.writes++
|
||||
return c.buf.Write(p)
|
||||
}
|
||||
func (c *carrierTestConn) Close() error { c.mu.Lock(); c.closed = true; c.mu.Unlock(); return nil }
|
||||
func (c *carrierTestConn) LocalAddr() net.Addr { return dummyAddr("local") }
|
||||
func (c *carrierTestConn) RemoteAddr() net.Addr { return dummyAddr("remote") }
|
||||
func (c *carrierTestConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *carrierTestConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *carrierTestConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func TestSSHCarrierCombinesPacketWrites(t *testing.T) {
|
||||
raw := &carrierTestConn{}
|
||||
conn := newSSHCarrierConn(raw, 128*1024, 512*1024, 5*time.Millisecond)
|
||||
want := make([]byte, 0, 128*1024)
|
||||
for i := 0; i < 4; i++ {
|
||||
part := bytes.Repeat([]byte{byte(i + 1)}, 32*1024)
|
||||
want = append(want, part...)
|
||||
if n, err := conn.Write(part); err != nil || n != len(part) {
|
||||
t.Fatalf("Write %d = %d, %v", i, n, err)
|
||||
}
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw.mu.Lock()
|
||||
got := append([]byte(nil), raw.buf.Bytes()...)
|
||||
writes := raw.writes
|
||||
raw.mu.Unlock()
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("carrier changed byte order/content")
|
||||
}
|
||||
if writes >= 4 {
|
||||
t.Fatalf("expected packet writes to be combined, raw writes=%d", writes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type sshTunnelManager struct {
|
||||
wires *wireSelector
|
||||
username string
|
||||
password string
|
||||
internalHost string
|
||||
internalPort int
|
||||
pinFile string
|
||||
udpgwHost string
|
||||
udpgwPort int
|
||||
|
||||
mu sync.Mutex
|
||||
client *ssh.Client
|
||||
closed bool
|
||||
|
||||
firstTCPLogged atomic.Bool
|
||||
udpLogged atomic.Bool
|
||||
}
|
||||
|
||||
func newSSHTunnelManager(wires *wireSelector, username, password, internalHost string, internalPort int, pinFile, udpgwHost string, udpgwPort int) (*sshTunnelManager, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, errors.New("SSH username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return nil, errors.New("SSH password is required")
|
||||
}
|
||||
if internalHost == "" {
|
||||
internalHost = defaultSSHInternalHostClient
|
||||
}
|
||||
if internalPort < 1 || internalPort > 65535 {
|
||||
return nil, errors.New("invalid SSH internal port")
|
||||
}
|
||||
if udpgwHost == "" {
|
||||
udpgwHost = "dragontcp-udpgw.internal"
|
||||
}
|
||||
if udpgwPort < 1 || udpgwPort > 65535 {
|
||||
return nil, errors.New("invalid UDPGW port")
|
||||
}
|
||||
return &sshTunnelManager{
|
||||
wires: wires, username: username, password: password,
|
||||
internalHost: internalHost, internalPort: internalPort,
|
||||
pinFile: pinFile, udpgwHost: udpgwHost, udpgwPort: udpgwPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSSHInternalHostClient = "dragontcp-ssh.internal"
|
||||
sshCarrierWriteBatch = 1024 * 1024
|
||||
sshCarrierMaxBuffered = 4 * 1024 * 1024
|
||||
sshCarrierFlushDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
func (m *sshTunnelManager) hostKeyCallback() ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
fingerprint := ssh.FingerprintSHA256(key)
|
||||
if strings.TrimSpace(m.pinFile) == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(m.pinFile)
|
||||
if err == nil {
|
||||
expected := strings.TrimSpace(string(data))
|
||||
if expected == fingerprint {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("SSH host key changed: expected %s got %s", expected, fingerprint)
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read SSH host key pin: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(m.pinFile, []byte(fingerprint+"\n"), 0600); err != nil {
|
||||
return fmt.Errorf("save SSH host key pin: %w", err)
|
||||
}
|
||||
fmt.Printf("ssh host key pinned: %s\n", fingerprint)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) connectLocked() (*ssh.Client, error) {
|
||||
if m.closed {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
if m.client != nil {
|
||||
return m.client, nil
|
||||
}
|
||||
|
||||
fmt.Printf("ssh carrier: opening DragonTCP stream to %s:%d\n", m.internalHost, m.internalPort)
|
||||
transport, err := m.wires.dial(m.internalHost, m.internalPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DragonTCP SSH carrier failed: %w", err)
|
||||
}
|
||||
// SSH emits encrypted packets in ~tens-of-KiB writes. Feeding each one
|
||||
// directly into the transactional DragonTCP transport creates a full RTT per
|
||||
// SSH packet. Combine them behind bounded backpressure so a busy SSH stream
|
||||
// reaches DragonTCP's discovered 256 KiB-1 MiB chunk sizes instead.
|
||||
transport = newSSHCarrierConn(transport, sshCarrierWriteBatch, sshCarrierMaxBuffered, sshCarrierFlushDelay)
|
||||
fmt.Printf("ssh carrier: DragonTCP stream connected write_batch=%d max_buffer=%d flush_delay=%s\n", sshCarrierWriteBatch, sshCarrierMaxBuffered, sshCarrierFlushDelay)
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: m.username,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(m.password)},
|
||||
HostKeyCallback: m.hostKeyCallback(),
|
||||
ClientVersion: "SSH-2.0-DragonTCP",
|
||||
}
|
||||
addr := net.JoinHostPort(m.internalHost, fmt.Sprintf("%d", m.internalPort))
|
||||
cc, chans, reqs, err := ssh.NewClientConn(transport, addr, cfg)
|
||||
if err != nil {
|
||||
_ = transport.Close()
|
||||
return nil, fmt.Errorf("SSH handshake/auth failed: %w", err)
|
||||
}
|
||||
client := ssh.NewClient(cc, chans, reqs)
|
||||
m.client = client
|
||||
fmt.Printf("ssh authenticated: user=%s transport=DragonTCP mode=tunnel-only\n", m.username)
|
||||
go m.keepalive(client)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) getClient() (*ssh.Client, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.connectLocked()
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) invalidate(client *ssh.Client) {
|
||||
m.mu.Lock()
|
||||
if m.client == client {
|
||||
m.client = nil
|
||||
_ = client.Close()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) keepalive(client *ssh.Client) {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_, _, err := client.SendRequest("keepalive@dragontcp", true, nil)
|
||||
if err != nil {
|
||||
m.invalidate(client)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
same := m.client == client && !m.closed
|
||||
m.mu.Unlock()
|
||||
if !same {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) Warmup() error {
|
||||
_, err := m.getClient()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) DialTCP(host string, port int) (net.Conn, error) {
|
||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
client, err := m.getClient()
|
||||
if err != nil {
|
||||
// Authentication, handshake, or physical-carrier failures are hard
|
||||
// boundaries. Retrying them immediately would duplicate expensive
|
||||
// DragonTCP/SSH connection attempts and can overload the server.
|
||||
return nil, err
|
||||
}
|
||||
conn, err := client.Dial("tcp", target)
|
||||
if err == nil {
|
||||
if m.firstTCPLogged.CompareAndSwap(false, true) {
|
||||
fmt.Printf("ssh traffic: direct-tcpip active\n")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
// A direct-tcpip channel rejection means the SSH transport is healthy
|
||||
// and only this destination failed (for example ECONNREFUSED or a
|
||||
// server-side target-policy rejection). Do not tear down the persistent
|
||||
// SSH carrier or redial the destination in that case.
|
||||
var openErr *ssh.OpenChannelError
|
||||
if errors.As(err, &openErr) {
|
||||
return nil, err
|
||||
}
|
||||
m.invalidate(client)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("SSH target dial failed")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) DialUDPGW() (net.Conn, error) {
|
||||
conn, err := m.DialTCP(m.udpgwHost, m.udpgwPort)
|
||||
if err == nil && m.udpLogged.CompareAndSwap(false, true) {
|
||||
fmt.Printf("ssh traffic: UDPGW active target=%s:%d\n", m.udpgwHost, m.udpgwPort)
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) Close() error {
|
||||
m.mu.Lock()
|
||||
m.closed = true
|
||||
client := m.client
|
||||
m.client = nil
|
||||
m.mu.Unlock()
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/cover"
|
||||
"dragontcp/internal/wire"
|
||||
"dragontcp/internal/xorchunk"
|
||||
)
|
||||
|
||||
// DragonTCP speaks three wires that are not interchangeable:
|
||||
//
|
||||
// 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
|
||||
// b — compact binary records (29/5-byte headers, SHA-256-masked payloads by default)
|
||||
// bp — compatible registration/upload/download/ACK records, SHA-256-masked by default
|
||||
// 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
|
||||
// left on auto, which decides by actually fetching a URL through each wire and
|
||||
// keeping the first that answers.
|
||||
// left on auto. Startup performs a tiny server-local profile probe and caches
|
||||
// the first wire/header profile that survives the carrier.
|
||||
const (
|
||||
WireBinary = "b"
|
||||
WireBP = "bp"
|
||||
@@ -28,14 +27,11 @@ const (
|
||||
WireAuto = "auto"
|
||||
)
|
||||
|
||||
// probeTarget is fetched through a candidate wire to decide whether it works.
|
||||
// A plain HTTP host is used deliberately: it exercises OPEN, upload and
|
||||
// download in one go, and a valid status line proves bytes survived intact.
|
||||
const (
|
||||
probeHost = "ip.dr2.site"
|
||||
probePort = 80
|
||||
probeTimeout = 8 * time.Second
|
||||
)
|
||||
// Header/profile discovery uses one tiny server-local protocol transaction.
|
||||
// It does not open an Internet target and therefore measures only whether the
|
||||
// candidate DragonTCP framing survives the carrier and is understood by the
|
||||
// server. Path chunk calibration runs separately after a header is selected.
|
||||
const profileProbeTimeout = 2 * time.Second
|
||||
|
||||
type wireSelector struct {
|
||||
mu sync.Mutex
|
||||
@@ -48,6 +44,7 @@ type wireSelector struct {
|
||||
xorOpts xorchunk.Options
|
||||
probeDelay time.Duration
|
||||
probeThreads int
|
||||
forceClear bool
|
||||
|
||||
// Test hooks are nil in production.
|
||||
candidateOverride []wireChoice
|
||||
@@ -60,6 +57,47 @@ type wireChoice struct {
|
||||
cover cover.Profile
|
||||
}
|
||||
|
||||
// forcedClearChoice builds a clear-payload cover profile with an explicit
|
||||
// binary header mask. Clear payload and header masking are independent: a
|
||||
// 0x00 header mask can still carry a fully clear payload because the cover
|
||||
// preface advertises Clear=true to the server.
|
||||
//
|
||||
// --force-clear-payload deliberately tries mask 0x00 first, then 0x25. Both
|
||||
// profiles keep SHA-256 payload masking disabled. A successful choice is cached
|
||||
// for the process lifetime, so all reconnects use the same clear profile.
|
||||
func forcedClearChoice(mode string, headerMask byte) wireChoice {
|
||||
id := uint16(0x0000)
|
||||
if headerMask == 0x25 {
|
||||
// Retain the previously deployed clear profile ID for the 0x25 fallback.
|
||||
id = 0x0065
|
||||
}
|
||||
profile := cover.Profile{
|
||||
Enabled: true,
|
||||
ID: id,
|
||||
Padding: 0,
|
||||
HeaderMask: headerMask,
|
||||
XOR: false,
|
||||
Clear: true,
|
||||
}
|
||||
return wireChoice{mode: mode, mask: profile.HeaderMask, cover: profile}
|
||||
}
|
||||
|
||||
// validBinaryHeaderMask reports whether a direct B/BP header mask is
|
||||
// unambiguous to the server's legacy classifier. B/BP encode the request mode
|
||||
// in the low three bits (0..4), so those bits in the mask must be zero. This
|
||||
// leaves exactly 32 valid masks: 00,08,10,...,F8.
|
||||
func validBinaryHeaderMask(mask byte) bool {
|
||||
return mask&0x07 == 0
|
||||
}
|
||||
|
||||
// validXORHeaderMask reports whether a direct X mask remains in the X side of
|
||||
// the server's first-byte partition. X starts with 'U'^mask and the server
|
||||
// recognizes X only when those low three bits are 5, 6, or 7. There are 96
|
||||
// such masks.
|
||||
func validXORHeaderMask(mask byte) bool {
|
||||
return ('U'^mask)&0x07 >= 5
|
||||
}
|
||||
|
||||
func (c wireChoice) String() string {
|
||||
if c.mode == WireBP {
|
||||
if c.cover.Enabled {
|
||||
@@ -73,7 +111,7 @@ func (c wireChoice) String() string {
|
||||
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 {
|
||||
func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options, probeDelay time.Duration, probeThreads int, forceClear bool) *wireSelector {
|
||||
s := &wireSelector{
|
||||
configured: configured,
|
||||
serverAddr: serverAddr,
|
||||
@@ -82,10 +120,90 @@ func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOp
|
||||
xorOpts: xorOpts,
|
||||
probeDelay: probeDelay,
|
||||
probeThreads: probeThreads,
|
||||
forceClear: forceClear,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveOnly validates and locks the wire/header profile without running UP/DW
|
||||
// chunk calibration. Port-range discovery uses this to ensure a TCP-open port is
|
||||
// actually a DragonTCP endpoint before it is exposed as the WORKING PORT.
|
||||
func (s *wireSelector) resolveOnly() (wireChoice, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.hasChoice {
|
||||
return s.resolved, nil
|
||||
}
|
||||
choice, ok := s.detectLocked()
|
||||
if !ok {
|
||||
return wireChoice{}, fmt.Errorf("no validated DragonTCP wire/header profile")
|
||||
}
|
||||
s.resolved = choice
|
||||
s.hasChoice = true
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
// prepare resolves/authenticates the wire and calibrates its carrier limits
|
||||
// before latency-sensitive protocols (notably SSH) are allowed to start. The
|
||||
// successful wire choice and path profile are cached, so the real SSH OPEN does
|
||||
// not repeat discovery/calibration.
|
||||
func (s *wireSelector) prepare() (wireChoice, error) {
|
||||
fmt.Printf("[D-TCP] phase=AUTH state=starting configured_wire=%s force_clear_payload=%t\n", s.configured, s.forceClear)
|
||||
|
||||
s.mu.Lock()
|
||||
choice := s.resolved
|
||||
ok := s.hasChoice
|
||||
if !ok {
|
||||
choice, ok = s.detectLocked()
|
||||
if ok {
|
||||
s.resolved = choice
|
||||
s.hasChoice = true
|
||||
}
|
||||
}
|
||||
opts := s.binOpts
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
fmt.Printf("[D-TCP] phase=AUTH state=failed reason=no_validated_wire\n")
|
||||
return wireChoice{}, fmt.Errorf("DragonTCP authentication/wire validation failed")
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=AUTH state=success wire=%s header_mask=%02x clear_payload=%t cover_id=%04x\n", choice.mode, choice.mask, choice.cover.Clear, choice.cover.ID)
|
||||
|
||||
if choice.mode == WireXOR {
|
||||
xopts := s.xorOpts
|
||||
if choice.cover.Enabled {
|
||||
xopts = xopts.WithCoverProfile(choice.cover)
|
||||
} else {
|
||||
xopts = xopts.WithHeaderMask(choice.mask)
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION state=starting wire=x strategy=ascending min=%d max=%d growth=4x fine_resolution=%d up_down=sequential\n", xopts.MinSize(), xopts.MaxSize(), calibrationFineResolution)
|
||||
up, down, persistent := xorchunk.Calibrate(s.serverAddr, s.token, xopts, calibrationFineResolution)
|
||||
xopts = xopts.WithCalibratedChunks(up, down)
|
||||
s.mu.Lock()
|
||||
s.xorOpts = xopts
|
||||
s.mu.Unlock()
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION state=success wire=x upload=%d download=%d persistent=%t lock_runtime_chunks=true\n", up, down, persistent)
|
||||
fmt.Printf("[D-TCP] phase=ACTIVE wire=%s header_mask=%02x clear_payload=%t upload_chunk=%d download_chunk=%d calibrated_locked=true runtime_adaptive=false\n", choice.mode, choice.mask, choice.cover.Clear, up, down)
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
opts.headerMask = choice.mask
|
||||
opts.coverProfile = choice.cover
|
||||
strategy := "ascending"
|
||||
if opts.forceMaxStart {
|
||||
strategy = "max-first"
|
||||
}
|
||||
if opts.forceMaxStart {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION state=starting strategy=%s min=%d max=%d coarse_step=%d fine_resolution=%d\n", strategy, opts.minSize, opts.maxSize, maxFirstCoarseStep, maxFirstFineResolution)
|
||||
} else {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION state=starting strategy=%s min=%d max=%d growth=4x fine_resolution=%d up_down=sequential\n", strategy, opts.minSize, opts.maxSize, calibrationFineResolution)
|
||||
}
|
||||
profile := getPathProfile(s.serverAddr, s.token, opts)
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION state=success upload=%d download=%d persistent=%t\n", profile.upload, profile.download, profile.persistent)
|
||||
fmt.Printf("[D-TCP] phase=ACTIVE wire=%s header_mask=%02x clear_payload=%t upload_chunk=%d download_chunk=%d\n", choice.mode, choice.mask, choice.cover.Clear, profile.upload, profile.download)
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
choice := s.mode()
|
||||
@@ -121,6 +239,13 @@ func (s *wireSelector) mode() wireChoice {
|
||||
s.hasChoice = true
|
||||
return picked
|
||||
}
|
||||
if s.forceClear {
|
||||
// Hard guarantee: never silently fall back to a legacy SHA-256-masked
|
||||
// payload if the user explicitly requested clear payloads.
|
||||
if candidates := s.profileCandidates(); len(candidates) > 0 {
|
||||
return candidates[0]
|
||||
}
|
||||
}
|
||||
// Undecided: honor an explicitly pinned family for this attempt without
|
||||
// caching it. Auto retains the original B fallback and retries discovery on
|
||||
// the next connection.
|
||||
@@ -134,101 +259,69 @@ func (s *wireSelector) mode() wireChoice {
|
||||
}
|
||||
}
|
||||
|
||||
// profileCandidates covers all compatible B first-byte bases and all X magic
|
||||
// masks that cannot be confused with B. Profile zero for each wire is first so
|
||||
// existing permissive networks complete discovery quickly.
|
||||
// profileCandidates returns only masks that are mathematically valid for the
|
||||
// direct server classifier. Numeric order keeps mask 0x00 first on permissive
|
||||
// networks and avoids the old exhaustive covered 0x00..0xFF scan.
|
||||
func (s *wireSelector) profileCandidates() []wireChoice {
|
||||
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)})
|
||||
}
|
||||
}
|
||||
if s.configured == WireAuto || s.configured == WireXOR {
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if ('U'^mask)&7 >= 5 {
|
||||
xorProfiles = append(xorProfiles, wireChoice{mode: WireXOR, mask: mask})
|
||||
// A forced clear payload is retained for CLI compatibility/testing only.
|
||||
// Android does not expose it. Clear payload and header mask are independent.
|
||||
if s.forceClear {
|
||||
switch s.configured {
|
||||
case WireBinary:
|
||||
return []wireChoice{
|
||||
forcedClearChoice(WireBinary, 0x00),
|
||||
forcedClearChoice(WireBinary, 0x25),
|
||||
}
|
||||
case WireBP:
|
||||
return []wireChoice{
|
||||
forcedClearChoice(WireBP, 0x00),
|
||||
forcedClearChoice(WireBP, 0x25),
|
||||
}
|
||||
case WireAuto:
|
||||
return []wireChoice{
|
||||
forcedClearChoice(WireBinary, 0x00),
|
||||
forcedClearChoice(WireBP, 0x00),
|
||||
forcedClearChoice(WireBinary, 0x25),
|
||||
forcedClearChoice(WireBP, 0x25),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
wantB := s.configured == WireAuto || s.configured == WireBinary
|
||||
wantBP := s.configured == WireAuto || s.configured == WireBP
|
||||
wantX := s.configured == WireAuto || s.configured == WireXOR
|
||||
|
||||
// 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.
|
||||
// Normal masked discovery uses only masks that the direct wire classifier
|
||||
// can decode without a cover preface. This removes the old 0x00..0xFF x 3
|
||||
// covered scan. Auto stays ordered by numeric mask so 0x00 is tested first.
|
||||
//
|
||||
// B/BP: 32 masks (low 3 bits must be zero).
|
||||
// X: 96 masks (('U'^mask)&7 must land in 5..7).
|
||||
// Auto: 160 total candidates, rather than ~900 covered/direct probes.
|
||||
out := make([]wireChoice, 0, 160)
|
||||
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})
|
||||
mask := byte(n)
|
||||
if validBinaryHeaderMask(mask) {
|
||||
if wantB {
|
||||
out = append(out, wireChoice{mode: WireBinary, mask: mask})
|
||||
}
|
||||
if wantBP {
|
||||
out = append(out, wireChoice{mode: WireBP, mask: mask})
|
||||
}
|
||||
}
|
||||
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})
|
||||
if wantX && validXORHeaderMask(mask) {
|
||||
out = append(out, wireChoice{mode: WireXOR, mask: mask})
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// detectLocked validates header/profile candidates with tiny server-local
|
||||
// protocol probes. The default is one worker. Users may explicitly allow more
|
||||
// workers, while the launch delay still spaces new attempts globally.
|
||||
func (s *wireSelector) detectLocked() (wireChoice, bool) {
|
||||
candidates := s.profileCandidates()
|
||||
if s.candidateOverride != nil {
|
||||
@@ -243,7 +336,7 @@ func (s *wireSelector) detectLocked() (wireChoice, bool) {
|
||||
}
|
||||
delay := s.probeDelay
|
||||
if delay <= 0 {
|
||||
delay = time.Second
|
||||
delay = 100 * time.Millisecond
|
||||
}
|
||||
type result struct {
|
||||
choice wireChoice
|
||||
@@ -295,59 +388,51 @@ func (s *wireSelector) detectLocked() (wireChoice, bool) {
|
||||
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)
|
||||
fmt.Printf("wire probe: selected=%s header_mask=%02x completed=%d launched=%d elapsed=%s protocol_probe=true threads=%d fixed_until_restart=true\n", got.choice, got.choice.mask, completed, next, time.Since(started).Round(time.Millisecond), 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: completed=%d/%d launched=%d elapsed=%s protocol_probe=true no validated profile yet\n", completed, len(candidates), next, time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
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))
|
||||
fmt.Printf("wire probe: no header/profile validated after %d candidates in %s; retrying later\n", completed, time.Since(started).Round(time.Millisecond))
|
||||
return wireChoice{}, false
|
||||
}
|
||||
|
||||
// probe fetches probeHost through one wire and reports whether a well-formed
|
||||
// HTTP status line came back.
|
||||
// probe performs one small server-local framing transaction. This is purposely
|
||||
// separate from UP/DW fake-iperf calibration: header discovery answers "which
|
||||
// byte/profile survives?", while calibration answers "what chunk size is safe?".
|
||||
func (s *wireSelector) probe(choice wireChoice) bool {
|
||||
var (
|
||||
conn net.Conn
|
||||
err error
|
||||
)
|
||||
if choice.mode == WireXOR {
|
||||
switch choice.mode {
|
||||
case WireXOR:
|
||||
opts := s.xorOpts
|
||||
if choice.cover.Enabled {
|
||||
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithCoverProfile(choice.cover))
|
||||
opts = opts.WithCoverProfile(choice.cover)
|
||||
} else {
|
||||
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithHeaderMask(choice.mask))
|
||||
opts = opts.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 {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(probeTimeout))
|
||||
return xorchunk.ProbeProfile(s.serverAddr, s.token, opts)
|
||||
|
||||
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 {
|
||||
return false
|
||||
case WireBP:
|
||||
opts := s.binOpts
|
||||
opts.headerMask = choice.mask
|
||||
opts.coverProfile = choice.cover
|
||||
if opts.txnTimeout <= 0 || opts.txnTimeout > profileProbeTimeout {
|
||||
opts.txnTimeout = profileProbeTimeout
|
||||
}
|
||||
return probeBPProfile(s.serverAddr, opts)
|
||||
|
||||
default:
|
||||
opts := s.binOpts
|
||||
opts.headerMask = choice.mask
|
||||
opts.coverProfile = choice.cover
|
||||
opts.skipPathProbe = true
|
||||
opts.minSize = 32
|
||||
opts.startSize = 32
|
||||
opts.maxSize = 32
|
||||
if opts.txnTimeout <= 0 || opts.txnTimeout > profileProbeTimeout {
|
||||
opts.txnTimeout = profileProbeTimeout
|
||||
}
|
||||
return probeOne(s.serverAddr, s.token, opts, wire.ProbeKeepalive, 0)
|
||||
}
|
||||
statusLine, err := bufio.NewReader(conn).ReadString('\n')
|
||||
return err == nil && strings.HasPrefix(statusLine, "HTTP/")
|
||||
}
|
||||
|
||||
@@ -6,79 +6,141 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProfileCandidatesCoverBothWireFamilies(t *testing.T) {
|
||||
func expectedBinaryMasks() []byte {
|
||||
out := make([]byte, 0, 32)
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if validBinaryHeaderMask(mask) {
|
||||
out = append(out, mask)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expectedXORMasks() []byte {
|
||||
out := make([]byte, 0, 96)
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if validXORHeaderMask(mask) {
|
||||
out = append(out, mask)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestProfileCandidatesUseOnlyMathematicallyValidDirectMasks(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
|
||||
t.Fatalf("normal discovery must not use a cover profile: %s", candidate)
|
||||
}
|
||||
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
|
||||
if !validBinaryHeaderMask(candidate.mask) {
|
||||
t.Fatalf("invalid B mask: %02x", candidate.mask)
|
||||
}
|
||||
case WireBP:
|
||||
bpCount++
|
||||
if candidate.cover.Enabled && !candidate.cover.Clear {
|
||||
t.Fatalf("covered BP profile must use clear payloads: %s", candidate)
|
||||
if !validBinaryHeaderMask(candidate.mask) {
|
||||
t.Fatalf("invalid BP mask: %02x", candidate.mask)
|
||||
}
|
||||
if !candidate.cover.Enabled && candidate.mask != 0 {
|
||||
t.Fatalf("direct BP profile must keep a clear header: %s", candidate)
|
||||
case WireXOR:
|
||||
xorCount++
|
||||
if !validXORHeaderMask(candidate.mask) {
|
||||
t.Fatalf("invalid X mask: %02x", candidate.mask)
|
||||
}
|
||||
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 binaryCount != 32 || bpCount != 32 || xorCount != 96 {
|
||||
t.Fatalf("profiles B=%d BP=%d X=%d, want B=32 BP=32 X=96", binaryCount, bpCount, xorCount)
|
||||
}
|
||||
if len(firstBytes) != 256 {
|
||||
t.Fatalf("profiles cover %d first-byte values, want 256", len(firstBytes))
|
||||
if len(candidates) != 160 {
|
||||
t.Fatalf("auto candidates=%d, want 160", len(candidates))
|
||||
}
|
||||
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]))
|
||||
}
|
||||
|
||||
func TestBinaryMasksAreExactly00ThroughF8InStepsOf08(t *testing.T) {
|
||||
want := expectedBinaryMasks()
|
||||
if len(want) != 32 {
|
||||
t.Fatalf("binary mask count=%d, want 32", len(want))
|
||||
}
|
||||
for i, mask := range want {
|
||||
if mask != byte(i*8) {
|
||||
t.Fatalf("binary mask[%d]=%02x, want %02x", i, mask, byte(i*8))
|
||||
}
|
||||
if len(coveredPadding[mode]) != 16 {
|
||||
t.Fatalf("mode %s covers %d padding lengths, want 16", mode, len(coveredPadding[mode]))
|
||||
}
|
||||
for _, mode := range []string{WireBinary, WireBP} {
|
||||
candidates := (&wireSelector{configured: mode}).profileCandidates()
|
||||
if len(candidates) != len(want) {
|
||||
t.Fatalf("mode %s candidates=%d, want %d", mode, len(candidates), len(want))
|
||||
}
|
||||
for i, candidate := range candidates {
|
||||
if candidate.mode != mode || candidate.mask != want[i] || candidate.cover.Enabled {
|
||||
t.Fatalf("mode %s candidate[%d]=%s mask=%02x, want direct/%02x", mode, i, candidate, candidate.mask, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualWireStillDiscoversAllProfilesForThatFamily(t *testing.T) {
|
||||
func TestXORMasksAreExactlyDirectClassifierValidSet(t *testing.T) {
|
||||
want := expectedXORMasks()
|
||||
if len(want) != 96 {
|
||||
t.Fatalf("X mask count=%d, want 96", len(want))
|
||||
}
|
||||
candidates := (&wireSelector{configured: WireXOR}).profileCandidates()
|
||||
if len(candidates) != len(want) {
|
||||
t.Fatalf("X candidates=%d, want %d", len(candidates), len(want))
|
||||
}
|
||||
for i, candidate := range candidates {
|
||||
if candidate.mode != WireXOR || candidate.mask != want[i] || candidate.cover.Enabled {
|
||||
t.Fatalf("X candidate[%d]=%s mask=%02x, want direct/%02x", i, candidate, candidate.mask, want[i])
|
||||
}
|
||||
if ('U'^candidate.mask)&7 < 5 {
|
||||
t.Fatalf("X candidate[%d] is classifier-invalid: %02x", i, candidate.mask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoInterleavesOnlyValidMasksInNumericOrder(t *testing.T) {
|
||||
candidates := (&wireSelector{configured: WireAuto}).profileCandidates()
|
||||
want := make([]wireChoice, 0, 160)
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if validBinaryHeaderMask(mask) {
|
||||
want = append(want,
|
||||
wireChoice{mode: WireBinary, mask: mask},
|
||||
wireChoice{mode: WireBP, mask: mask},
|
||||
)
|
||||
}
|
||||
if validXORHeaderMask(mask) {
|
||||
want = append(want, wireChoice{mode: WireXOR, mask: mask})
|
||||
}
|
||||
}
|
||||
if len(candidates) != len(want) {
|
||||
t.Fatalf("auto candidates=%d, want %d", len(candidates), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if candidates[i] != want[i] {
|
||||
t.Fatalf("auto candidate[%d]=%s/%02x, want %s/%02x", i, candidates[i].mode, candidates[i].mask, want[i].mode, want[i].mask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualWireUsesOnlyValidProfilesForThatFamily(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
want int
|
||||
}{{WireBinary, 544}, {WireBP, 257}, {WireXOR, 352}} {
|
||||
}{{WireBinary, 32}, {WireBP, 32}, {WireXOR, 96}} {
|
||||
selector := &wireSelector{configured: tc.mode}
|
||||
candidates := selector.profileCandidates()
|
||||
if len(candidates) != tc.want {
|
||||
@@ -88,22 +150,107 @@ func TestManualWireStillDiscoversAllProfilesForThatFamily(t *testing.T) {
|
||||
if candidate.mode != tc.mode {
|
||||
t.Fatalf("mode %s included %s", tc.mode, candidate)
|
||||
}
|
||||
if candidate.cover.Enabled {
|
||||
t.Fatalf("mode %s normal discovery included cover profile %s", tc.mode, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearProfilesAreTriedBeforeLegacyFallbacks(t *testing.T) {
|
||||
for _, mode := range []string{WireBinary, WireBP} {
|
||||
func TestNormalProfilesNeverUseClearPayload(t *testing.T) {
|
||||
for _, mode := range []string{WireAuto, WireBinary, WireBP, WireXOR} {
|
||||
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 len(candidates) == 0 {
|
||||
t.Fatalf("mode %s has no candidates", mode)
|
||||
}
|
||||
if candidates[1].cover.Enabled {
|
||||
t.Fatalf("mode %s does not fall back immediately to a legacy direct profile", mode)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.cover.Clear {
|
||||
t.Fatalf("mode %s normal discovery included clear payload profile: %s", mode, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearProfilesTry00Before25WithoutMaskedFallbacks(t *testing.T) {
|
||||
selector := &wireSelector{configured: WireAuto, forceClear: true}
|
||||
candidates := selector.profileCandidates()
|
||||
if len(candidates) != 4 {
|
||||
t.Fatalf("force-clear auto profiles=%d, want B00/BP00/B25/BP25", len(candidates))
|
||||
}
|
||||
wantModes := []string{WireBinary, WireBP, WireBinary, WireBP}
|
||||
wantMasks := []byte{0x00, 0x00, 0x25, 0x25}
|
||||
for i, candidate := range candidates {
|
||||
if candidate.mode != wantModes[i] || candidate.mask != wantMasks[i] {
|
||||
t.Fatalf("candidate %d=%s mask=%02x, want mode=%s mask=%02x", i, candidate.mode, candidate.mask, wantModes[i], wantMasks[i])
|
||||
}
|
||||
if candidate.mode == WireXOR {
|
||||
t.Fatalf("force-clear mode included X candidate: %s", candidate)
|
||||
}
|
||||
if !candidate.cover.Enabled || !candidate.cover.Clear {
|
||||
t.Fatalf("force-clear mode included masked/direct candidate: %s", candidate)
|
||||
}
|
||||
if candidate.cover.HeaderMask != candidate.mask {
|
||||
t.Fatalf("candidate %d cover/header mismatch: %+v", i, candidate.cover)
|
||||
}
|
||||
}
|
||||
if candidates[0].cover.ID != 0x0000 || candidates[2].cover.ID != 0x0065 {
|
||||
t.Fatalf("unexpected clear profile IDs: 00=%04x 25=%04x", candidates[0].cover.ID, candidates[2].cover.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearPinnedFamilyTries00Then25(t *testing.T) {
|
||||
for _, mode := range []string{WireBinary, WireBP} {
|
||||
candidates := (&wireSelector{configured: mode, forceClear: true}).profileCandidates()
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("mode %s force-clear candidates=%d, want 2", mode, len(candidates))
|
||||
}
|
||||
if candidates[0].mode != mode || candidates[0].mask != 0x00 || !candidates[0].cover.Clear {
|
||||
t.Fatalf("mode %s first forced clear profile is not clear/00: %+v", mode, candidates[0])
|
||||
}
|
||||
if candidates[1].mode != mode || candidates[1].mask != 0x25 || !candidates[1].cover.Clear {
|
||||
t.Fatalf("mode %s second forced clear profile is not clear/25: %+v", mode, candidates[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearDiscoveryFallsBackFrom00To25(t *testing.T) {
|
||||
var seen []byte
|
||||
selector := &wireSelector{
|
||||
configured: WireBinary,
|
||||
forceClear: true,
|
||||
probeDelay: time.Nanosecond,
|
||||
probeThreads: 1,
|
||||
probeOverride: func(choice wireChoice) bool {
|
||||
seen = append(seen, choice.mask)
|
||||
return choice.mask == 0x25
|
||||
},
|
||||
}
|
||||
choice := selector.mode()
|
||||
if choice.mask != 0x25 || !choice.cover.Clear {
|
||||
t.Fatalf("selected=%s, want clear mask 25 fallback", choice)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != 0x00 || seen[1] != 0x25 {
|
||||
t.Fatalf("probe order=%v, want [0 37]", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearFailedDiscoveryStillFallsBackToClear00(t *testing.T) {
|
||||
selector := &wireSelector{
|
||||
configured: WireAuto,
|
||||
forceClear: true,
|
||||
candidateOverride: []wireChoice{{mode: WireBinary}},
|
||||
probeDelay: time.Nanosecond,
|
||||
probeOverride: func(wireChoice) bool { return false },
|
||||
}
|
||||
choice := selector.mode()
|
||||
if !choice.cover.Enabled || !choice.cover.Clear || choice.mode == WireXOR {
|
||||
t.Fatalf("force-clear discovery failure fell back to non-clear wire: %s", choice)
|
||||
}
|
||||
if choice.mask != 0x00 {
|
||||
t.Fatalf("force-clear discovery failure did not keep first clear mask 00: %02x", choice.mask)
|
||||
}
|
||||
}
|
||||
|
||||
func measureDiscoveryConcurrency(t *testing.T, threads int) int32 {
|
||||
t.Helper()
|
||||
candidates := make([]wireChoice, 24)
|
||||
|
||||
@@ -369,7 +369,8 @@ func processBHTTPRequest(conn net.Conn, req bhttpRequest, ctx *bhttpServerContex
|
||||
if err != nil {
|
||||
return writeBHTTPError(conn, err.Error())
|
||||
}
|
||||
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, ctx.debug)
|
||||
_, internalCarrier := lookupInternalTarget(host, port)
|
||||
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, internalCarrier, ctx.debug)
|
||||
session.mu.Lock()
|
||||
if session.stream == nil {
|
||||
session.stream = stream
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type streamSession struct {
|
||||
sid wire.SessionID
|
||||
target net.Conn
|
||||
targetName string
|
||||
maxChunk int
|
||||
maxBuffer int
|
||||
debug *serverDebug
|
||||
sid wire.SessionID
|
||||
target net.Conn
|
||||
targetName string
|
||||
maxChunk int
|
||||
maxBuffer int
|
||||
debug *serverDebug
|
||||
bulkCoalesce bool
|
||||
|
||||
mu sync.Mutex
|
||||
notify chan struct{}
|
||||
@@ -33,16 +34,17 @@ type streamSession struct {
|
||||
expectedUp uint64
|
||||
}
|
||||
|
||||
func newStreamSession(sid wire.SessionID, target net.Conn, targetName string, maxChunk, maxBuffer int, debug *serverDebug) *streamSession {
|
||||
func newStreamSession(sid wire.SessionID, target net.Conn, targetName string, maxChunk, maxBuffer int, bulkCoalesce bool, debug *serverDebug) *streamSession {
|
||||
s := &streamSession{
|
||||
sid: sid,
|
||||
target: target,
|
||||
targetName: targetName,
|
||||
maxChunk: maxChunk,
|
||||
maxBuffer: maxBuffer,
|
||||
debug: debug,
|
||||
notify: make(chan struct{}),
|
||||
lastSeen: time.Now(),
|
||||
sid: sid,
|
||||
target: target,
|
||||
targetName: targetName,
|
||||
maxChunk: maxChunk,
|
||||
maxBuffer: maxBuffer,
|
||||
debug: debug,
|
||||
bulkCoalesce: bulkCoalesce,
|
||||
notify: make(chan struct{}),
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
go s.readTarget()
|
||||
return s
|
||||
@@ -155,14 +157,34 @@ func (s *streamSession) readAt(offset uint64, limit int, wait time.Duration) ([]
|
||||
if firstDataAt.IsZero() {
|
||||
firstDataAt = time.Now()
|
||||
}
|
||||
// Coalesce tiny target reads briefly. This prevents a 1-2 byte
|
||||
// producer read from becoming a permanent tiny tunnel record.
|
||||
if available < limit && !s.eof && wait > 0 && time.Since(firstDataAt) < 2*time.Millisecond {
|
||||
// SSH packetization naturally feeds this stream in ~tens-of-KiB
|
||||
// bursts. Returning the first burst turns a DragonTCP download into
|
||||
// one SSH packet per WAN RTT. Internal carrier sessions therefore
|
||||
// get a slightly wider coalescing window and can accumulate at least
|
||||
// 512 KiB before the pull response is emitted. Ordinary destinations
|
||||
// retain the original 2 ms latency-oriented behavior.
|
||||
coalesceDelay := 2 * time.Millisecond
|
||||
coalesceGoal := limit
|
||||
if s.bulkCoalesce {
|
||||
coalesceDelay = 25 * time.Millisecond
|
||||
coalesceGoal = 512 * 1024
|
||||
if coalesceGoal > limit {
|
||||
coalesceGoal = limit
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(firstDataAt)
|
||||
if available < limit && available < coalesceGoal && !s.eof && wait > 0 && elapsed < coalesceDelay {
|
||||
ch := s.notify
|
||||
remaining := coalesceDelay - elapsed
|
||||
if untilDeadline := time.Until(deadline); untilDeadline < remaining {
|
||||
remaining = untilDeadline
|
||||
}
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
if remaining > 0 {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -339,6 +361,23 @@ func probePattern(n int) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func validateIperfUploadPayload(payload []byte, token string, candidate int) bool {
|
||||
base := 11 + len(token)
|
||||
wantLen := candidate
|
||||
if wantLen < base {
|
||||
wantLen = base
|
||||
}
|
||||
if len(payload) != wantLen {
|
||||
return false
|
||||
}
|
||||
for i := base; i < len(payload); i++ {
|
||||
if payload[i] != byte((i*31+17)&0xff) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseOpen(payload []byte) (token, host string, port int, err error) {
|
||||
if len(payload) < 6 {
|
||||
return "", "", 0, fmt.Errorf("bad OPEN payload")
|
||||
@@ -395,6 +434,32 @@ func processWireRequest(conn net.Conn, req wire.Request, token string, allowPriv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case wire.ProbeIperfUpload:
|
||||
if value < 1 || value > maxChunk || len(req.Payload) > maxChunk {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf upload chunk too large"))
|
||||
}
|
||||
if !validateIperfUploadPayload(req.Payload, supplied, value) {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf upload validation failed"))
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=upload peer=%s chunk=%d bytes=%d seq=%d pollers=1 outstanding=1", conn.RemoteAddr(), value, len(req.Payload), req.Seq)
|
||||
}
|
||||
return wire.WriteResponse(conn, wire.StatusOK, nil)
|
||||
case wire.ProbeIperfDownload:
|
||||
if value < 1 || value > maxChunk {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf download chunk too large"))
|
||||
}
|
||||
count := wire.ProbeBurstCount(value)
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=download peer=%s chunk=%d records=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), value, count, value*count)
|
||||
}
|
||||
data := probePattern(value)
|
||||
for i := 0; i < count; i++ {
|
||||
if err := wire.WriteMaskedResponse(conn, wire.StatusData, data, req.Session, wire.ModeProbe, req.Seq+uint64(i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("unknown probe kind"))
|
||||
}
|
||||
@@ -418,7 +483,8 @@ func processWireRequest(conn net.Conn, req wire.Request, token string, allowPriv
|
||||
if err != nil {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte(err.Error()))
|
||||
}
|
||||
session := newStreamSession(req.Session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, maxBuffer, debug)
|
||||
_, internalCarrier := lookupInternalTarget(host, port)
|
||||
session := newStreamSession(req.Session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, maxBuffer, internalCarrier, debug)
|
||||
_, created := manager.addOrGet(req.Session, session)
|
||||
if created && debug != nil && debug.enabled {
|
||||
debug.sessionsOpened.Add(1)
|
||||
|
||||
@@ -103,6 +103,77 @@ func TestXORProfileProbeEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoveredProfilesSupportEveryHeaderMask(t *testing.T) {
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
for _, xor := range []bool{false, true} {
|
||||
profile := cover.Profile{
|
||||
Enabled: true,
|
||||
ID: uint16(mask)<<8 | uint16(mask^0xa5),
|
||||
Padding: 0,
|
||||
HeaderMask: mask,
|
||||
XOR: xor,
|
||||
Clear: false,
|
||||
}
|
||||
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, 17, []byte("CPROBE -"), mask); err != nil {
|
||||
clientResult <- err
|
||||
return
|
||||
}
|
||||
id, payload, err := protocol.ReadResponseFrameProfile(client, mask)
|
||||
if err == nil && (id != 17 || 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, 17, 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, gotXOR, gotMask, err := sniffWire(server)
|
||||
if err != nil || gotXOR != xor || gotMask != mask {
|
||||
t.Fatalf("mask=%02x xor=%t sniff got xor=%t mask=%02x err=%v", mask, 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("mask=%02x binary server: %v", mask, readErr)
|
||||
}
|
||||
}
|
||||
if err := <-clientResult; err != nil {
|
||||
t.Fatalf("mask=%02x xor=%t client: %v", mask, xor, err)
|
||||
}
|
||||
_ = server.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoveredProfilesProbeEndToEnd(t *testing.T) {
|
||||
for _, padding := range []uint16{0, 64, cover.MaxPadding} {
|
||||
for _, xor := range []bool{false, true} {
|
||||
@@ -201,3 +272,108 @@ func TestSniffWireRecognizesAllHeaderProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSessionBulkCoalescesSSHLikeBursts(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
var sid wire.SessionID
|
||||
s := newStreamSession(sid, server, "dragontcp-ssh.internal:2222", 1024*1024, 4*1024*1024, true, nil)
|
||||
defer s.close()
|
||||
|
||||
const packet = 32 * 1024
|
||||
const packets = 16 // 512 KiB, matching the bulk coalescing goal.
|
||||
go func() {
|
||||
buf := make([]byte, packet)
|
||||
for i := 0; i < packets; i++ {
|
||||
for j := range buf {
|
||||
buf[j] = byte(i)
|
||||
}
|
||||
if _, err := client.Write(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
data, status, err := s.readAt(0, 1024*1024, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != wire.StatusData {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if len(data) < 512*1024 {
|
||||
t.Fatalf("bulk carrier returned only %d bytes; want at least 512 KiB", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeIperfProbeUploadAndDownload(t *testing.T) {
|
||||
const token = "test-token"
|
||||
const candidate = 512
|
||||
var sid wire.SessionID
|
||||
copy(sid[:], []byte("iperf-test-sid!!"))
|
||||
|
||||
makePayload := func(kind byte, total int) []byte {
|
||||
base := 11 + len(token)
|
||||
if total < base {
|
||||
total = base
|
||||
}
|
||||
p := make([]byte, total)
|
||||
copy(p[:4], wire.ProbeMagic[:])
|
||||
p[4] = kind
|
||||
binary.BigEndian.PutUint16(p[5:7], uint16(len(token)))
|
||||
binary.BigEndian.PutUint32(p[7:11], candidate)
|
||||
copy(p[11:base], token)
|
||||
for i := base; i < len(p); i++ {
|
||||
p[i] = byte((i*31 + 17) & 0xff)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("upload", func(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := wire.Request{Mode: wire.ModeProbe, Session: sid, Seq: 10, Payload: makePayload(wire.ProbeIperfUpload, candidate)}
|
||||
errCh <- processWireRequest(server, req, token, false, nil, 0, nil, 1024, 0, 0, nil)
|
||||
}()
|
||||
status, body, err := wire.ReadResponse(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != wire.StatusOK || len(body) != 0 {
|
||||
t.Fatalf("upload status=%d body=%q", status, body)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download", func(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := wire.Request{Mode: wire.ModeProbe, Session: sid, Seq: 20, Payload: makePayload(wire.ProbeIperfDownload, 0)}
|
||||
errCh <- processWireRequest(server, req, token, false, nil, 0, nil, 1024, 0, 0, nil)
|
||||
}()
|
||||
want := probePattern(candidate)
|
||||
for i := 0; i < wire.ProbeBurstCount(candidate); i++ {
|
||||
status, body, err := wire.ReadResponse(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeProbe, 20+uint64(i))
|
||||
if status != wire.StatusData || !bytes.Equal(body, want) {
|
||||
t.Fatalf("download record=%d status=%d len=%d", i, status, len(body))
|
||||
}
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,854 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/protocol"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSSHInternalHost = "dragontcp-ssh.internal"
|
||||
defaultSSHListen = "127.0.0.1:2222"
|
||||
)
|
||||
|
||||
type sshUserRecord struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
MaxConnections int `json:"max_connections,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
type sshUserFile struct {
|
||||
Version int `json:"version"`
|
||||
Users []sshUserRecord `json:"users"`
|
||||
}
|
||||
|
||||
type sshUserStore struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
users map[string]sshUserRecord
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func newSSHUserStore(path string) *sshUserStore {
|
||||
return &sshUserStore{path: path, users: make(map[string]sshUserRecord)}
|
||||
}
|
||||
|
||||
func normalizeSSHUsername(v string) string {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func validateSSHUsername(v string) error {
|
||||
v = normalizeSSHUsername(v)
|
||||
if v == "" {
|
||||
return errors.New("SSH username is required")
|
||||
}
|
||||
if len(v) > 64 {
|
||||
return errors.New("SSH username is too long")
|
||||
}
|
||||
for _, r := range v {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("SSH username contains unsupported character %q", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) loadLocked() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.users = make(map[string]sshUserRecord)
|
||||
s.modTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var file sshUserFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
users := make(map[string]sshUserRecord, len(file.Users))
|
||||
for _, u := range file.Users {
|
||||
u.Username = normalizeSSHUsername(u.Username)
|
||||
if u.Username == "" || u.PasswordHash == "" {
|
||||
continue
|
||||
}
|
||||
users[u.Username] = u
|
||||
}
|
||||
s.users = users
|
||||
if st, err := os.Stat(s.path); err == nil {
|
||||
s.modTime = st.ModTime()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) Load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
func (s *sshUserStore) reloadIfChanged() error {
|
||||
st, err := os.Stat(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.mu.RLock()
|
||||
alreadyEmpty := len(s.users) == 0 && s.modTime.IsZero()
|
||||
s.mu.RUnlock()
|
||||
if alreadyEmpty {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users = make(map[string]sshUserRecord)
|
||||
s.modTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.mu.RLock()
|
||||
unchanged := st.ModTime().Equal(s.modTime)
|
||||
s.mu.RUnlock()
|
||||
if unchanged {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st2, err := os.Stat(s.path); err == nil && st2.ModTime().Equal(s.modTime) {
|
||||
return nil
|
||||
}
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
func (s *sshUserStore) snapshot() ([]sshUserRecord, error) {
|
||||
if err := s.reloadIfChanged(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
out := make([]sshUserRecord, 0, len(s.users))
|
||||
for _, u := range s.users {
|
||||
out = append(out, u)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) get(username string) (sshUserRecord, bool) {
|
||||
_ = s.reloadIfChanged()
|
||||
s.mu.RLock()
|
||||
u, ok := s.users[normalizeSSHUsername(username)]
|
||||
s.mu.RUnlock()
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *sshUserStore) authenticate(username string, password []byte) (sshUserRecord, error) {
|
||||
if err := s.reloadIfChanged(); err != nil {
|
||||
return sshUserRecord{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
u, ok := s.users[normalizeSSHUsername(username)]
|
||||
s.mu.RUnlock()
|
||||
if !ok || u.Disabled {
|
||||
return sshUserRecord{}, errors.New("authentication failed")
|
||||
}
|
||||
if !u.ExpiresAt.IsZero() && time.Now().After(u.ExpiresAt) {
|
||||
return sshUserRecord{}, errors.New("account expired")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), password) != nil {
|
||||
return sshUserRecord{}, errors.New("authentication failed")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) writeRecords(records []sshUserRecord) error {
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].Username < records[j].Username })
|
||||
file := sshUserFile{Version: 1, Users: records}
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return s.Load()
|
||||
}
|
||||
|
||||
func (s *sshUserStore) upsert(username, password string, days, maxConnections int) error {
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("SSH password is required")
|
||||
}
|
||||
if days < 0 {
|
||||
return errors.New("account lifetime days cannot be negative")
|
||||
}
|
||||
if maxConnections < 0 {
|
||||
return errors.New("max connections cannot be negative")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var expires time.Time
|
||||
if days > 0 {
|
||||
expires = time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated := false
|
||||
for i := range records {
|
||||
if records[i].Username == normalizeSSHUsername(username) {
|
||||
records[i].PasswordHash = string(hash)
|
||||
records[i].ExpiresAt = expires
|
||||
records[i].MaxConnections = maxConnections
|
||||
records[i].Disabled = false
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
records = append(records, sshUserRecord{
|
||||
Username: normalizeSSHUsername(username),
|
||||
PasswordHash: string(hash),
|
||||
ExpiresAt: expires,
|
||||
MaxConnections: maxConnections,
|
||||
})
|
||||
}
|
||||
return s.writeRecords(records)
|
||||
}
|
||||
|
||||
func (s *sshUserStore) delete(username string) error {
|
||||
username = normalizeSSHUsername(username)
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := records[:0]
|
||||
found := false
|
||||
for _, u := range records {
|
||||
if u.Username == username {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SSH user %q not found", username)
|
||||
}
|
||||
return s.writeRecords(out)
|
||||
}
|
||||
|
||||
func (s *sshUserStore) setPassword(username, password string) error {
|
||||
username = normalizeSSHUsername(username)
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("SSH password is required")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range records {
|
||||
if records[i].Username == username {
|
||||
records[i].PasswordHash = string(hash)
|
||||
return s.writeRecords(records)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SSH user %q not found", username)
|
||||
}
|
||||
|
||||
func (s *sshUserStore) updateSettings(username string, days, maxConnections int) error {
|
||||
username = normalizeSSHUsername(username)
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if days < 0 {
|
||||
return errors.New("account lifetime days cannot be negative")
|
||||
}
|
||||
if maxConnections < 0 {
|
||||
return errors.New("max connections cannot be negative")
|
||||
}
|
||||
var expires time.Time
|
||||
if days > 0 {
|
||||
expires = time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range records {
|
||||
if records[i].Username == username {
|
||||
records[i].ExpiresAt = expires
|
||||
records[i].MaxConnections = maxConnections
|
||||
return s.writeRecords(records)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SSH user %q not found", username)
|
||||
}
|
||||
|
||||
func generateSSHPassword() (string, error) {
|
||||
buf := make([]byte, 18)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
type sshRuntime struct {
|
||||
store *sshUserStore
|
||||
mu sync.Mutex
|
||||
conns map[string]int
|
||||
}
|
||||
|
||||
func newSSHRuntime(store *sshUserStore) *sshRuntime {
|
||||
return &sshRuntime{store: store, conns: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (r *sshRuntime) acquire(username string) (sshUserRecord, error) {
|
||||
u, ok := r.store.get(username)
|
||||
if !ok || u.Disabled || (!u.ExpiresAt.IsZero() && time.Now().After(u.ExpiresAt)) {
|
||||
return sshUserRecord{}, errors.New("account unavailable")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if u.MaxConnections > 0 && r.conns[username] >= u.MaxConnections {
|
||||
return sshUserRecord{}, fmt.Errorf("max connections reached (%d)", u.MaxConnections)
|
||||
}
|
||||
r.conns[username]++
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *sshRuntime) release(username string) {
|
||||
r.mu.Lock()
|
||||
if r.conns[username] <= 1 {
|
||||
delete(r.conns, username)
|
||||
} else {
|
||||
r.conns[username]--
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func ensureSSHHostSigner(path string) (ssh.Signer, error) {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
return ssh.ParsePrivateKey(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
|
||||
data := pem.EncodeToMemory(block)
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssh.ParsePrivateKey(data)
|
||||
}
|
||||
|
||||
type sshDirectTCPIPRequest struct {
|
||||
Host string
|
||||
Port uint32
|
||||
OriginHost string
|
||||
OriginPort uint32
|
||||
}
|
||||
|
||||
func handleSSHDirectTCPIP(newChan ssh.NewChannel, allowPrivate bool, cache *dnsCache, tcpBuffer int) {
|
||||
var req sshDirectTCPIPRequest
|
||||
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil || req.Host == "" || req.Port == 0 || req.Port > 65535 {
|
||||
_ = newChan.Reject(ssh.Prohibited, "bad direct-tcpip request")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
var backend net.Conn
|
||||
var err error
|
||||
if internalAddr, ok := lookupSSHOnlyInternalTarget(req.Host, int(req.Port)); ok {
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
backend, err = d.DialContext(ctx, "tcp", internalAddr)
|
||||
if err == nil {
|
||||
protocol.TuneTCP(backend)
|
||||
protocol.TuneTCPBuffer(backend, tcpBuffer)
|
||||
}
|
||||
} else {
|
||||
backend, err = dialTarget(ctx, req.Host, int(req.Port), allowPrivate, cache, tcpBuffer)
|
||||
}
|
||||
if err != nil {
|
||||
_ = newChan.Reject(ssh.ConnectionFailed, "connect failed")
|
||||
return
|
||||
}
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
_ = backend.Close()
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
// Preserve TCP half-close semantics. A client may finish uploading while the
|
||||
// destination is still sending a large response, so do not close both sides
|
||||
// merely because one copy direction reached EOF.
|
||||
var relayWG sync.WaitGroup
|
||||
relayWG.Add(2)
|
||||
go func() {
|
||||
defer relayWG.Done()
|
||||
_, _ = io.Copy(backend, ch)
|
||||
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer relayWG.Done()
|
||||
_, _ = io.Copy(ch, backend)
|
||||
if cw, ok := ch.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
relayWG.Wait()
|
||||
_ = backend.Close()
|
||||
_ = ch.Close()
|
||||
}
|
||||
|
||||
func handleSSHDummySession(newChan ssh.NewChannel) {
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
for req := range reqs {
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func serveSSHConn(conn net.Conn, cfg *ssh.ServerConfig, runtime *sshRuntime, allowPrivate bool, cache *dnsCache, tcpBuffer int) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
username := sshConn.User()
|
||||
user, err := runtime.acquire(username)
|
||||
if err != nil {
|
||||
log.Printf("fake-ssh rejected user=%q remote=%s: %v", username, sshConn.RemoteAddr(), err)
|
||||
_ = sshConn.Close()
|
||||
return
|
||||
}
|
||||
log.Printf("fake-ssh connected user=%q remote=%s mode=tunnel-only", username, sshConn.RemoteAddr())
|
||||
defer func() {
|
||||
runtime.release(username)
|
||||
log.Printf("fake-ssh disconnected user=%q remote=%s", username, sshConn.RemoteAddr())
|
||||
}()
|
||||
defer sshConn.Close()
|
||||
if !user.ExpiresAt.IsZero() {
|
||||
remaining := time.Until(user.ExpiresAt)
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
expiryTimer := time.AfterFunc(remaining, func() { _ = sshConn.Close() })
|
||||
defer expiryTimer.Stop()
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
switch newChan.ChannelType() {
|
||||
case "direct-tcpip":
|
||||
go handleSSHDirectTCPIP(newChan, allowPrivate, cache, tcpBuffer)
|
||||
case "session":
|
||||
go handleSSHDummySession(newChan)
|
||||
default:
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeSSH(listenAddr, hostKeyPath string, store *sshUserStore, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Listener, string, error) {
|
||||
if err := store.Load(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
signer, err := ensureSSHHostSigner(hostKeyPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
runtime := newSSHRuntime(store)
|
||||
cfg := &ssh.ServerConfig{
|
||||
NoClientAuth: false,
|
||||
PasswordCallback: func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if _, err := store.authenticate(meta.User(), password); err != nil {
|
||||
log.Printf("fake-ssh auth failed user=%q remote=%s", meta.User(), meta.RemoteAddr())
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
cfg.AddHostKey(signer)
|
||||
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("fake-ssh accept: %v", err)
|
||||
continue
|
||||
}
|
||||
go serveSSHConn(conn, cfg, runtime, allowPrivate, cache, tcpBuffer)
|
||||
}
|
||||
}()
|
||||
return ln, ssh.FingerprintSHA256(signer.PublicKey()), nil
|
||||
}
|
||||
|
||||
type sshCLIFlags struct {
|
||||
usersPath *string
|
||||
addUser *string
|
||||
deleteUser *string
|
||||
password *string
|
||||
passwordEnv *string
|
||||
days *int
|
||||
maxConnections *int
|
||||
listUsers *bool
|
||||
menu *bool
|
||||
}
|
||||
|
||||
func registerSSHCLIFlags() sshCLIFlags {
|
||||
return sshCLIFlags{
|
||||
usersPath: flag.String("ssh-users", "dragontcp-users.json", "fake SSH user database JSON path"),
|
||||
addUser: flag.String("ssh-user-add", "", "create or update an SSH tunnel user, then exit"),
|
||||
deleteUser: flag.String("ssh-user-delete", "", "delete an SSH tunnel user, then exit"),
|
||||
password: flag.String("ssh-user-password", "", "password used with --ssh-user-add"),
|
||||
passwordEnv: flag.String("ssh-user-password-env", "", "environment variable containing password for --ssh-user-add"),
|
||||
days: flag.Int("ssh-user-days", 0, "account lifetime in days; 0 means no expiry"),
|
||||
maxConnections: flag.Int("ssh-user-max-connections", 1, "maximum simultaneous SSH connections for the account; 0 means unlimited"),
|
||||
listUsers: flag.Bool("ssh-user-list", false, "list SSH tunnel users, then exit"),
|
||||
menu: flag.Bool("ssh-menu", false, "interactive SSH tunnel user management menu, then exit"),
|
||||
}
|
||||
}
|
||||
|
||||
func menuReadLine(reader *bufio.Reader, prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
func menuReadInt(reader *bufio.Reader, prompt string, defaultValue, minValue int) (int, error) {
|
||||
for {
|
||||
line, err := menuReadLine(reader, prompt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if line == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
v, err := strconv.Atoi(line)
|
||||
if err != nil || v < minValue {
|
||||
fmt.Printf("Enter a number >= %d.\n", minValue)
|
||||
continue
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
func printSSHUserList(store *sshUserStore) error {
|
||||
records, err := store.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
fmt.Println("No SSH tunnel users.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%-22s %-26s %-16s %s\n", "USERNAME", "EXPIRES", "MAX CONNECTIONS", "STATUS")
|
||||
fmt.Printf("%-22s %-26s %-16s %s\n", strings.Repeat("-", 8), strings.Repeat("-", 7), strings.Repeat("-", 15), strings.Repeat("-", 6))
|
||||
now := time.Now()
|
||||
for _, u := range records {
|
||||
expiry := "never"
|
||||
status := "active"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Local().Format("2006-01-02 15:04 MST")
|
||||
if now.After(u.ExpiresAt) {
|
||||
status = "expired"
|
||||
}
|
||||
}
|
||||
if u.Disabled {
|
||||
status = "disabled"
|
||||
}
|
||||
max := "unlimited"
|
||||
if u.MaxConnections > 0 {
|
||||
max = strconv.Itoa(u.MaxConnections)
|
||||
}
|
||||
fmt.Printf("%-22s %-26s %-16s %s\n", u.Username, expiry, max, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSSHUserMenu(store *sshUserStore) error {
|
||||
if err := store.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" DragonTCP SSH Tunnel User Manager")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("User database: %s\n\n", store.path)
|
||||
fmt.Println(" 1) Create user (automatic password)")
|
||||
fmt.Println(" 2) Delete user")
|
||||
fmt.Println(" 3) List users")
|
||||
fmt.Println(" 4) Reset user password (automatic)")
|
||||
fmt.Println(" 5) Renew/edit expiry and connection limit")
|
||||
fmt.Println(" 0) Exit")
|
||||
choice, err := menuReadLine(reader, "\nSelect: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch choice {
|
||||
case "0", "q", "quit", "exit":
|
||||
return nil
|
||||
case "1":
|
||||
username, err := menuReadLine(reader, "Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if _, exists := store.get(username); exists {
|
||||
fmt.Printf("User %q already exists. Use option 4 or 5 to change it.\n", username)
|
||||
continue
|
||||
}
|
||||
days, err := menuReadInt(reader, "Days [30, 0 = never expires]: ", 30, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxConnections, err := menuReadInt(reader, "Max connections [1, 0 = unlimited]: ", 1, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating password: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if err := store.upsert(username, password, days, maxConnections); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
u, _ := store.get(username)
|
||||
expiry := "never"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Local().Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
fmt.Println("\nUser created successfully.")
|
||||
fmt.Printf("Username: %s\n", u.Username)
|
||||
fmt.Printf("Password: %s\n", password)
|
||||
fmt.Printf("Expires: %s\n", expiry)
|
||||
fmt.Printf("Max connections: %d\n", u.MaxConnections)
|
||||
fmt.Println("Save the password now. DragonTCP stores only its bcrypt hash and cannot display it later.")
|
||||
case "2":
|
||||
username, err := menuReadLine(reader, "Username to delete: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.get(username); !exists {
|
||||
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
|
||||
continue
|
||||
}
|
||||
confirm, err := menuReadLine(reader, fmt.Sprintf("Delete %q? [y/N]: ", normalizeSSHUsername(username)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(confirm, "y") && !strings.EqualFold(confirm, "yes") {
|
||||
fmt.Println("Delete cancelled.")
|
||||
continue
|
||||
}
|
||||
if err := store.delete(username); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("User %q deleted.\n", normalizeSSHUsername(username))
|
||||
case "3":
|
||||
if err := printSSHUserList(store); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
}
|
||||
case "4":
|
||||
username, err := menuReadLine(reader, "Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.get(username); !exists {
|
||||
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
|
||||
continue
|
||||
}
|
||||
password, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating password: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if err := store.setPassword(username, password); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("New password for %s: %s\n", normalizeSSHUsername(username), password)
|
||||
fmt.Println("Save it now; only the bcrypt hash is stored.")
|
||||
case "5":
|
||||
username, err := menuReadLine(reader, "Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, exists := store.get(username)
|
||||
if !exists {
|
||||
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
|
||||
continue
|
||||
}
|
||||
days, err := menuReadInt(reader, "New lifetime from now in days [30, 0 = never expires]: ", 30, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxDefault := u.MaxConnections
|
||||
maxConnections, err := menuReadInt(reader, fmt.Sprintf("Max connections [%d, 0 = unlimited]: ", maxDefault), maxDefault, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.updateSettings(username, days, maxConnections); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("User %q updated. Password was not changed.\n", normalizeSSHUsername(username))
|
||||
default:
|
||||
fmt.Println("Invalid selection.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSSHCLI(flags sshCLIFlags) (bool, error) {
|
||||
store := newSSHUserStore(*flags.usersPath)
|
||||
actions := 0
|
||||
if strings.TrimSpace(*flags.addUser) != "" {
|
||||
actions++
|
||||
}
|
||||
if strings.TrimSpace(*flags.deleteUser) != "" {
|
||||
actions++
|
||||
}
|
||||
if *flags.listUsers {
|
||||
actions++
|
||||
}
|
||||
if *flags.menu {
|
||||
actions++
|
||||
}
|
||||
if actions == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if actions > 1 {
|
||||
return true, errors.New("choose only one of --ssh-menu, --ssh-user-add, --ssh-user-delete, or --ssh-user-list")
|
||||
}
|
||||
if *flags.menu {
|
||||
return true, runSSHUserMenu(store)
|
||||
}
|
||||
if strings.TrimSpace(*flags.addUser) != "" {
|
||||
password := *flags.password
|
||||
if *flags.passwordEnv != "" {
|
||||
password = os.Getenv(*flags.passwordEnv)
|
||||
}
|
||||
if err := store.upsert(*flags.addUser, password, *flags.days, *flags.maxConnections); err != nil {
|
||||
return true, err
|
||||
}
|
||||
u, _ := store.get(*flags.addUser)
|
||||
expiry := "never"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
fmt.Printf("SSH user %s saved (expires=%s max_connections=%d)\n", u.Username, expiry, u.MaxConnections)
|
||||
return true, nil
|
||||
}
|
||||
if strings.TrimSpace(*flags.deleteUser) != "" {
|
||||
if err := store.delete(*flags.deleteUser); err != nil {
|
||||
return true, err
|
||||
}
|
||||
fmt.Printf("SSH user %s deleted\n", normalizeSSHUsername(*flags.deleteUser))
|
||||
return true, nil
|
||||
}
|
||||
records, err := store.snapshot()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
for _, u := range records {
|
||||
expiry := "never"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
fmt.Printf("%s expires=%s max_connections=%d disabled=%t\n", u.Username, expiry, u.MaxConnections, u.Disabled)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGeneratedSSHPassword(t *testing.T) {
|
||||
p1, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p1) != 24 || len(p2) != 24 {
|
||||
t.Fatalf("generated password lengths = %d, %d; want 24", len(p1), len(p2))
|
||||
}
|
||||
if p1 == p2 {
|
||||
t.Fatal("two generated passwords were identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHUserMenuOperationsPreservePasswordWhenEditingSettings(t *testing.T) {
|
||||
store := newSSHUserStore(filepath.Join(t.TempDir(), "users.json"))
|
||||
if err := store.upsert("alice", "initial-password", 7, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, ok := store.get("alice")
|
||||
if !ok {
|
||||
t.Fatal("user missing after create")
|
||||
}
|
||||
|
||||
if err := store.updateSettings("alice", 30, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, ok := store.get("alice")
|
||||
if !ok {
|
||||
t.Fatal("user missing after settings update")
|
||||
}
|
||||
if after.PasswordHash != before.PasswordHash {
|
||||
t.Fatal("editing expiry/connection limit changed password hash")
|
||||
}
|
||||
if after.MaxConnections != 5 {
|
||||
t.Fatalf("max connections = %d; want 5", after.MaxConnections)
|
||||
}
|
||||
if after.ExpiresAt.Before(time.Now().UTC().Add(29 * 24 * time.Hour)) {
|
||||
t.Fatalf("expiry was not renewed: %v", after.ExpiresAt)
|
||||
}
|
||||
|
||||
if err := store.setPassword("alice", "replacement-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset, ok := store.get("alice")
|
||||
if !ok {
|
||||
t.Fatal("user missing after password reset")
|
||||
}
|
||||
if reset.PasswordHash == after.PasswordHash {
|
||||
t.Fatal("password reset did not change password hash")
|
||||
}
|
||||
if reset.MaxConnections != after.MaxConnections || !reset.ExpiresAt.Equal(after.ExpiresAt) {
|
||||
t.Fatal("password reset changed account limits")
|
||||
}
|
||||
|
||||
if err := store.delete("alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := store.get("alice"); ok {
|
||||
t.Fatal("user still present after delete")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type internalTargetRegistry struct {
|
||||
sync.RWMutex
|
||||
m map[string]string
|
||||
}
|
||||
|
||||
var dragonTCPInternalTargets = internalTargetRegistry{m: make(map[string]string)}
|
||||
var sshOnlyInternalTargets = internalTargetRegistry{m: make(map[string]string)}
|
||||
|
||||
func internalTargetKey(host string, port int) string {
|
||||
return strings.ToLower(strings.TrimSpace(host)) + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
func registerInternalTarget(host string, port int, dialAddr string) {
|
||||
registerTarget(&dragonTCPInternalTargets, host, port, dialAddr)
|
||||
}
|
||||
|
||||
func lookupInternalTarget(host string, port int) (string, bool) {
|
||||
return lookupTarget(&dragonTCPInternalTargets, host, port)
|
||||
}
|
||||
|
||||
// registerSSHOnlyInternalTarget creates a destination that is reachable only
|
||||
// after SSH authentication. It is deliberately not exposed to raw DragonTCP
|
||||
// clients, which prevents direct access to services such as the UDP gateway.
|
||||
func registerSSHOnlyInternalTarget(host string, port int, dialAddr string) {
|
||||
registerTarget(&sshOnlyInternalTargets, host, port, dialAddr)
|
||||
}
|
||||
|
||||
func lookupSSHOnlyInternalTarget(host string, port int) (string, bool) {
|
||||
return lookupTarget(&sshOnlyInternalTargets, host, port)
|
||||
}
|
||||
|
||||
func registerTarget(registry *internalTargetRegistry, host string, port int, dialAddr string) {
|
||||
if strings.TrimSpace(host) == "" || port < 1 || port > 65535 || strings.TrimSpace(dialAddr) == "" {
|
||||
return
|
||||
}
|
||||
registry.Lock()
|
||||
registry.m[internalTargetKey(host, port)] = dialAddr
|
||||
registry.Unlock()
|
||||
}
|
||||
|
||||
func lookupTarget(registry *internalTargetRegistry, host string, port int) (string, bool) {
|
||||
registry.RLock()
|
||||
addr, ok := registry.m[internalTargetKey(host, port)]
|
||||
registry.RUnlock()
|
||||
return addr, ok
|
||||
}
|
||||
|
||||
func dialInternalTarget(dialer *net.Dialer, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSSHOnlyInternalTargetIsNotPublicDragonTCPTarget(t *testing.T) {
|
||||
const host = "test-udpgw.internal"
|
||||
const port = 17400
|
||||
registerSSHOnlyInternalTarget(host, port, "127.0.0.1:17400")
|
||||
|
||||
if _, ok := lookupInternalTarget(host, port); ok {
|
||||
t.Fatal("SSH-only target leaked into raw DragonTCP internal target registry")
|
||||
}
|
||||
if got, ok := lookupSSHOnlyInternalTarget(host, port); !ok || got != "127.0.0.1:17400" {
|
||||
t.Fatalf("SSH-only target lookup = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/cover"
|
||||
"dragontcp/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -135,6 +136,17 @@ func addressAllowed(addr netip.Addr, allowPrivate bool) bool {
|
||||
}
|
||||
|
||||
func dialTarget(ctx context.Context, host string, port int, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Conn, error) {
|
||||
if internalAddr, ok := lookupInternalTarget(host, port); ok {
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", internalAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protocol.TuneTCP(conn)
|
||||
protocol.TuneTCPBuffer(conn, tcpBuffer)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
ips, err := cache.resolve(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -215,11 +227,24 @@ func handle(
|
||||
return
|
||||
}
|
||||
clearPayload := false
|
||||
coverID := uint16(0)
|
||||
covered := false
|
||||
if profiled, ok := conn.(interface{ ClearPayload() bool }); ok {
|
||||
clearPayload = profiled.ClearPayload()
|
||||
}
|
||||
if profiled, ok := conn.(interface{ CoverProfile() cover.Profile }); ok {
|
||||
profile := profiled.CoverProfile()
|
||||
if profile.Enabled {
|
||||
covered = true
|
||||
coverID = profile.ID
|
||||
}
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t", conn.RemoteAddr(), headerMask, clearPayload)
|
||||
if covered {
|
||||
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t cover_id=%04x", conn.RemoteAddr(), headerMask, clearPayload, coverID)
|
||||
} else {
|
||||
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t cover_id=direct", conn.RemoteAddr(), headerMask, clearPayload)
|
||||
}
|
||||
}
|
||||
|
||||
handleBinary(conn, headerMask, clearPayload, token, allowPrivate, cache, tcpBuffer, manager,
|
||||
@@ -268,6 +293,7 @@ func acceptLoop(
|
||||
}
|
||||
|
||||
func main() {
|
||||
sshCLI := registerSSHCLIFlags()
|
||||
var (
|
||||
host = flag.String("host", "0.0.0.0", "listen host")
|
||||
port = flag.Int("port", 53, "listen port")
|
||||
@@ -285,9 +311,27 @@ func main() {
|
||||
debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics")
|
||||
debugChunks = flag.Bool("debug-chunks", false, "log every chunk protocol record; very verbose")
|
||||
debugStats = flag.Duration("debug-stats-interval", 5*time.Second, "periodic debug statistics interval; 0 disables")
|
||||
|
||||
sshEnable = flag.Bool("ssh-enable", true, "enable the internal tunnel-only SSH service")
|
||||
sshListen = flag.String("ssh-listen", defaultSSHListen, "internal fake SSH listen address")
|
||||
sshInternalHost = flag.String("ssh-internal-host", defaultSSHInternalHost, "reserved DragonTCP target name used by clients for SSH")
|
||||
sshHostKey = flag.String("ssh-host-key", "dragontcp_ssh_host_key", "SSH host private-key path; generated automatically if missing")
|
||||
udpgwEnable = flag.Bool("udpgw-enable", true, "enable integrated BadVPN-compatible UDPGW")
|
||||
udpgwListen = flag.String("udpgw-listen", "127.0.0.1:7400", "UDPGW listen address; loopback is recommended")
|
||||
udpgwInternalHost = flag.String("udpgw-internal-host", "dragontcp-udpgw.internal", "reserved SSH direct-tcpip target name for UDPGW")
|
||||
udpgwMaxClients = flag.Int("udpgw-max-clients", 10000, "maximum concurrent UDPGW TCP clients")
|
||||
udpgwDebug = flag.Bool("udpgw-debug", false, "verbose UDPGW errors")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if handled, err := handleSSHCLI(sshCLI); handled {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *chunkMax < 32 || *chunkMax > protocol.MaxChunkPayload {
|
||||
fmt.Fprintf(os.Stderr, "--chunk-max must be between 32 and %d\n", protocol.MaxChunkPayload)
|
||||
os.Exit(2)
|
||||
@@ -297,6 +341,58 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
|
||||
|
||||
var udpServer *udpgwServer
|
||||
if *udpgwEnable {
|
||||
var err error
|
||||
udpServer, err = startUDPGWServer(udpgwServerConfig{
|
||||
Listen: *udpgwListen, MaxClients: *udpgwMaxClients, Debug: *udpgwDebug,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "UDPGW start failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer udpServer.Close()
|
||||
_, udpPortText, err := net.SplitHostPort(udpServer.ln.Addr().String())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid UDPGW listener: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
udpPort, err := strconv.Atoi(udpPortText)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid UDPGW port: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
registerSSHOnlyInternalTarget(*udpgwInternalHost, udpPort, udpServer.ln.Addr().String())
|
||||
fmt.Printf("udpgw=true listen=%s internal_target=%s:%d max_clients=%d\n", udpServer.ln.Addr(), *udpgwInternalHost, udpPort, *udpgwMaxClients)
|
||||
}
|
||||
|
||||
var sshListener net.Listener
|
||||
if *sshEnable {
|
||||
sshStore := newSSHUserStore(*sshCLI.usersPath)
|
||||
listener, fingerprint, err := startFakeSSH(*sshListen, *sshHostKey, sshStore, *allowPrivate, cache, *tcpBuffer)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fake SSH start failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sshListener = listener
|
||||
defer sshListener.Close()
|
||||
sshBoundAddr := sshListener.Addr().String()
|
||||
_, sshPortText, err := net.SplitHostPort(sshBoundAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid fake SSH listener: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
sshPort, err := strconv.Atoi(sshPortText)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid fake SSH listener port: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
registerInternalTarget(*sshInternalHost, sshPort, sshBoundAddr)
|
||||
fmt.Printf("fake_ssh=true listen=%s internal_target=%s:%d hostkey=%s users=%s\n", sshBoundAddr, *sshInternalHost, sshPort, fingerprint, *sshCLI.usersPath)
|
||||
}
|
||||
|
||||
listenAddr := net.JoinHostPort(*host, strconv.Itoa(*port))
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
@@ -324,7 +420,6 @@ func main() {
|
||||
fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer)
|
||||
|
||||
slots := make(chan struct{}, *maxConnections)
|
||||
cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
|
||||
debug := newServerDebug(*debugEnabled, *debugChunks, *debugStats)
|
||||
bufferBytes := *chunkBuffered * 65536
|
||||
if bufferBytes < 1024*1024 {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type udpgwServerConfig struct {
|
||||
Listen string
|
||||
MaxFrame int
|
||||
MaxClients int
|
||||
MaxClientConns int
|
||||
MaxMapEntries int
|
||||
MapTTL time.Duration
|
||||
IdleTimeout time.Duration
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type udpgwServer struct {
|
||||
cfg udpgwServerConfig
|
||||
ln net.Listener
|
||||
slots chan struct{}
|
||||
closeMu sync.Once
|
||||
}
|
||||
|
||||
type udpDestKey struct {
|
||||
ip [4]byte
|
||||
port uint16
|
||||
}
|
||||
|
||||
type udpMapVal struct {
|
||||
connID uint16
|
||||
x byte
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
func startUDPGWServer(cfg udpgwServerConfig) (*udpgwServer, error) {
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = "127.0.0.1:7400"
|
||||
}
|
||||
if cfg.MaxFrame <= 0 || cfg.MaxFrame > 65535 {
|
||||
cfg.MaxFrame = 65535
|
||||
}
|
||||
if cfg.MaxClients <= 0 {
|
||||
cfg.MaxClients = 10000
|
||||
}
|
||||
if cfg.MaxClientConns <= 0 {
|
||||
cfg.MaxClientConns = 64
|
||||
}
|
||||
if cfg.MaxMapEntries <= 0 {
|
||||
cfg.MaxMapEntries = 32768
|
||||
}
|
||||
if cfg.MapTTL <= 0 {
|
||||
cfg.MapTTL = 90 * time.Second
|
||||
}
|
||||
if cfg.IdleTimeout <= 0 {
|
||||
cfg.IdleTimeout = 2 * time.Minute
|
||||
}
|
||||
ln, err := net.Listen("tcp", cfg.Listen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &udpgwServer{cfg: cfg, ln: ln, slots: make(chan struct{}, cfg.MaxClients)}
|
||||
go s.acceptLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *udpgwServer) Close() error {
|
||||
var err error
|
||||
s.closeMu.Do(func() { err = s.ln.Close() })
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *udpgwServer) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("udpgw accept: %v", err)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.slots <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-s.slots }()
|
||||
s.handleClient(conn)
|
||||
}()
|
||||
default:
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *udpgwServer) handleClient(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp4", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer udpConn.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
writeCh := make(chan []byte, 256)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case frame := <-writeCh:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
if _, err := conn.Write(frame); err != nil {
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var mu sync.Mutex
|
||||
mappings := make(map[udpDestKey]udpMapVal)
|
||||
connSeen := make(map[uint16]time.Time)
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := udpConn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ip4 := from.IP.To4()
|
||||
if ip4 == nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
var ip [4]byte
|
||||
copy(ip[:], ip4)
|
||||
key := udpDestKey{ip: ip, port: uint16(from.Port)}
|
||||
mu.Lock()
|
||||
v, ok := mappings[key]
|
||||
mu.Unlock()
|
||||
if !ok || time.Now().After(v.exp) {
|
||||
continue
|
||||
}
|
||||
frame := udpgwBuildFrame(v.connID, v.x, ip, uint16(from.Port), buf[:n])
|
||||
select {
|
||||
case writeCh <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
reap := time.NewTicker(10 * time.Second)
|
||||
defer reap.Stop()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-reap.C:
|
||||
mu.Lock()
|
||||
for k, v := range mappings {
|
||||
if now.After(v.exp) {
|
||||
delete(mappings, k)
|
||||
}
|
||||
}
|
||||
for id, seen := range connSeen {
|
||||
if now.Sub(seen) > s.cfg.MapTTL {
|
||||
delete(connSeen, id)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
br := bufio.NewReaderSize(conn, 32*1024)
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(s.cfg.IdleTimeout))
|
||||
payload, err := udpgwReadPayload(br, s.cfg.MaxFrame)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
<-done
|
||||
return
|
||||
}
|
||||
if len(payload) < 9 {
|
||||
continue
|
||||
}
|
||||
connID := binary.BigEndian.Uint16(payload[0:2])
|
||||
x := payload[2]
|
||||
var dstIP [4]byte
|
||||
copy(dstIP[:], payload[3:7])
|
||||
dstPort := binary.BigEndian.Uint16(payload[7:9])
|
||||
data := payload[9:]
|
||||
now := time.Now()
|
||||
key := udpDestKey{ip: dstIP, port: dstPort}
|
||||
|
||||
mu.Lock()
|
||||
for id, seen := range connSeen {
|
||||
if now.Sub(seen) > s.cfg.MapTTL {
|
||||
delete(connSeen, id)
|
||||
}
|
||||
}
|
||||
if _, ok := connSeen[connID]; !ok && len(connSeen) >= s.cfg.MaxClientConns {
|
||||
var oldestID uint16
|
||||
var oldestTime time.Time
|
||||
first := true
|
||||
for id, seen := range connSeen {
|
||||
if first || seen.Before(oldestTime) {
|
||||
oldestID, oldestTime, first = id, seen, false
|
||||
}
|
||||
}
|
||||
delete(connSeen, oldestID)
|
||||
}
|
||||
connSeen[connID] = now
|
||||
if len(mappings) >= s.cfg.MaxMapEntries {
|
||||
for k, v := range mappings {
|
||||
if now.After(v.exp) {
|
||||
delete(mappings, k)
|
||||
}
|
||||
}
|
||||
if len(mappings) >= s.cfg.MaxMapEntries {
|
||||
for k := range mappings {
|
||||
delete(mappings, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
mappings[key] = udpMapVal{connID: connID, x: x, exp: now.Add(s.cfg.MapTTL)}
|
||||
mu.Unlock()
|
||||
|
||||
addr := &net.UDPAddr{IP: net.IPv4(dstIP[0], dstIP[1], dstIP[2], dstIP[3]), Port: int(dstPort)}
|
||||
if _, err := udpConn.WriteToUDP(data, addr); err != nil && s.cfg.Debug {
|
||||
log.Printf("udpgw write %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func udpgwReadPayload(r *bufio.Reader, max int) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lenBuf[:]))
|
||||
if n <= 0 || n > max {
|
||||
return nil, fmt.Errorf("udpgw invalid frame length %d", n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func udpgwBuildFrame(connID uint16, x byte, ip [4]byte, port uint16, data []byte) []byte {
|
||||
payloadLen := 9 + len(data)
|
||||
out := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(out[0:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(out[2:4], connID)
|
||||
out[4] = x
|
||||
copy(out[5:9], ip[:])
|
||||
binary.BigEndian.PutUint16(out[9:11], port)
|
||||
copy(out[11:], data)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUDPGWRelaysIPv4Datagram(t *testing.T) {
|
||||
echo, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer echo.Close()
|
||||
go func() {
|
||||
b := make([]byte, 2048)
|
||||
n, addr, e := echo.ReadFromUDP(b)
|
||||
if e == nil {
|
||||
_, _ = echo.WriteToUDP(b[:n], addr)
|
||||
}
|
||||
}()
|
||||
|
||||
srv, err := startUDPGWServer(udpgwServerConfig{Listen: "127.0.0.1:0", MaxClients: 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
c, err := net.DialTimeout("tcp", srv.ln.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
port := echo.LocalAddr().(*net.UDPAddr).Port
|
||||
data := []byte("udpgw-ok")
|
||||
payloadLen := 9 + len(data)
|
||||
frame := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(frame[:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(frame[2:4], 1)
|
||||
frame[4] = 0
|
||||
copy(frame[5:9], []byte{127, 0, 0, 1})
|
||||
binary.BigEndian.PutUint16(frame[9:11], uint16(port))
|
||||
copy(frame[11:], data)
|
||||
if _, err := c.Write(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := bufio.NewReader(c)
|
||||
var lb [2]byte
|
||||
if _, err := io.ReadFull(r, lb[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lb[:]))
|
||||
reply := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n < 9 || string(reply[9:]) != string(data) {
|
||||
t.Fatalf("bad reply n=%d data=%q", n, reply[9:])
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,8 @@ func decodeWireToken(token string) string {
|
||||
|
||||
func isChunkCommand(payload []byte) bool {
|
||||
return bytes.HasPrefix(payload, []byte("CPROBE ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CIPERFUP ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CIPERFDW ")) ||
|
||||
bytes.HasPrefix(payload, []byte("COPEN ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CPUSH ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CPULL ")) ||
|
||||
@@ -388,6 +390,46 @@ func processChunkCommand(
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("PROBEOK"))
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
|
||||
parts := bytes.SplitN(payload, []byte(" "), 4)
|
||||
if len(parts) != 4 {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CIPERFUP"))
|
||||
}
|
||||
if !tokenEqual(decodeWireToken(string(parts[1])), token) {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||
}
|
||||
size, err := strconv.Atoi(string(parts[2]))
|
||||
if err != nil || size < 1 || size > maxChunk {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf upload chunk too large"))
|
||||
}
|
||||
data := parts[3]
|
||||
if len(data) != size || !bytes.Equal(data, probePattern(size)) {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf upload validation failed"))
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=upload wire=x peer=%s chunk=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), size, len(data))
|
||||
}
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("IPERFOK"))
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFDW ")) {
|
||||
parts := strings.Fields(string(payload))
|
||||
if len(parts) != 3 {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CIPERFDW"))
|
||||
}
|
||||
if !tokenEqual(decodeWireToken(parts[1]), token) {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||
}
|
||||
size, err := strconv.Atoi(parts[2])
|
||||
if err != nil || size < 1 || size > maxChunk {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf download chunk too large"))
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=download wire=x peer=%s chunk=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), size, size)
|
||||
}
|
||||
return protocol.WriteResponseFrame(conn, requestID, probePattern(size))
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(payload, []byte("COPEN ")) {
|
||||
parts := strings.Fields(string(payload))
|
||||
if len(parts) != 5 {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,5 @@
|
||||
module dragontcp
|
||||
|
||||
go 1.22
|
||||
|
||||
require golang.org/x/crypto v0.31.0
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
@@ -26,14 +26,25 @@ const (
|
||||
StatusWait byte = 3
|
||||
StatusEOF byte = 4
|
||||
|
||||
ProbeUpload byte = 1
|
||||
ProbeDownload byte = 2
|
||||
ProbeKeepalive byte = 3
|
||||
ProbeBatch byte = 4
|
||||
ProbeUpload byte = 1
|
||||
ProbeDownload byte = 2
|
||||
ProbeKeepalive byte = 3
|
||||
ProbeBatch byte = 4
|
||||
ProbeIperfUpload byte = 5
|
||||
ProbeIperfDownload byte = 6
|
||||
)
|
||||
|
||||
var ProbeMagic = [4]byte{'D', 'T', 'P', '2'}
|
||||
|
||||
// ProbeBurstCount is deliberately fixed at one. Startup calibration measures
|
||||
// the safe record size of a single DragonTCP lane, not aggregate throughput.
|
||||
// Multiple outstanding calibration records can make a constrained carrier look
|
||||
// artificially better or worse and can produce a false ceiling. Confirmation
|
||||
// retries are performed sequentially on fresh connections by the client.
|
||||
func ProbeBurstCount(chunk int) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
type SessionID [16]byte
|
||||
|
||||
type Request struct {
|
||||
|
||||
@@ -103,3 +103,11 @@ func BenchmarkWriteRequest1MiB(b *testing.B) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeBurstCountSinglePoller(t *testing.T) {
|
||||
for _, chunk := range []int{0, 1, 1024, 128 * 1024, 512 * 1024, 1024 * 1024} {
|
||||
if got := ProbeBurstCount(chunk); got != 1 {
|
||||
t.Fatalf("ProbeBurstCount(%d)=%d, want 1", chunk, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+382
-26
@@ -7,6 +7,7 @@
|
||||
package xorchunk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -29,7 +30,7 @@ var requestCounter atomic.Uint32
|
||||
|
||||
// NewOptions builds the transport options from the values the CLI already
|
||||
// parses, keeping the struct fields unexported as in the original.
|
||||
func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses int, adaptLog bool,
|
||||
func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses, shrinkAfter int, adaptLog bool,
|
||||
pollers, reconnectEvery int, pollDelay, txnTimeout time.Duration, tcpBuffer int) Options {
|
||||
return Options{
|
||||
startSize: startSize,
|
||||
@@ -37,6 +38,7 @@ func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses i
|
||||
maxSize: maxSize,
|
||||
adaptive: adaptive,
|
||||
adaptSuccesses: adaptSuccesses,
|
||||
shrinkAfter: shrinkAfter,
|
||||
adaptLog: adaptLog,
|
||||
pollers: pollers,
|
||||
reconnectEvery: reconnectEvery,
|
||||
@@ -47,19 +49,24 @@ func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses i
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
startSize int
|
||||
minSize int
|
||||
maxSize int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
adaptLog bool
|
||||
pollers int
|
||||
reconnectEvery int
|
||||
pollDelay time.Duration
|
||||
txnTimeout time.Duration
|
||||
tcpBuffer int
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
startSize int
|
||||
minSize int
|
||||
maxSize int
|
||||
uploadStartSize int
|
||||
downloadStartSize int
|
||||
uploadMaxSize int
|
||||
downloadMaxSize int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
adaptLog bool
|
||||
pollers int
|
||||
reconnectEvery int
|
||||
pollDelay time.Duration
|
||||
txnTimeout time.Duration
|
||||
tcpBuffer int
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
}
|
||||
|
||||
// WithHeaderMask returns a copy using one fixed frame-magic profile. The mask
|
||||
@@ -78,6 +85,38 @@ func (o Options) WithCoverProfile(profile cover.Profile) Options {
|
||||
return o
|
||||
}
|
||||
|
||||
// MinSize and MaxSize expose the configured X carrier calibration bounds.
|
||||
func (o Options) MinSize() int { return o.minSize }
|
||||
func (o Options) MaxSize() int { return o.maxSize }
|
||||
|
||||
// WithCalibratedChunks locks X to the UP/DW sizes proven by the pre-tunnel
|
||||
// fake-iperf calibration. Once calibration succeeds, runtime adaptive sizing is
|
||||
// disabled for X: transport successes cannot grow the chunk and transport
|
||||
// failures cannot shrink it. Failed physical transactions reconnect/retry using
|
||||
// the same calibrated size.
|
||||
func (o Options) WithCalibratedChunks(upload, download int) Options {
|
||||
if upload < o.minSize {
|
||||
upload = o.minSize
|
||||
}
|
||||
if download < o.minSize {
|
||||
download = o.minSize
|
||||
}
|
||||
if upload > o.maxSize {
|
||||
upload = o.maxSize
|
||||
}
|
||||
if download > o.maxSize {
|
||||
download = o.maxSize
|
||||
}
|
||||
o.uploadStartSize = upload
|
||||
o.downloadStartSize = download
|
||||
o.uploadMaxSize = upload
|
||||
o.downloadMaxSize = download
|
||||
// Calibration replaces runtime X chunk adaptation. The calibrated values are
|
||||
// the operating sizes for this session, not merely adaptive ceilings.
|
||||
o.adaptive = false
|
||||
return o
|
||||
}
|
||||
|
||||
func wireToken(token string) string {
|
||||
if token == "" {
|
||||
return "-"
|
||||
@@ -93,28 +132,44 @@ type adaptiveSizer struct {
|
||||
max int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
failures int
|
||||
successes int
|
||||
good int
|
||||
bad int
|
||||
logChanges bool
|
||||
}
|
||||
|
||||
func newAdaptiveSizer(name string, opts Options) *adaptiveSizer {
|
||||
start := opts.startSize
|
||||
func newAdaptiveSizer(name string, start, ceiling int, opts Options) *adaptiveSizer {
|
||||
if ceiling <= 0 || ceiling > opts.maxSize {
|
||||
ceiling = opts.maxSize
|
||||
}
|
||||
if ceiling < opts.minSize {
|
||||
ceiling = opts.minSize
|
||||
}
|
||||
if start <= 0 {
|
||||
start = opts.startSize
|
||||
}
|
||||
if start < opts.minSize {
|
||||
start = opts.minSize
|
||||
}
|
||||
if start > opts.maxSize {
|
||||
start = opts.maxSize
|
||||
if start > ceiling {
|
||||
start = ceiling
|
||||
}
|
||||
return &adaptiveSizer{
|
||||
name: name,
|
||||
current: start,
|
||||
min: opts.minSize,
|
||||
max: opts.maxSize,
|
||||
max: ceiling,
|
||||
adaptive: opts.adaptive,
|
||||
adaptSuccesses: opts.adaptSuccesses,
|
||||
logChanges: opts.adaptLog,
|
||||
shrinkAfter: func() int {
|
||||
if opts.shrinkAfter > 0 {
|
||||
return opts.shrinkAfter
|
||||
}
|
||||
return 1
|
||||
}(),
|
||||
logChanges: opts.adaptLog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +184,7 @@ func (s *adaptiveSizer) Success(attempted int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.adaptive || s.current >= s.max {
|
||||
if !s.adaptive {
|
||||
return
|
||||
}
|
||||
// Ignore stale successes from records that were already in flight when
|
||||
@@ -137,6 +192,10 @@ func (s *adaptiveSizer) Success(attempted int) {
|
||||
if attempted != s.current {
|
||||
return
|
||||
}
|
||||
s.failures = 0
|
||||
if s.current >= s.max {
|
||||
return
|
||||
}
|
||||
|
||||
if attempted > s.good {
|
||||
s.good = attempted
|
||||
@@ -201,6 +260,14 @@ func (s *adaptiveSizer) Failure(attempted int) (old, next int) {
|
||||
return old, old
|
||||
}
|
||||
s.successes = 0
|
||||
s.failures++
|
||||
if s.failures < s.shrinkAfter {
|
||||
if s.logChanges && s.shrinkAfter > 1 {
|
||||
fmt.Printf("adaptive %s chunk: holding %d after failure %d/%d\n", s.name, old, s.failures, s.shrinkAfter)
|
||||
}
|
||||
return old, old
|
||||
}
|
||||
s.failures = 0
|
||||
|
||||
if s.bad == 0 || attempted < s.bad {
|
||||
s.bad = attempted
|
||||
@@ -373,6 +440,288 @@ func ProbeProfile(serverAddr, token string, opts Options) bool {
|
||||
return opts.headerMask == 0 && strings.HasPrefix(string(resp), "ERR expected TUNNEL")
|
||||
}
|
||||
|
||||
type calibrationProbeResult struct {
|
||||
ok bool
|
||||
bytes int
|
||||
elapsed time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
func (r calibrationProbeResult) mbps() float64 {
|
||||
if r.bytes <= 0 || r.elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.bytes*8) / r.elapsed.Seconds() / 1_000_000
|
||||
}
|
||||
|
||||
func calibrationPattern(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = byte((i*31 + 17) & 0xff)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func calibrationBurstCount(chunk int) int {
|
||||
// Calibration is strictly single-poller/single-outstanding-request. Runtime
|
||||
// X traffic may use its normal concurrency after calibration completes.
|
||||
return 1
|
||||
}
|
||||
|
||||
func calibrationTimeout(opts Options) time.Duration {
|
||||
t := opts.txnTimeout
|
||||
if t < 8*time.Second {
|
||||
t = 8 * time.Second
|
||||
}
|
||||
if t > 20*time.Second {
|
||||
t = 20 * time.Second
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func isCalibrationTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "i/o timeout") || strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
|
||||
const calibrationDecisionAttempts = 3
|
||||
|
||||
// confirmCalibrationResult uses a 2-of-3 decision near the carrier boundary.
|
||||
// Each retry gets a fresh X connection. Timeouts are treated as inconclusive
|
||||
// connection failures, not as evidence that the candidate chunk is too large.
|
||||
func confirmCalibrationResult(serverAddr, token string, opts Options, download bool, candidate int, first calibrationProbeResult, stage string) calibrationProbeResult {
|
||||
name := "upload"
|
||||
if download {
|
||||
name = "download"
|
||||
}
|
||||
successes, failures := 0, 0
|
||||
var lastSuccess, lastFailure, lastTimeout calibrationProbeResult
|
||||
|
||||
observe := func(r calibrationProbeResult) {
|
||||
if r.ok {
|
||||
successes++
|
||||
lastSuccess = r
|
||||
return
|
||||
}
|
||||
if isCalibrationTimeout(r.err) {
|
||||
lastTimeout = r
|
||||
return
|
||||
}
|
||||
failures++
|
||||
lastFailure = r
|
||||
}
|
||||
|
||||
observe(first)
|
||||
for attempt := 2; attempt <= calibrationDecisionAttempts && successes < 2 && failures < 2; attempt++ {
|
||||
r := probeCalibrationSize(serverAddr, token, opts, download, candidate)
|
||||
observe(r)
|
||||
result := "failure"
|
||||
if r.ok {
|
||||
result = "success"
|
||||
} else if isCalibrationTimeout(r.err) {
|
||||
result = "connection_timeout"
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=%s chunk=%d confirmation=%d/%d result=%s\n", name, stage, candidate, attempt, calibrationDecisionAttempts, result)
|
||||
}
|
||||
|
||||
if successes >= 2 {
|
||||
return lastSuccess
|
||||
}
|
||||
if failures >= 2 {
|
||||
return lastFailure
|
||||
}
|
||||
if lastTimeout.err != nil {
|
||||
return lastTimeout
|
||||
}
|
||||
if successes > failures && lastSuccess.ok {
|
||||
return lastSuccess
|
||||
}
|
||||
return lastFailure
|
||||
}
|
||||
|
||||
// probeCalibrationSize runs a short repeated UP or DW transfer over one X
|
||||
// physical connection. It exercises the same UP/OK framing and XOR payload path
|
||||
// as real X traffic without opening a destination tunnel.
|
||||
func probeCalibrationSize(serverAddr, token string, opts Options, download bool, candidate int) calibrationProbeResult {
|
||||
timeout := calibrationTimeout(opts)
|
||||
lane := newTxnLane(serverAddr, opts.tcpBuffer, 0, timeout, opts.headerMask, opts.coverProfile)
|
||||
defer lane.Close()
|
||||
count := calibrationBurstCount(candidate)
|
||||
started := time.Now()
|
||||
total := 0
|
||||
want := calibrationPattern(candidate)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
if download {
|
||||
resp, err := lane.Do([]byte(fmt.Sprintf("CIPERFDW %s %d", wireToken(token), candidate)))
|
||||
if err != nil {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
if !bytes.Equal(resp, want) {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: fmt.Errorf("X download validation failed len=%d want=%d", len(resp), candidate)}
|
||||
}
|
||||
total += len(resp)
|
||||
continue
|
||||
}
|
||||
|
||||
prefix := []byte(fmt.Sprintf("CIPERFUP %s %d ", wireToken(token), candidate))
|
||||
payload := make([]byte, len(prefix)+len(want))
|
||||
copy(payload, prefix)
|
||||
copy(payload[len(prefix):], want)
|
||||
resp, err := lane.Do(payload)
|
||||
if err != nil {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
if string(resp) != "IPERFOK" {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: fmt.Errorf("X upload rejected: %s", string(resp))}
|
||||
}
|
||||
total += candidate
|
||||
}
|
||||
return calibrationProbeResult{ok: true, bytes: total, elapsed: time.Since(started)}
|
||||
}
|
||||
|
||||
func calibrateMaximum(serverAddr, token string, opts Options, download bool, fine int) int {
|
||||
if fine < 1 {
|
||||
fine = 32
|
||||
}
|
||||
name := "upload"
|
||||
if download {
|
||||
name = "download"
|
||||
}
|
||||
candidate := opts.minSize
|
||||
if candidate < 32 {
|
||||
candidate = 32
|
||||
}
|
||||
if candidate > opts.maxSize {
|
||||
candidate = opts.maxSize
|
||||
}
|
||||
good, bad := 0, 0
|
||||
|
||||
for {
|
||||
r := probeCalibrationSize(serverAddr, token, opts, download, candidate)
|
||||
if r.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, candidate, calibrationBurstCount(candidate), r.bytes, r.mbps())
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s wire=x stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
r = confirmCalibrationResult(serverAddr, token, opts, download, candidate, r, "ascend-confirm")
|
||||
if r.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d result=recovered_after_retry\n", name, candidate)
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s wire=x stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
if isCalibrationTimeout(r.err) {
|
||||
selected := good
|
||||
if selected == 0 {
|
||||
selected = opts.minSize
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, candidate, selected, r.err)
|
||||
return selected
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, candidate, calibrationBurstCount(candidate), r.bytes, r.mbps(), r.err)
|
||||
bad = candidate
|
||||
if good == 0 {
|
||||
return opts.minSize
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for bad-good > fine {
|
||||
next := good + (bad-good)/2
|
||||
if next <= good || next >= bad {
|
||||
break
|
||||
}
|
||||
r := probeCalibrationSize(serverAddr, token, opts, download, next)
|
||||
r = confirmCalibrationResult(serverAddr, token, opts, download, next, r, "refine-confirm")
|
||||
if r.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, next, calibrationBurstCount(next), r.bytes, r.mbps())
|
||||
good = next
|
||||
continue
|
||||
}
|
||||
if isCalibrationTimeout(r.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=refine chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, next, good, r.err)
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, next, calibrationBurstCount(next), r.bytes, r.mbps(), r.err)
|
||||
bad = next
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s wire=x stage=refine selected=%d failed_above=%d resolution=%d\n", name, good, bad, fine)
|
||||
return good
|
||||
}
|
||||
|
||||
func probePersistent(serverAddr, token string, opts Options) bool {
|
||||
lane := newTxnLane(serverAddr, opts.tcpBuffer, 0, minDurationX(calibrationTimeout(opts), 2500*time.Millisecond), opts.headerMask, opts.coverProfile)
|
||||
defer lane.Close()
|
||||
for i := 0; i < 8; i++ {
|
||||
resp, err := lane.Do([]byte("CPROBE " + wireToken(token)))
|
||||
if err != nil || string(resp) != "PROBEOK" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func minDurationX(a, b time.Duration) time.Duration {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Calibrate performs the X wire's pre-tunnel UP/DW fake-iperf calibration.
|
||||
// It ascends by 4x and only spends extra probes around the first failure, where
|
||||
// it resolves the highest stable boundary to the requested byte precision.
|
||||
func Calibrate(serverAddr, token string, opts Options, fine int) (upload, download int, persistent bool) {
|
||||
if opts.minSize < 32 {
|
||||
opts.minSize = 32
|
||||
}
|
||||
if opts.maxSize < opts.minSize {
|
||||
opts.maxSize = opts.minSize
|
||||
}
|
||||
if opts.maxSize > protocol.MaxChunkPayload {
|
||||
opts.maxSize = protocol.MaxChunkPayload
|
||||
}
|
||||
upload = calibrateMaximum(serverAddr, token, opts, false, fine)
|
||||
download = calibrateMaximum(serverAddr, token, opts, true, fine)
|
||||
persistent = probePersistent(serverAddr, token, opts)
|
||||
return
|
||||
}
|
||||
|
||||
type chunkResult struct {
|
||||
seq uint64
|
||||
data []byte
|
||||
@@ -471,8 +820,8 @@ func Open(serverAddr, token, targetHost string, targetPort int, opts Options) (n
|
||||
pending: make(map[uint64][]byte, opts.pollers*2),
|
||||
}
|
||||
c.ack.Store(-1)
|
||||
c.upSizer = newAdaptiveSizer("upload", opts)
|
||||
c.downSizer = newAdaptiveSizer("download", opts)
|
||||
c.upSizer = newAdaptiveSizer("upload", opts.uploadStartSize, opts.uploadMaxSize, opts)
|
||||
c.downSizer = newAdaptiveSizer("download", opts.downloadStartSize, opts.downloadMaxSize, opts)
|
||||
|
||||
c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout, opts.headerMask, opts.coverProfile)
|
||||
|
||||
@@ -589,14 +938,18 @@ func (c *chunkConn) pullWorker(lane *txnLane) {
|
||||
resp, err := lane.Do(payload)
|
||||
if err != nil {
|
||||
old, next := c.downSizer.Failure(limit)
|
||||
if next == old && next == c.opts.minSize {
|
||||
if !c.opts.adaptive || (next == old && next == c.opts.minSize) {
|
||||
consecutiveMinFailures++
|
||||
} else {
|
||||
consecutiveMinFailures = 0
|
||||
}
|
||||
if consecutiveMinFailures >= 8 {
|
||||
detail := fmt.Sprintf("download failed at minimum chunk %d", next)
|
||||
if !c.opts.adaptive {
|
||||
detail = fmt.Sprintf("download failed repeatedly at fixed calibrated chunk %d", next)
|
||||
}
|
||||
select {
|
||||
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("download failed at minimum chunk %d: %w", next, err)}:
|
||||
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("%s: %w", detail, err)}:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
return
|
||||
@@ -773,12 +1126,15 @@ func (c *chunkConn) Write(p []byte) (int, error) {
|
||||
resp, err := c.pushLane.Do(payload)
|
||||
if err != nil {
|
||||
old, next := c.upSizer.Failure(size)
|
||||
if next == old && next == c.opts.minSize {
|
||||
if !c.opts.adaptive || (next == old && next == c.opts.minSize) {
|
||||
consecutiveMinFailures++
|
||||
} else {
|
||||
consecutiveMinFailures = 0
|
||||
}
|
||||
if consecutiveMinFailures >= 8 {
|
||||
if !c.opts.adaptive {
|
||||
return total, fmt.Errorf("upload failed repeatedly at fixed calibrated chunk %d: %w", next, err)
|
||||
}
|
||||
return total, fmt.Errorf("upload failed at minimum chunk %d: %w", next, err)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package xorchunk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/protocol"
|
||||
)
|
||||
|
||||
func TestAdaptiveSizerShrinkBudget(t *testing.T) {
|
||||
opts := Options{
|
||||
startSize: 1024,
|
||||
minSize: 32,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 3,
|
||||
}
|
||||
s := newAdaptiveSizer("test", opts.startSize, opts.maxSize, opts)
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, next := s.Failure(1024)
|
||||
if next != 1024 {
|
||||
t.Fatalf("failure %d reduced early to %d", i, next)
|
||||
}
|
||||
}
|
||||
s.Success(1024)
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, next := s.Failure(1024)
|
||||
if next != 1024 {
|
||||
t.Fatalf("post-success failure %d reduced early to %d", i, next)
|
||||
}
|
||||
}
|
||||
_, next := s.Failure(1024)
|
||||
if next != 512 {
|
||||
t.Fatalf("third consecutive failure reduced to %d, want 512", next)
|
||||
}
|
||||
}
|
||||
|
||||
func startCalibrationTestServer(t *testing.T, threshold int) (string, func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
for {
|
||||
id, _, payload, err := protocol.ReadRequestFrameProfile(c, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
|
||||
parts := bytes.SplitN(payload, []byte(" "), 4)
|
||||
if len(parts) != 4 {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR bad upload"))
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(string(parts[2]))
|
||||
if size > threshold || len(parts[3]) != size || !bytes.Equal(parts[3], calibrationPattern(size)) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR too large"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("IPERFOK"))
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFDW ")) {
|
||||
parts := strings.Fields(string(payload))
|
||||
if len(parts) != 3 {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR bad download"))
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(parts[2])
|
||||
if size > threshold {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR too large"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, calibrationPattern(size))
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(payload, []byte("CPROBE ")) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("PROBEOK"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR unsupported"))
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
close(stop)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestXCalibrationRefinesUploadAndDownloadTo32Bytes(t *testing.T) {
|
||||
const threshold = 731237
|
||||
addr, closeServer := startCalibrationTestServer(t, threshold)
|
||||
defer closeServer()
|
||||
opts := Options{
|
||||
startSize: 1024 * 1024,
|
||||
minSize: 32,
|
||||
maxSize: 1024 * 1024,
|
||||
txnTimeout: time.Second,
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
download bool
|
||||
}{
|
||||
{"upload", false},
|
||||
{"download", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := calibrateMaximum(addr, "", opts, tc.download, 32)
|
||||
if got > threshold {
|
||||
t.Fatalf("calibrated size=%d exceeds threshold=%d", got, threshold)
|
||||
}
|
||||
if threshold-got > 32 {
|
||||
t.Fatalf("calibrated size=%d is more than 32 bytes below threshold=%d", got, threshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXCalibrationRetriesTransientFailure(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
const (
|
||||
threshold = 4096
|
||||
transientChunk = 2048
|
||||
)
|
||||
var transient atomic.Int32
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
for {
|
||||
id, _, payload, err := protocol.ReadRequestFrameProfile(c, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR unsupported"))
|
||||
continue
|
||||
}
|
||||
parts := bytes.SplitN(payload, []byte(" "), 4)
|
||||
if len(parts) != 4 {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR bad upload"))
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(string(parts[2]))
|
||||
if size == transientChunk && transient.CompareAndSwap(0, 1) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR transient"))
|
||||
return
|
||||
}
|
||||
if size > threshold || len(parts[3]) != size || !bytes.Equal(parts[3], calibrationPattern(size)) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR too large"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("IPERFOK"))
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
opts := Options{
|
||||
startSize: 16 * 1024,
|
||||
minSize: 32,
|
||||
maxSize: 16 * 1024,
|
||||
txnTimeout: time.Second,
|
||||
}
|
||||
got := calibrateMaximum(ln.Addr().String(), "", opts, false, 32)
|
||||
if transient.Load() != 1 {
|
||||
t.Fatalf("transient failure count=%d, want 1", transient.Load())
|
||||
}
|
||||
if got < transientChunk {
|
||||
t.Fatalf("X calibration collapsed below transiently failed %d-byte probe: got %d", transientChunk, got)
|
||||
}
|
||||
if got > threshold || threshold-got > 32 {
|
||||
t.Fatalf("X calibrated size=%d, want within 32 bytes below threshold=%d", got, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCalibratedChunksLocksIndependentXSizes(t *testing.T) {
|
||||
opts := Options{minSize: 32, maxSize: 1024 * 1024, startSize: 1024 * 1024, adaptive: true}
|
||||
opts = opts.WithCalibratedChunks(900000, 500000)
|
||||
if opts.adaptive {
|
||||
t.Fatal("X runtime adaptation remained enabled after calibration")
|
||||
}
|
||||
|
||||
up := newAdaptiveSizer("upload", opts.uploadStartSize, opts.uploadMaxSize, opts)
|
||||
down := newAdaptiveSizer("download", opts.downloadStartSize, opts.downloadMaxSize, opts)
|
||||
if up.Current() != 900000 || up.max != 900000 {
|
||||
t.Fatalf("upload current/max=%d/%d, want 900000", up.Current(), up.max)
|
||||
}
|
||||
if down.Current() != 500000 || down.max != 500000 {
|
||||
t.Fatalf("download current/max=%d/%d, want 500000", down.Current(), down.max)
|
||||
}
|
||||
|
||||
// The calibrated sizes are immutable during the X session. Neither a
|
||||
// transport failure nor a long run of successes may move them.
|
||||
if old, next := up.Failure(900000); old != 900000 || next != 900000 {
|
||||
t.Fatalf("upload failure changed calibrated chunk: %d -> %d", old, next)
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
up.Success(900000)
|
||||
down.Success(500000)
|
||||
}
|
||||
if up.Current() != 900000 {
|
||||
t.Fatalf("upload success changed calibrated chunk to %d", up.Current())
|
||||
}
|
||||
if old, next := down.Failure(500000); old != 500000 || next != 500000 {
|
||||
t.Fatalf("download failure changed calibrated chunk: %d -> %d", old, next)
|
||||
}
|
||||
if down.Current() != 500000 {
|
||||
t.Fatalf("download calibrated chunk changed to %d", down.Current())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) BIN="$ROOT/bin/dragontcp-hybrid-server-linux-amd64" ;;
|
||||
aarch64|arm64) BIN="$ROOT/bin/dragontcp-hybrid-server-linux-arm64" ;;
|
||||
*)
|
||||
echo "Unsupported Linux architecture: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "DragonTCP server binary not found: $BIN" >&2
|
||||
echo "Build it first with: ./build_core.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USERS_FILE="${DRAGONTCP_USERS_FILE:-$ROOT/dragontcp-users.json}"
|
||||
exec "$BIN" --ssh-users "$USERS_FILE" --ssh-menu "$@"
|
||||
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Reference in New Issue
Block a user