# 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` (1–256, 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() 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 :` 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-`; 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, 1–256 | | `--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 4–16. 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 8–32. * **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-.zip` by hand, extract it, and pass `-KotlinHome \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, 1–256, 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.