1319 lines
57 KiB
Markdown
1319 lines
57 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,
|
||
* 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.
|
||
|
||
---
|
||
|
||
## 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
|
||
```
|
||
|
||
### 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.
|
||
|
||
**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
|
||
|
||
**DragonTCP does not provide authenticated encryption. It is not a VPN in the
|
||
security sense.**
|
||
|
||
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.
|
||
|
||
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, no dependencies
|
||
cmd/dragontcp-client/
|
||
main.go local HTTP/HTTPS proxy, CLI flags
|
||
chunk.go transport: lanes, probing, adaptation, batching
|
||
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
|
||
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 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
|
||
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
|
||
licenses/ full Apache 2.0 text
|
||
SHA256SUMS digests for the built binaries
|
||
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`.
|
||
|
||
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 profile and path probing
|
||
|
||
Before normal traffic, the client discovers one fixed wire/header profile. The
|
||
original direct range remains intact: B provides 32 masks for its mode byte, X
|
||
provides 96 masks for its `UP`/`OK` magic, and BP provides its original direct
|
||
profile. The cover range adds both legacy-masked and clear-payload B profiles,
|
||
clear-payload BP profiles, and covered X profiles. It varies a masked multi-byte
|
||
preface, all 256 header-mask/first-byte values, and a distributed set of padding
|
||
lengths: `0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 768, 1024, 1400, 2048,
|
||
4096` bytes.
|
||
|
||
Every candidate is tested with real tunnel traffic: the client opens the tunnel,
|
||
sends an HTTP request to `http://ip.dr2.site/` over TCP port 80, and accepts the
|
||
profile only after receiving an HTTP status line. Discovery no longer rejects a
|
||
profile based only on the synthetic `CPROBE`/`DTP2` exchange.
|
||
|
||
The safe default is one probe thread. `--wire-probe-threads` can allow 1–16
|
||
in-flight attempts, while `--wire-probe-delay` (default 1 second) is still the
|
||
global minimum time between new connection starts. Increasing the thread count
|
||
therefore permits slow attempts to overlap; it does not launch all candidates at
|
||
once. One thread is recommended on carriers with connection-rate filtering.
|
||
|
||
The winner is cached for the lifetime of the client process and is used
|
||
unchanged for every connection and record. Padding contents may be fresh random
|
||
bytes, but the selected length and profile never change until restart; there is
|
||
no per-packet profile mutation.
|
||
|
||
`--wire auto` searches 1,153 distributed B/BP/X profiles. Pinning `--wire b`
|
||
searches 544 B profiles, `--wire bp` searches 257 BP profiles, and `--wire x`
|
||
searches 352 X profiles. B and BP try a clear-payload candidate first, followed
|
||
immediately by a legacy direct fallback. A successful selection is logged as:
|
||
|
||
```text
|
||
wire probe: selected=x/mask-6b/cover-91e7/pad-64 completed=141 launched=142 elapsed=42.8s target=http://ip.dr2.site/ validated=true threads=1 fixed_until_restart=true
|
||
```
|
||
|
||
Before the first real connection, `getPathProfile` measures the path once and
|
||
caches the result for **30 minutes**, keyed by server address, token, and size
|
||
bounds.
|
||
|
||
Upload and download are probed **independently and concurrently**, each by binary
|
||
search over a fixed ladder:
|
||
|
||
```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
|
||
```
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
The outcome is logged once:
|
||
|
||
```text
|
||
path probe: upload=32768 download=1400 persistent=true
|
||
```
|
||
|
||
### 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.
|
||
|
||
**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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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 | UDP handling; in practice DNS only |
|
||
| `net/*` | Kotlin | Header parsing, checksums, packet construction, DNS parsing |
|
||
| `ProxyClient` | Kotlin | HTTP CONNECT client against the local Go proxy |
|
||
|
||
### 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 `127.0.0.1:8080` for up to 10 s (150 ms connect timeout,
|
||
100 ms between attempts) until the proxy accepts.
|
||
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
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 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 | 53 | `--server-port` |
|
||
| Token | empty | `--token` (omitted entirely when blank) |
|
||
| Wire | auto | `--wire`; Auto, B, BP, and X are selectable |
|
||
| Probe delay (ms) | 1000 | `--wire-probe-delay`; global minimum delay between profile connection starts |
|
||
| Probe threads | 1 | `--wire-probe-threads`; maximum concurrent profile attempts, 1–16 |
|
||
| Max chunk | 1048576 | `--chunk-max` **and** `--chunk-start` |
|
||
| 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`.
|
||
|
||
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
|
||
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: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
|
||
batch values 1–256 with `min ≤ max`, reconnect 0–1000000,
|
||
timeout 1–120, probe delay 200–30000 ms, probe threads 1–16.
|
||
|
||
### 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
|
||
|
||
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 -AndroidLibDir <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` does not build the Linux **client**; `build_core.ps1` does. Build
|
||
it by hand if you need it.
|
||
|
||
Go builds by hand:
|
||
|
||
```bash
|
||
cd core
|
||
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
|
||
```
|
||
|
||
CGO is off for every target, so no NDK and no C toolchain are needed.
|
||
|
||
### 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`.
|
||
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
|
||
|
||
```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.
|
||
|
||
### 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
|
||
```
|
||
|
||
### 9.3 Android
|
||
|
||
Install the APK, enter the server address, grant the VPN prompt, connect.
|
||
A reasonable starting point:
|
||
|
||
```text
|
||
Server: YOUR_SERVER_IP
|
||
Port: 53
|
||
Token: (match the server, or leave blank)
|
||
Max chunk: 1048576
|
||
Min chunk: 32
|
||
Batch max: 1
|
||
Batch min: 1
|
||
Pollers: 1
|
||
Reconnect every: 1
|
||
Timeout (s): 5
|
||
Probe delay (ms): 1000
|
||
Probe threads: 1
|
||
```
|
||
|
||
Use **OPEN LOGS** to watch the path probe and any adaptation.
|
||
|
||
---
|
||
|
||
## 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 |
|
||
|
||
### 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) |
|
||
| `--token` | empty | Shared secret |
|
||
| `--transport` | `chunk` | Must be `chunk` |
|
||
| `--wire` | `auto` | Search B/BP/X; `b`, `bp`, or `x` pins one mode |
|
||
| `--wire-probe-delay` | `1s` | Global minimum delay between profile probe starts; range 200ms–30s |
|
||
| `--wire-probe-threads` | `1` | Maximum concurrent real HTTP profile probes; range 1–16 |
|
||
| `--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 |
|
||
|
||
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 | Per-probe timeout cap |
|
||
| 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 |
|
||
|
||
---
|
||
|
||
## 11. Tuning
|
||
|
||
Start with the defaults. Path probing already picks sensible sizes, and most
|
||
manual tuning makes things worse.
|
||
|
||
* **Throughput feels capped.** Raise `Batch max` to 4–16 and leave `Batch min` at
|
||
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. If reuse itself failed, only that lane switches to one
|
||
logical request per connection. This avoids turning one imperfect startup
|
||
probe into connection churn for every active tunnel.
|
||
* **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. Lower `Max chunk` to a size the probe already found safe — 1400 is
|
||
common — so the client stops rediscovering the limit.
|
||
* **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` | Raise the server's `--chunk-max` or lower the client's `Min chunk`. |
|
||
| `download failed at minimum chunk` | Eight consecutive failures at the smallest record size — the path is not passing traffic at all. |
|
||
| DNS works, QUIC/UDP apps do not | By design: only DNS is carried over UDP (§7.7). |
|
||
| 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,
|
||
* 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.
|
||
|
||
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.
|
||
|
||
`SHA256SUMS` records digests for the built binaries; the digest for
|
||
`android/lib/arm64-v8a/libdragontcp_client.so` matches the copy packaged inside
|
||
the APK.
|
||
|
||
---
|
||
|
||
## 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)
|
||
|
||
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.
|