1613 lines
74 KiB
Markdown
1613 lines
74 KiB
Markdown
# DragonTCP Hybrid
|
||
|
||
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 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
|
||
|
||
1. [Overview](#1-overview)
|
||
2. [Security model](#2-security-model)
|
||
3. [Repository layout](#3-repository-layout)
|
||
4. [The wire protocol](#4-the-wire-protocol)
|
||
5. [The Go client](#5-the-go-client)
|
||
6. [The Go server](#6-the-go-server)
|
||
7. [The Android app](#7-the-android-app)
|
||
8. [Building](#8-building)
|
||
9. [Running](#9-running)
|
||
10. [Configuration reference](#10-configuration-reference)
|
||
11. [Tuning](#11-tuning)
|
||
12. [Troubleshooting](#12-troubleshooting)
|
||
13. [Testing](#13-testing)
|
||
14. [Licensing](#14-licensing)
|
||
|
||
---
|
||
|
||
## 1. Overview
|
||
|
||
### 1.1 The data path
|
||
|
||
```text
|
||
Android apps (any app, unmodified)
|
||
│
|
||
│ IP packets
|
||
▼
|
||
VpnService TUN interface 10.77.0.2/32, MTU 1400
|
||
│
|
||
│ userspace TCP/IP reassembly
|
||
▼
|
||
TunnelEngine (Kotlin, in-process)
|
||
│
|
||
│ HTTP CONNECT to 127.0.0.1:8080
|
||
▼
|
||
dragontcp-client (Go, child process on the phone)
|
||
│
|
||
│ DragonTCP binary records over TCP/53
|
||
▼
|
||
dragontcp-server (Go, on the host)
|
||
│
|
||
│ plain TCP
|
||
▼
|
||
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. 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
|
||
two properties:
|
||
|
||
* *Record size is negotiable at runtime.* Some networks silently drop or truncate
|
||
large writes on port 53. Because every record is framed and acknowledged, the
|
||
client can discover the largest size that survives the path, and adapt if
|
||
conditions change later.
|
||
* *The transport survives connection rotation.* Session state lives in a 16-byte
|
||
session ID, not in the TCP connection. The client can close and reopen the
|
||
underlying connection between any two records without losing the stream, which
|
||
matters on middleboxes that limit how long a port-53 connection may live or how
|
||
many requests it may carry.
|
||
|
||
**Stream position is explicit.** Both directions are addressed by absolute byte
|
||
offset rather than by message counter. That makes retries idempotent and makes
|
||
flow control a matter of reporting a number.
|
||
|
||
---
|
||
|
||
## 2. Security model
|
||
|
||
**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
|
||
also support a self-described **clear-payload** profile that removes the per-32-byte
|
||
SHA-256 work. Auto tries clear first and falls back to the legacy mask for older
|
||
servers or networks that reject the clear profile.
|
||
|
||
This defeats trivial pattern matching. It does not defeat anyone who can read the
|
||
traffic:
|
||
|
||
* The mask is derived from the **session ID, which is transmitted in cleartext in
|
||
every request header.** Anyone who sees the header can regenerate the keystream
|
||
and recover the plaintext. This is obfuscation, not confidentiality.
|
||
* Session, sequence, and length fields remain clear. The mode/status byte may
|
||
use a startup-selected header mask, but this is traffic shaping rather than
|
||
cryptographic protection.
|
||
* `StatusError` bodies are sent **unmasked**, as plain text.
|
||
* There is no integrity check, so payloads can be tampered with undetected.
|
||
|
||
The optional `--token` is a shared secret compared in constant time. It gates who
|
||
may open sessions. It is not a key and does not affect the mask.
|
||
|
||
**Consequence:** rely on TLS end to end. HTTPS through DragonTCP is protected by
|
||
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.
|
||
|
||
---
|
||
|
||
## 3. Repository layout
|
||
|
||
```text
|
||
core/
|
||
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/
|
||
protocol.go record format and the masking keystream
|
||
protocol_test.go
|
||
internal/protocol/
|
||
protocol.go TCP tuning, relay loops, legacy UP/OK framing
|
||
xor_*.go word-at-a-time XOR helpers (legacy path)
|
||
|
||
android/
|
||
AndroidManifest.xml package com.dragontcp.client, minSdk 29
|
||
src/com/dragontcp/client/ Java: UI, VpnService, log screen
|
||
MainActivity.java settings screen
|
||
DragonService.java VpnService: spawns the core, owns the TUN
|
||
LogActivity.java live log viewer
|
||
AppLog.java in-memory log buffer
|
||
src/tech/xvanturing/freeproxy/ Kotlin: userspace TCP/IP stack (Apache 2.0, §14)
|
||
vpn/TunnelEngine.kt TUN read/write loops, session tables
|
||
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 + SOCKS5/UDP client
|
||
res/ icon and theme
|
||
assets/ license texts shipped inside the APK
|
||
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.cmd / build_all.sh core + APK in one step (Windows / Unix)
|
||
bin/ generated server and client binaries
|
||
licenses/ full Apache 2.0 text
|
||
SHA256SUMS generated digests after Unix core build
|
||
THIRD_PARTY_NOTICES.md upstream attribution (a license condition)
|
||
```
|
||
|
||
There is exactly one copy of every source file. A second `core/`, or a nested
|
||
`android/android/`, is stale — `build_apk.ps1` guards against this by locating
|
||
the repo root as the nearest ancestor holding **both** `core/go.mod` and
|
||
`build_core.ps1`.
|
||
|
||
`core/internal/protocol` still contains the legacy `UP`/`OK` text framing and a
|
||
fixed `0xAD` XOR. That package is retained because it also holds the TCP tuning
|
||
helpers and relay loops the current transport uses. The legacy framing itself is
|
||
unreachable: `dragontcp-client` refuses to start unless `--transport chunk`.
|
||
|
||
---
|
||
|
||
## 4. The wire protocol
|
||
|
||
Implemented in `core/internal/wire/protocol.go`.
|
||
|
||
### 4.1 Record framing
|
||
|
||
Client to server — a **request**:
|
||
|
||
```text
|
||
offset size field
|
||
0 1 mode
|
||
1 16 session ID
|
||
17 8 sequence (big-endian uint64)
|
||
25 4 payload length (big-endian uint32)
|
||
29 n payload (masked)
|
||
```
|
||
|
||
Server to client — a **response**:
|
||
|
||
```text
|
||
offset size field
|
||
0 1 status
|
||
1 4 body length (big-endian uint32)
|
||
5 n body (masked, with exceptions in §4.3)
|
||
```
|
||
|
||
Headers are **29 bytes** and **5 bytes**. The wire layer rejects payloads above
|
||
2 MiB (`MaxPayload`); the transport never exceeds 1 MiB.
|
||
|
||
A single request may be answered by **several** responses — see `ModeDownload`.
|
||
|
||
### 4.2 Modes and statuses
|
||
|
||
| Mode | Value | Meaning |
|
||
|---|---|---|
|
||
| `ModeProbe` | 0 | Path measurement; no session required |
|
||
| `ModeOpen` | 1 | Create a session and connect to the target |
|
||
| `ModeUpload` | 2 | Push payload bytes toward the target |
|
||
| `ModeDownload` | 3 | Request buffered bytes coming back |
|
||
| `ModeClose` | 4 | Tear the session down |
|
||
|
||
| Status | Value | Meaning |
|
||
|---|---|---|
|
||
| `StatusOK` | 0 | Success; body may carry a result |
|
||
| `StatusError` | 1 | Failure; body is a plaintext message |
|
||
| `StatusData` | 2 | Body is stream data |
|
||
| `StatusWait` | 3 | Nothing available yet; ask again |
|
||
| `StatusEOF` | 4 | Target closed the stream |
|
||
|
||
### 4.3 Payload encoding
|
||
|
||
```text
|
||
seed[0:16] = session ID
|
||
seed[16] = mode
|
||
seed[17:25] = sequence (big-endian)
|
||
seed[25] = 1 for responses, 0 for requests
|
||
seed[26:30] = block counter (big-endian, increments every 32 bytes)
|
||
|
||
keystream_block = SHA256(seed)
|
||
payload ^= keystream
|
||
```
|
||
|
||
Masking is its own inverse, so one function both encodes and decodes. Because the
|
||
sequence field is a **byte offset** (§4.5), consecutive records never reuse a
|
||
keystream position, and re-sending the same offset reproduces the same bytes —
|
||
which is what makes idempotent retries safe.
|
||
|
||
When the cover preface carries the clear-payload flag, request and data bodies are
|
||
sent without that transform. Headers, session semantics, retry offsets, and all
|
||
payload layouts remain identical. Old clients remain masked and are accepted by
|
||
the new server. An old server rejects the new flag, so the new client's next
|
||
startup candidate is the corresponding legacy direct profile.
|
||
|
||
Not everything is masked. `WriteResponse` sends the body as-is and is used for
|
||
empty `StatusOK`, `StatusWait`, `StatusEOF`, and every `StatusError`.
|
||
`WriteMaskedResponse` is used for `StatusData` and for the `OPEN` result. On the
|
||
client, `DecodeMaskedResponse` deliberately skips decoding when the status is
|
||
`StatusError`, so both sides agree.
|
||
|
||
### 4.4 Payload layouts
|
||
|
||
**PROBE** request payload:
|
||
|
||
```text
|
||
offset size field
|
||
0 4 magic "DTP2"
|
||
4 1 probe kind
|
||
5 2 token length (big-endian uint16)
|
||
7 4 value (big-endian uint32)
|
||
11 t token
|
||
11+t … filler, byte i = (i*31 + 17) & 0xFF
|
||
```
|
||
|
||
| Probe kind | Value | Server behaviour |
|
||
|---|---|---|
|
||
| `ProbeUpload` | 1 | Replies `OK` if the whole record arrived and is within `--chunk-max`. The *filler* is what is being measured. |
|
||
| `ProbeDownload` | 2 | Replies `StatusData` with exactly `value` bytes of the same generated pattern. |
|
||
| `ProbeKeepalive` | 3 | Replies `OK`. Used to test whether one connection may carry several requests. |
|
||
| `ProbeBatch` | 4 | Replies with up to 16 back-to-back 32-byte `StatusData` records. |
|
||
|
||
The client verifies download probes byte for byte, so a middlebox that truncates
|
||
or rewrites the response fails the probe instead of silently corrupting data.
|
||
|
||
**OPEN** request payload:
|
||
|
||
```text
|
||
offset size field
|
||
0 2 token length
|
||
2 2 host length
|
||
4 2 target port
|
||
6 t token
|
||
6+t h target host (name or literal IP)
|
||
```
|
||
|
||
The response is `StatusOK` with a masked 4-byte body: the server's `--chunk-max`.
|
||
The client clamps its own maximum to that value. Repeating `OPEN` for an existing
|
||
session is idempotent and returns the same limit.
|
||
|
||
**UPLOAD** — the sequence field is the byte offset in the upload stream and the
|
||
payload is the data. The response is an empty `StatusOK` acknowledgement.
|
||
|
||
**DOWNLOAD** — the sequence field is the byte offset the client wants next. The
|
||
14-byte payload is:
|
||
|
||
```text
|
||
offset size field
|
||
0 8 ack offset — everything below this has been consumed
|
||
8 4 maximum bytes per record
|
||
12 2 how many records the client will accept in this batch
|
||
```
|
||
|
||
The server answers with up to `count` `StatusData` records, each masked with the
|
||
running offset, terminated early by one `StatusWait` or `StatusEOF`.
|
||
|
||
**CLOSE** — no payload; the server drops the session and replies `StatusOK`.
|
||
|
||
### 4.5 Stream offsets and flow control
|
||
|
||
Three offsets drive the whole protocol:
|
||
|
||
| Offset | Held by | Meaning |
|
||
|---|---|---|
|
||
| upload sequence | client | bytes already sent toward the target |
|
||
| download sequence | client | next byte the client wants |
|
||
| ack offset | client, reported to server | next byte the **application** has not yet read |
|
||
|
||
The ack offset trails the download offset. It advances only when the consuming
|
||
application actually reads bytes out of the client's buffer. The server uses it to
|
||
discard acknowledged data; when the client stops reading, the server's buffer
|
||
fills, its reader goroutine blocks, and TCP backpressure propagates to the origin
|
||
server. There is no separate window mechanism — the ack number is the window.
|
||
|
||
### 4.6 A session end to end
|
||
|
||
```text
|
||
client server
|
||
│── PROBE upload (binary search) ─────────▶│
|
||
│◀─ OK / error ────────────────────────────────│
|
||
│── PROBE download (binary search) ─────────▶│
|
||
│◀─ DATA(pattern) ─────────────────────────────│
|
||
│── PROBE keepalive ×8 on one connection ────▶│
|
||
│◀─ OK ×8 ─────────────────────────────────────│
|
||
│
|
||
│── OPEN sid=… host=example.com port=443 ─────▶│ dial example.com:443
|
||
│◀─ OK body=chunk_max ─────────────────────────│
|
||
│
|
||
│── UPLOAD sid seq=0 payload=ClientHello ─────▶│ write() to target
|
||
│◀─ OK ────────────────────────────────────────│
|
||
│── DOWNLOAD sid seq=0 ack=0 limit=1400 cnt=4 ▶│
|
||
│◀─ DATA(1400) DATA(1400) DATA(900) WAIT ──────│
|
||
│── UPLOAD sid seq=517 payload=… ─────────────▶│
|
||
│◀─ OK ────────────────────────────────────────│
|
||
│── DOWNLOAD sid seq=3700 ack=3700 … ─────────▶│
|
||
│◀─ EOF ───────────────────────────────────────│
|
||
│── CLOSE sid ────────────────────────────────▶│
|
||
│◀─ OK ────────────────────────────────────────│
|
||
```
|
||
|
||
### 4.7 BP transport compatibility
|
||
|
||
Binary connections are auto-detected as either native Dragon B or the
|
||
observable BP protocol used by `bhttp_remote_test.py`. BP support uses the
|
||
same 29-byte request header, five-byte response header, and SHA-256 counter mask,
|
||
but maps modes as `0=probe`, `1=upload/register`, `2=single download`, `3=batch
|
||
download`, and `4=ACK`. It implements `BHP1` version-1 probe integrity, probe
|
||
batching, empty mode-1 session registration, upload acknowledgements, mode-2's
|
||
header-only size hint, the six-byte batch request, ACK, expiry, and unknown-session
|
||
errors. Dragon peers can additionally negotiate the clear-payload encoding via
|
||
the cover preface; reference clients continue through the original direct masked
|
||
encoding unchanged.
|
||
|
||
The available reference client does not expose a destination-selection or
|
||
authentication exchange. Its observable registration/upload/download/ACK
|
||
behavior remains accepted unchanged. Dragon's BP client adds a `DOP1` upload
|
||
extension after registration to carry the normal token, target host, and port;
|
||
that extension gives the Android app a complete bidirectional stream without
|
||
changing the reference client's frames. Native Dragon B remains available and
|
||
is not replaced.
|
||
|
||
---
|
||
|
||
## 5. The Go client
|
||
|
||
Source: `core/cmd/dragontcp-client/`.
|
||
|
||
### 5.1 Local proxy front end
|
||
|
||
`main.go` listens on `127.0.0.1:8080` and speaks ordinary HTTP proxy protocol.
|
||
|
||
**`CONNECT host:port`** opens a tunnel, replies `200 Connection Established`, and
|
||
relays bytes both ways. This is the path used for HTTPS and, on Android, for
|
||
everything.
|
||
|
||
**Plain `GET http://…`** is rewritten to origin form: the request line is
|
||
rebuilt, `Connection`, `Proxy-Connection`, and `Proxy-Authorization` are stripped,
|
||
a `Host` header is synthesised if absent, and `Connection: close` is appended.
|
||
|
||
Request headers are read until `\r\n\r\n`, with a 128 KiB ceiling. The local
|
||
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`.
|
||
|
||
### 5.2 `chunkConn`
|
||
|
||
`openChunkTunnel` returns a `chunkConn` implementing `net.Conn`, so the proxy
|
||
front end never knows it is talking to a record protocol. It holds:
|
||
|
||
* `upOffset` — bytes sent, used as the upload sequence,
|
||
* `downloadOffset` — next byte to request,
|
||
* `consumedOffset` — next unread byte, sent as `ack`,
|
||
* `readBuf` — received but not yet delivered to the reader,
|
||
* two `requestLane`s of its own, one for uploads and one for downloads, so a
|
||
blocking download poll never delays an upload.
|
||
|
||
`Write` slices the caller's buffer into records of the current upload size, each
|
||
acknowledged before the next is sent. `Read` refills `readBuf` via
|
||
`fillReadBuffer`, which issues batched download requests. `Close` sends
|
||
`ModeClose` over the upload lane, then closes both lanes.
|
||
|
||
### 5.3 Request lanes and connection reuse
|
||
|
||
A `requestLane` owns at most one physical TCP connection and serialises requests
|
||
onto it under a mutex. Each tunnel has two of them (§5.7). Any I/O error discards
|
||
the connection; the next request redials. Sockets get `TCP_NODELAY`, 30-second
|
||
keepalives, and optionally explicit buffer sizes via `--tcp-buffer`.
|
||
|
||
| `--chunk-reconnect-every` | Behaviour |
|
||
|---|---|
|
||
| `0` | Persistent — one connection for the life of the lane |
|
||
| `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 header scan and path calibration
|
||
|
||
Before normal traffic, the client selects one fixed wire/header profile. Normal
|
||
Android discovery always keeps SHA-256 payload masking enabled.
|
||
|
||
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.
|
||
|
||
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
|
||
00 08 10 18 20 28 30 38 ... E8 F0 F8
|
||
```
|
||
|
||
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.
|
||
|
||
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
|
||
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 ...
|
||
```
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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
|
||
|
||
`adaptiveSizer` keeps one instance per direction plus two landmarks: `good`, the
|
||
largest size known to work, and `bad`, the smallest known to fail.
|
||
|
||
An I/O timeout is different from a size failure. It is a hard tunnel boundary:
|
||
the timed-out socket is closed and the error is returned immediately. The client
|
||
does not replay that request on the same logical session and does not walk every
|
||
smaller record or batch size. The application's next connection creates a new
|
||
tunnel. Other non-timeout transport failures can still use the adaptation below.
|
||
|
||
**On failure** at the current size:
|
||
|
||
* record `bad = min(bad, attempted)`,
|
||
* drop to `good` if a smaller known-good size exists, otherwise halve,
|
||
* clamp to `--chunk-min` and force a strict decrease.
|
||
|
||
**On success** at the current size, after `--chunk-grow-after` consecutive
|
||
successes (default 16):
|
||
|
||
* if `bad` is known and more than one step above, move **halfway toward it** — a
|
||
binary search upward rather than a blind jump,
|
||
* otherwise clear the stale `bad` landmark and grow by `max(current/4, 32)`,
|
||
* clamp to the maximum.
|
||
|
||
When `bad - good ≤ 64` the required success count is multiplied by eight: once the
|
||
working size is tightly bracketed the controller stops probing the ceiling and
|
||
settles.
|
||
|
||
```text
|
||
adaptive upload chunk: 32768 -> 16384 after transport failure
|
||
adaptive download chunk: 1400 -> 700 after transport failure
|
||
adaptive upload chunk: 700 -> 1050 after stable success
|
||
```
|
||
|
||
### 5.6 Download batching
|
||
|
||
One download request can return many records. **This is a transport setting, not
|
||
a thread count** — nothing here creates threads. It controls how many records the
|
||
server may stream back in reply to a single request.
|
||
|
||
Two bounds define it:
|
||
|
||
| Flag | Default | Meaning |
|
||
|---|---|---|
|
||
| `--chunk-concurrency` | `1` | Maximum records per request (1–256) |
|
||
| `--chunk-concurrency-min` | `1` | Minimum records per request (1–256) |
|
||
|
||
Batching matters most on restricted paths. If the safe record size is 32 bytes,
|
||
one TCP/53 request can still return many 32-byte records instead of needing a
|
||
fresh request for every 32 useful bytes.
|
||
|
||
**Two modes**, decided entirely by whether the bounds are equal:
|
||
|
||
* **Pinned** (`min == max`). The depth never changes: it does not grow, it does
|
||
not halve on failure, and it is not reduced by the 1 MiB batch cap. Use this
|
||
when a path only works at one specific number of records — the adaptive
|
||
controller stays completely out of the way.
|
||
* **Adaptive** (`min < max`). The depth starts at the ceiling, **halves** on
|
||
transport failure but never below `min`, and **grows by one** after successful
|
||
data responses until it reaches the ceiling again.
|
||
|
||
`1..1` is the pinned case at one record, which is the default.
|
||
|
||
```text
|
||
adaptive download batch: 64 -> 32 after transport failure
|
||
```
|
||
|
||
Outside pinned mode, each batch is additionally capped to carry roughly 1 MiB of
|
||
useful data:
|
||
|
||
```go
|
||
count = min(pipeline, maxPipeline, (1 MiB) / chunkSize)
|
||
count = max(count, minPipeline) // the floor always wins
|
||
```
|
||
|
||
The server independently clamps `count` to 256 and `limit` to its own
|
||
`--chunk-max`, so a client can never demand more than the server allows.
|
||
|
||
### 5.7 Connection model
|
||
|
||
Each proxied socket gets its own tunnel, and each tunnel dials **two** TCP
|
||
connections to the server: one upload lane and one download lane. `OPEN` and
|
||
`CLOSE` ride the upload lane rather than dialling their own connections.
|
||
|
||
So a device browsing normally holds roughly `2 × active flows` connections to
|
||
port 53, plus churn as flows come and go. That is the transport behaving as a
|
||
proxy, not as a single multiplexed link.
|
||
|
||
**Why it is not one connection.** A response header is only `status + length` —
|
||
it carries no session or request ID. Responses can therefore only be matched to
|
||
requests by **arrival order**, which means a connection must finish one full
|
||
exchange before another session may use it. Since a download is a long poll that
|
||
can block for `--chunk-poll-wait`, sharing one connection across sessions lets
|
||
idle pollers starve real traffic. Per-tunnel lanes are a requirement of the
|
||
current wire format, not an oversight.
|
||
|
||
Making the client hold a single connection would require adding a session ID to
|
||
the response header, demultiplexing responses asynchronously on the client, and
|
||
handling requests concurrently per connection on the server — a wire-format
|
||
change affecting both ends.
|
||
|
||
`--chunk-pollers` is accepted for compatibility and validated to 1–128, but the
|
||
transport uses one download worker per tunnel and never reads it.
|
||
|
||
### 5.8 Failure escalation
|
||
|
||
On a download failure the client escalates in a fixed order:
|
||
|
||
1. **Shrink the batch** — halve it, never below the floor.
|
||
2. **Shrink the record size** — only once the batch is already at its floor.
|
||
3. **Give up** — after eight consecutive failures at the minimum record size,
|
||
return an error rather than spinning forever.
|
||
|
||
Uploads have no batch dimension, so they go straight to steps 2 and 3. A 30 ms
|
||
pause separates retries. `StatusWait` responses are followed by a
|
||
`--chunk-poll-delay` pause (default 2 ms) instead of being treated as failures.
|
||
|
||
---
|
||
|
||
## 6. The Go server
|
||
|
||
Source: `core/cmd/dragontcp-server/`.
|
||
|
||
### 6.1 Connection handling
|
||
|
||
The server listens on `0.0.0.0:53` by default, bounded by `--max-connections`
|
||
through a slot channel. Each connection runs a loop: read one request with an
|
||
idle deadline, dispatch it, repeat. The deadline is refreshed halfway through its
|
||
window rather than issuing a system call for every record. Because session state
|
||
is keyed by session ID rather than by connection, requests for one logical stream
|
||
may arrive over many connections in whatever pattern the client chooses.
|
||
|
||
### 6.2 Session state and buffering
|
||
|
||
Each `OPEN` creates a `streamSession` holding the real TCP connection to the
|
||
target plus a download buffer:
|
||
|
||
* `buf` — bytes received from the target but not yet acknowledged,
|
||
* `base` — the absolute stream offset of `buf[0]`,
|
||
* a dedicated goroutine reading the target in 64 KiB chunks.
|
||
|
||
That goroutine **blocks when the buffer is full**, which is the entire flow
|
||
control story described in §4.5. Buffer size is `--chunk-buffered × 65536`,
|
||
clamped to 1 MiB…64 MiB (default 32 → 2 MiB per session). The same byte limit is
|
||
enforced for X; it is not multiplied by the negotiated chunk size.
|
||
|
||
Acknowledgement drops bytes off the front and advances `base`. The buffer is
|
||
compacted when its capacity exceeds four times its length and is over 1 MiB, so
|
||
long-lived sessions do not hold onto peak allocations.
|
||
|
||
Sessions are stored in a map keyed by the raw 16 session bytes. Creating a session
|
||
that already exists closes the newcomer and keeps the original.
|
||
|
||
### 6.3 Serving downloads
|
||
|
||
`readAt(offset, limit, wait)` requires `offset` to lie within
|
||
`[base, base+len(buf)]`. An offset below `base` is an error — those bytes were
|
||
acknowledged and discarded. An offset beyond the buffered end is also an error.
|
||
|
||
Two behaviours matter:
|
||
|
||
* **Long poll.** The *first* record of a batch waits up to `--chunk-poll-wait`
|
||
(default 200 ms) for data to arrive. Later records in the same batch do not
|
||
wait: the batch drains what is buffered and then returns `StatusWait`. This
|
||
stops batches stalling on partially-filled pipelines.
|
||
* **Coalescing.** If fewer than `limit` bytes are available, the server waits up
|
||
to 2 ms more for the target to produce more. Without this, a 1-byte read from
|
||
the origin would become a permanent 1-byte tunnel record and per-record overhead
|
||
would dominate.
|
||
|
||
### 6.4 Serving uploads
|
||
|
||
Uploads must arrive in exact order: the offset must equal the session's
|
||
`expectedUp`. Two cases are special:
|
||
|
||
* an offset entirely **below** `expectedUp` is an idempotent retry after a lost
|
||
acknowledgement and silently succeeds,
|
||
* a partially overlapping retry is rejected as an error.
|
||
|
||
This is what makes it safe for the client to resend a record whose response was
|
||
lost when a connection died mid-request.
|
||
|
||
### 6.5 Target resolution and address filtering
|
||
|
||
`dialTarget` resolves through a bounded DNS cache (`--dns-cache-ttl`, default
|
||
30 s; `--dns-cache-size`, default 4096). When the cache is full it resets
|
||
wholesale rather than evicting entry by entry — cheap, and adequate for a hot
|
||
cache. Literal IPs bypass resolution.
|
||
|
||
Each candidate address is checked before dialling. Rejected by default:
|
||
unspecified, multicast, non-global-unicast, private, loopback, link-local, and
|
||
these special-use prefixes:
|
||
|
||
```text
|
||
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24
|
||
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 240.0.0.0/4
|
||
2001:db8::/32
|
||
```
|
||
|
||
`--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.
|
||
|
||
### 6.6 Lifecycle and diagnostics
|
||
|
||
A sweep every 30 s closes sessions idle longer than `--chunk-session-timeout`
|
||
(default 2 minutes), and any session already marked closed.
|
||
|
||
`--debug` logs accepts, session opens, and errors to stderr.
|
||
`--debug-stats-interval` prints counters: bytes up and down, push records, pull
|
||
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
|
||
|
||
Package `com.dragontcp.client`, `minSdk 29`, `targetSdk 29`, arm64 only.
|
||
|
||
### 7.1 Components
|
||
|
||
| Component | Language | Role |
|
||
|---|---|---|
|
||
| `MainActivity` | Java | Settings screen; validates and persists configuration |
|
||
| `DragonService` | Java | `VpnService`: spawns the Go core, owns the TUN, runs the foreground notification |
|
||
| `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 | DNS/direct-mode UDP handling and SOCKS5 UDP in SSH mode |
|
||
| `net/*` | Kotlin | Header parsing, checksums, packet construction, DNS parsing |
|
||
| `ProxyClient` | Kotlin | HTTP CONNECT or SOCKS5 CONNECT/UDP ASSOCIATE against the local Go core |
|
||
|
||
### 7.2 Process model
|
||
|
||
The APK ships the Go client at `lib/arm64-v8a/libdragontcp_client.so`. Despite the
|
||
name it is not a shared library — it is a statically linked Go executable. The
|
||
`lib*.so` naming plus `extractNativeLibs="true"` makes Android unpack it into
|
||
`nativeLibraryDir` with the executable bit set, which is the standard way to ship
|
||
a helper binary in an APK.
|
||
|
||
`DragonService` launches it with `ProcessBuilder`, merges stderr into stdout, and
|
||
reads the output on a background thread. Only interesting lines reach the UI log:
|
||
those beginning with `wire`, `adaptive `, or `path probe:`, and anything
|
||
containing `error` or `failed`. A watchdog thread waits on the process; if the
|
||
core exits while the tunnel is supposed to be up, the whole VPN is torn down.
|
||
|
||
### 7.3 Startup and shutdown
|
||
|
||
Startup:
|
||
|
||
1. `MainActivity` validates the form and saves it to `SharedPreferences`.
|
||
2. `VpnService.prepare()` shows the system consent dialog if needed.
|
||
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 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`.
|
||
|
||
A service-side guard makes a duplicate CONNECT intent a no-op, so a double tap
|
||
cannot tear down a healthy tunnel and restart it.
|
||
|
||
Shutdown runs in a fixed order: stop the engine, close the TUN descriptor, then
|
||
`destroy()` the core process, waiting 1200 ms before `destroyForcibly()` and a
|
||
further 800 ms. `onRevoke()` (permission withdrawn by the system) and `onDestroy()`
|
||
both route through the same path.
|
||
|
||
### 7.4 The TUN interface
|
||
|
||
```java
|
||
setSession("DragonTCP Lite")
|
||
setMtu(1400)
|
||
addAddress("10.77.0.2", 32) addRoute("0.0.0.0", 0)
|
||
addAddress("fd77:6472:6167:6f6e::2", 128) addRoute("::", 0)
|
||
addDnsServer("1.1.1.1")
|
||
addDisallowedApplication(<self>)
|
||
setBlocking(true) setMetered(false)
|
||
```
|
||
|
||
Two decisions matter:
|
||
|
||
* **IPv6 is captured, then dropped.** The userspace stack is IPv4-only. Routing
|
||
`::/0` into the tunnel and discarding it prevents apps from bypassing the tunnel
|
||
over IPv6. It is a blackhole by design.
|
||
* **The app excludes itself** from the VPN, and every upstream socket is
|
||
additionally passed through `VpnService.protect()`. Both are needed so the Go
|
||
core's connection to the server is not routed back into the TUN it serves.
|
||
|
||
### 7.5 Packet dispatch
|
||
|
||
`TunnelEngine` runs one reader thread and one writer thread on the TUN file
|
||
descriptor, plus a cached thread pool exposed to coroutines for per-session
|
||
blocking I/O.
|
||
|
||
The reader parses IPv4 only. `Ipv4Header.parse` rejects anything that is not
|
||
version 4, has an inconsistent length, or is a **fragment** (MF set or a non-zero
|
||
fragment offset) — the stack does no reassembly, and since the MTU is chosen
|
||
locally, normal traffic never fragments. Non-IPv4, ICMP, and malformed packets are
|
||
dropped silently.
|
||
|
||
* **TCP** goes to a `TcpSession` keyed by the 4-tuple. New flows may only be
|
||
created by a SYN; anything else receives an RST so apps fail fast instead of
|
||
hanging. `putIfAbsent` prevents a retransmitted SYN from creating two sessions.
|
||
* **UDP** goes to a `UdpSession`, created on first packet.
|
||
|
||
Limits: 512 concurrent TCP sessions, 256 UDP. The TUN write queue holds 1024
|
||
packets and **drops** on overflow rather than blocking session threads — if the
|
||
kernel side cannot keep up, dropping is the correct behaviour for a link layer.
|
||
Housekeeping every 5 s expires idle sessions: TCP 300 s, DNS 20 s, other UDP 120 s.
|
||
|
||
### 7.6 The userspace TCP endpoint
|
||
|
||
`TcpSession` acts as the *server* toward the phone's own kernel: it answers SYN
|
||
with SYN-ACK, acknowledges data, and sends FIN or RST. The real traffic travels
|
||
through a socket to the local proxy.
|
||
|
||
The key simplification: packets written to the TUN go to the local kernel over a
|
||
lossless path, so **no congestion control is required**. Respecting the peer's
|
||
advertised receive window is sufficient; retransmission exists only as a backstop.
|
||
|
||
| Constant | Value |
|
||
|---|---|
|
||
| MSS | `MTU − 40`, clamped to 536…1460 (1360 at MTU 1400) |
|
||
| Receive window | 65535 |
|
||
| Max in flight | 65535 |
|
||
| Upstream queue | 64 chunks |
|
||
| Retransmit timeout | 400 ms |
|
||
| Window poll interval | 100 ms |
|
||
|
||
**Connection setup.** On SYN the session records the peer's sequence and window,
|
||
then asynchronously opens the tunnel. If that fails it sends an RST immediately so
|
||
the app sees "connection refused" instead of waiting for a timeout. On success it
|
||
sends SYN-ACK carrying an MSS option, so the kernel never hands down a segment
|
||
larger than the tunnel MTU. The initial sequence number is random.
|
||
|
||
**Inbound data.** Only in-order segments are accepted (`sequence == receiveNext`);
|
||
anything else is answered with a bare ACK. Accepted payloads are pushed to a
|
||
bounded channel. If that channel is full, `receiveNext` is *not* advanced and the
|
||
advertised window shrinks — eventually to zero — which pauses the application.
|
||
When the upstream pump drains a chunk and the window crosses back above one MSS,
|
||
a single window-update ACK is sent, avoiding a redundant ACK per chunk.
|
||
|
||
**Outbound data.** The downstream pump reads MSS-sized buffers from the proxy
|
||
socket and writes them back as IPv4+TCP packets with PSH|ACK, throttled by
|
||
`awaitSendWindow`, which blocks until in-flight bytes plus the new chunk fit
|
||
inside `min(max(peerWindow, mss), 65535)`. Every segment is copied into a
|
||
retransmit queue; if no ACK arrives for 400 ms, the queue head is retransmitted.
|
||
Acknowledged segments are released from the front of the queue, with sequence
|
||
comparisons done as 32-bit signed differences so wraparound is handled correctly.
|
||
|
||
**Teardown.** A FIN from the app closes the upstream channel; once the pump
|
||
drains it, `shutdownOutput()` tells the proxy the request is complete. When the
|
||
proxy side reaches EOF the session sends FIN; on an exception while established it
|
||
sends RST. `sendReset` uses its own buffer because it may run while another thread
|
||
holds the shared output buffer.
|
||
|
||
### 7.7 UDP and DNS
|
||
|
||
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.
|
||
|
||
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, DNS blocking, and
|
||
per-app attribution. `AppResolver`, `DnsBlocker`, and `TunnelLog` remain
|
||
deliberate no-op stubs that preserve the upstream API.
|
||
|
||
### 7.8 Packet construction
|
||
|
||
`PacketBuilder` writes IPv4 packets into a caller-supplied buffer and returns the
|
||
length, allocating nothing on the hot path. Headers carry TTL 64, the Don't
|
||
Fragment flag, and a monotonically increasing identification field. Checksums
|
||
follow RFC 1071, with the TCP/UDP pseudo-header sum folded in; a computed UDP
|
||
checksum of zero is written as `0xFFFF` per RFC 768.
|
||
|
||
### 7.9 Settings
|
||
|
||
| UI field | Default | Flag passed to the core |
|
||
|---|---|---|
|
||
| Server | — | `--server-host` |
|
||
| 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) | 100 | `--wire-probe-delay`; spacing between sequential valid-mask probes |
|
||
| Probe threads | 1 | `--wire-probe-threads`; maximum concurrent profile attempts, 1–16 |
|
||
| 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-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 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
|
||
Batch max 1 Batch min 1 → Fixed: 1 record per request, never adapts.
|
||
Batch max 5 Batch min 5 → Pinned: exactly 5 records per request, never adapts.
|
||
Batch max 16 Batch min 1 → Adaptive: starts at 16, falls back toward 1
|
||
on errors, recovers to 16.
|
||
Batch max 4 Batch min 9 → Batch min must not be greater than batch max.
|
||
```
|
||
|
||
The service independently clamps both values into 1–256 and forces `min ≤ max`
|
||
before spawning the core, and records the resulting mode in the log:
|
||
|
||
```text
|
||
Download batch: pinned at 5 records per request (never adapts)
|
||
```
|
||
|
||
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
|
||
|
||
`AppLog` keeps the last 600 lines in memory and pushes them live to any
|
||
registered listener. `LogActivity` renders them in a selectable monospace view
|
||
with BACK and CLEAR actions and subscribes for live updates while visible. Nothing
|
||
is written to disk.
|
||
|
||
---
|
||
|
||
## 8. Building
|
||
|
||
### 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.
|
||
|
||
```powershell
|
||
cd android
|
||
.\build_apk.ps1
|
||
```
|
||
|
||
Or double-click `android\build_apk.cmd`. The result is
|
||
`android\build\DragonTCP-Hybrid-arm64.apk`, signed with a debug keystore
|
||
generated on first run.
|
||
|
||
| Component | How it is found | Required? |
|
||
|---|---|---|
|
||
| **Android SDK** | `ANDROID_SDK_ROOT`, `ANDROID_HOME`, `%LOCALAPPDATA%\Android\Sdk`, `C:\Android\Sdk`, or `-SdkRoot` | Yes |
|
||
| **build-tools** | Newest version having `aapt.exe`, `d8.bat`, `apksigner.bat`, `zipalign.exe`; or `-BuildTools` | Yes |
|
||
| **Platform** | `android-35` if present, else newest with an `android.jar`; or `-Platform` | Yes |
|
||
| **JDK 17+** | `JAVA_HOME`, then `javac` on `PATH` (only if `jar.exe` sits beside it), then `C:\Program Files\Java\*`; or `-JavaHome` | Yes — a JRE is not enough |
|
||
| **Kotlin** | `KOTLIN_HOME`, else `android\.tools\kotlinc-<version>`; downloaded automatically if absent | Auto |
|
||
| **Go** | `PATH`, or `-GoBin` on `build_core.ps1` | Only to rebuild the `.so` |
|
||
|
||
Options:
|
||
|
||
```powershell
|
||
.\build_apk.ps1 -BuildCore # rebuild the Go .so first
|
||
.\build_apk.ps1 -BuildTools 35.0.0 -Platform android-35
|
||
.\build_apk.ps1 -JavaHome 'C:\Program Files\Java\jdk-21.0.10'
|
||
.\build_apk.ps1 -KotlinHome C:\kotlinc -NoDownload
|
||
.\build_apk.ps1 -KotlinVersion 2.2.0
|
||
.\build_apk.ps1 -RepoRoot C:\path\to\DragonTCP
|
||
.\build_apk.ps1 -Keystore C:\keys\release.jks -KsPass … -KeyAlias … -KeyPass …
|
||
```
|
||
|
||
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 -AndroidLibRoot <dir>
|
||
```
|
||
|
||
### 8.2 Linux and macOS
|
||
|
||
```bash
|
||
./build_core.sh
|
||
./android/build_apk.sh
|
||
./build_all.sh # both
|
||
```
|
||
|
||
`build_apk.sh` expects `ANDROID_SDK_ROOT` (or `ANDROID_HOME`) and a `KOTLIN_HOME`
|
||
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` 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 \
|
||
go build -trimpath -ldflags='-s -w' \
|
||
-o ../android/lib/arm64-v8a/libdragontcp_client.so ./cmd/dragontcp-client
|
||
|
||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
|
||
go build -trimpath -ldflags='-s -w' \
|
||
-o ../bin/dragontcp-hybrid-server-linux-amd64 ./cmd/dragontcp-server
|
||
```
|
||
|
||
`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 -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
|
||
1.8, against `android.jar` + coroutines + stdlib.
|
||
5. **Java** — compile every `.java` under `src\` to `build\jclasses` with
|
||
`--release 8`, against `android.jar` + the Kotlin output + stdlib.
|
||
6. **`jar`** both class trees, then **`d8`** them with `kotlin-stdlib`,
|
||
`kotlin-stdlib-jdk7/8`, and `kotlinx-coroutines-core-jvm` into `classes.dex` at
|
||
`--min-api 29`.
|
||
7. **Package** — copy `resources.ap_` to the APK and add `classes*.dex` plus the
|
||
whole `lib\` tree using .NET's `ZipArchive`.
|
||
8. **`zipalign -p -f 4`**, then **`apksigner sign`**, then
|
||
**`apksigner verify --verbose`**.
|
||
|
||
### 8.4 Windows implementation notes
|
||
|
||
* **The Kotlin compiler is invoked as
|
||
`java -cp kotlin-compiler.jar org.jetbrains.kotlin.cli.jvm.K2JVMCompiler`, not
|
||
through `kotlinc.bat`.** `cmd.exe` treats `;` as an argument separator, so
|
||
`-classpath a.jar;b.jar` handed to a batch file is split into two arguments and
|
||
the second jar is misread as a source file. Calling `java.exe` directly bypasses
|
||
the batch tokenizer.
|
||
* **`d8.bat` and `apksigner.bat` remain batch files.** Their arguments contain no
|
||
semicolons, but a project path containing spaces or semicolons could hit the
|
||
same class of problem.
|
||
* **Packaging uses .NET `ZipArchive`** because Windows has no `zip` command.
|
||
* **`zipalign` runs before signing**, which is the required ordering.
|
||
* **Native-tool stderr is not treated as failure.** The JVM emits a
|
||
`sun.misc.Unsafe` warning on recent JDKs and `apksigner` emits a native-access
|
||
warning; under `$ErrorActionPreference = 'Stop'` with a merged output stream
|
||
those would abort the build, so `Invoke-Tool` judges success by exit code alone.
|
||
|
||
A `.gitignore` in the app directory keeps `build/`, `.tools/`, and the debug
|
||
keystore out of version control.
|
||
|
||
---
|
||
|
||
## 9. Running
|
||
|
||
### 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
|
||
```
|
||
|
||
With a token:
|
||
|
||
```bash
|
||
sudo ./dragontcp-hybrid-server-linux-amd64 \
|
||
--token 'YOUR_SECRET' --port 53 --port-alt 80 --chunk-max 1048576
|
||
```
|
||
|
||
With diagnostics:
|
||
|
||
```bash
|
||
sudo ./dragontcp-hybrid-server-linux-amd64 \
|
||
--port 53 --port-alt 80 --chunk-max 1048576 --debug --debug-stats-interval 10s
|
||
```
|
||
|
||
The server listens on TCP ports 53 and 80 simultaneously by default. Set
|
||
`--port-alt 0` to disable the second listener. Failure to bind the primary port
|
||
is fatal; failure to bind the secondary port prints a warning and leaves the
|
||
primary listener running. `sudo` is normally needed because both defaults are
|
||
privileged. If another service owns either port, free it or select a different
|
||
port. No TUN device or NAT rules are required.
|
||
|
||
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
|
||
./dragontcp-hybrid-client-linux-amd64 \
|
||
--server-host YOUR_SERVER_IP --server-port 53 \
|
||
--listen-port 8080 --chunk-max 1048576
|
||
```
|
||
|
||
Then point anything at `http://127.0.0.1:8080` as an HTTP proxy. The startup line
|
||
summarises the active configuration:
|
||
|
||
```text
|
||
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, 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 start: 53
|
||
Port end: 53
|
||
Token: (match the server, or leave blank)
|
||
Wire: Auto
|
||
Batch max: 1
|
||
Batch min: 1
|
||
Reconnect every: 1
|
||
Timeout (s): 5
|
||
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 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.
|
||
|
||
---
|
||
|
||
## 10. Configuration reference
|
||
|
||
### 10.1 Server flags
|
||
|
||
| Flag | Default | Meaning |
|
||
|---|---|---|
|
||
| `--host` | `0.0.0.0` | Listen address |
|
||
| `--port` | `53` | Primary listen port |
|
||
| `--port-alt` | `80` | Simultaneous secondary listen port; 0 disables it |
|
||
| `--token` | empty | Optional shared secret |
|
||
| `--max-connections` | `20000` | Concurrent TCP connections |
|
||
| `--allow-private` | `false` | Allow private/loopback targets — keep off in public |
|
||
| `--dns-cache-ttl` | `30s` | Resolver cache lifetime |
|
||
| `--dns-cache-size` | `4096` | Cached hostnames |
|
||
| `--tcp-buffer` | `0` | Explicit socket buffers; 0 = OS autotuning |
|
||
| `--chunk-max` | `1048576` | Largest record accepted (32 B – 1 MiB) |
|
||
| `--chunk-buffered` | `32` | Per-session buffer in 64 KiB units (≈2 MiB) |
|
||
| `--chunk-poll-wait` | `200ms` | Long-poll wait for a batch's first record |
|
||
| `--chunk-session-timeout` | `2m` | Idle session reaping |
|
||
| `--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; `--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` | `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) |
|
||
| `--chunk-min` | `32` | Record size floor |
|
||
| `--chunk-max` | `1048576` | Record size ceiling, further clamped by the server |
|
||
| `--chunk-adaptive` | `true` | Enable runtime record resizing |
|
||
| `--chunk-grow-after` | `16` | Successes before growing |
|
||
| `--chunk-adapt-log` | `true` | Print size and batch changes |
|
||
| `--chunk-size` | `0` | Legacy: pins start/min/max and disables adaptation |
|
||
| `--chunk-concurrency` | `1` | Download batch ceiling, 1–256 |
|
||
| `--chunk-concurrency-min` | `1` | Download batch floor, 1–256; equal to the ceiling pins the depth |
|
||
| `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
|
||
| `--chunk-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.
|
||
|
||
### 10.3 Fixed timings
|
||
|
||
| Where | Value | What |
|
||
|---|---|---|
|
||
| Client | 15 s | Local-connection handshake deadline |
|
||
| Client | 10 s | Dial timeout to the server |
|
||
| 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 |
|
||
| Server | 30 s | Per-request read deadline |
|
||
| Server | 2 ms | Small-read coalescing window |
|
||
| Server | 30 s | Session sweep interval |
|
||
| Android | 10 s | Wait for the local proxy to accept |
|
||
| Android | 1200 + 800 ms | Graceful then forced core shutdown |
|
||
| Android | 400 ms | TCP retransmit timeout |
|
||
| Android | 5 s | Session housekeeping interval |
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
* **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.
|
||
* **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every`
|
||
to `1` (auto). Auto starts persistent and retries an idempotent request once on
|
||
a fresh connection for non-timeout failures. An I/O timeout ends the logical
|
||
tunnel immediately because replaying an upload whose response was lost can
|
||
duplicate data. If reuse itself failed without timing out, only that lane
|
||
switches to one logical request per connection.
|
||
* **Logs show the same transition many times over (`128 -> 64` repeatedly).**
|
||
Each tunnel adapts independently, so a burst of flows produces a burst of
|
||
identical lines. The app collapses consecutive duplicates into a counted line;
|
||
the underlying behaviour is normal.
|
||
* **The batch keeps collapsing to 1 and throughput dies with it.** Some paths only
|
||
deliver correctly at one specific number of records. Try `Batch max = Batch min
|
||
= N` for a few values of N and leave it pinned at whichever works. Pinned mode
|
||
never halves and is never trimmed by the 1 MiB batch cap.
|
||
* **You want a floor but still want headroom.** Set `Batch min` to the smallest
|
||
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. 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.
|
||
* **Connection dies periodically, then recovers.** A middlebox is capping requests
|
||
per connection. Set `Reconnect every` to 8–32.
|
||
* **High-latency, high-bandwidth link.** Leave `--tcp-buffer` at 0 first. Only for
|
||
a small number of high-BDP connections is `1048576` or `4194304` worth trying;
|
||
across many connections it costs memory for nothing.
|
||
* **Server memory.** Each session can hold `--chunk-buffered × 64 KiB` (default
|
||
2 MiB). Lower it when running many concurrent sessions; the minimum effective
|
||
window is 1 MiB.
|
||
|
||
---
|
||
|
||
## 12. Troubleshooting
|
||
|
||
### 12.1 Build
|
||
|
||
| Symptom | Cause and fix |
|
||
|---|---|
|
||
| `Android SDK not found` | Set `ANDROID_SDK_ROOT` or pass `-SdkRoot`. |
|
||
| `No usable build-tools found` | Install build-tools; `aapt`, `d8`, `apksigner`, and `zipalign` must all be present in one version. |
|
||
| `Missing …\jar.exe (a JRE is not enough)` | You have a JRE or the `javapath` shim. Install a JDK and set `JAVA_HOME`. |
|
||
| `Kotlin download failed` | No network, or a proxy. Download `kotlin-compiler-<ver>.zip` manually, extract it, pass `-KotlinHome <dir>\kotlinc`. |
|
||
| `source entry is not a Kotlin file: …jar` | The compiler is being called through `kotlinc.bat`. See §8.4. |
|
||
| `libdragontcp_client.so is missing` | Install Go and run `.\build_core.ps1 -ClientOnly`, or pass `-BuildCore`. |
|
||
| `…but no core\go.mod was found above` | Run the script from the app directory, or pass `-RepoRoot`. |
|
||
|
||
### 12.2 Runtime
|
||
|
||
| Symptom | Cause and fix |
|
||
|---|---|
|
||
| `CONNECT failed: Server is required` | Empty server field. |
|
||
| `Local proxy did not start` | The core died within 10 s. Check the logs; usually a bad flag or an unusable port. |
|
||
| `DragonTCP core exited: N` | The core died while connected; the tunnel is torn down deliberately. |
|
||
| `authentication failed` | Token mismatch between app and server. |
|
||
| `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` | 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 | 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. |
|
||
|
||
---
|
||
|
||
## 13. Testing
|
||
|
||
```bash
|
||
cd core && go test ./...
|
||
```
|
||
|
||
Coverage:
|
||
|
||
* legacy masking round-trips and different-sequence wire bytes,
|
||
* clear-payload B and BP profiles end to end, including nonzero header masks,
|
||
* the adaptive record sizer recovering from the minimum rather than latching
|
||
there,
|
||
* `reconnectEvery == 0` meaning persistent,
|
||
* B and BP timeouts returning after one physical connection without replay,
|
||
* a pinned batch never growing or shrinking across repeated failures,
|
||
* 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,
|
||
* 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,
|
||
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.
|
||
|
||
After a successful Unix `build_core.sh`, `SHA256SUMS` is regenerated for the
|
||
new client/server binaries and Android core.
|
||
|
||
---
|
||
|
||
## 14. Licensing
|
||
|
||
The Android userspace TCP/IP stack under
|
||
`android/src/tech/xvanturing/freeproxy/` is adapted from **FreeProxy** by
|
||
xVanTuring, licensed under the **Apache License 2.0**. Modified files carry a
|
||
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
|
||
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.
|
||
|