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
VpnServiceand 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
- What it does and why
- Security model — read this
- Repository layout
- The wire protocol
- The Go client
- The Go server
- The Android app
- Building on Windows
- Building on Linux and macOS
- Running the server
- Running the client
- Tuning guide
- Troubleshooting
- Version history
- Testing and validation
- 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:
- 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.
- 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,
StatusErrorbodies 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, replies200 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, theConnection,Proxy-Connection, andProxy-Authorizationheaders are stripped, aHostheader is synthesised if missing, andConnection: closeis 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 asack,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
goodif 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
badlandmark 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
badlandmark and grow bymax(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 (1–256, 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:
bufholds bytes that have arrived from the target but are not yet acknowledged by the client,baseis the absolute stream offset ofbuf[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 returnsStatusWait. This keeps batches from stalling on partially-filled pipelines. - Coalescing. If less than
limitbytes 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
expectedUpis 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.ConstantTimeCompareon bothPROBEandOPEN. 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-privatedisables 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.
--debuglogs accepts, session opens, and errors to stderr, and--debug-stats-intervalprints counters (bytes up/down, push records, pull requests, data/wait records, active sessions).--debug-chunkslogs 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
MainActivityvalidates the form and saves it toSharedPreferences.VpnService.prepare()— the system consent dialog, if not already granted.DragonServicestarts in the foreground with a notification carrying a STOP action.- The Go core is spawned with flags built from the saved settings.
- The service polls
127.0.0.1:8080for up to 10 s until the proxy accepts. - The TUN interface is established.
TunnelEnginestarts and the state broadcasts flip toCONNECTED.
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
::/0into 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
TcpSessionkeyed 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
- Resolve the toolchain and print what it picked.
- Build the native core if
lib\arm64-v8a\libdragontcp_client.sois missing (or-BuildCorewas passed) by calling..\build_core.ps1 -ClientOnly. aapt package— compileres/, packassets/, bind the manifest againstandroid.jar, producingresources.ap_.- Kotlin — compile every
.ktundersrc\tobuild\kclasses, targeting JVM 1.8, againstandroid.jar+ coroutines + stdlib. - Java — compile every
.javaundersrc\tobuild\jclasseswith--release 8, againstandroid.jar+ the Kotlin output + stdlib. jarboth class trees, thend8them together withkotlin-stdlib,kotlin-stdlib-jdk7/8, andkotlinx-coroutines-core-jvmintoclasses.dexat--min-api 29.- Package — copy
resources.ap_to the APK and addclasses*.dexplus the wholelib\tree using .NET'sZipArchive(Windows has nozipcommand). zipalign -p -f 4, thenapksigner sign, thenapksigner 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 throughkotlinc.bat.cmd.exetreats;as an argument separator, so a-classpath a.jar;b.jarhanded to a batch file is split into two arguments and the second jar is misread as a source file. Callingjava.exedirectly avoids the batch tokenizer entirely. d8.batandapksigner.batare 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 4runs 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, 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
Concurrencyto 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 repeatedadaptive download pipeline: N -> N/2, the path cannot sustain that depth. - Frequent
after transport failurelines. The network is dropping large records. LowerMax chunkto 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 everyto 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-bufferat 0 first. Only if you have a small number of high-BDP connections is1048576or4194304worth 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/CPULLframing with compact binary records. - Replaced the fixed
0xADXOR 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.
1keeps the pipeline fixed at one. Above 1, depth starts at the ceiling, halves on transport failure, and grows by one on success, staying in1..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 == 0meaning 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
CONNECTdownload verified byte for byte, - persistent connection mode,
- forced
reconnect-every-1mode, - 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 - Full license text:
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.