2026-08-16 13:17:19 -03:00
V13
2026-08-16 13:17:19 -03:00
V13
2026-08-16 13:17:19 -03:00
V13
2026-08-16 13:17:19 -03:00
V7
2026-08-16 03:48:57 -03:00
2026-08-16 02:33:07 -03:00
V13
2026-08-16 13:17:19 -03:00
V13
2026-08-16 13:17:19 -03:00
V13
2026-08-16 13:17:19 -03:00
V13
2026-08-16 13:17:19 -03:00
V7
2026-08-16 03:48:57 -03:00

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
  2. Security model — read this
  3. Repository layout
  4. The wire protocol
  5. The Go client
  6. The Go server
  7. The Android app
  8. Building on Windows
  9. Building on Linux and macOS
  10. Running the server
  11. Running the client
  12. Tuning guide
  13. Troubleshooting
  14. Version history
  15. Testing and validation
  16. Licensing

1. What it does and why

The data path

  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

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:

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:

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

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:

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:

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:

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

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 requestLanes, 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:

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:

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:

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:

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.

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

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

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 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

.\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:

.\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

./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:

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

sudo ./dragontcp-hybrid-server-linux-amd64 --port 53 --chunk-max 1048576

With a token:

sudo ./dragontcp-hybrid-server-linux-amd64 \
  --token 'YOUR_SECRET' --port 53 --chunk-max 1048576

With diagnostics:

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:

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

./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:

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.

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.

S
Description
No description provided
Readme
163 MiB
Languages
Go 64.7%
Kotlin 14%
Java 13.8%
PowerShell 4.2%
Python 1.6%
Other 1.7%