Mult Port + TCP Calibration (SSH DEAD)

This commit is contained in:
2026-08-17 17:08:57 -03:00
parent 7ea221a99c
commit b997294607
58 changed files with 6033 additions and 497 deletions
+391 -109
View File
@@ -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 116
in-flight attempts, while `--wire-probe-delay` (default 1 second) is still the
global minimum time between new connection starts. Increasing the thread count
therefore permits slow attempts to overlap; it does not launch all candidates at
once. One thread is recommended on carriers with connection-rate filtering.
The winner is cached for the lifetime of the client process and is used
unchanged for every connection and record. Padding contents may be fresh random
bytes, but the selected length and profile never change until restart; there is
no per-packet profile mutation.
`--wire auto` searches 1,153 distributed B/BP/X profiles. Pinning `--wire b`
searches 544 B profiles, `--wire bp` searches 257 BP profiles, and `--wire x`
searches 352 X profiles. B and BP try a clear-payload candidate first, followed
immediately by a legacy direct fallback. A successful selection is logged as:
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, 116 |
| 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 165535, max chunk 321048576, min chunk 32max chunk,
batch values 1256 with `min ≤ max`, reconnect 01000000,
timeout 1120, probe delay 20030000 ms, probe threads 116.
Validation ranges: both ports 165535 with `start ≤ end`, batch values 1256 with `min ≤ max`,
reconnect 01000000, timeout 1120, probe delay 5030000 ms, probe threads 116.
### 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 200ms30s |
| `--wire-probe-threads` | `1` | Maximum concurrent real HTTP profile probes; range 116 |
| `--wire-probe-delay` | `100ms` | Spacing between header-profile probe starts; range 50ms30s |
| `--wire-probe-threads` | `1` | Maximum concurrent server-local header probes; range 116 |
| `--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 1128 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 416 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 416 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.