# DragonTCP Hybrid DragonTCP carries ordinary TCP traffic inside a compact binary record protocol, normally over TCP port 53. It has three parts: * a **Go server** on a Linux host that relays streams to their real destinations, * a **Go client** that exposes a local HTTP/HTTPS proxy and, optionally, an authenticated tunnel-only SSH/SOCKS5 VPN path, * an **Android app** that captures all device traffic with `VpnService`, runs a userspace TCP/IP stack, and feeds everything into that local proxy. This document is a complete reference for how all of it works. > **Current Android build:** SSH support is retained in the Go client/server source, > but the Android app intentionally runs direct DragonTCP only. Startup performs > server-backed fake-iperf calibration for B, BP, and X: it starts at 32 bytes, grows > candidate UP/DW chunks by 4x toward the fixed 1 MiB ceiling, then refines the first > good/bad boundary to 32-byte precision. For X, the calibrated UP/DW values are > locked for the lifetime of the tunnel: runtime successes do not grow them and > transport failures do not shrink them. The Android main screen shows live UP and > DW chunk candidates/active sizes without requiring the log screen. Android always > uses SHA-256-masked payload framing; the clear-payload control is not exposed. --- ## Table of contents 1. [Overview](#1-overview) 2. [Security model](#2-security-model) 3. [Repository layout](#3-repository-layout) 4. [The wire protocol](#4-the-wire-protocol) 5. [The Go client](#5-the-go-client) 6. [The Go server](#6-the-go-server) 7. [The Android app](#7-the-android-app) 8. [Building](#8-building) 9. [Running](#9-running) 10. [Configuration reference](#10-configuration-reference) 11. [Tuning](#11-tuning) 12. [Troubleshooting](#12-troubleshooting) 13. [Testing](#13-testing) 14. [Licensing](#14-licensing) --- ## 1. Overview ### 1.1 The data path ```text Android apps (any app, unmodified) │ │ IP packets ▼ VpnService TUN interface 10.77.0.2/32, MTU 1400 │ │ userspace TCP/IP reassembly ▼ TunnelEngine (Kotlin, in-process) │ │ HTTP CONNECT to 127.0.0.1:8080 ▼ dragontcp-client (Go, child process on the phone) │ │ DragonTCP binary records over TCP/53 ▼ dragontcp-server (Go, on the host) │ │ plain TCP ▼ destination server ``` When SSH mode is enabled, the path becomes: ```text Android apps │ IP packets ▼ VpnService + TunnelEngine │ SOCKS5 TCP / UDP ASSOCIATE ▼ 127.0.0.1:1080 (dragontcp-client) │ │ one persistent SSH connection carried by one DragonTCP stream ▼ dragontcp-server │ ├─ SSH direct-tcpip channels ───────────────► TCP destinations │ └─ SSH-only dragontcp-udpgw.internal:7400 ─► embedded UDPGW ─► UDP ``` The SSH service is deliberately **tunnel-only**. It supports password authentication and `direct-tcpip`; a `session` channel is only a dummy channel whose shell/exec/PTY requests are refused. There is no usable interactive shell, SCP, or command execution path. ### 1.2 Design decisions **The server is only a relay.** It creates no TUN device, performs no NAT, and needs no `iptables` rules. All packet-level work happens on the phone in userspace. SSH and UDPGW are embedded in the same Go server binary; no separate OpenSSH or BadVPN daemon is required. **Each direction is split into records, not a byte stream.** Every record is carried by its own request/response exchange. That costs some efficiency and buys two properties: * *Record size is negotiable at runtime.* Some networks silently drop or truncate large writes on port 53. Because every record is framed and acknowledged, the client can discover the largest size that survives the path, and adapt if conditions change later. * *The transport survives connection rotation.* Session state lives in a 16-byte session ID, not in the TCP connection. The client can close and reopen the underlying connection between any two records without losing the stream, which matters on middleboxes that limit how long a port-53 connection may live or how many requests it may carry. **Stream position is explicit.** Both directions are addressed by absolute byte offset rather than by message counter. That makes retries idempotent and makes flow control a matter of reporting a number. --- ## 2. Security model **Raw DragonTCP does not provide authenticated encryption.** In the legacy/direct HTTP-proxy mode, DragonTCP is transport obfuscation rather than a cryptographic VPN. Legacy payloads are **masked**: XORed with a keystream derived from SHA-256 that varies with session ID, mode, sequence, direction, and block number. New clients also support a self-described **clear-payload** profile that removes the per-32-byte SHA-256 work. Auto tries clear first and falls back to the legacy mask for older servers or networks that reject the clear profile. This defeats trivial pattern matching. It does not defeat anyone who can read the traffic: * The mask is derived from the **session ID, which is transmitted in cleartext in every request header.** Anyone who sees the header can regenerate the keystream and recover the plaintext. This is obfuscation, not confidentiality. * Session, sequence, and length fields remain clear. The mode/status byte may use a startup-selected header mask, but this is traffic shaping rather than cryptographic protection. * `StatusError` bodies are sent **unmasked**, as plain text. * There is no integrity check, so payloads can be tampered with undetected. The optional `--token` is a shared secret compared in constant time. It gates who may open sessions. It is not a key and does not affect the mask. **Consequence:** rely on TLS end to end. HTTPS through DragonTCP is protected by HTTPS, not by DragonTCP. Do not assume anything sent over plain HTTP through this tunnel is private. **SSH mode changes the client-to-server security layer.** The persistent SSH session provides SSH confidentiality, integrity, server host-key verification (TOFU pinning when configured), and password user authentication inside the DragonTCP carrier. Traffic leaving the DragonTCP server toward its final destination is still protected only by the destination protocol, so HTTPS/TLS remains the correct end-to-end security boundary. On the server side, the relay refuses private and special-use destinations by default (§6.5). That restriction is what stops the tunnel being used to reach the host's own localhost services or cloud metadata endpoints. --- ## 3. Repository layout ```text core/ go.mod module "dragontcp", Go 1.22; x/crypto for SSH/bcrypt cmd/dragontcp-client/ main.go local HTTP/HTTPS proxy, CLI flags chunk.go transport: lanes, probing, adaptation, batching ssh_tunnel.go persistent SSH-over-DragonTCP carrier socks5.go local SOCKS5 TCP + UDP ASSOCIATE front end chunk_test.go cmd/dragontcp-server/ main.go listener, DNS cache, address filtering, CLI flags chunk.go session manager, buffering, request dispatch bhttp.go auto-detected BP/BHP1 transport compatibility fake_ssh.go tunnel-only SSH + JSON user CLI udpgw.go embedded BadVPN-compatible UDP gateway internal_targets.go reserved internal-service routing debug.go counters and periodic statistics chunk_test.go internal/wire/ protocol.go record format and the masking keystream protocol_test.go internal/protocol/ protocol.go TCP tuning, relay loops, legacy UP/OK framing xor_*.go word-at-a-time XOR helpers (legacy path) android/ AndroidManifest.xml package com.dragontcp.client, minSdk 29 src/com/dragontcp/client/ Java: UI, VpnService, log screen MainActivity.java settings screen DragonService.java VpnService: spawns the core, owns the TUN LogActivity.java live log viewer AppLog.java in-memory log buffer src/tech/xvanturing/freeproxy/ Kotlin: userspace TCP/IP stack (Apache 2.0, §14) vpn/TunnelEngine.kt TUN read/write loops, session tables vpn/TcpSession.kt userspace TCP endpoint vpn/UdpSession.kt UDP/DNS handling vpn/net/ IP/TCP/UDP headers, checksums, packet builder, DNS vpn/proxy/ProxyClient.kt HTTP CONNECT + SOCKS5/UDP client res/ icon and theme assets/ license texts shipped inside the APK lib/arm64-v8a/libdragontcp_client.so generated 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.cmd / build_all.sh core + APK in one step (Windows / Unix) bin/ generated server and client binaries licenses/ full Apache 2.0 text SHA256SUMS generated digests after Unix core build THIRD_PARTY_NOTICES.md upstream attribution (a license condition) ``` There is exactly one copy of every source file. A second `core/`, or a nested `android/android/`, is stale — `build_apk.ps1` guards against this by locating the repo root as the nearest ancestor holding **both** `core/go.mod` and `build_core.ps1`. `core/internal/protocol` still contains the legacy `UP`/`OK` text framing and a fixed `0xAD` XOR. That package is retained because it also holds the TCP tuning helpers and relay loops the current transport uses. The legacy framing itself is unreachable: `dragontcp-client` refuses to start unless `--transport chunk`. --- ## 4. The wire protocol Implemented in `core/internal/wire/protocol.go`. ### 4.1 Record framing Client to server — a **request**: ```text offset size field 0 1 mode 1 16 session ID 17 8 sequence (big-endian uint64) 25 4 payload length (big-endian uint32) 29 n payload (masked) ``` Server to client — a **response**: ```text offset size field 0 1 status 1 4 body length (big-endian uint32) 5 n body (masked, with exceptions in §4.3) ``` Headers are **29 bytes** and **5 bytes**. The wire layer rejects payloads above 2 MiB (`MaxPayload`); the transport never exceeds 1 MiB. A single request may be answered by **several** responses — see `ModeDownload`. ### 4.2 Modes and statuses | Mode | Value | Meaning | |---|---|---| | `ModeProbe` | 0 | Path measurement; no session required | | `ModeOpen` | 1 | Create a session and connect to the target | | `ModeUpload` | 2 | Push payload bytes toward the target | | `ModeDownload` | 3 | Request buffered bytes coming back | | `ModeClose` | 4 | Tear the session down | | Status | Value | Meaning | |---|---|---| | `StatusOK` | 0 | Success; body may carry a result | | `StatusError` | 1 | Failure; body is a plaintext message | | `StatusData` | 2 | Body is stream data | | `StatusWait` | 3 | Nothing available yet; ask again | | `StatusEOF` | 4 | Target closed the stream | ### 4.3 Payload encoding ```text seed[0:16] = session ID seed[16] = mode seed[17:25] = sequence (big-endian) seed[25] = 1 for responses, 0 for requests seed[26:30] = block counter (big-endian, increments every 32 bytes) keystream_block = SHA256(seed) payload ^= keystream ``` Masking is its own inverse, so one function both encodes and decodes. Because the sequence field is a **byte offset** (§4.5), consecutive records never reuse a keystream position, and re-sending the same offset reproduces the same bytes — which is what makes idempotent retries safe. When the cover preface carries the clear-payload flag, request and data bodies are sent without that transform. Headers, session semantics, retry offsets, and all payload layouts remain identical. Old clients remain masked and are accepted by the new server. An old server rejects the new flag, so the new client's next startup candidate is the corresponding legacy direct profile. Not everything is masked. `WriteResponse` sends the body as-is and is used for empty `StatusOK`, `StatusWait`, `StatusEOF`, and every `StatusError`. `WriteMaskedResponse` is used for `StatusData` and for the `OPEN` result. On the client, `DecodeMaskedResponse` deliberately skips decoding when the status is `StatusError`, so both sides agree. ### 4.4 Payload layouts **PROBE** request payload: ```text offset size field 0 4 magic "DTP2" 4 1 probe kind 5 2 token length (big-endian uint16) 7 4 value (big-endian uint32) 11 t token 11+t … filler, byte i = (i*31 + 17) & 0xFF ``` | Probe kind | Value | Server behaviour | |---|---|---| | `ProbeUpload` | 1 | Replies `OK` if the whole record arrived and is within `--chunk-max`. The *filler* is what is being measured. | | `ProbeDownload` | 2 | Replies `StatusData` with exactly `value` bytes of the same generated pattern. | | `ProbeKeepalive` | 3 | Replies `OK`. Used to test whether one connection may carry several requests. | | `ProbeBatch` | 4 | Replies with up to 16 back-to-back 32-byte `StatusData` records. | The client verifies download probes byte for byte, so a middlebox that truncates or rewrites the response fails the probe instead of silently corrupting data. **OPEN** request payload: ```text offset size field 0 2 token length 2 2 host length 4 2 target port 6 t token 6+t h target host (name or literal IP) ``` The response is `StatusOK` with a masked 4-byte body: the server's `--chunk-max`. The client clamps its own maximum to that value. Repeating `OPEN` for an existing session is idempotent and returns the same limit. **UPLOAD** — the sequence field is the byte offset in the upload stream and the payload is the data. The response is an empty `StatusOK` acknowledgement. **DOWNLOAD** — the sequence field is the byte offset the client wants next. The 14-byte payload is: ```text offset size field 0 8 ack offset — everything below this has been consumed 8 4 maximum bytes per record 12 2 how many records the client will accept in this batch ``` The server answers with up to `count` `StatusData` records, each masked with the running offset, terminated early by one `StatusWait` or `StatusEOF`. **CLOSE** — no payload; the server drops the session and replies `StatusOK`. ### 4.5 Stream offsets and flow control Three offsets drive the whole protocol: | Offset | Held by | Meaning | |---|---|---| | upload sequence | client | bytes already sent toward the target | | download sequence | client | next byte the client wants | | ack offset | client, reported to server | next byte the **application** has not yet read | The ack offset trails the download offset. It advances only when the consuming application actually reads bytes out of the client's buffer. The server uses it to discard acknowledged data; when the client stops reading, the server's buffer fills, its reader goroutine blocks, and TCP backpressure propagates to the origin server. There is no separate window mechanism — the ack number is the window. ### 4.6 A session end to end ```text client server │── PROBE upload (binary search) ─────────▶│ │◀─ OK / error ────────────────────────────────│ │── PROBE download (binary search) ─────────▶│ │◀─ DATA(pattern) ─────────────────────────────│ │── PROBE keepalive ×8 on one connection ────▶│ │◀─ OK ×8 ─────────────────────────────────────│ │ │── OPEN sid=… host=example.com port=443 ─────▶│ dial example.com:443 │◀─ OK body=chunk_max ─────────────────────────│ │ │── UPLOAD sid seq=0 payload=ClientHello ─────▶│ write() to target │◀─ OK ────────────────────────────────────────│ │── DOWNLOAD sid seq=0 ack=0 limit=1400 cnt=4 ▶│ │◀─ DATA(1400) DATA(1400) DATA(900) WAIT ──────│ │── UPLOAD sid seq=517 payload=… ─────────────▶│ │◀─ OK ────────────────────────────────────────│ │── DOWNLOAD sid seq=3700 ack=3700 … ─────────▶│ │◀─ EOF ───────────────────────────────────────│ │── CLOSE sid ────────────────────────────────▶│ │◀─ OK ────────────────────────────────────────│ ``` ### 4.7 BP transport compatibility Binary connections are auto-detected as either native Dragon B or the observable BP protocol used by `bhttp_remote_test.py`. BP support uses the same 29-byte request header, five-byte response header, and SHA-256 counter mask, but maps modes as `0=probe`, `1=upload/register`, `2=single download`, `3=batch download`, and `4=ACK`. It implements `BHP1` version-1 probe integrity, probe batching, empty mode-1 session registration, upload acknowledgements, mode-2's header-only size hint, the six-byte batch request, ACK, expiry, and unknown-session errors. Dragon peers can additionally negotiate the clear-payload encoding via the cover preface; reference clients continue through the original direct masked encoding unchanged. The available reference client does not expose a destination-selection or authentication exchange. Its observable registration/upload/download/ACK behavior remains accepted unchanged. Dragon's BP client adds a `DOP1` upload extension after registration to carry the normal token, target host, and port; that extension gives the Android app a complete bidirectional stream without changing the reference client's frames. Native Dragon B remains available and is not replaced. --- ## 5. The Go client Source: `core/cmd/dragontcp-client/`. ### 5.1 Local proxy front end `main.go` listens on `127.0.0.1:8080` and speaks ordinary HTTP proxy protocol. **`CONNECT host:port`** opens a tunnel, replies `200 Connection Established`, and relays bytes both ways. This is the path used for HTTPS and, on Android, for everything. **Plain `GET http://…`** is rewritten to origin form: the request line is rebuilt, `Connection`, `Proxy-Connection`, and `Proxy-Authorization` are stripped, a `Host` header is synthesised if absent, and `Connection: close` is appended. Request headers are read until `\r\n\r\n`, with a 128 KiB ceiling. The local connection carries a 15-second deadline during the handshake, cleared once relaying starts. Accepts are bounded by `--max-connections` through a slot channel; over the limit the client returns `503`. If `--ssh-user` is set, the client also starts a no-auth SOCKS5 listener on `127.0.0.1:1080` by default. That listener is local-only; SSH credentials are used between the DragonTCP client and the embedded SSH service, not by local SOCKS clients. * SOCKS5 `CONNECT` opens an SSH `direct-tcpip` channel. * SOCKS5 `UDP ASSOCIATE` opens one SSH `direct-tcpip` channel to the embedded UDPGW and translates SOCKS UDP datagrams to/from BadVPN UDPGW frames. * UDPGW is IPv4-only. Domain-form SOCKS UDP destinations are rejected instead of being resolved locally, preventing an accidental local DNS leak. `ssh_tunnel.go` maintains one persistent authenticated SSH connection over one DragonTCP stream and multiplexes the per-flow SSH channels inside it. The SSH packet layer normally emits writes in the tens-of-KiB range; the client therefore uses a bounded 4 MiB carrier send queue and combines consecutive SSH packets into DragonTCP writes of up to 1 MiB (2 ms combine window). On the server, the reserved internal SSH target gets a 25 ms / 512 KiB downstream coalescing policy before a DragonTCP pull response is emitted. This prevents one SSH packet from costing one full DragonTCP WAN round trip while leaving ordinary non-SSH sessions on the original low-latency coalescing behavior. A failed SSH connection is discarded and redialled; individual app flows do not each create their own DragonTCP carrier connection. Relaying uses `io.Copy` in both directions and waits for **both** to finish, so TCP half-close is preserved and slow or large responses are not truncated. On Linux this lets the kernel use `splice`. ### 5.2 `chunkConn` `openChunkTunnel` returns a `chunkConn` implementing `net.Conn`, so the proxy front end never knows it is talking to a record protocol. It holds: * `upOffset` — bytes sent, used as the upload sequence, * `downloadOffset` — next byte to request, * `consumedOffset` — next unread byte, sent as `ack`, * `readBuf` — received but not yet delivered to the reader, * two `requestLane`s of its own, one for uploads and one for downloads, so a blocking download poll never delays an upload. `Write` slices the caller's buffer into records of the current upload size, each acknowledged before the next is sent. `Read` refills `readBuf` via `fillReadBuffer`, which issues batched download requests. `Close` sends `ModeClose` over the upload lane, then closes both lanes. ### 5.3 Request lanes and connection reuse A `requestLane` owns at most one physical TCP connection and serialises requests onto it under a mutex. Each tunnel has two of them (§5.7). Any I/O error discards the connection; the next request redials. Sockets get `TCP_NODELAY`, 30-second keepalives, and optionally explicit buffer sizes via `--tcp-buffer`. | `--chunk-reconnect-every` | Behaviour | |---|---| | `0` | Persistent — one connection for the life of the lane | | `1` (app default) | Auto — starts persistent, retries once after reconnect, and switches that lane to one request per connection only when reuse fails during real traffic | | `N ≥ 2` | Rotate — close and redial after N logical requests | ### 5.4 Startup header scan and path calibration Before normal traffic, the client selects one fixed wire/header profile. Normal Android discovery always keeps SHA-256 payload masking enabled. Normal masked discovery now tests **only masks that are mathematically valid for the server's direct first-byte classifier**. It does not run the old covered `0x00`-`0xFF` sweep. For B and BP the low three bits carry the request mode (`0..4`), so the mask's low three bits must be zero. That produces exactly 32 candidates: ```text 00 08 10 18 20 28 30 38 ... E8 F0 F8 ``` For X the first byte is `'U' ^ mask`; the server classifies X only when its low three bits are 5, 6, or 7. That produces exactly 96 valid direct X masks. Auto interleaves only these valid B/BP/X candidates in numeric mask order, for 160 candidates total instead of roughly 900 probes. Header discovery uses one tiny **server-local protocol probe**. It does not open `ip.dr2.site` or another Internet target. This keeps the test focused on whether the DragonTCP framing survives the carrier and avoids mixing server-outbound Internet conditions into header selection. The Android default is one probe worker and a 100 ms launch spacing. The winner is cached for the lifetime of the client process and reused by every reconnect. A successful selection is logged as: ```text wire probe: selected=x/mask-37/cover-3792/pad-0/masked header_mask=37 completed=57 launched=57 elapsed=... protocol_probe=true threads=1 fixed_until_restart=true [D-TCP] phase=AUTH state=success wire=x header_mask=37 clear_payload=false ... ``` Candidate counts remain bounded: pinned B has 288 candidates, BP 257, X 352, and Auto 897. The extra direct nonzero B/X profiles are retained after the full-range covered scan for compatibility with older servers. After the header is fixed, UP and DW chunk calibration runs separately. It starts at 32 bytes, grows by 4x toward 1 MiB, and after the first failed size refines the last good/bad interval to **32-byte precision**. Boundary decisions use 2-of-3 confirmation on fresh sequential connections. Calibration is always single-poller/single-outstanding-request so the measured ceiling is not inflated by aggregate parallel traffic. Connection-level I/O timeout is not treated as proof that the candidate chunk is too large. A timeout kills/replaces the physical connection instead of forcing a size downgrade. ### 5.5 Adaptive record sizing `adaptiveSizer` keeps one instance per direction plus two landmarks: `good`, the largest size known to work, and `bad`, the smallest known to fail. An I/O timeout is different from a size failure. It is a hard tunnel boundary: the timed-out socket is closed and the error is returned immediately. The client does not replay that request on the same logical session and does not walk every smaller record or batch size. The application's next connection creates a new tunnel. Other non-timeout transport failures can still use the adaptation below. **On failure** at the current size: * record `bad = min(bad, attempted)`, * drop to `good` if a smaller known-good size exists, otherwise halve, * clamp to `--chunk-min` and force a strict decrease. **On success** at the current size, after `--chunk-grow-after` consecutive successes (default 16): * if `bad` is known and more than one step above, move **halfway toward it** — a binary search upward rather than a blind jump, * otherwise clear the stale `bad` landmark and grow by `max(current/4, 32)`, * clamp to the maximum. When `bad - good ≤ 64` the required success count is multiplied by eight: once the working size is tightly bracketed the controller stops probing the ceiling and settles. ```text adaptive upload chunk: 32768 -> 16384 after transport failure adaptive download chunk: 1400 -> 700 after transport failure adaptive upload chunk: 700 -> 1050 after stable success ``` ### 5.6 Download batching One download request can return many records. **This is a transport setting, not a thread count** — nothing here creates threads. It controls how many records the server may stream back in reply to a single request. Two bounds define it: | Flag | Default | Meaning | |---|---|---| | `--chunk-concurrency` | `1` | Maximum records per request (1–256) | | `--chunk-concurrency-min` | `1` | Minimum records per request (1–256) | Batching matters most on restricted paths. If the safe record size is 32 bytes, one TCP/53 request can still return many 32-byte records instead of needing a fresh request for every 32 useful bytes. **Two modes**, decided entirely by whether the bounds are equal: * **Pinned** (`min == max`). The depth never changes: it does not grow, it does not halve on failure, and it is not reduced by the 1 MiB batch cap. Use this when a path only works at one specific number of records — the adaptive controller stays completely out of the way. * **Adaptive** (`min < max`). The depth starts at the ceiling, **halves** on transport failure but never below `min`, and **grows by one** after successful data responses until it reaches the ceiling again. `1..1` is the pinned case at one record, which is the default. ```text adaptive download batch: 64 -> 32 after transport failure ``` Outside pinned mode, each batch is additionally capped to carry roughly 1 MiB of useful data: ```go count = min(pipeline, maxPipeline, (1 MiB) / chunkSize) count = max(count, minPipeline) // the floor always wins ``` The server independently clamps `count` to 256 and `limit` to its own `--chunk-max`, so a client can never demand more than the server allows. ### 5.7 Connection model Each proxied socket gets its own tunnel, and each tunnel dials **two** TCP connections to the server: one upload lane and one download lane. `OPEN` and `CLOSE` ride the upload lane rather than dialling their own connections. So a device browsing normally holds roughly `2 × active flows` connections to port 53, plus churn as flows come and go. That is the transport behaving as a proxy, not as a single multiplexed link. **Why it is not one connection.** A response header is only `status + length` — it carries no session or request ID. Responses can therefore only be matched to requests by **arrival order**, which means a connection must finish one full exchange before another session may use it. Since a download is a long poll that can block for `--chunk-poll-wait`, sharing one connection across sessions lets idle pollers starve real traffic. Per-tunnel lanes are a requirement of the current wire format, not an oversight. Making the client hold a single connection would require adding a session ID to the response header, demultiplexing responses asynchronously on the client, and handling requests concurrently per connection on the server — a wire-format change affecting both ends. `--chunk-pollers` is accepted for compatibility and validated to 1–128, but the transport uses one download worker per tunnel and never reads it. ### 5.8 Failure escalation On a download failure the client escalates in a fixed order: 1. **Shrink the batch** — halve it, never below the floor. 2. **Shrink the record size** — only once the batch is already at its floor. 3. **Give up** — after eight consecutive failures at the minimum record size, return an error rather than spinning forever. Uploads have no batch dimension, so they go straight to steps 2 and 3. A 30 ms pause separates retries. `StatusWait` responses are followed by a `--chunk-poll-delay` pause (default 2 ms) instead of being treated as failures. --- ## 6. The Go server Source: `core/cmd/dragontcp-server/`. ### 6.1 Connection handling The server listens on `0.0.0.0:53` by default, bounded by `--max-connections` through a slot channel. Each connection runs a loop: read one request with an idle deadline, dispatch it, repeat. The deadline is refreshed halfway through its window rather than issuing a system call for every record. Because session state is keyed by session ID rather than by connection, requests for one logical stream may arrive over many connections in whatever pattern the client chooses. ### 6.2 Session state and buffering Each `OPEN` creates a `streamSession` holding the real TCP connection to the target plus a download buffer: * `buf` — bytes received from the target but not yet acknowledged, * `base` — the absolute stream offset of `buf[0]`, * a dedicated goroutine reading the target in 64 KiB chunks. That goroutine **blocks when the buffer is full**, which is the entire flow control story described in §4.5. Buffer size is `--chunk-buffered × 65536`, clamped to 1 MiB…64 MiB (default 32 → 2 MiB per session). The same byte limit is enforced for X; it is not multiplied by the negotiated chunk size. Acknowledgement drops bytes off the front and advances `base`. The buffer is compacted when its capacity exceeds four times its length and is over 1 MiB, so long-lived sessions do not hold onto peak allocations. Sessions are stored in a map keyed by the raw 16 session bytes. Creating a session that already exists closes the newcomer and keeps the original. ### 6.3 Serving downloads `readAt(offset, limit, wait)` requires `offset` to lie within `[base, base+len(buf)]`. An offset below `base` is an error — those bytes were acknowledged and discarded. An offset beyond the buffered end is also an error. Two behaviours matter: * **Long poll.** The *first* record of a batch waits up to `--chunk-poll-wait` (default 200 ms) for data to arrive. Later records in the same batch do not wait: the batch drains what is buffered and then returns `StatusWait`. This stops batches stalling on partially-filled pipelines. * **Coalescing.** If fewer than `limit` bytes are available, the server waits up to 2 ms more for the target to produce more. Without this, a 1-byte read from the origin would become a permanent 1-byte tunnel record and per-record overhead would dominate. ### 6.4 Serving uploads Uploads must arrive in exact order: the offset must equal the session's `expectedUp`. Two cases are special: * an offset entirely **below** `expectedUp` is an idempotent retry after a lost acknowledgement and silently succeeds, * a partially overlapping retry is rejected as an error. This is what makes it safe for the client to resend a record whose response was lost when a connection died mid-request. ### 6.5 Target resolution and address filtering `dialTarget` resolves through a bounded DNS cache (`--dns-cache-ttl`, default 30 s; `--dns-cache-size`, default 4096). When the cache is full it resets wholesale rather than evicting entry by entry — cheap, and adequate for a hot cache. Literal IPs bypass resolution. Each candidate address is checked before dialling. Rejected by default: unspecified, multicast, non-global-unicast, private, loopback, link-local, and these special-use prefixes: ```text 0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 240.0.0.0/4 2001:db8::/32 ``` `--allow-private` disables the whole check. Leave it off on a public server. The SSH tunnel uses the **same** destination policy for `direct-tcpip`, so adding SSH does not automatically expose localhost, RFC1918 services, or cloud metadata addresses. Two reserved names are exceptions with intentionally narrow scope: * `dragontcp-ssh.internal:2222` is reachable by raw DragonTCP so the client can perform the SSH handshake against the loopback-only embedded SSH listener. * `dragontcp-udpgw.internal:7400` is in a separate SSH-only registry. It is reachable only after SSH authentication and cannot be opened directly by a raw DragonTCP stream. If every resolved address is blocked, the error names them. Dial timeout is 10 s, with 30-second keepalives on the resulting connection. ### 6.6 Lifecycle and diagnostics A sweep every 30 s closes sessions idle longer than `--chunk-session-timeout` (default 2 minutes), and any session already marked closed. `--debug` logs accepts, session opens, and errors to stderr. `--debug-stats-interval` prints counters: bytes up and down, push records, pull requests, data and wait records, sessions opened and closed, active sessions. `--debug-chunks` logs every record and is very verbose. ### 6.7 Tunnel-only SSH and user database The embedded SSH listener defaults to `127.0.0.1:2222`. It is not published as a normal Internet-facing SSH port; DragonTCP reaches it through the reserved internal target above. Host keys are Ed25519 and are generated automatically on first start with mode `0600`. SSH users are stored in `dragontcp-users.json`. Passwords are bcrypt hashes; the database can set an expiry and a maximum number of simultaneous persistent SSH connections per user. The same server binary acts as the account-management CLI with `--ssh-user-add`, `--ssh-user-delete`, and `--ssh-user-list`. The integrated UDPGW listens on `127.0.0.1:7400` by default. It implements the BadVPN UDPGW framing used by the SSH VPN path and is not a public UDP service. --- ## 7. The Android app Package `com.dragontcp.client`, `minSdk 29`, `targetSdk 29`, arm64 only. ### 7.1 Components | Component | Language | Role | |---|---|---| | `MainActivity` | Java | Settings screen; validates and persists configuration | | `DragonService` | Java | `VpnService`: spawns the Go core, owns the TUN, runs the foreground notification | | `LogActivity` / `AppLog` | Java | Live log viewer over a 600-line in-memory ring | | `TunnelEngine` | Kotlin | TUN read/write loops, session tables, housekeeping | | `TcpSession` | Kotlin | Userspace TCP endpoint, one per 4-tuple | | `UdpSession` | Kotlin | DNS/direct-mode UDP handling and SOCKS5 UDP in SSH mode | | `net/*` | Kotlin | Header parsing, checksums, packet construction, DNS parsing | | `ProxyClient` | Kotlin | HTTP CONNECT or SOCKS5 CONNECT/UDP ASSOCIATE against the local Go core | ### 7.2 Process model The APK ships the Go client at `lib/arm64-v8a/libdragontcp_client.so`. Despite the name it is not a shared library — it is a statically linked Go executable. The `lib*.so` naming plus `extractNativeLibs="true"` makes Android unpack it into `nativeLibraryDir` with the executable bit set, which is the standard way to ship a helper binary in an APK. `DragonService` launches it with `ProcessBuilder`, merges stderr into stdout, and reads the output on a background thread. Only interesting lines reach the UI log: those beginning with `wire`, `adaptive `, or `path probe:`, and anything containing `error` or `failed`. A watchdog thread waits on the process; if the core exits while the tunnel is supposed to be up, the whole VPN is torn down. ### 7.3 Startup and shutdown Startup: 1. `MainActivity` validates the form and saves it to `SharedPreferences`. 2. `VpnService.prepare()` shows the system consent dialog if needed. 3. `DragonService` starts in the foreground with a notification carrying a STOP action. 4. The Go core is spawned with flags built from the saved settings. 5. The service polls the selected local proxy for up to 10 s (150 ms connect timeout, 100 ms between attempts): `127.0.0.1:8080` in direct mode or `127.0.0.1:1080` in SSH mode. 6. The TUN interface is established. 7. `TunnelEngine` starts; state broadcasts flip to `CONNECTED`. A service-side guard makes a duplicate CONNECT intent a no-op, so a double tap cannot tear down a healthy tunnel and restart it. Shutdown runs in a fixed order: stop the engine, close the TUN descriptor, then `destroy()` the core process, waiting 1200 ms before `destroyForcibly()` and a further 800 ms. `onRevoke()` (permission withdrawn by the system) and `onDestroy()` both route through the same path. ### 7.4 The TUN interface ```java setSession("DragonTCP Lite") setMtu(1400) addAddress("10.77.0.2", 32) addRoute("0.0.0.0", 0) addAddress("fd77:6472:6167:6f6e::2", 128) addRoute("::", 0) addDnsServer("1.1.1.1") addDisallowedApplication() setBlocking(true) setMetered(false) ``` Two decisions matter: * **IPv6 is captured, then dropped.** The userspace stack is IPv4-only. Routing `::/0` into the tunnel and discarding it prevents apps from bypassing the tunnel over IPv6. It is a blackhole by design. * **The app excludes itself** from the VPN, and every upstream socket is additionally passed through `VpnService.protect()`. Both are needed so the Go core's connection to the server is not routed back into the TUN it serves. ### 7.5 Packet dispatch `TunnelEngine` runs one reader thread and one writer thread on the TUN file descriptor, plus a cached thread pool exposed to coroutines for per-session blocking I/O. The reader parses IPv4 only. `Ipv4Header.parse` rejects anything that is not version 4, has an inconsistent length, or is a **fragment** (MF set or a non-zero fragment offset) — the stack does no reassembly, and since the MTU is chosen locally, normal traffic never fragments. Non-IPv4, ICMP, and malformed packets are dropped silently. * **TCP** goes to a `TcpSession` keyed by the 4-tuple. New flows may only be created by a SYN; anything else receives an RST so apps fail fast instead of hanging. `putIfAbsent` prevents a retransmitted SYN from creating two sessions. * **UDP** goes to a `UdpSession`, created on first packet. Limits: 512 concurrent TCP sessions, 256 UDP. The TUN write queue holds 1024 packets and **drops** on overflow rather than blocking session threads — if the kernel side cannot keep up, dropping is the correct behaviour for a link layer. Housekeeping every 5 s expires idle sessions: TCP 300 s, DNS 20 s, other UDP 120 s. ### 7.6 The userspace TCP endpoint `TcpSession` acts as the *server* toward the phone's own kernel: it answers SYN with SYN-ACK, acknowledges data, and sends FIN or RST. The real traffic travels through a socket to the local proxy. The key simplification: packets written to the TUN go to the local kernel over a lossless path, so **no congestion control is required**. Respecting the peer's advertised receive window is sufficient; retransmission exists only as a backstop. | Constant | Value | |---|---| | MSS | `MTU − 40`, clamped to 536…1460 (1360 at MTU 1400) | | Receive window | 65535 | | Max in flight | 65535 | | Upstream queue | 64 chunks | | Retransmit timeout | 400 ms | | Window poll interval | 100 ms | **Connection setup.** On SYN the session records the peer's sequence and window, then asynchronously opens the tunnel. If that fails it sends an RST immediately so the app sees "connection refused" instead of waiting for a timeout. On success it sends SYN-ACK carrying an MSS option, so the kernel never hands down a segment larger than the tunnel MTU. The initial sequence number is random. **Inbound data.** Only in-order segments are accepted (`sequence == receiveNext`); anything else is answered with a bare ACK. Accepted payloads are pushed to a bounded channel. If that channel is full, `receiveNext` is *not* advanced and the advertised window shrinks — eventually to zero — which pauses the application. When the upstream pump drains a chunk and the window crosses back above one MSS, a single window-update ACK is sent, avoiding a redundant ACK per chunk. **Outbound data.** The downstream pump reads MSS-sized buffers from the proxy socket and writes them back as IPv4+TCP packets with PSH|ACK, throttled by `awaitSendWindow`, which blocks until in-flight bytes plus the new chunk fit inside `min(max(peerWindow, mss), 65535)`. Every segment is copied into a retransmit queue; if no ACK arrives for 400 ms, the queue head is retransmitted. Acknowledged segments are released from the front of the queue, with sequence comparisons done as 32-bit signed differences so wraparound is handled correctly. **Teardown.** A FIN from the app closes the upstream channel; once the pump drains it, `shutdownOutput()` tells the proxy the request is complete. When the proxy side reaches EOF the session sends FIN; on an exception while established it sends RST. `sendReset` uses its own buffer because it may run while another thread holds the shared output buffer. ### 7.7 UDP and DNS Direct mode still uses an HTTP CONNECT upstream, so general UDP cannot be carried there. `UdpSession` handles DNS and drops other UDP flows, which makes QUIC fail and pushes apps back to TCP. SSH mode enables `ProxyProfile.udpOverSocks`. `UdpSession` opens a SOCKS5 UDP association to `127.0.0.1:1080`; the Go client converts those local SOCKS UDP datagrams into BadVPN-compatible UDPGW frames and sends them through an SSH `direct-tcpip` channel. This gives the Android VPN general IPv4 UDP support in SSH mode without running the external `badvpn-udpgw` executable. DNS is forced to **1.1.1.1** by the TUN configuration and follows the selected mode. In direct mode the adapter can use DNS-over-TCP through HTTP CONNECT; in SSH mode DNS UDP traffic can use the SOCKS5/UDPGW path. Responses are also parsed to populate `HostRegistry`, a 512-entry access-ordered LRU mapping IP → hostname. It exists so diagnostics can say `github.com:443` rather than `140.82.121.4:443`. The stack retains hooks for direct (non-tunnelled) DNS, DNS blocking, and per-app attribution. `AppResolver`, `DnsBlocker`, and `TunnelLog` remain deliberate no-op stubs that preserve the upstream API. ### 7.8 Packet construction `PacketBuilder` writes IPv4 packets into a caller-supplied buffer and returns the length, allocating nothing on the hot path. Headers carry TTL 64, the Don't Fragment flag, and a monotonically increasing identification field. Checksums follow RFC 1071, with the TCP/UDP pseudo-header sum folded in; a computed UDP checksum of zero is written as `0xFFFF` per RFC 768. ### 7.9 Settings | UI field | Default | Flag passed to the core | |---|---|---| | Server | — | `--server-host` | | Port start | 53 | `--server-port-start` | | Port end | 53 | `--server-port-end` | | Token | empty | `--token` (omitted entirely when blank) | | Wire | auto | `--wire`; Auto, B, BP, and X are selectable | | Probe delay (ms) | 100 | `--wire-probe-delay`; spacing between sequential valid-mask probes | | Probe threads | 1 | `--wire-probe-threads`; maximum concurrent profile attempts, 1–16 | | Batch max | 1 | `--chunk-concurrency` | | Batch min | 1 | `--chunk-concurrency-min` | | Reconnect every | 1 (auto) | `--chunk-reconnect-every` | | Timeout (s) | 5 | `--chunk-timeout` | Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`, `--transport chunk`, `--chunk-min 32`, `--chunk-max 1048576`, `--chunk-start 1048576`, `--chunk-grow-after 16`, and `--chunk-adapt-log=true`. The Android app does not expose manual record-size or clear-payload controls; startup calibration determines independent UP/DW ceilings automatically. Settings are laid out in CONNECTION, WIRE, LIVE TRANSPORT, DOWNLOAD BATCH, and ADVANCED cards. The client scans Port start → Port end in ascending order. A port is shown as WORKING PORT only after a TCP connection and a real DragonTCP wire/header probe both succeed; a merely open TCP port is not accepted. The batch card carries a live hint that restates the current setting in words as you type, so the mode is never ambiguous: ```text Batch max 1 Batch min 1 → Fixed: 1 record per request, never adapts. Batch max 5 Batch min 5 → Pinned: exactly 5 records per request, never adapts. Batch max 16 Batch min 1 → Adaptive: starts at 16, falls back toward 1 on errors, recovers to 16. Batch max 4 Batch min 9 → Batch min must not be greater than batch max. ``` The service independently clamps both values into 1–256 and forces `min ≤ max` before spawning the core, and records the resulting mode in the log: ```text Download batch: pinned at 5 records per request (never adapts) ``` Validation ranges: both ports 1–65535 with `start ≤ end`, batch values 1–256 with `min ≤ max`, reconnect 0–1000000, timeout 1–120, probe delay 50–30000 ms, probe threads 1–16. ### 7.10 Logs `AppLog` keeps the last 600 lines in memory and pushes them live to any registered listener. `LogActivity` renders them in a selectable monospace view with BACK and CLEAR actions and subscribes for live updates while visible. Nothing is written to disk. --- ## 8. Building ### 8.1 Windows For a complete build, double-click `build_all.cmd` at the repository root, or run: ```cmd build_all.cmd ``` It builds the Go core first and then the signed Android APK. For CI/non-interactive use, `build_all.cmd --no-pause` suppresses the final pause. No Gradle and no Android Studio required; `android\build_apk.ps1` drives the SDK command-line tools directly. ```powershell cd android .\build_apk.ps1 ``` Or double-click `android\build_apk.cmd`. The result is `android\build\DragonTCP-Hybrid-arm64.apk`, signed with a debug keystore generated on first run. | Component | How it is found | Required? | |---|---|---| | **Android SDK** | `ANDROID_SDK_ROOT`, `ANDROID_HOME`, `%LOCALAPPDATA%\Android\Sdk`, `C:\Android\Sdk`, or `-SdkRoot` | Yes | | **build-tools** | Newest version having `aapt.exe`, `d8.bat`, `apksigner.bat`, `zipalign.exe`; or `-BuildTools` | Yes | | **Platform** | `android-35` if present, else newest with an `android.jar`; or `-Platform` | Yes | | **JDK 17+** | `JAVA_HOME`, then `javac` on `PATH` (only if `jar.exe` sits beside it), then `C:\Program Files\Java\*`; or `-JavaHome` | Yes — a JRE is not enough | | **Kotlin** | `KOTLIN_HOME`, else `android\.tools\kotlinc-`; downloaded automatically if absent | Auto | | **Go** | `PATH`, or `-GoBin` on `build_core.ps1` | Only to rebuild the `.so` | Options: ```powershell .\build_apk.ps1 -BuildCore # rebuild the Go .so first .\build_apk.ps1 -BuildTools 35.0.0 -Platform android-35 .\build_apk.ps1 -JavaHome 'C:\Program Files\Java\jdk-21.0.10' .\build_apk.ps1 -KotlinHome C:\kotlinc -NoDownload .\build_apk.ps1 -KotlinVersion 2.2.0 .\build_apk.ps1 -RepoRoot C:\path\to\DragonTCP .\build_apk.ps1 -Keystore C:\keys\release.jks -KsPass … -KeyAlias … -KeyPass … ``` Go binaries alone: ```powershell .\build_core.ps1 # android .so + linux client + linux amd64/arm64 servers .\build_core.ps1 -ClientOnly # just the .so .\build_core.ps1 -AndroidLibRoot ``` ### 8.2 Linux and macOS ```bash ./build_core.sh ./android/build_apk.sh ./build_all.sh # both ``` `build_apk.sh` expects `ANDROID_SDK_ROOT` (or `ANDROID_HOME`) and a `KOTLIN_HOME` pointing at a Kotlin distribution bundling `lib/kotlinx-coroutines-core-jvm.jar`; it defaults to `~/.sdkman/candidates/kotlin/current`. Unlike the PowerShell script it downloads nothing and does not build the Go core for you. `build_core.sh` builds the Android arm64 client plus Linux amd64 client and amd64/arm64 servers. The PowerShell build can additionally build other Android ABIs when an NDK is available. Go builds by hand: ```bash cd core go mod download 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 ``` `golang.org/x/crypto` is the only additional Go module used by the fake-SSH feature (SSH protocol and bcrypt password hashes). CGO is off for every target, so no NDK and no C toolchain are needed for arm64. The first Go build needs network access to download the module unless it is already present in the Go module cache. ### 8.3 What the build does 1. **Resolve the toolchain and repo root**, and print what was chosen. 2. **Build the native core** if the `.so` is missing or `-BuildCore` was passed, by calling `\build_core.ps1 -ClientOnly -AndroidLibRoot \lib`. 3. **`aapt package`** — compile `res/`, pack `assets/`, bind the manifest against `android.jar`, producing `resources.ap_`. 4. **Kotlin** — compile every `.kt` under `src\` to `build\kclasses`, JVM target 1.8, against `android.jar` + coroutines + stdlib. 5. **Java** — compile every `.java` under `src\` to `build\jclasses` with `--release 8`, against `android.jar` + the Kotlin output + stdlib. 6. **`jar`** both class trees, then **`d8`** them with `kotlin-stdlib`, `kotlin-stdlib-jdk7/8`, and `kotlinx-coroutines-core-jvm` into `classes.dex` at `--min-api 29`. 7. **Package** — copy `resources.ap_` to the APK and add `classes*.dex` plus the whole `lib\` tree using .NET's `ZipArchive`. 8. **`zipalign -p -f 4`**, then **`apksigner sign`**, then **`apksigner verify --verbose`**. ### 8.4 Windows implementation notes * **The Kotlin compiler is invoked as `java -cp kotlin-compiler.jar org.jetbrains.kotlin.cli.jvm.K2JVMCompiler`, not through `kotlinc.bat`.** `cmd.exe` treats `;` as an argument separator, so `-classpath a.jar;b.jar` handed to a batch file is split into two arguments and the second jar is misread as a source file. Calling `java.exe` directly bypasses the batch tokenizer. * **`d8.bat` and `apksigner.bat` remain batch files.** Their arguments contain no semicolons, but a project path containing spaces or semicolons could hit the same class of problem. * **Packaging uses .NET `ZipArchive`** because Windows has no `zip` command. * **`zipalign` runs before signing**, which is the required ordering. * **Native-tool stderr is not treated as failure.** The JVM emits a `sun.misc.Unsafe` warning on recent JDKs and `apksigner` emits a native-access warning; under `$ErrorActionPreference = 'Stop'` with a merged output stream those would abort the build, so `Invoke-Tool` judges success by exit code alone. A `.gitignore` in the app directory keeps `build/`, `.tools/`, and the debug keystore out of version control. --- ## 9. Running ### 9.1 Server The easiest way to manage tunnel users is the interactive menu: ```bash ./dragontcp-menu.sh ``` or directly: ```bash ./bin/dragontcp-hybrid-server-linux-amd64 --ssh-menu ``` The menu can create, delete, list, reset, and renew users. **Creating or resetting a user automatically generates a strong password**, so there is no password prompt and no environment variable to export. The generated password is shown once; only its bcrypt hash is stored in `dragontcp-users.json`. Renewing expiry or changing the connection limit does not change the existing password. The menu defaults are 30 days and 1 simultaneous SSH connection. Enter `0` for no expiry or unlimited connections. `dragontcp-menu.sh` automatically selects the AMD64 or ARM64 Linux server binary and uses the project-root `dragontcp-users.json`. Set `DRAGONTCP_USERS_FILE=/path/to/users.json` if your running server uses a different account database. The original non-interactive CLI is still available for automation. Using an environment variable avoids putting a chosen password directly in shell history: ```bash export DRAGONTCP_SSH_PASS='CHANGE_ME' ./bin/dragontcp-hybrid-server-linux-amd64 \ --ssh-user-add alice \ --ssh-user-password-env DRAGONTCP_SSH_PASS \ --ssh-user-days 30 \ --ssh-user-max-connections 1 unset DRAGONTCP_SSH_PASS ``` List or delete accounts non-interactively: ```bash ./bin/dragontcp-hybrid-server-linux-amd64 --ssh-user-list ./bin/dragontcp-hybrid-server-linux-amd64 --ssh-user-delete alice ``` Then run the server normally: ```bash sudo ./dragontcp-hybrid-server-linux-amd64 \ --port 53 --port-alt 80 --chunk-max 1048576 ``` With a token: ```bash sudo ./dragontcp-hybrid-server-linux-amd64 \ --token 'YOUR_SECRET' --port 53 --port-alt 80 --chunk-max 1048576 ``` With diagnostics: ```bash sudo ./dragontcp-hybrid-server-linux-amd64 \ --port 53 --port-alt 80 --chunk-max 1048576 --debug --debug-stats-interval 10s ``` The server listens on TCP ports 53 and 80 simultaneously by default. Set `--port-alt 0` to disable the second listener. Failure to bind the primary port is fatal; failure to bind the secondary port prints a warning and leaves the primary listener running. `sudo` is normally needed because both defaults are privileged. If another service owns either port, free it or select a different port. No TUN device or NAT rules are required. The embedded SSH listener and UDPGW bind loopback by default (`127.0.0.1:2222` and `127.0.0.1:7400`). Do **not** expose those ports publicly just to make the Android mode work: the intended path is through DragonTCP's reserved internal SSH target. ### 9.2 Client CLI ```bash ./dragontcp-hybrid-client-linux-amd64 \ --server-host YOUR_SERVER_IP --server-port 53 \ --listen-port 8080 --chunk-max 1048576 ``` Then point anything at `http://127.0.0.1:8080` as an HTTP proxy. The startup line summarises the active configuration: ```text adaptive_chunk=true start=1048576 min=32 max=1048576 grow_after=16 pollers=1 \ batch=5-5(pinned) reconnect_every=0 timeout=5s ``` For tunnel-only SSH mode: ```bash export DRAGONTCP_SSH_PASS='CHANGE_ME' ./dragontcp-hybrid-client-linux-amd64 \ --server-host YOUR_SERVER_IP --server-port 53 \ --ssh-user alice \ --ssh-password-env DRAGONTCP_SSH_PASS \ --ssh-hostkey-pin-file "$HOME/.dragontcp-ssh-hostkey.pin" ``` The first successful connection writes the SSH host-key SHA-256 fingerprint to the pin file. Later connections fail if that key changes. Point applications at `socks5://127.0.0.1:1080`; TCP is multiplexed as SSH `direct-tcpip` and UDP uses SOCKS5 UDP ASSOCIATE -> SSH -> UDPGW. ### 9.3 Android Install the APK, enter the server address, grant the VPN prompt, and connect. The Android build is direct DragonTCP only; SSH fields and manual record-size controls are intentionally absent. A reasonable starting point is: ```text Server: YOUR_SERVER_IP Port start: 53 Port end: 53 Token: (match the server, or leave blank) Wire: Auto Batch max: 1 Batch min: 1 Reconnect every: 1 Timeout (s): 5 Probe delay (ms): 100 Probe threads: 1 Automatic chunk calibration: 32 B -> 1 MiB, 4x ascent, 32 B refinement Payload framing: SHA-256 masked ``` Use the **LIVE TRANSPORT** card to watch the selected WORKING PORT plus UP/DW calibration/runtime sizes without opening the log screen. **OPEN LOGS** still shows the detailed AUTH/CALIBRATION/ACTIVE state transitions and fake-iperf results. The app package is excluded from its own VPN so the DragonTCP core does not loop back into the TUN. --- ## 10. Configuration reference ### 10.1 Server flags | Flag | Default | Meaning | |---|---|---| | `--host` | `0.0.0.0` | Listen address | | `--port` | `53` | Primary listen port | | `--port-alt` | `80` | Simultaneous secondary listen port; 0 disables it | | `--token` | empty | Optional shared secret | | `--max-connections` | `20000` | Concurrent TCP connections | | `--allow-private` | `false` | Allow private/loopback targets — keep off in public | | `--dns-cache-ttl` | `30s` | Resolver cache lifetime | | `--dns-cache-size` | `4096` | Cached hostnames | | `--tcp-buffer` | `0` | Explicit socket buffers; 0 = OS autotuning | | `--chunk-max` | `1048576` | Largest record accepted (32 B – 1 MiB) | | `--chunk-buffered` | `32` | Per-session buffer in 64 KiB units (≈2 MiB) | | `--chunk-poll-wait` | `200ms` | Long-poll wait for a batch's first record | | `--chunk-session-timeout` | `2m` | Idle session reaping | | `--debug` | `false` | Session/connect/error logs plus periodic stats | | `--debug-chunks` | `false` | Log every record — very verbose | | `--debug-stats-interval` | `5s` | Statistics period; 0 disables | | `--ssh-enable` | `true` | Enable embedded tunnel-only SSH | | `--ssh-listen` | `127.0.0.1:2222` | Embedded SSH listener; keep loopback for normal use | | `--ssh-internal-host` | `dragontcp-ssh.internal` | Reserved DragonTCP name for the embedded SSH service | | `--ssh-host-key` | `dragontcp_ssh_host_key` | Ed25519 host-key file, generated if absent | | `--ssh-users` | `dragontcp-users.json` | JSON SSH user database | | `--ssh-user-add` | empty | Create/update a tunnel user and exit | | `--ssh-user-delete` | empty | Delete a tunnel user and exit | | `--ssh-user-list` | `false` | List tunnel users and exit | | `--ssh-menu` | `false` | Interactive create/delete/list/reset/renew user menu and exit | | `--ssh-user-password-env` | empty | Env var holding password for `--ssh-user-add` | | `--ssh-user-days` | `0` | Account lifetime in days; 0 = no expiry | | `--ssh-user-max-connections` | `1` | Persistent SSH sessions allowed per user; 0 = unlimited | | `--udpgw-enable` | `true` | Enable embedded BadVPN-compatible UDPGW | | `--udpgw-listen` | `127.0.0.1:7400` | UDPGW TCP listener; keep loopback | | `--udpgw-internal-host` | `dragontcp-udpgw.internal` | SSH-only reserved UDPGW destination | | `--udpgw-max-clients` | `10000` | Concurrent UDPGW TCP clients | | `--udpgw-debug` | `false` | Verbose UDPGW errors | ### 10.2 Client flags | Flag | Default | Meaning | |---|---|---| | `--listen-host` / `--listen-port` | `127.0.0.1` / `8080` | Local proxy bind | | `--server-host` / `--server-port` | — / `53` | Remote server; `--server-port` remains the single-port compatibility default | | `--server-port-start` | `0` | First port in ascending scan; 0 uses `--server-port` | | `--server-port-end` | `0` | Last port in ascending scan; 0 uses the resolved start port | | `--token` | empty | Shared secret | | `--transport` | `chunk` | Must be `chunk` | | `--wire` | `auto` | Search B/BP/X; `b`, `bp`, or `x` pins one mode | | `--wire-probe-delay` | `100ms` | Spacing between header-profile probe starts; range 50ms–30s | | `--wire-probe-threads` | `1` | Maximum concurrent server-local header probes; range 1–16 | | `--max-connections` | `20000` | Concurrent proxied connections | | `--tcp-buffer` | `0` | Explicit socket buffers | | `--chunk-start` | `1048576` | Initial record size (the probe overrides it) | | `--chunk-min` | `32` | Record size floor | | `--chunk-max` | `1048576` | Record size ceiling, further clamped by the server | | `--chunk-adaptive` | `true` | Enable runtime record resizing | | `--chunk-grow-after` | `16` | Successes before growing | | `--chunk-adapt-log` | `true` | Print size and batch changes | | `--chunk-size` | `0` | Legacy: pins start/min/max and disables adaptation | | `--chunk-concurrency` | `1` | Download batch ceiling, 1–256 | | `--chunk-concurrency-min` | `1` | Download batch floor, 1–256; equal to the ceiling pins the depth | | `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate | | `--chunk-poll-delay` | `2ms` | Pause after an empty poll | | `--chunk-timeout` | `5s` | Per-record transaction timeout | | `--chunk-pollers` | `1` | Accepted for compatibility; validated 1–128 but unused | | `--ssh-user` | empty | Enables tunnel-only SSH/SOCKS mode when non-empty | | `--ssh-password` | empty | SSH password; prefer `--ssh-password-env` on CLI | | `--ssh-password-env` | empty | Environment variable containing the SSH password | | `--ssh-internal-host` | `dragontcp-ssh.internal` | Reserved DragonTCP SSH target | | `--ssh-internal-port` | `2222` | Embedded SSH target port | | `--ssh-hostkey-pin-file` | empty | TOFU SHA-256 host-key fingerprint file | | `--ssh-socks-host` / `--ssh-socks-port` | `127.0.0.1` / `1080` | Local SOCKS5 listener in SSH mode | | `--ssh-udpgw-host` / `--ssh-udpgw-port` | `dragontcp-udpgw.internal` / `7400` | SSH-only embedded UDPGW target | The `concurrency` flag names are historical. They control the download **batch depth** described in §5.6, not any form of threading. ### 10.3 Fixed timings | Where | Value | What | |---|---|---| | Client | 15 s | Local-connection handshake deadline | | Client | 10 s | Dial timeout to the server | | Client | 2.5 s | Quick single-record probe timeout cap | | Client | 8-20 s | Synthetic UP/DW calibration connection deadline | | Client | 20 s | Whole-probe timeout per direction | | Client | 30 min | Path profile cache lifetime | | Client | 30 ms | Pause between failed record retries | | Server | 30 s | Per-request read deadline | | Server | 2 ms | Small-read coalescing window | | Server | 30 s | Session sweep interval | | Android | 10 s | Wait for the local proxy to accept | | Android | 1200 + 800 ms | Graceful then forced core shutdown | | Android | 400 ms | TCP retransmit timeout | | Android | 5 s | Session housekeeping interval | --- ## 10.4 Staged startup and fake-iperf calibration The client uses an explicit startup boundary before it exposes the local proxy: ```text AUTH / wire validation -> UP calibration -> DW calibration -> lock the validated per-direction chunk ceilings -> DragonTCP ACTIVE -> local proxy / VPN ready ``` Calibration is server-backed and is implemented for **B, BP, and X**. It does not download an external file. Each direction starts at 32 bytes, grows aggressively by 4x while candidates succeed, and stops the coarse ascent at the first failed size or at the fixed 1 MiB ceiling. It then binary-refines the last good/failed interval until the boundary is within **32 bytes**. UP and DW are calibrated sequentially to avoid self-induced startup bursts. Carrier decisions near the boundary use confirmation retries so one transient loss or short-lived network hiccup does not collapse the selected size. The fast 4x ascent is still single-shot while it succeeds. When the first candidate fails, DragonTCP retries that exact size on fresh connections and requires a **2-of-3** success/failure majority. Every 32-byte refinement candidate uses the same majority rule. Socket timeouts are inconclusive and are not counted as evidence that a size is too large. For B/BP, normal Android discovery uses SHA-256-masked payload framing only. Clear payload support remains in the Go CLI for compatibility/testing, but the Android app does not expose or request it. X uses its native UP/OK + XOR framing and has dedicated `CIPERFUP`/`CIPERFDW` server calibration commands, so X is measured on the real X wire rather than through the binary protocol. A real socket I/O timeout is treated as a dead physical connection, not evidence that the candidate size is too large. The last proven size is retained and the connection is replaced instead of walking the chunk size downward. The validated UP/DW sizes become the runtime ceilings for that VPN session. Typical client logs are: ```text [D-TCP] phase=AUTH state=success wire=x ... [D-TCP] phase=CALIBRATION state=starting wire=x strategy=ascending min=32 max=1048576 growth=4x fine_resolution=32 up_down=sequential [D-TCP] phase=CALIBRATION fake_iperf=upload wire=x stage=ascend chunk=524288 ... result=success [D-TCP] phase=CALIBRATION fake_iperf=upload wire=x stage=refine chunk=... result=... [D-TCP] phase=CALIBRATION state=success wire=x upload=... download=... [D-TCP] phase=ACTIVE wire=x upload_chunk=... download_chunk=... ``` Server debug logs show matching `CALIBRATION fake_iperf=upload` and `CALIBRATION fake_iperf=download` events for B/BP and X. ## 11. Tuning Start with the defaults. Path probing already picks sensible sizes, and most manual tuning makes things worse. * **SSH mode is much slower than direct mode.** Current builds combine small SSH packets before they enter DragonTCP and coalesce the internal SSH downstream stream on the server. You should see `ssh carrier: DragonTCP stream connected` followed by `ssh authenticated:` in the Android log. If those lines are absent, rebuild both the server and the Android native client so they run the same source version. This SSH-specific combining does **not** override `Batch min` / `Batch max`; a user-pinned value of 1 remains pinned at 1. * **Throughput feels capped in direct/non-SSH mode.** Raise `Batch max` to 4–16 and leave `Batch min` at 1. More records per request is the main lever when latency to the server is high, because each round trip returns more data. If the log repeatedly shows `adaptive download batch: N -> N/2`, the path cannot sustain that depth. * **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every` to `1` (auto). Auto starts persistent and retries an idempotent request once on a fresh connection for non-timeout failures. An I/O timeout ends the logical tunnel immediately because replaying an upload whose response was lost can duplicate data. If reuse itself failed without timing out, only that lane switches to one logical request per connection. * **Logs show the same transition many times over (`128 -> 64` repeatedly).** Each tunnel adapts independently, so a burst of flows produces a burst of identical lines. The app collapses consecutive duplicates into a counted line; the underlying behaviour is normal. * **The batch keeps collapsing to 1 and throughput dies with it.** Some paths only deliver correctly at one specific number of records. Try `Batch max = Batch min = N` for a few values of N and leave it pinned at whichever works. Pinned mode never halves and is never trimmed by the 1 MiB batch cap. * **You want a floor but still want headroom.** Set `Batch min` to the smallest depth the path tolerates and `Batch max` higher; adaptation then works inside that window instead of falling all the way to 1. * **Frequent `after transport failure` lines.** The network is dropping large records. Android startup already runs ascending UP/DW calibration and locks the highest validated ceiling. Runtime recovery reduces the active chunk by 200 B immediately for recoverable transfer failures; an actual I/O timeout kills the physical tunnel instead of changing the chunk size. * **`i/o timeout`.** The active physical connection is closed and the tunnel exits immediately. It no longer redials that session or repeats the request at 512 KiB, 256 KiB, and every smaller size. * **Connection dies periodically, then recovers.** A middlebox is capping requests per connection. Set `Reconnect every` to 8–32. * **High-latency, high-bandwidth link.** Leave `--tcp-buffer` at 0 first. Only for a small number of high-BDP connections is `1048576` or `4194304` worth trying; across many connections it costs memory for nothing. * **Server memory.** Each session can hold `--chunk-buffered × 64 KiB` (default 2 MiB). Lower it when running many concurrent sessions; the minimum effective window is 1 MiB. --- ## 12. Troubleshooting ### 12.1 Build | Symptom | Cause and fix | |---|---| | `Android SDK not found` | Set `ANDROID_SDK_ROOT` or pass `-SdkRoot`. | | `No usable build-tools found` | Install build-tools; `aapt`, `d8`, `apksigner`, and `zipalign` must all be present in one version. | | `Missing …\jar.exe (a JRE is not enough)` | You have a JRE or the `javapath` shim. Install a JDK and set `JAVA_HOME`. | | `Kotlin download failed` | No network, or a proxy. Download `kotlin-compiler-.zip` manually, extract it, pass `-KotlinHome \kotlinc`. | | `source entry is not a Kotlin file: …jar` | The compiler is being called through `kotlinc.bat`. See §8.4. | | `libdragontcp_client.so is missing` | Install Go and run `.\build_core.ps1 -ClientOnly`, or pass `-BuildCore`. | | `…but no core\go.mod was found above` | Run the script from the app directory, or pass `-RepoRoot`. | ### 12.2 Runtime | Symptom | Cause and fix | |---|---| | `CONNECT failed: Server is required` | Empty server field. | | `Local proxy did not start` | The core died within 10 s. Check the logs; usually a bad flag or an unusable port. | | `DragonTCP core exited: N` | The core died while connected; the tunnel is torn down deliberately. | | `authentication failed` | Token mismatch between app and server. | | `target resolves only to blocked addresses` | The destination is private or special-use. Intentional; see §6.5. | | `download offset N was already acknowledged` | Client and server disagree on stream position, almost always a stale session after a restart. Reconnect. | | `upload gap: got N expected M` | The same, in the upload direction. Reconnect. | | `server maximum chunk N is below client minimum M` | Android uses a fixed 32-byte minimum; raise the server's `--chunk-max` to at least 32. CLI users may also lower `--chunk-min`. | | `download failed at minimum chunk` | Eight consecutive failures at the smallest record size — the path is not passing traffic at all. | | `i/o timeout` | The physical connection timed out; its tunnel was terminated immediately without replay or a chunk-size retry loop. | | DNS works, QUIC/UDP apps do not | The current Android build is direct DragonTCP/HTTP only, so general UDP is not tunneled. SSH/UDPGW code remains available in the Go project but is disabled in the app. | | `SSH handshake/auth failed` | Check the SSH username/password, account expiry/connection limit, and that the server was rebuilt with the new core. | | Android says SSH mode but no `ssh authenticated:` line | The old app filtered SSH core logs or the native core is stale. Current startup blocks until authentication succeeds and exposes all `ssh ...` state lines. Rebuild the native core/APK. | | `SSH host key changed` | The saved TOFU pin no longer matches. Verify that the server key was intentionally replaced before deleting the pin. | | No IPv6 anywhere | By design: IPv6 is captured and blackholed (§7.4). | | Connections hang instead of failing fast | Expected only for non-SYN packets to unknown flows, which receive an RST. Anything else warrants the logs. | --- ## 13. Testing ```bash cd core && go test ./... ``` Coverage: * legacy masking round-trips and different-sequence wire bytes, * clear-payload B and BP profiles end to end, including nonzero header masks, * the adaptive record sizer recovering from the minimum rather than latching there, * `reconnectEvery == 0` meaning persistent, * B and BP timeouts returning after one physical connection without replay, * a pinned batch never growing or shrinking across repeated failures, * a pinned batch surviving the ~1 MiB per-batch cap while an unpinned one is still trimmed by it, * an adaptive batch halving down to its floor and growing back to its ceiling, * `1..1` being treated as pinned, * SOCKS5 IPv4 UDP parsing and UDPGW frame layout, * embedded UDPGW IPv4 relay against a real local UDP echo socket, * separation of raw-DragonTCP internal targets from SSH-only UDPGW targets. Beyond unit tests, the transport has been exercised with an 8 MiB HTTP download verified by SHA-256, an HTTPS `CONNECT` download verified byte for byte, persistent and forced-rotation connection modes, and a server restricted to 1400-byte records where probing selected 1400 automatically and the download still completed correctly. After a successful Unix `build_core.sh`, `SHA256SUMS` is regenerated for the new client/server binaries and Android core. --- ## 14. Licensing The Android userspace TCP/IP stack under `android/src/tech/xvanturing/freeproxy/` is adapted from **FreeProxy** by xVanTuring, licensed under the **Apache License 2.0**. Modified files carry a marker comment at the top. * Attribution: [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) * License text: [`licenses/FreeProxy-APACHE-2.0.txt`](licenses/FreeProxy-APACHE-2.0.txt) * Go SSH/bcrypt dependency license: [`licenses/golang-x-crypto-BSD-3-Clause.txt`](licenses/golang-x-crypto-BSD-3-Clause.txt) Both are also shipped inside the APK under `assets/`. They are a license condition rather than documentation, and must not be folded into this file or removed. The remaining DragonTCP glue, UI, Go transport, and server code is provided as part of this project. ## Calibration single-poller rule Startup UP/DW calibration is strictly single-lane: each candidate size sends exactly one outstanding record, waits for its response/ACK, and only then continues. Boundary stability retries (2-of-3) are sequential and use fresh connections; they never overlap. Runtime concurrency is unchanged and starts only after calibration. Server debug lines include `pollers=1 outstanding=1` so this is directly visible.