Files
DragonTCP/README.md
T
2026-08-16 13:17:19 -03:00

1025 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DragonTCP Hybrid
DragonTCP is a tunnelling proxy that carries ordinary TCP traffic inside a
compact binary record protocol, normally over TCP port 53. It is made of three
pieces that all live in this repository:
* a **Go server** that runs on a Linux VPS and relays streams to their real
destinations,
* a **Go client** that runs on the phone and exposes a local HTTP/HTTPS proxy,
* an **Android app** that captures all device traffic with `VpnService` and
feeds it into that local proxy.
This document explains the whole system: the wire format, both Go programs, the
Android app, how to build everything (Windows and Linux), how to run it, and how
to tune it.
---
## Table of contents
1. [What it does and why](#1-what-it-does-and-why)
2. [Security model — read this](#2-security-model--read-this)
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 on Windows](#8-building-on-windows)
9. [Building on Linux and macOS](#9-building-on-linux-and-macos)
10. [Running the server](#10-running-the-server)
11. [Running the client](#11-running-the-client)
12. [Tuning guide](#12-tuning-guide)
13. [Troubleshooting](#13-troubleshooting)
14. [Version history](#14-version-history)
15. [Testing and validation](#15-testing-and-validation)
16. [Licensing](#16-licensing)
---
## 1. What it does and why
### The data path
```text
Android apps (any app, unmodified)
|
| IP packets
v
Android VpnService TUN interface (10.77.0.2/32, MTU 1400)
|
| userspace TCP/IP reassembly
v
TunnelEngine (Kotlin, in-process)
|
| HTTP CONNECT to 127.0.0.1:8080
v
dragontcp-client (Go, child process on the phone)
|
| DragonTCP binary records over TCP/53
v
dragontcp-server (Go, on the VPS)
|
| plain TCP
v
destination website
```
The important architectural decision is that the **server is only a relay**. It
does not create a TUN device, does not do NAT, and needs no `iptables` rules.
All the packet-level work happens on the phone, in userspace. This keeps the
server trivial to deploy (a single static binary) and keeps the phone side
independent of what the server can do.
### Why records instead of a raw stream
A plain TCP tunnel sends a continuous byte stream. DragonTCP instead splits each
direction into independent **records**, each carried by its own request/response
exchange. That costs a little efficiency and buys two things:
1. **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 when conditions change.
2. **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 TCP connection between any two records without losing
the stream, which matters on middleboxes that cap how long a port-53
connection may live or how many requests it may carry.
---
## 2. Security model — read this
**DragonTCP does not provide authenticated encryption. Do not treat it as a VPN
in the security sense.**
What it actually does to payload bytes is **masking**: every payload is XORed
with a keystream derived from SHA-256. The keystream varies with session ID,
mode, sequence, direction, and block number, so the same plaintext does not
produce the same ciphertext twice, and there are no fixed ASCII markers such as
`UP`, `OK`, `CPUSH`, or `CPULL` on the wire.
That defeats trivial pattern matching. It does **not** defeat an adversary who
can read the traffic, because:
* the mask is derived from the **session ID, which is sent in cleartext in every
request header**. Anyone who sees the header can regenerate the keystream and
recover the plaintext. This is obfuscation, not confidentiality.
* record headers (mode, session, sequence, length) are never masked,
* `StatusError` bodies are sent **unmasked**, in plain text,
* there is no integrity check, so a network attacker can tamper with payloads
undetected.
The optional `--token` is a shared secret compared in constant time. It gates
who may open sessions. It is not a key — it does not affect the mask.
**Practical consequence:** keep using TLS end to end. HTTPS through DragonTCP is
protected by HTTPS, not by DragonTCP. Never send plaintext credentials through a
plain-HTTP site over this tunnel and assume they are private.
---
## 3. Repository layout
```text
core/
go.mod module "dragontcp", Go 1.22, zero dependencies
cmd/dragontcp-client/
main.go local HTTP/HTTPS proxy, CLI flags
chunk.go the client transport: lanes, probing, adaptation
chunk_test.go adaptive sizer + reconnect tests
cmd/dragontcp-server/
main.go listener, DNS cache, address filtering, CLI flags
chunk.go session manager, buffering, request dispatch
debug.go optional counters and periodic statistics
chunk_test.go
internal/wire/
protocol.go the record format and the masking keystream
protocol_test.go mask round-trip and sequence-variance test
internal/protocol/
protocol.go TCP tuning, relays, 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
src/tech/xvanturing/freeproxy/ Kotlin: userspace TCP/IP stack (Apache 2.0, see §16)
res/ icon + 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 binaries
licenses/ full Apache 2.0 text
THIRD_PARTY_NOTICES.md upstream attribution (required — do not delete)
```
Note that `core/internal/protocol` still contains the **legacy** `UP`/`OK` text
framing and the fixed `0xAD` XOR. That code is retained because
`internal/protocol` also holds the TCP tuning helpers and relay loops that the
current transport uses. The legacy framing itself is unreachable in normal
operation: `dragontcp-client` refuses to start unless `--transport chunk`.
---
## 4. The wire protocol
Everything below is implemented in `core/internal/wire/protocol.go`.
### 4.1 Record framing
Every client-to-server message is 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)
```
Every server-to-client message is a **response**:
```text
offset size field
0 1 status
1 4 body length (big-endian uint32)
5 n body (masked, except where noted)
```
Header sizes are therefore **29 bytes** and **5 bytes**. The hard payload ceiling
in the wire layer is 2 MiB (`MaxPayload`); the transport never exceeds 1 MiB.
### 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; poll again |
| `StatusEOF` | 4 | Target closed the stream |
### 4.3 The masking keystream
```go
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[i] = SHA256(seed)
payload ^= keystream
```
Masking is its own inverse, so the same call encodes and decodes. Because the
sequence field is the **byte offset within the stream** (see below), consecutive
records never reuse a keystream position, and retransmitting the same offset
reproduces the same bytes — which is what makes idempotent retries safe.
Not everything is masked. `WriteResponse` sends the body unmasked and is used for
`StatusOK` with no body, `StatusWait`, `StatusEOF`, and all `StatusError`
messages. `WriteMaskedResponse` is used for `StatusData` and for the `OPEN`
result. On the client, `DecodeMaskedResponse` deliberately skips decoding when
the status is `StatusError`, so the two sides agree.
### 4.4 Payload layouts per mode
**PROBE** (`ModeProbe`) — 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 was received and is within `--chunk-max`. The *filler* is the thing being measured. |
| `ProbeDownload` | 2 | Replies `StatusData` with exactly `value` bytes of the same generated pattern. |
| `ProbeKeepalive` | 3 | Replies `OK`. Used to test whether a 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 rather than silently corrupting data.
**OPEN** (`ModeOpen`) — 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)
```
Response is `StatusOK` with a masked 4-byte body: the server's `--chunk-max`.
The client immediately clamps its own maximum to that value. Re-sending `OPEN`
for an existing session is idempotent and just returns the same limit again.
**UPLOAD** (`ModeUpload`) — the sequence field is the **byte offset in the upload
stream**, and the payload is the data. The server requires `offset` to equal
exactly what it expects next. Response is an empty `StatusOK` ACK.
**DOWNLOAD** (`ModeDownload`) — 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 replies with a *stream* of responses to that single request: up to
`count` `StatusData` records, each masked with the running offset, terminated
early by a single `StatusWait` or `StatusEOF`. This is the batching mechanism —
one request, many records.
**CLOSE** (`ModeClose`) — no payload; the server drops the session and replies
`StatusOK`.
### 4.5 A complete session, end to end
```text
client server
|-- PROBE upload (binary search) ---------->|
|<- OK / error ---------------------------------|
|-- PROBE download (binary search) ---------->|
|<- DATA(pattern) ------------------------------|
|-- PROBE keepalive x8 on one connection ----->|
|<- OK x8 -------------------------------------|
|
|-- OPEN sid=… host=example.com port=443 ------>| dial example.com:443
|<- OK body=chunk_max --------------------------|
|
|-- UPLOAD sid seq=0 payload=TLS ClientHello>| write() to target
|<- OK ----------------------------------------|
|-- DOWNLOAD sid seq=0 ack=0 limit=1400 count=4>|
|<- DATA(1400) DATA(1400) DATA(900) WAIT --------|
|-- UPLOAD sid seq=517 payload=… ------------->|
|<- OK ----------------------------------------|
|-- DOWNLOAD sid seq=3700 ack=3700 … ---------->|
|<- EOF ----------------------------------------|
|-- CLOSE sid ---------------------------------->|
|<- OK ----------------------------------------|
```
Note `ack` trailing behind `seq`: the client advances `ack` only when the
application has actually read the bytes, which is what applies backpressure all
the way to the origin server.
---
## 5. The Go client
Source: `core/cmd/dragontcp-client/`.
### 5.1 The local proxy front end (`main.go`)
The client listens on `127.0.0.1:8080` and speaks ordinary HTTP proxy protocol:
* **`CONNECT host:port`** — opens a tunnel, replies `200 Connection
Established`, then relays bytes in both directions. This is the path used for
HTTPS and, on Android, for everything.
* **Plain `GET http://…`** — the request line is rewritten to origin form, the
`Connection`, `Proxy-Connection`, and `Proxy-Authorization` headers are
stripped, a `Host` header is synthesised if missing, and `Connection: close`
is appended.
Accepts are bounded by `--max-connections` (default 20 000) using a slot
channel; over the limit the client returns `503`. Relaying uses `io.Copy` in both
directions and waits for **both** directions to finish, preserving TCP half-close
so large or slow responses are not truncated.
### 5.2 `chunkConn` — a stream that looks like a socket
`openChunkTunnel` returns a `chunkConn` that implements `net.Conn`, so the proxy
front end does not know it is talking to a record protocol. Internally it keeps:
* `upOffset` — bytes sent so far; used as the upload sequence,
* `downloadOffset` — the next byte the client will ask for,
* `consumedOffset` — the next byte the application has not yet read; sent as
`ack`,
* `readBuf` — data received but not yet handed to the reader,
* two independent `requestLane`s, 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 and
sends them one at a time, each acknowledged before the next. `Read` refills
`readBuf` through `fillReadBuffer`, which issues batched download requests.
### 5.3 Request lanes and connection reuse
A `requestLane` owns at most one physical TCP connection and serialises requests
onto it with a mutex. `reconnectEvery` controls rotation:
| `--chunk-reconnect-every` | Behaviour |
|---|---|
| `0` | Persistent. Keep one connection for the life of the lane. |
| `1` | Auto. The path probe decides: persistent if reuse worked, otherwise one logical request per TCP connection. |
| `N ≥ 2` | Rotate: close and redial after N logical requests. |
Any I/O error discards the connection immediately; the next request redials. All
sockets get `TCP_NODELAY` and 30-second keepalives, and optionally explicit
socket buffer sizes via `--tcp-buffer` (0 leaves OS autotuning alone, which is
the right default).
### 5.4 Path probing
Before the first real connection, `getPathProfile` measures the path once and
caches the result for **30 minutes**, keyed by server address, token, and the
size bounds.
Upload and download are probed **independently and concurrently**, each by
binary search over a fixed ladder of candidate sizes:
```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]`, and the configured bounds
are added if missing. Binary search means roughly five probes instead of
twenty-eight, and — critically — it means the client does not have to *fail* at
every size on the way down during real traffic.
A third probe sends eight keepalives on a single connection to decide whether
request reuse survives the path. Each probe uses a fresh random session ID and a
timeout capped at 2.5 s; if the whole search does not finish within 20 s, the
client falls back to 32 768 up / 1 350 down.
The result is logged once:
```text
path probe: upload=32768 download=1400 persistent=true
```
### 5.5 Adaptive record sizing
Runtime adaptation remains as a safety net after probing, in `adaptiveSizer`.
Each direction keeps its own instance plus two landmarks: `good` (largest size
known to work) and `bad` (smallest size 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 strict decrease.
**On success** at the current size, after `--chunk-grow-after` consecutive
successes (default 16):
* if a `bad` landmark is known and is 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.
If `bad - good ≤ 64` the required success count is multiplied by eight: once the
working size is bracketed tightly, the controller stops probing the ceiling
aggressively and settles.
Changes are logged:
```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 and pipeline depth
One download request can return many records. The batch size is
`--chunk-concurrency` (1256, default 1), additionally capped so that one batch
carries roughly 1 MiB of useful data:
```go
count = min(pipeline, maxPipeline, (1 MiB) / chunkSize)
```
This 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.
Depth adapts within `1..N`: it starts at the ceiling, **halves** on transport
failure, and **grows by one** after successful data responses. A ceiling of 1
disables the mechanism and stays fixed at 1.
```text
adaptive download pipeline: 64 -> 32 after transport failure
```
The escalation order on repeated failure is deliberate: shrink the pipeline
first, and only when depth is already 1 start shrinking the record size. Eight
consecutive failures at the minimum record size abort the connection with an
error rather than spinning forever.
---
## 6. The Go server
Source: `core/cmd/dragontcp-server/`.
### 6.1 Connection handling
The server accepts on `0.0.0.0:53` by default, bounded by `--max-connections`.
Each connection runs a loop: read one request (30-second deadline), dispatch it,
repeat. Because session state is keyed by session ID rather than by connection,
requests for one logical stream may arrive over many connections, in any order
the client chooses.
### 6.2 Session state
Each `OPEN` creates a `streamSession` holding the real TCP connection to the
target plus a **download buffer**:
* `buf` holds bytes that have arrived from the target but are not yet
acknowledged by the client,
* `base` is the absolute stream offset of `buf[0]`,
* a dedicated goroutine reads the target in 64 KiB chunks and appends to `buf`.
That goroutine **blocks when the buffer is full**, which is the whole flow
control story: a slow phone stops draining, `ack` stops advancing, the buffer
fills, the server stops reading, and TCP backpressure propagates to the origin
server. Buffer size is `--chunk-buffered × 65536`, clamped to 1 MiB…64 MiB
(default 256 → 16 MiB per session).
`ack` drops acknowledged bytes off the front and advances `base`. The buffer is
compacted when its capacity grows past four times its length and exceeds 1 MiB,
so long-lived sessions do not hold onto peak allocations.
### 6.3 Serving a download
`readAt(offset, limit, wait)` enforces that `offset` is within
`[base, base+len(buf)]` — a request below `base` is an error, because those bytes
were already acknowledged and discarded.
Two behaviours are worth knowing:
* **Long poll.** The *first* record of a batch waits up to `--chunk-poll-wait`
(default 200 ms) for data. Later records in the same batch do not wait: the
batch drains whatever is buffered and then returns `StatusWait`. This keeps
batches from stalling on partially-filled pipelines.
* **Coalescing.** If less 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 the per-record
overhead would dominate.
### 6.4 Serving an upload
Uploads must arrive in exact order: `offset` must equal the session's
`expectedUp`. Two cases are special-cased:
* an offset entirely **below** `expectedUp` is treated as an idempotent retry
after a lost ACK 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 Safety and lifecycle
* **Token.** Compared with `crypto/subtle.ConstantTimeCompare` on both `PROBE`
and `OPEN`. An empty token means no authentication.
* **Target filtering.** By default the server refuses to dial unspecified,
multicast, private, loopback, link-local, and a list of special-use prefixes
(`0.0.0.0/8`, `100.64.0.0/10`, `192.0.2.0/24`, `198.18.0.0/15`, `240.0.0.0/4`,
`2001:db8::/32`, and others). `--allow-private` disables this. **Leave it off
on a public server** — it is what stops the tunnel being used to reach your
VPS's own localhost services and cloud metadata endpoints.
* **DNS cache.** Bounded map with a TTL (`--dns-cache-ttl`, default 30 s;
`--dns-cache-size`, default 4096). When full it resets wholesale rather than
evicting entry by entry — cheap, and adequate for a hot cache.
* **Idle reaping.** A sweep every 30 s closes sessions idle longer than
`--chunk-session-timeout` (default 2 minutes).
* **Debug.** `--debug` logs accepts, session opens, and errors to stderr, and
`--debug-stats-interval` prints counters (bytes up/down, push records, pull
requests, data/wait records, 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 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 and `extractNativeLibs="true"` make Android
unpack it into `nativeLibraryDir` with the executable bit set, which is the
standard way to ship a helper binary in an APK without needing an installer.
`DragonService` launches it with `ProcessBuilder`, merges stderr into stdout,
and reads its output on a background thread. Only interesting lines reach the
UI log: those starting with `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.2 Startup sequence
1. `MainActivity` validates the form and saves it to `SharedPreferences`.
2. `VpnService.prepare()` — the system consent dialog, if not already granted.
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 until the proxy accepts.
6. The TUN interface is established.
7. `TunnelEngine` starts and the 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.
### 7.3 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 here:
* **IPv6 is captured, then dropped.** The userspace stack is IPv4-only. Routing
`::/0` into the tunnel and discarding it is what prevents apps from quietly
bypassing the tunnel over IPv6. It is a blackhole by design, not an oversight.
* **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 your VPS does not get routed back into the TUN it is
serving.
### 7.4 The userspace TCP/IP stack
`TunnelEngine` (Kotlin, adapted from FreeProxy — see §16) runs one reader thread
and one writer thread over the TUN file descriptor, plus a cached thread pool
exposed to coroutines for per-session blocking I/O.
* IPv4 packets are parsed; anything else (IPv6, fragments, ICMP) is dropped.
* **TCP** goes to a `TcpSession` keyed by the 4-tuple. The session acts as the
server endpoint towards the phone's own kernel: it answers SYN with SYN-ACK,
acknowledges data, and sends FIN/RST. Because the "link" to the kernel is
lossless, there is no congestion control — it only has to respect the peer's
advertised receive window. New flows are only created by a SYN; anything else
gets an RST so apps fail fast instead of hanging. Limit: 512 concurrent TCP
sessions.
* **UDP** goes to a `UdpSession`. With an HTTP CONNECT upstream, general UDP
cannot be carried, so **only DNS is handled**: queries are converted to
DNS-over-TCP (RFC 7766, 2-byte length prefix) and sent to **1.1.1.1:53**
through the tunnel. All other UDP is dropped, which makes QUIC fail and pushes
apps back to TCP. Limit: 256 sessions.
* Housekeeping every 5 s expires idle sessions (TCP 300 s, DNS 20 s, other UDP
120 s). The TUN write queue holds 1024 packets and drops on overflow rather
than blocking session threads.
Real traffic reaches the Go proxy through `ProxyClient`, which opens a protected
socket to `127.0.0.1:8080` and issues `CONNECT <ip>:<port>` per stream.
### 7.5 Settings and how they map to flags
| UI field | Default | Flag passed to the core |
|---|---|---|
| Server | — | `--server-host` |
| Port | 53 | `--server-port` |
| Token | empty | `--token` (omitted entirely when blank) |
| Max chunk | 1048576 | `--chunk-max` **and** `--chunk-start` |
| Min chunk | 32 | `--chunk-min` |
| Concurrency | 1 | `--chunk-concurrency` |
| Reconnect every | 0 | `--chunk-reconnect-every` |
| Timeout (s) | 2 | `--chunk-timeout` |
Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`,
`--transport chunk`, `--chunk-pollers 1`, `--chunk-grow-after 16`,
`--chunk-adapt-log=true`.
In the current source, `Reconnect every` accepts `0` and `0` means persistent.
(Older *prebuilt* APKs shipped a Logs-page UI whose Reconnect field required at
least `1`; on those, `1` selects Auto. If you build from this source you get the
explicit `0 = persistent` behaviour.)
`AppLog` keeps the last 600 lines in memory and pushes them live to
`LogActivity`. It is not persisted to disk.
---
## 8. Building on Windows
No Gradle and no Android Studio required. `android\build_apk.ps1` drives the
Android SDK command-line tools directly.
### 8.1 Quick start
```powershell
cd android
.\build_apk.ps1
```
Or double-click `android\build_apk.cmd`. The result is:
```text
android\build\DragonTCP-Hybrid-arm64.apk
```
signed with a debug keystore that is generated on first run.
### 8.2 Requirements
| Component | How it is found | Needed? |
|---|---|---|
| **Android SDK** | `ANDROID_SDK_ROOT`, `ANDROID_HOME`, `%LOCALAPPDATA%\Android\Sdk`, `C:\Android\Sdk`, or `-SdkRoot` | Yes |
| **build-tools** | Newest installed version that has `aapt.exe`, `d8.bat`, `apksigner.bat`, `zipalign.exe`; or `-BuildTools` | Yes |
| **Platform** | `android-35` if present, else the 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` |
If the Kotlin compiler is missing, the script downloads it once (~85 MB) from
the JetBrains GitHub releases into `android\.tools\` and reuses it forever after.
Pass `-NoDownload` to make a missing Kotlin a hard error instead.
### 8.3 What the script actually does
1. **Resolve the toolchain** and print what it picked.
2. **Build the native core** if `lib\arm64-v8a\libdragontcp_client.so` is missing
(or `-BuildCore` was passed) by calling `..\build_core.ps1 -ClientOnly`.
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`, targeting
JVM 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 together 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` (Windows has no `zip` command).
8. **`zipalign -p -f 4`**, then **`apksigner sign`**, then
**`apksigner verify --verbose`**.
### 8.4 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 -Keystore C:\keys\release.jks -KsPass -KeyAlias -KeyPass
```
Go binaries alone:
```powershell
.\build_core.ps1 # android client .so + linux amd64/arm64 servers
.\build_core.ps1 -ClientOnly # just the .so
```
### 8.5 Windows-specific notes
Three things differ from the shell build and are worth knowing before editing the
script:
* **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 a
`-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 avoids
the batch tokenizer entirely.
* **`d8.bat` and `apksigner.bat` are still batch files.** Their arguments contain
no semicolons today, so they work — but a project path containing spaces or
semicolons could hit the same class of problem.
* **`zipalign -p -f 4` runs before signing.** The shell script omits it; it is
the canonical ordering and costs nothing.
`android\.gitignore` keeps `build/`, `.tools/`, and the debug keystore out of
version control.
---
## 9. Building on Linux and macOS
```bash
./build_core.sh # Go: android .so + linux amd64/arm64 servers
./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 that bundles
`lib/kotlinx-coroutines-core-jvm.jar` — it defaults to
`~/.sdkman/candidates/kotlin/current`. Unlike the Windows script it does not
download anything for you.
Go builds by hand, if you prefer:
```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 everywhere, so no NDK and no C toolchain are required for any target.
---
## 10. Running the server
```bash
sudo ./dragontcp-hybrid-server-linux-amd64 --port 53 --chunk-max 1048576
```
With a token:
```bash
sudo ./dragontcp-hybrid-server-linux-amd64 \
--token 'YOUR_SECRET' --port 53 --chunk-max 1048576
```
With diagnostics:
```bash
sudo ./dragontcp-hybrid-server-linux-amd64 \
--port 53 --chunk-max 1048576 --debug --debug-stats-interval 10s
```
`sudo` is only needed because port 53 is privileged. If `systemd-resolved` or
`dnsmasq` already owns port 53, free it first or pick another port. The server
creates no TUN device and needs no NAT or `iptables` rules.
| Flag | Default | Meaning |
|---|---|---|
| `--host` | `0.0.0.0` | Listen address |
| `--port` | `53` | Listen port |
| `--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 the server accepts (32 B 1 MiB) |
| `--chunk-buffered` | `256` | Per-session buffer in 64 KiB units (≈16 MiB) |
| `--chunk-poll-wait` | `200ms` | Long-poll wait for the first record of a batch |
| `--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 |
---
## 11. Running the client
### On Android
Install the APK, enter the server IP and port, grant the VPN prompt, connect.
Recommended starting point:
```text
Server: YOUR_SERVER_IP
Port: 53
Token: (match the server, or leave blank)
Max chunk: 1048576
Min chunk: 32
Concurrency: 1
Reconnect every: 0
Timeout (s): 2
```
Use **OPEN LOGS** to watch the path probe and any adaptation.
### As a 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.
| 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` |
| `--max-connections` | `20000` | Concurrent proxied connections |
| `--tcp-buffer` | `0` | Explicit socket buffers |
| `--chunk-start` | `1048576` | Initial record size (probe overrides it) |
| `--chunk-min` | `32` | Floor |
| `--chunk-max` | `1048576` | Ceiling, further clamped by the server |
| `--chunk-adaptive` | `true` | Enable runtime resizing |
| `--chunk-grow-after` | `16` | Successes before growing |
| `--chunk-adapt-log` | `true` | Print size changes |
| `--chunk-size` | `0` | Legacy: pins start/min/max and disables adaptation |
| `--chunk-concurrency` | `1` | Download batch ceiling, 1256 |
| `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
| `--chunk-timeout` | `2s` | Per-record transaction timeout |
| `--chunk-pollers` | `1` | Reserved compatibility knob; unused |
---
## 12. Tuning guide
**Start with the defaults.** Path probing already picks sensible sizes; most
manual tuning makes things worse.
* **Throughput feels capped.** Raise `Concurrency` to 416. More records per
request is the main lever when latency to the server is high, because each
round trip returns more data. Watch the logs — if you see repeated
`adaptive download pipeline: N -> N/2`, the path cannot sustain that depth.
* **Frequent `after transport failure` lines.** The network is dropping large
records. Lower `Max chunk` to something the probe already found safe (1400 is
common) so the client stops rediscovering the limit.
* **Connection dies after a while, then recovers.** A middlebox is capping
requests per connection. Set `Reconnect every` to something like 832.
* **Nothing connects at all, but the probe succeeds.** Check the token matches,
and check that the server is not refusing the target because it resolves to a
private address.
* **High latency, low bandwidth link.** Leave `--tcp-buffer` at 0 first. Only if
you have a small number of high-BDP connections is `1048576` or `4194304`
worth trying; on many connections it costs memory for nothing.
* **Server memory.** Each session can hold up to `--chunk-buffered × 64 KiB`
(default 16 MiB). With many concurrent sessions, lower it.
---
## 13. Troubleshooting
### Build
| Symptom | Cause and fix |
|---|---|
| `Android SDK not found` | Set `ANDROID_SDK_ROOT` or pass `-SdkRoot 'D:\Android\Sdk'`. |
| `No usable build-tools found` | Install build-tools via the SDK Manager; the script needs `aapt`, `d8`, `apksigner`, `zipalign` together. |
| `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` by hand, extract it, and pass `-KotlinHome <dir>\kotlinc`. |
| `source entry is not a Kotlin file: …jar` | You reintroduced `kotlinc.bat`. See §8.5 — call the compiler jar through `java.exe`. |
| `libdragontcp_client.so is missing` | Install Go and run `.\build_core.ps1 -ClientOnly`, or pass `-BuildCore`. |
| `run ..\build_core.ps1 first` on Linux | Use `./build_core.sh`; the shell script does not build the core for you. |
### Runtime
| Symptom | Cause and fix |
|---|---|
| `CONNECT failed: Server is required` | Empty server field. |
| `Local proxy did not start` | The Go core died within 10 s. Open the logs; usually a bad flag or an unusable port. |
| `DragonTCP core exited: N` | The core process died while connected. The whole tunnel is torn down deliberately. |
| `authentication failed` | Token mismatch between app and server. |
| `target resolves only to blocked addresses` | The destination is private/loopback. Intentional; `--allow-private` on the server overrides it, at real risk. |
| `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` | Same, in the upload direction. Reconnect. |
| DNS works, QUIC/UDP apps do not | By design: only DNS is carried over UDP. Apps fall back to TCP. |
| No IPv6 anywhere | By design: IPv6 is captured and blackholed to prevent bypass. |
---
## 14. Version history
**Hybrid v1** — the current wire protocol.
* Kept the lightweight Android TUN → local HTTP proxy architecture.
* Replaced ASCII `UP`/`OK`/`CPUSH`/`CPULL` framing with compact binary records.
* Replaced the fixed `0xAD` XOR with a changing SHA-256-derived keystream.
* Added automatic upload/download path-size probing with binary search.
* Kept separate adaptive sizes per direction.
* Added download batching with adaptive pipeline depth.
* Records up to 1 MiB; token optional; TCP/53 default.
* Reconnect selectable: persistent, auto, or forced rotation.
**Hybrid v2** — withdrawn. Introduced a multi-request upload pipeline and
65 535-record mega-batches; both proved unreliable. None of it is present here.
**Hybrid v3 "SafeSpeed"** — built directly from the confirmed-working v1.
* Wire encoding byte-for-byte unchanged from v1: same headers, same 16-byte
session IDs, same keystream, same probes, same upload transactions, same
server framing.
* Download concurrency became user-configurable, 1256, default 1.
* `1` keeps the pipeline fixed at one. Above 1, depth starts at the ceiling,
halves on transport failure, and grows by one on success, staying in `1..N`.
* Server cap remains 256 records per batch.
* Upload remains one framed request followed by one response ACK.
---
## 15. Testing and validation
`cd core && go test ./...` covers:
* the masking round-trip, and that different sequences produce different wire
bytes,
* the adaptive sizer recovering from the minimum rather than latching there,
* `reconnectEvery == 0` meaning persistent.
Current status on this checkout:
```text
ok dragontcp/cmd/dragontcp-client
ok dragontcp/cmd/dragontcp-server
? dragontcp/internal/protocol [no test files]
ok dragontcp/internal/wire
```
Beyond unit tests, the transport was exercised with:
* an 8 MiB HTTP download through the proxy, verified by SHA-256,
* an HTTPS `CONNECT` download verified byte for byte,
* persistent connection mode,
* forced `reconnect-every-1` mode,
* a server restricted to 1400-byte records, where probing selected 1400
automatically and the download still completed correctly.
`SHA256SUMS` records digests for the published binaries.
---
## 16. 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.
* Full attribution: [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)
* Full license text: [`licenses/FreeProxy-APACHE-2.0.txt`](licenses/FreeProxy-APACHE-2.0.txt)
* Both are also shipped inside the APK under `assets/`.
Those notice files are a license condition, not documentation — they are kept
separate from this README deliberately, and should not be folded into it or
deleted.
The remaining DragonTCP glue, UI, Go transport, and server code is provided as
part of this project.