diff --git a/README.md b/README.md
index b9488f1..159ab61 100644
--- a/README.md
+++ b/README.md
@@ -1,209 +1,136 @@
-# DragonTCP VPN v3
+# DragonTCP Lite VPN
-DragonTCP v3 is a real Android layer-3 VPN over DragonTCP's adaptive XOR-framed
-TCP transport. It captures IPv4 and IPv6 through Android `VpnService`, passes
-the TUN file descriptor to the Go core, and transfers raw IP packets to a Linux
-DragonTCP server listening on TCP/53.
-
-This release fixes two important problems from the previous packet-VPN build:
-
-1. Android can emit IPv6 link-local/control packets such as `fe80::...` on the
- VPN TUN. Those packets no longer terminate the DragonTCP session. The client
- drops packets whose source is not the assigned DragonTCP VPN address, and the
- server independently treats source-mismatch/control packets as non-fatal
- drops.
-2. The DragonTCP transport chunk ceiling is restored to **1 MiB (1,048,576
- bytes)**. Raw IP packets remain limited to 65,535 bytes, but multiple TUN
- packets are batched into transfer objects up to 1 MiB so chunk sizes above
- the VPN MTU are actually useful.
+This version deliberately returns to the lightweight DragonTCP architecture.
+DragonTCP itself is an HTTP/HTTPS CONNECT proxy tunnel; Android's `VpnService`
+is only the local adapter that feeds normal app TCP traffic into that proxy.
## Architecture
```text
Android apps
- |
- | IPv4 + IPv6 default routes
- v
-Android VpnService TUN (MTU 1280)
- |
- | raw IPv4/IPv6 packets
- v
-DragonTCP Android Go core
- |
- | packet batching (up to 1 MiB transfer objects)
- | adaptive fragmentation 32 B .. 1 MiB
- | XOR 0xAD framing
- v
-TCP/53
- |
- v
-DragonTCP Linux server
- |
- v
-Linux TUN dragontcp0
- |
- | forwarding / NAT
- v
-Internet
+ |
+ | IPv4 TCP / DNS packets
+ v
+Android VpnService TUN
+ |
+ | lightweight userspace TCP adapter
+ v
+127.0.0.1:8080 HTTP CONNECT
+ |
+ v
+DragonTCP Go client
+ |
+ | adaptive records + mandatory XOR 0xAD
+ | TCP/53
+ v
+DragonTCP Lite server
+ |
+ v
+Internet destination
```
-Because the tunnel carries raw IP packets, it can carry TCP, UDP, DNS, ICMP,
-IPv4 and IPv6. It does not depend on applications supporting an HTTP proxy.
+There is **no Linux TUN**, no server NAT, no raw-IP DragonTCP protocol, and no
+packet batching on the DragonTCP server. The remote side is the same style of
+lightweight stream proxy that worked in the earlier Termux tests.
-## Included files
+## Defaults
+
+Android:
```text
-bin/dragontcp-vpn-server-linux-amd64
-bin/dragontcp-vpn-server-linux-arm64
-bin/dragontcp-vpn-client-linux-amd64 # test/debug client
-android/build/DragonTCP-VPN.apk
-android/lib/arm64-v8a/libdragontcp_vpn.so
-core/ # complete Go source
-android/src/ # complete Android Java source
-build_core.sh
-build_all.sh
-android/build_apk.sh
+Server port: 53/TCP
+Token: optional / empty allowed
+Local proxy: 127.0.0.1:8080
+Pollers: 1 (fixed)
+Reconnect Every: 1
+Chunk Start: Max Chunk
+Chunk Min: 32
+Chunk Max: 1,048,576 bytes
+Chunk Grow After: 16 successes
+Chunk timeout: 2 seconds
+XOR: 0xAD, mandatory
+DNS: 1.1.1.1 through DNS-over-TCP through DragonTCP
```
-## Server
+The Android log is intentionally quiet. It shows service state, actual errors,
+and DragonTCP adaptive changes such as:
-The server needs root or equivalent CAP_NET_ADMIN permissions because it
-creates a Linux TUN and configures forwarding/NAT.
+```text
+adaptive upload chunk: 65536 -> 32768 after transport failure
+adaptive download chunk: 32 -> 64 after stable success
+```
-Install networking tools on Debian/Ubuntu if needed:
+It does not redraw the screen or print periodic traffic statistics.
+
+## Android traffic behavior
+
+- IPv4 TCP: forwarded through DragonTCP.
+- DNS UDP/53: converted to DNS-over-TCP and sent to `1.1.1.1` through the local
+ DragonTCP HTTP CONNECT proxy.
+- IPv6: captured by the VPN and dropped so it cannot bypass DragonTCP.
+- Other UDP: not forwarded in this HTTP CONNECT build.
+- ICMP/ping: not forwarded.
+
+This trade-off keeps DragonTCP itself lightweight and stream-oriented.
+
+## Start the server
+
+Port 53 is privileged on Linux, so run as root:
```bash
-sudo apt-get update
-sudo apt-get install -y iproute2 iptables
+sudo ./dragontcp-lite-server-linux-amd64 --chunk-max 1048576
```
-Start:
+No token is required by default. To require one:
```bash
-sudo ./dragontcp-vpn-server-linux-amd64 \
- --token 'YOUR_SECRET' \
- --debug
+sudo ./dragontcp-lite-server-linux-amd64 --token 'SECRET' --chunk-max 1048576
```
-Defaults:
+The Android Token field must contain the same value.
-```text
-listen TCP port 53
-server chunk max 1048576 bytes (1 MiB)
-transfer batch max 1048576 bytes (1 MiB)
-batch delay 1ms
-TUN MTU 1280
-server IPv4 10.123.0.1/16
-server IPv6 fd7a:4472:6167:6f6e::1/64
-poll wait 100ms
-queued packet limit 2048/client
-queued byte limit 8 MiB/client
-auto NAT enabled
-```
-
-Useful explicit command:
+If you do not want to run the binary as root:
```bash
-sudo ./dragontcp-vpn-server-linux-amd64 \
- --token 'YOUR_SECRET' \
- --chunk-max 1048576 \
- --vpn-buffer-bytes 8388608 \
- --batch-delay 1ms \
- --debug \
- --debug-stats-interval 5s
+sudo setcap cap_net_bind_service=+ep ./dragontcp-lite-server-linux-amd64
+./dragontcp-lite-server-linux-amd64
```
-Per-packet/batch diagnostics are very verbose:
+If TCP/53 is already occupied by a DNS resolver, free that port first.
-```bash
---debug-packets
-```
+## Android usage
-## Android app
+1. Install `DragonTCP-LiteVPN-arm64.apk`.
+2. Enter the server address.
+3. Leave Port at `53`.
+4. Leave Token empty if the server was started without `--token`.
+5. Keep `Reconnect every = 1` for restrictive networks.
+6. Press **CONNECT** and approve Android's VPN dialog.
+7. Press **STOP** to terminate both the TUN adapter and DragonTCP core.
-Install `android/build/DragonTCP-VPN.apk`.
+The app excludes its own UID from the VPN, so the DragonTCP TCP/53 transport
+uses the physical/mobile network and does not loop into its own TUN interface.
-The UI asks for:
-
-```text
-Server
-TCP port
-Token
-Maximum transport fragment
-Minimum transport fragment
-Timeout
-```
-
-Defaults:
-
-```text
-Port 53
-Max 1048576
-Min 32
-Timeout 2s
-Pollers 1 (fixed)
-VPN MTU 1280
-```
-
-The adaptive record starts at Max and shrinks after transport failures. The
-1 MiB value is a DragonTCP transport ceiling, not the IP MTU.
-
-### Why 1 MiB can now help even though the VPN MTU is 1280
-
-The old packet-VPN sent one TUN packet per DragonTCP transfer object, so a
-record size larger than the IP packet had no benefit. v3 batches adjacent TUN
-packets for a short window:
-
-```text
-1280-byte packet --+
-1280-byte packet ---+
-1280-byte packet ----+--> one DragonTCP transfer object --> adaptive fragments
-... |
-1280-byte packet ----+
-```
-
-A busy flow can therefore produce transfer objects much larger than 65,535
-bytes. If the network accepts large DragonTCP records, fewer transactions are
-needed. If it does not, the same transfer object is automatically fragmented
-into smaller records and retried.
-
-## Android link-local source fix
-
-A log such as this from the previous build:
-
-```text
-VPN stopped: source fe80::... does not match session address
-```
-
-is no longer fatal.
-
-The Android core now logs an occasional line such as:
-
-```text
-VPN DROP local packet (source fe80::... is not assigned VPN address) dropped=1
-```
-
-and continues running. The server also performs a non-fatal drop as a second
-line of defense.
-
-## Build from source
+## Build everything from source
Requirements:
- Go 1.22+
- JDK 17+
-- Android SDK platform/build-tools
-- zip
+- Android SDK platform + build-tools (35 works)
+- Android 10 / API 29 or newer on the phone
+- Kotlin compiler 1.9.x distribution containing
+ `kotlinx-coroutines-core-jvm.jar`
+- `zip`
-No Android NDK is required for this build.
-
-Set the SDK directory:
+Example environment:
```bash
export ANDROID_SDK_ROOT="$HOME/Android/Sdk"
+export KOTLIN_HOME="$HOME/.sdkman/candidates/kotlin/current"
```
-Build native components and APK:
+Then:
```bash
./build_all.sh
@@ -212,37 +139,62 @@ Build native components and APK:
Outputs:
```text
-bin/dragontcp-vpn-server-linux-amd64
-bin/dragontcp-vpn-server-linux-arm64
-android/lib/arm64-v8a/libdragontcp_vpn.so
-android/build/DragonTCP-VPN.apk
+bin/dragontcp-lite-server-linux-amd64
+bin/dragontcp-lite-server-linux-arm64
+android/lib/arm64-v8a/libdragontcp_client.so
+android/build/DragonTCP-LiteVPN-arm64.apk
```
-Build only the Go/native components:
+`libdragontcp_client.so` is intentionally the Android ARM64 Go executable stored
+in the APK native-library directory. `DragonService` launches it with
+`ProcessBuilder`; it is not JNI.
+
+## Build only the Go core/server
```bash
./build_core.sh
```
-Build only the APK after the native core exists:
+## Build only the APK
+
+After the core has been built:
```bash
cd android
./build_apk.sh
```
-## Testing performed
+The script creates a local debug signing key if one does not already exist.
+For production distribution, supply your own keystore/signing process.
-The Go packages compile with `go test ./...`.
+## Source layout
-A local mock-TUN test verified:
+```text
+core/ DragonTCP Go client/server
+android/src/com/dragontcp/client/ Android UI + VpnService
+android/src/tech/xvanturing/... Lightweight TUN-to-proxy stack
+android/lib/arm64-v8a/ Embedded DragonTCP Android core
+licenses/ Third-party licenses
+```
-- an IPv6 `fe80::` source packet is dropped without terminating the client;
-- a valid packet immediately afterward still passes;
-- 120 IPv4 packets were combined into a **120,241-byte transfer object**,
- proving that transfer objects larger than 65,535 bytes work;
-- the echoed packets were returned byte-for-byte and in order;
-- the same path also works with a fixed **32-byte DragonTCP fragment size**.
+## Third-party stack
-A physical Android phone is still required to validate device/vendor-specific
-`VpnService` behavior and the real mobile-network TCP/53 path.
+The userspace Android TUN/TCP adapter is adapted from the Apache-2.0-licensed
+FreeProxy project. See `THIRD_PARTY_NOTICES.md` and
+`licenses/FreeProxy-APACHE-2.0.txt`.
+
+## Validation performed for this package
+
+- Go unit/build checks: PASS.
+- Empty-token client/server protocol: PASS.
+- Fixed 1 poller + reconnect every 1: PASS.
+- 8 MiB HTTP transfer through local DragonTCP proxy: SHA-256 exact.
+- Kotlin TUN adapter compilation: PASS.
+- Android Java service/UI compilation: PASS.
+- ARM64 Android DragonTCP core build: PASS.
+- APK resource/DEX/native packaging: PASS.
+- APK signature verification (v3): PASS.
+- Supplied APK uses the same signing certificate as the previous v7 APK (versionCode 8), so it can be installed as an in-place update over v7.
+
+A physical Android device is still required to validate the final VpnService
+path against a real mobile network.
diff --git a/SHA256SUMS b/SHA256SUMS
deleted file mode 100644
index f862aeb..0000000
--- a/SHA256SUMS
+++ /dev/null
@@ -1,4 +0,0 @@
-26b46523f9100ebb7c84a606cfad37d5d17e45a1c6eb1571340f7b5f74668f3e /mnt/data/DragonTCP-VPN-v3/android/build/DragonTCP-VPN.apk
-2e70feec6ea544efc9a3b3ea8c66a205c4827176d80374fa2b4b48845ec09e2e /mnt/data/DragonTCP-VPN-v3/bin/dragontcp-vpn-server-linux-amd64
-c049c8449b01d46049c2cf4a0368d23562219025d38aa9024ea3966c15dcc3b0 /mnt/data/DragonTCP-VPN-v3/bin/dragontcp-vpn-server-linux-arm64
-c0f600c1cff7bc7809c2afab0272fe528d36a7cac6815820e8fd454e81b0894c /mnt/data/DragonTCP-VPN-v3/android/lib/arm64-v8a/libdragontcp_vpn.so
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..c8e6795
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,20 @@
+# Third-party notices
+
+DragonTCP Lite VPN includes portions of the Android userspace TCP/IP stack from
+**FreeProxy** by xVanTuring. The upstream project is licensed under the Apache
+License, Version 2.0.
+
+Included/adapted upstream areas:
+
+- `android/src/tech/xvanturing/freeproxy/vpn/TunnelEngine.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/TcpSession.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/UdpSession.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/TunWriter.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/net/*`
+- the `SocketProtector` interface
+
+DragonTCP-specific modifications are marked in modified source files. The full
+Apache 2.0 license is included at `licenses/FreeProxy-APACHE-2.0.txt`.
+
+The rest of the DragonTCP-specific glue, UI, Go transport, and server code in
+this bundle is provided as part of this generated project.
diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml
index 1838c6f..3e7feac 100644
--- a/android/AndroidManifest.xml
+++ b/android/AndroidManifest.xml
@@ -1,17 +1,37 @@
+ android:versionCode="8"
+ android:versionName="8.0-lite">
+
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
diff --git a/android/assets/FreeProxy-APACHE-2.0.txt b/android/assets/FreeProxy-APACHE-2.0.txt
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/android/assets/FreeProxy-APACHE-2.0.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/android/assets/THIRD_PARTY_NOTICES.md b/android/assets/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..c8e6795
--- /dev/null
+++ b/android/assets/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,20 @@
+# Third-party notices
+
+DragonTCP Lite VPN includes portions of the Android userspace TCP/IP stack from
+**FreeProxy** by xVanTuring. The upstream project is licensed under the Apache
+License, Version 2.0.
+
+Included/adapted upstream areas:
+
+- `android/src/tech/xvanturing/freeproxy/vpn/TunnelEngine.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/TcpSession.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/UdpSession.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/TunWriter.kt`
+- `android/src/tech/xvanturing/freeproxy/vpn/net/*`
+- the `SocketProtector` interface
+
+DragonTCP-specific modifications are marked in modified source files. The full
+Apache 2.0 license is included at `licenses/FreeProxy-APACHE-2.0.txt`.
+
+The rest of the DragonTCP-specific glue, UI, Go transport, and server code in
+this bundle is provided as part of this generated project.
diff --git a/android/build_apk.sh b/android/build_apk.sh
index 3bc20b5..aa15caa 100644
--- a/android/build_apk.sh
+++ b/android/build_apk.sh
@@ -2,22 +2,74 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
-[[ -n "$SDK" ]] || { echo "Set ANDROID_SDK_ROOT" >&2; exit 1; }
-BUILD_TOOLS="${BUILD_TOOLS:-35.0.0}"; PLATFORM="${PLATFORM:-android-35}"
-if [[ ! -d "$SDK/build-tools/$BUILD_TOOLS" ]]; then BUILD_TOOLS="$(find "$SDK/build-tools" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"; fi
-if [[ ! -f "$SDK/platforms/$PLATFORM/android.jar" ]]; then PLATFORM="$(find "$SDK/platforms" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"; fi
-BT="$SDK/build-tools/$BUILD_TOOLS"; AJ="$SDK/platforms/$PLATFORM/android.jar"
-for tool in aapt d8 apksigner; do [[ -x "$BT/$tool" ]] || { echo "Missing $BT/$tool" >&2; exit 1; }; done
-CORE="$ROOT/lib/arm64-v8a/libdragontcp_vpn.so"; [[ -f "$CORE" ]] || { echo "Run ../build_core.sh first" >&2; exit 1; }
-B="$ROOT/build"; rm -rf "$B"; mkdir -p "$B/classes" "$B/dex"
-"$BT/aapt" package -f -M "$ROOT/AndroidManifest.xml" -S "$ROOT/res" -I "$AJ" -F "$B/resources.ap_"
-javac -source 8 -target 8 -classpath "$AJ" -d "$B/classes" $(find "$ROOT/src" -name '*.java' -print)
-"$BT/d8" --lib "$AJ" --min-api 29 --output "$B/dex" $(find "$B/classes" -name '*.class' -print)
-cp "$B/resources.ap_" "$B/DragonTCP-VPN-unsigned.apk"
-(cd "$B/dex" && zip -q "$B/DragonTCP-VPN-unsigned.apk" classes.dex)
-(cd "$ROOT" && zip -q -r "$B/DragonTCP-VPN-unsigned.apk" lib)
-KEYSTORE="$ROOT/dragontcp-debug.jks"
-if [[ ! -f "$KEYSTORE" ]]; then keytool -genkeypair -keystore "$KEYSTORE" -storepass dragontcp -keypass dragontcp -alias dragontcp -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=DragonTCP VPN,O=DragonTCP,C=US"; fi
-"$BT/apksigner" sign --ks "$KEYSTORE" --ks-pass pass:dragontcp --key-pass pass:dragontcp --out "$B/DragonTCP-VPN.apk" "$B/DragonTCP-VPN-unsigned.apk"
-"$BT/apksigner" verify --verbose "$B/DragonTCP-VPN.apk"
-echo "Built APK: $B/DragonTCP-VPN.apk"
+KOTLIN_HOME="${KOTLIN_HOME:-$HOME/.sdkman/candidates/kotlin/current}"
+BUILD_TOOLS="${BUILD_TOOLS:-35.0.0}"
+PLATFORM="${PLATFORM:-android-35}"
+
+[[ -n "$SDK" ]] || { echo "Set ANDROID_SDK_ROOT (or ANDROID_HOME)." >&2; exit 1; }
+[[ -d "$KOTLIN_HOME" ]] || { echo "Set KOTLIN_HOME to a Kotlin compiler distribution." >&2; exit 1; }
+BT="$SDK/build-tools/$BUILD_TOOLS"
+AJ="$SDK/platforms/$PLATFORM/android.jar"
+
+if [[ ! -d "$BT" ]]; then
+ BUILD_TOOLS="$(find "$SDK/build-tools" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"
+ BT="$SDK/build-tools/$BUILD_TOOLS"
+fi
+if [[ ! -f "$AJ" ]]; then
+ PLATFORM="$(find "$SDK/platforms" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"
+ AJ="$SDK/platforms/$PLATFORM/android.jar"
+fi
+
+for tool in aapt d8 apksigner; do
+ [[ -x "$BT/$tool" ]] || { echo "Missing $BT/$tool" >&2; exit 1; }
+done
+[[ -f "$AJ" ]] || { echo "Android platform android.jar not found" >&2; exit 1; }
+[[ -x "$KOTLIN_HOME/bin/kotlinc" ]] || { echo "kotlinc not found under KOTLIN_HOME" >&2; exit 1; }
+CORO="$KOTLIN_HOME/lib/kotlinx-coroutines-core-jvm.jar"
+[[ -f "$CORO" ]] || { echo "Missing $CORO (install a Kotlin distribution that includes kotlinx-coroutines-core-jvm.jar)" >&2; exit 1; }
+[[ -f "$ROOT/lib/arm64-v8a/libdragontcp_client.so" ]] || { echo "Run ../build_core.sh first" >&2; exit 1; }
+
+B="$ROOT/build"
+rm -rf "$B"
+mkdir -p "$B/kclasses" "$B/jclasses" "$B/dex"
+
+echo "[apk] Resources..."
+"$BT/aapt" package -f -M "$ROOT/AndroidManifest.xml" -S "$ROOT/res" -A "$ROOT/assets" -I "$AJ" -F "$B/resources.ap_"
+
+echo "[apk] Kotlin TUN adapter..."
+CP="$AJ:$CORO"
+find "$ROOT/src" -name '*.kt' -print > "$B/kotlin-sources.txt"
+"$KOTLIN_HOME/bin/kotlinc" -jvm-target 1.8 -classpath "$CP" -d "$B/kclasses" @"$B/kotlin-sources.txt"
+
+echo "[apk] Java UI/service..."
+JCP="$CP:$B/kclasses:$KOTLIN_HOME/lib/kotlin-stdlib.jar:$KOTLIN_HOME/lib/kotlin-stdlib-jdk7.jar:$KOTLIN_HOME/lib/kotlin-stdlib-jdk8.jar"
+javac -source 8 -target 8 -classpath "$JCP" -d "$B/jclasses" $(find "$ROOT/src" -name '*.java' -print)
+jar cf "$B/kclasses.jar" -C "$B/kclasses" .
+jar cf "$B/jclasses.jar" -C "$B/jclasses" .
+
+echo "[apk] DEX..."
+"$BT/d8" --lib "$AJ" --min-api 29 --output "$B/dex" \
+ "$B/kclasses.jar" "$B/jclasses.jar" \
+ "$KOTLIN_HOME/lib/kotlin-stdlib.jar" \
+ "$KOTLIN_HOME/lib/kotlin-stdlib-jdk7.jar" \
+ "$KOTLIN_HOME/lib/kotlin-stdlib-jdk8.jar" \
+ "$CORO"
+
+cp "$B/resources.ap_" "$B/DragonTCP-LiteVPN-unsigned.apk"
+(cd "$B/dex" && zip -q "$B/DragonTCP-LiteVPN-unsigned.apk" classes*.dex)
+(cd "$ROOT" && zip -q -r "$B/DragonTCP-LiteVPN-unsigned.apk" lib)
+
+KEYSTORE="${KEYSTORE:-$ROOT/dragontcp-lite-debug.jks}"
+KS_PASS="${KS_PASS:-dragontcp}"
+KEY_ALIAS="${KEY_ALIAS:-dragontcp}"
+KEY_PASS="${KEY_PASS:-dragontcp}"
+if [[ ! -f "$KEYSTORE" ]]; then
+ keytool -genkeypair -keystore "$KEYSTORE" -storepass "$KS_PASS" -keypass "$KEY_PASS" \
+ -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \
+ -dname 'CN=DragonTCP Lite,O=DragonTCP,C=US'
+fi
+
+"$BT/apksigner" sign --ks "$KEYSTORE" --ks-pass "pass:$KS_PASS" --key-pass "pass:$KEY_PASS" \
+ --out "$B/DragonTCP-LiteVPN-arm64.apk" "$B/DragonTCP-LiteVPN-unsigned.apk"
+"$BT/apksigner" verify --verbose "$B/DragonTCP-LiteVPN-arm64.apk"
+echo "APK: $B/DragonTCP-LiteVPN-arm64.apk"
diff --git a/android/lib/arm64-v8a/libdragontcp_vpn.so b/android/lib/arm64-v8a/libdragontcp_vpn.so
deleted file mode 100644
index 2a4b79a..0000000
Binary files a/android/lib/arm64-v8a/libdragontcp_vpn.so and /dev/null differ
diff --git a/android/res/drawable/ic_dragontcp.xml b/android/res/drawable/ic_dragontcp.xml
new file mode 100644
index 0000000..5a972b7
--- /dev/null
+++ b/android/res/drawable/ic_dragontcp.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml
deleted file mode 100644
index 62f8bb8..0000000
--- a/android/res/values/strings.xml
+++ /dev/null
@@ -1 +0,0 @@
-DragonTCP VPN
diff --git a/android/src/com/dragontcp/client/AppLog.java b/android/src/com/dragontcp/client/AppLog.java
new file mode 100644
index 0000000..f84d320
--- /dev/null
+++ b/android/src/com/dragontcp/client/AppLog.java
@@ -0,0 +1,44 @@
+package com.dragontcp.client;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+public final class AppLog {
+ public interface Listener { void onLine(String line); }
+
+ private static final int MAX_LINES = 600;
+ private static final ArrayDeque lines = new ArrayDeque<>();
+ private static final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>();
+
+ private AppLog() {}
+
+ public static void append(String line) {
+ if (line == null) return;
+ line = line.trim();
+ if (line.isEmpty()) return;
+ synchronized (lines) {
+ while (lines.size() >= MAX_LINES) lines.removeFirst();
+ lines.addLast(line);
+ }
+ for (Listener listener : listeners) {
+ try { listener.onLine(line); } catch (Throwable ignored) {}
+ }
+ }
+
+ public static String history() {
+ StringBuilder out = new StringBuilder();
+ synchronized (lines) {
+ for (String line : lines) out.append(line).append('\n');
+ }
+ return out.toString();
+ }
+
+ public static void clear() {
+ synchronized (lines) { lines.clear(); }
+ }
+
+ public static void addListener(Listener listener) { listeners.addIfAbsent(listener); }
+ public static void removeListener(Listener listener) { listeners.remove(listener); }
+}
diff --git a/android/src/com/dragontcp/client/DragonService.java b/android/src/com/dragontcp/client/DragonService.java
index ff21d0c..9cb79e4 100644
--- a/android/src/com/dragontcp/client/DragonService.java
+++ b/android/src/com/dragontcp/client/DragonService.java
@@ -6,101 +6,376 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
-import android.net.LocalSocket;
-import android.net.LocalSocketAddress;
import android.net.VpnService;
import android.os.Build;
+import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import java.io.BufferedReader;
import java.io.File;
-import java.io.FileOutputStream;
-import java.io.FileDescriptor;
import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
-public class DragonService extends VpnService {
- public static final String ACTION_CONNECT="com.dragontcp.client.CONNECT";
- public static final String ACTION_STOP="com.dragontcp.client.STOP";
- public static volatile boolean active=false,running=false;
- public static volatile String state="Stopped";
- private static final String CHANNEL_ID="dragontcp_vpn";
- private static final int NOTIFICATION_ID=53;
- private final Object lifecycleLock=new Object();
- private Process process;
- private Thread outputThread;
- private ParcelFileDescriptor vpnInterface;
- private File fdSocketFile;
+import tech.xvanturing.freeproxy.data.model.DnsMode;
+import tech.xvanturing.freeproxy.data.model.ProxyProfile;
+import tech.xvanturing.freeproxy.data.model.ProxyType;
+import tech.xvanturing.freeproxy.vpn.TunnelEngine;
+import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector;
- @Override public void onCreate(){super.onCreate();createNotificationChannel();}
- @Override public int onStartCommand(Intent intent,int flags,int startId){
- if(intent==null)return START_NOT_STICKY;String action=intent.getAction();
- if(ACTION_STOP.equals(action)){appendLog("STOP requested");shutdown("Stopped by user",true);return START_NOT_STICKY;}
- if(!ACTION_CONNECT.equals(action))return START_NOT_STICKY;
- cleanupResources(true);clearLog();active=true;running=false;state="Starting VPN";startForeground(NOTIFICATION_ID,buildNotification("Starting full VPN"));
- String server=intent.getStringExtra("server"),token=intent.getStringExtra("token"),timeout=intent.getStringExtra("timeout"),v4=intent.getStringExtra("vpnIPv4"),v6=intent.getStringExtra("vpnIPv6");
- int port=intent.getIntExtra("port",53),max=intent.getIntExtra("chunkMax",1048576),min=intent.getIntExtra("chunkMin",32),start=intent.getIntExtra("chunkStart",max);
- if(server==null||server.trim().isEmpty()){failStart("Server is empty");return START_NOT_STICKY;}if(token==null)token="";if(timeout==null||timeout.isEmpty())timeout="2s";if(v4==null||v6==null){failStart("Missing VPN client address");return START_NOT_STICKY;}
- start=max;
- try{
- establishPacketVpn(v4,v6);
- state="Starting DragonTCP core";
- startCore(server.trim(),port,token,start,min,max,timeout.trim(),v4,v6);
- state="Connecting to DragonTCP server";
- updateNotification("Connecting • TCP/"+port);
- }catch(Exception e){failStart(e.getMessage()==null?e.toString():e.getMessage());}
+public class DragonService extends VpnService {
+ public static final String ACTION_CONNECT = "com.dragontcp.client.CONNECT";
+ public static final String ACTION_STOP = "com.dragontcp.client.STOP";
+
+ public static final String EXTRA_SERVER = "server";
+ public static final String EXTRA_PORT = "port";
+ public static final String EXTRA_TOKEN = "token";
+ public static final String EXTRA_CHUNK_MAX = "chunkMax";
+ public static final String EXTRA_CHUNK_MIN = "chunkMin";
+ public static final String EXTRA_RECONNECT = "reconnect";
+ public static final String EXTRA_TIMEOUT = "timeout";
+
+ private static final int NOTIFICATION_ID = 53;
+ private static final String CHANNEL_ID = "dragontcp-lite";
+ private static final int LOCAL_PROXY_PORT = 8080;
+ private static final int TUN_MTU = 1400;
+
+ private final Object stateLock = new Object();
+ private volatile Process coreProcess;
+ private volatile TunnelEngine tunnelEngine;
+ private volatile ParcelFileDescriptor tunFd;
+ private volatile boolean connected;
+ private volatile boolean stopping;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ createNotificationChannel();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ String action = intent != null ? intent.getAction() : null;
+ if (ACTION_STOP.equals(action)) {
+ new Thread(() -> stopEverything("Stopped"), "dragontcp-stop").start();
+ return START_NOT_STICKY;
+ }
+ if (ACTION_CONNECT.equals(action)) {
+ startForeground(NOTIFICATION_ID, buildNotification("Starting..."));
+ Intent copy = new Intent(intent);
+ new Thread(() -> startEverything(copy), "dragontcp-start").start();
+ return START_STICKY;
+ }
return START_NOT_STICKY;
}
- private void establishPacketVpn(String v4,String v6)throws Exception{
- VpnService.Builder b=new VpnService.Builder();b.setSession("DragonTCP VPN");b.setMtu(1280);
- b.addAddress(v4,32);b.addAddress(v6,128);b.addRoute("0.0.0.0",0);b.addRoute("::",0);
- b.addDnsServer("1.1.1.1");b.addDnsServer("2606:4700:4700::1111");
- try{b.addDisallowedApplication(getPackageName());}catch(PackageManager.NameNotFoundException e){throw new Exception("Cannot exclude DragonTCP from its own VPN",e);}
- Intent open=new Intent(this,MainActivity.class);open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP|Intent.FLAG_ACTIVITY_CLEAR_TOP);
- PendingIntent pi=PendingIntent.getActivity(this,1,open,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);b.setConfigureIntent(pi);
- vpnInterface=b.establish();if(vpnInterface==null)throw new Exception("Android did not establish the TUN interface");
- appendLog("TUN established: "+v4+" + "+v6+" MTU=1280");appendLog("Routes captured: 0.0.0.0/0 and ::/0");appendLog("DNS through VPN: 1.1.1.1 + 2606:4700:4700::1111");appendLog("DragonTCP app UID excluded from VPN to prevent recursion");
+ private void startEverything(Intent intent) {
+ synchronized (stateLock) {
+ if (connected || coreProcess != null || tunnelEngine != null) {
+ // Restart in-place without stopSelf(); this avoids a race where
+ // Android destroys the service just after a new CONNECT begins.
+ stopping = true;
+ cleanupComponentsLocked();
+ }
+ stopping = false;
+ }
+
+ String server = intent.getStringExtra(EXTRA_SERVER);
+ int port = intent.getIntExtra(EXTRA_PORT, 53);
+ String token = intent.getStringExtra(EXTRA_TOKEN);
+ int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024);
+ int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32);
+ int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1);
+ int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2);
+
+ if (server == null || server.trim().isEmpty()) {
+ failStart("Server is required");
+ return;
+ }
+ server = server.trim();
+ if (token == null) token = "";
+ chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
+ chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
+ reconnect = Math.max(1, reconnect);
+ timeout = Math.max(1, timeout);
+
+ try {
+ AppLog.append("Starting DragonTCP → " + server + ":" + port);
+ Process process = startDragonCore(server, port, token, chunkMax, chunkMin, reconnect, timeout);
+ synchronized (stateLock) { coreProcess = process; }
+
+ startCoreLogReader(process);
+ waitForLocalProxy(process);
+ AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:8080");
+
+ ParcelFileDescriptor pfd = establishVpn();
+ if (pfd == null) throw new IllegalStateException("Android refused to establish the VPN interface");
+
+ SocketProtector protector = new SocketProtector() {
+ @Override public boolean protect(Socket socket) {
+ return DragonService.this.protect(socket);
+ }
+ @Override public boolean protect(DatagramSocket socket) {
+ return DragonService.this.protect(socket);
+ }
+ };
+
+ ProxyProfile profile = new ProxyProfile(ProxyType.HTTP, DnsMode.PROXY, false);
+ InetSocketAddress localProxy = new InetSocketAddress(
+ InetAddress.getByAddress(new byte[]{127, 0, 0, 1}),
+ LOCAL_PROXY_PORT
+ );
+ TunnelEngine engine = new TunnelEngine(
+ pfd,
+ profile,
+ localProxy,
+ TUN_MTU,
+ protector,
+ null,
+ null
+ );
+
+ synchronized (stateLock) {
+ tunFd = pfd;
+ tunnelEngine = engine;
+ connected = true;
+ }
+ engine.start();
+ updateNotification("Connected");
+ AppLog.append("VPN connected");
+ AppLog.append("DNS: forced through 1.1.1.1 over DragonTCP");
+ AppLog.append("IPv6: captured and blocked to prevent bypass");
+ } catch (Throwable t) {
+ failStart(t.getMessage() != null ? t.getMessage() : t.toString());
+ }
}
- private void startCore(String server,int port,String token,int start,int min,int max,String timeout,String v4,String v6)throws Exception{
- String executable=getApplicationInfo().nativeLibraryDir+"/libdragontcp_vpn.so";File exe=new File(executable);if(!exe.exists())throw new Exception("Embedded DragonTCP VPN core was not extracted");
- fdSocketFile=new File(getFilesDir(),"dragontcp-tunfd.sock");if(fdSocketFile.exists())fdSocketFile.delete();
- List cmd=new ArrayList();cmd.add(executable);cmd.add("--server-host");cmd.add(server);cmd.add("--server-port");cmd.add(String.valueOf(port));cmd.add("--token");cmd.add(token);
- cmd.add("--tun-fd-socket");cmd.add(fdSocketFile.getAbsolutePath());cmd.add("--vpn-ipv4");cmd.add(v4);cmd.add("--vpn-ipv6");cmd.add(v6);cmd.add("--vpn-mtu");cmd.add("1280");
- cmd.add("--chunk-start");cmd.add(String.valueOf(max));cmd.add("--chunk-max");cmd.add(String.valueOf(max));cmd.add("--chunk-min");cmd.add(String.valueOf(min));cmd.add("--chunk-grow-after");cmd.add("64");cmd.add("--chunk-timeout");cmd.add(timeout);cmd.add("--chunk-reconnect-every");cmd.add("32");cmd.add("--chunk-adapt-log");
- appendLog("Server: "+server+":"+port);appendLog("Transport chunks: start=max="+max+" min="+min+" pollers=1 timeout="+timeout);
- ProcessBuilder pb=new ProcessBuilder(cmd);pb.redirectErrorStream(true);pb.directory(getFilesDir());final Process p=pb.start();synchronized(lifecycleLock){process=p;}
- outputThread=new Thread(()->readCoreOutput(p),"DragonTCP-output");outputThread.setDaemon(true);outputThread.start();
- passTunFdWhenReady();
+ private Process startDragonCore(
+ String server,
+ int port,
+ String token,
+ int chunkMax,
+ int chunkMin,
+ int reconnect,
+ int timeout
+ ) throws Exception {
+ File executable = new File(getApplicationInfo().nativeLibraryDir, "libdragontcp_client.so");
+ if (!executable.exists()) throw new IllegalStateException("Embedded DragonTCP core is missing");
+
+ List cmd = new ArrayList<>();
+ cmd.add(executable.getAbsolutePath());
+ cmd.add("--listen-host"); cmd.add("127.0.0.1");
+ cmd.add("--listen-port"); cmd.add(Integer.toString(LOCAL_PROXY_PORT));
+ cmd.add("--server-host"); cmd.add(server);
+ cmd.add("--server-port"); cmd.add(Integer.toString(port));
+ if (!token.isEmpty()) { cmd.add("--token"); cmd.add(token); }
+ cmd.add("--transport"); cmd.add("chunk");
+ cmd.add("--chunk-start"); cmd.add(Integer.toString(chunkMax));
+ cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin));
+ cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax));
+ cmd.add("--chunk-pollers"); cmd.add("1");
+ cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect));
+ cmd.add("--chunk-timeout"); cmd.add(timeout + "s");
+ cmd.add("--chunk-grow-after"); cmd.add("16");
+ cmd.add("--chunk-adapt-log=true");
+
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.redirectErrorStream(true);
+ return pb.start();
}
- private void passTunFdWhenReady()throws Exception{
- long deadline=System.currentTimeMillis()+5000;while(System.currentTimeMillis() {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = br.readLine()) != null) {
+ // Keep the UI useful: adaptation changes and real errors only.
+ String lower = line.toLowerCase();
+ if (line.startsWith("adaptive ") || lower.contains("error") || lower.contains("failed")) {
+ AppLog.append(line);
+ }
+ }
+ } catch (Throwable ignored) {
+ }
+ }, "dragontcp-core-log");
+ reader.setDaemon(true);
+ reader.start();
+
+ Thread watcher = new Thread(() -> {
+ try {
+ int code = process.waitFor();
+ boolean shouldStop;
+ synchronized (stateLock) {
+ shouldStop = !stopping && coreProcess == process && connected;
+ }
+ if (shouldStop) {
+ AppLog.append("DragonTCP core exited: " + code);
+ stopEverything("Core stopped");
+ }
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }, "dragontcp-core-watch");
+ watcher.setDaemon(true);
+ watcher.start();
}
- private void readCoreOutput(Process p){
- try{BufferedReader br=new BufferedReader(new InputStreamReader(p.getInputStream()));String line;while((line=br.readLine())!=null){appendLog(line);if(line.contains("VPN READY")){running=true;active=true;state="Connected • Full VPN";updateNotification("Connected • IPv4 + IPv6 • TCP/UDP");}}
- int code=p.waitFor();handleCoreExit(p,code);
- }catch(Exception e){appendLog("Core reader: "+e);handleCoreExit(p,-1);}
+ private void waitForLocalProxy(Process process) throws Exception {
+ long deadline = System.currentTimeMillis() + 10_000;
+ Throwable last = null;
+ while (System.currentTimeMillis() < deadline) {
+ if (!process.isAlive()) throw new IllegalStateException("DragonTCP core exited before proxy startup");
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("127.0.0.1", LOCAL_PROXY_PORT), 150);
+ return;
+ } catch (Throwable t) {
+ last = t;
+ Thread.sleep(100);
+ }
+ }
+ throw new IllegalStateException("Local proxy did not start" + (last != null ? ": " + last.getMessage() : ""));
}
- private void handleCoreExit(Process p,int code){boolean owns; synchronized(lifecycleLock){owns=process==p;if(owns)process=null;}if(!owns)return;appendLog("DragonTCP core exited: "+code);running=false;active=false;state="Core exited ("+code+")";closeVpn();stopForeground(true);stopSelf();}
- private Notification buildNotification(String msg){Intent open=new Intent(this,MainActivity.class);open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP|Intent.FLAG_ACTIVITY_CLEAR_TOP);PendingIntent op=PendingIntent.getActivity(this,0,open,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);Intent stop=new Intent(this,DragonService.class);stop.setAction(ACTION_STOP);PendingIntent sp=PendingIntent.getService(this,2,stop,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);Notification.Builder nb=Build.VERSION.SDK_INT>=26?new Notification.Builder(this,CHANNEL_ID):new Notification.Builder(this);return nb.setContentTitle("DragonTCP VPN").setContentText(msg).setSmallIcon(android.R.drawable.stat_sys_upload).setOngoing(true).setContentIntent(op).addAction(android.R.drawable.ic_menu_close_clear_cancel,"STOP",sp).build();}
- private void updateNotification(String m){NotificationManager nm=(NotificationManager)getSystemService(NOTIFICATION_SERVICE);if(nm!=null)nm.notify(NOTIFICATION_ID,buildNotification(m));}
- private void createNotificationChannel(){if(Build.VERSION.SDK_INT>=26){NotificationChannel c=new NotificationChannel(CHANNEL_ID,"DragonTCP VPN",NotificationManager.IMPORTANCE_LOW);c.setDescription("DragonTCP full packet VPN status");NotificationManager nm=(NotificationManager)getSystemService(NOTIFICATION_SERVICE);if(nm!=null)nm.createNotificationChannel(c);}}
- private synchronized void appendLog(String line){try(PrintWriter out=new PrintWriter(new OutputStreamWriter(new FileOutputStream(new File(getFilesDir(),"dragontcp.log"),true),"UTF-8"))){out.println(line);out.flush();}catch(Exception ignored){}}
- private void clearLog(){try{new FileOutputStream(new File(getFilesDir(),"dragontcp.log"),false).close();}catch(Exception ignored){}}
- private void failStart(String m){appendLog("START ERROR: "+m);running=false;active=false;state="Start failed: "+m;cleanupResources(true);stopForeground(true);stopSelf();}
- private void shutdown(String reason,boolean stop){state="Stopping";running=false;appendLog(reason);cleanupResources(true);active=false;state="Stopped";stopForeground(true);if(stop)stopSelf();}
- private void cleanupResources(boolean kill){Process p; synchronized(lifecycleLock){p=process;process=null;}if(p!=null){try{p.getInputStream().close();}catch(Exception ignored){}try{p.destroy();}catch(Exception ignored){}if(kill){try{if(!p.waitFor(800,TimeUnit.MILLISECONDS)){p.destroyForcibly();p.waitFor(800,TimeUnit.MILLISECONDS);}}catch(Exception ignored){try{p.destroyForcibly();}catch(Exception ignored2){}}}}Thread t=outputThread;outputThread=null;if(t!=null&&t!=Thread.currentThread())t.interrupt();closeVpn();if(fdSocketFile!=null){fdSocketFile.delete();fdSocketFile=null;}running=false;}
- private void closeVpn(){ParcelFileDescriptor v=vpnInterface;vpnInterface=null;if(v!=null){try{v.close();}catch(Exception ignored){}}}
- @Override public void onRevoke(){appendLog("VPN permission revoked");shutdown("VPN revoked",true);super.onRevoke();}
- @Override public void onDestroy(){cleanupResources(true);active=false;running=false;if(!state.startsWith("Start failed")&&!state.startsWith("Core exited"))state="Stopped";stopForeground(true);super.onDestroy();}
+ private ParcelFileDescriptor establishVpn() throws Exception {
+ Builder builder = new Builder()
+ .setSession("DragonTCP Lite")
+ .setMtu(TUN_MTU)
+ .addAddress("10.77.0.2", 32)
+ .addRoute("0.0.0.0", 0)
+ .addDnsServer("1.1.1.1")
+ // The embedded userspace adapter is intentionally IPv4-only.
+ // Capturing ::/0 blocks IPv6 instead of leaking it outside the VPN.
+ .addAddress("fd77:6472:6167:6f6e::2", 128)
+ .addRoute("::", 0);
+
+ try {
+ builder.addDisallowedApplication(getPackageName());
+ } catch (PackageManager.NameNotFoundException ignored) {
+ }
+
+ if (Build.VERSION.SDK_INT >= 29) {
+ builder.setBlocking(true);
+ builder.setMetered(false);
+ }
+ return builder.establish();
+ }
+
+ private void failStart(String message) {
+ AppLog.append("CONNECT failed: " + message);
+ stopEverything("Failed");
+ }
+
+ private void stopEverything(String logMessage) {
+ synchronized (stateLock) {
+ stopEverythingLocked(logMessage);
+ }
+ }
+
+ private void cleanupComponentsLocked() {
+ connected = false;
+
+ TunnelEngine engine = tunnelEngine;
+ tunnelEngine = null;
+ if (engine != null) {
+ try { engine.stop(); } catch (Throwable ignored) {}
+ }
+
+ ParcelFileDescriptor fd = tunFd;
+ tunFd = null;
+ if (fd != null) {
+ try { fd.close(); } catch (Throwable ignored) {}
+ }
+
+ Process process = coreProcess;
+ coreProcess = null;
+ if (process != null) {
+ try {
+ process.destroy();
+ if (!process.waitFor(1200, TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly();
+ process.waitFor(800, TimeUnit.MILLISECONDS);
+ }
+ } catch (Throwable ignored) {
+ try { process.destroyForcibly(); } catch (Throwable ignored2) {}
+ }
+ }
+ }
+
+ private void stopEverythingLocked(String logMessage) {
+ if (stopping) return;
+ stopping = true;
+ cleanupComponentsLocked();
+ if (logMessage != null) AppLog.append(logMessage);
+ try { stopForeground(true); } catch (Throwable ignored) {}
+ stopSelf();
+ stopping = false;
+ }
+
+ @Override
+ public void onRevoke() {
+ stopEverything("VPN permission revoked");
+ super.onRevoke();
+ }
+
+ @Override
+ public void onDestroy() {
+ stopEverything(null);
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return super.onBind(intent);
+ }
+
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= 26) {
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ "DragonTCP VPN",
+ NotificationManager.IMPORTANCE_LOW
+ );
+ nm.createNotificationChannel(channel);
+ }
+ }
+
+ private Notification buildNotification(String status) {
+ Intent open = new Intent(this, MainActivity.class);
+ PendingIntent contentIntent = PendingIntent.getActivity(
+ this, 0, open,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)
+ );
+
+ Intent stopIntent = new Intent(this, DragonService.class).setAction(ACTION_STOP);
+ PendingIntent stopPending = PendingIntent.getService(
+ this, 1, stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)
+ );
+
+ Notification.Builder b = Build.VERSION.SDK_INT >= 26
+ ? new Notification.Builder(this, CHANNEL_ID)
+ : new Notification.Builder(this);
+ return b.setContentTitle("DragonTCP Lite")
+ .setContentText(status)
+ .setSmallIcon(android.R.drawable.stat_sys_upload_done)
+ .setContentIntent(contentIntent)
+ .setOngoing(true)
+ .addAction(new Notification.Action.Builder(
+ android.R.drawable.ic_menu_close_clear_cancel,
+ "STOP",
+ stopPending
+ ).build())
+ .build();
+ }
+
+ private void updateNotification(String status) {
+ NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
+ nm.notify(NOTIFICATION_ID, buildNotification(status));
+ }
}
diff --git a/android/src/com/dragontcp/client/MainActivity.java b/android/src/com/dragontcp/client/MainActivity.java
index 531f62d..047cf2d 100644
--- a/android/src/com/dragontcp/client/MainActivity.java
+++ b/android/src/com/dragontcp/client/MainActivity.java
@@ -1,14 +1,13 @@
package com.dragontcp.client;
import android.app.Activity;
+import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
-import android.graphics.Color;
import android.graphics.Typeface;
import android.net.VpnService;
import android.os.Build;
import android.os.Bundle;
-import android.os.Handler;
import android.text.InputType;
import android.view.Gravity;
import android.view.View;
@@ -17,117 +16,254 @@ import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
+import android.widget.TableLayout;
+import android.widget.TableRow;
import android.widget.TextView;
-import android.widget.Toast;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.security.SecureRandom;
public class MainActivity extends Activity {
- private static final int VPN_REQUEST = 5301;
+ private static final int VPN_REQUEST = 100;
+ private static final String PREFS = "dragontcp";
- private EditText server, port, token, chunkMax, chunkMin, timeout;
- private TextView status, logs;
+ private EditText server;
+ private EditText port;
+ private EditText token;
+ private EditText chunkMax;
+ private EditText chunkMin;
+ private EditText reconnect;
+ private EditText timeout;
+ private TextView logText;
private ScrollView logScroll;
- private Button connectButton, stopButton;
- private Intent pendingServiceIntent;
- private SharedPreferences prefs;
- private String lastLogText = "";
- private final Handler handler = new Handler();
- private final Runnable refresher = new Runnable() {
- @Override public void run() {
- refreshStatus();
- handler.postDelayed(this, 500);
- }
- };
+ private final AppLog.Listener logListener = line -> runOnUiThread(() -> appendLogLine(line));
- @Override protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- prefs = getSharedPreferences("dragontcp", MODE_PRIVATE);
- setTitle("DragonTCP VPN");
+ @Override
+ public void onCreate(Bundle state) {
+ super.onCreate(state);
buildUi();
loadSettings();
- handler.post(refresher);
}
- private int dp(int v) { return (int)(v * getResources().getDisplayMetrics().density + 0.5f); }
- private TextView text(String s, float sp, boolean bold) {
- TextView v = new TextView(this); v.setText(s); v.setTextSize(sp); v.setTextColor(Color.rgb(232,236,241));
- if (bold) v.setTypeface(Typeface.DEFAULT, Typeface.BOLD); return v;
+ @Override
+ protected void onStart() {
+ super.onStart();
+ logText.setText(AppLog.history());
+ AppLog.addListener(logListener);
+ logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN));
}
- private EditText field(LinearLayout root, String label, int type) {
- TextView t=text(label,13f,false);t.setPadding(0,dp(9),0,dp(4));root.addView(t);
- EditText e=new EditText(this);e.setSingleLine(true);e.setTextColor(Color.WHITE);e.setHintTextColor(Color.GRAY);e.setInputType(type);
- e.setBackgroundColor(Color.rgb(42,47,54));e.setPadding(dp(12),dp(9),dp(12),dp(9));
- root.addView(e,new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT));return e;
+
+ @Override
+ protected void onStop() {
+ AppLog.removeListener(logListener);
+ super.onStop();
}
private void buildUi() {
- ScrollView page=new ScrollView(this);page.setFillViewport(true);page.setBackgroundColor(Color.rgb(20,23,27));
- LinearLayout root=new LinearLayout(this);root.setOrientation(LinearLayout.VERTICAL);root.setPadding(dp(18),dp(18),dp(18),dp(24));page.addView(root);
- TextView title=text("DragonTCP VPN",27f,true);title.setTextColor(Color.rgb(104,207,255));root.addView(title);
- TextView sub=text("Full IPv4 / IPv6 packet VPN over adaptive TCP/53",13f,false);sub.setTextColor(Color.rgb(170,179,188));sub.setPadding(0,dp(2),0,dp(12));root.addView(sub);
- status=text("Stopped",16f,true);status.setPadding(dp(12),dp(12),dp(12),dp(12));status.setBackgroundColor(Color.rgb(34,39,45));root.addView(status);
+ int pad = dp(14);
+ ScrollView outer = new ScrollView(this);
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setPadding(pad, pad, pad, pad);
+ outer.addView(root, new ScrollView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ));
- server=field(root,"Server IP / hostname",InputType.TYPE_CLASS_TEXT);
- port=field(root,"TCP port",InputType.TYPE_CLASS_NUMBER);
- token=field(root,"Token",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);
- chunkMax=field(root,"Maximum transport fragment bytes (start = max)",InputType.TYPE_CLASS_NUMBER);
- chunkMin=field(root,"Minimum transport fragment bytes",InputType.TYPE_CLASS_NUMBER);
- timeout=field(root,"Transaction timeout (example: 2s)",InputType.TYPE_CLASS_TEXT);
+ TextView title = new TextView(this);
+ title.setText("DragonTCP Lite VPN");
+ title.setTextSize(24);
+ title.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
+ root.addView(title);
- TextView note=text("Pollers are fixed at 1. Adaptive chunks always start at Max and shrink on failures. All IPv4 and IPv6 routes are captured by the VPN; DragonTCP itself is excluded to prevent a tunnel loop.",12f,false);
- note.setTextColor(Color.rgb(160,170,180));note.setPadding(0,dp(10),0,dp(8));root.addView(note);
+ TextView subtitle = new TextView(this);
+ subtitle.setText("Android VPN → local HTTP CONNECT proxy → adaptive XOR over TCP/53");
+ subtitle.setTextSize(13);
+ subtitle.setPadding(0, dp(4), 0, dp(12));
+ root.addView(subtitle);
- LinearLayout buttons=new LinearLayout(this);buttons.setOrientation(LinearLayout.HORIZONTAL);buttons.setGravity(Gravity.CENTER);buttons.setPadding(0,dp(8),0,dp(10));root.addView(buttons);
- connectButton=new Button(this);connectButton.setText("CONNECT");buttons.addView(connectButton,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f));
- stopButton=new Button(this);stopButton.setText("STOP");buttons.addView(stopButton,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f));
- connectButton.setOnClickListener(v -> startDragon()); stopButton.setOnClickListener(v -> stopDragon());
+ TableLayout table = new TableLayout(this);
+ table.setStretchAllColumns(false);
+ table.setColumnStretchable(1, true);
+ root.addView(table, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ));
- LinearLayout lh=new LinearLayout(this);lh.setOrientation(LinearLayout.HORIZONTAL);lh.setGravity(Gravity.CENTER_VERTICAL);root.addView(lh);
- TextView lt=text("Live log",17f,true);lh.addView(lt,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f));
- Button clear=new Button(this);clear.setText("CLEAR");lh.addView(clear);clear.setOnClickListener(v -> clearLog());
- logScroll=new ScrollView(this);logScroll.setFillViewport(true);logScroll.setVerticalScrollBarEnabled(true);logScroll.setBackgroundColor(Color.BLACK);
- logs=text("",11f,false);logs.setTypeface(Typeface.MONOSPACE);logs.setTextIsSelectable(true);logs.setPadding(dp(10),dp(10),dp(10),dp(10));logs.setBackgroundColor(Color.BLACK);
- logScroll.addView(logs,new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT));
- root.addView(logScroll,new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,dp(320)));
- setContentView(page);
+ server = addField(table, "Server", "", false, false);
+ port = addField(table, "Port", "53", true, false);
+ token = addField(table, "Token", "", false, true);
+ chunkMax = addField(table, "Max chunk", "1048576", true, false);
+ chunkMin = addField(table, "Min chunk", "32", true, false);
+ reconnect = addField(table, "Reconnect every", "1", true, false);
+ timeout = addField(table, "Timeout (s)", "2", true, false);
+
+ TextView fixed = new TextView(this);
+ fixed.setText("Pollers: 1 (fixed) • Start chunk = Max chunk • XOR 0xAD always on");
+ fixed.setTextSize(12);
+ fixed.setPadding(0, dp(8), 0, dp(8));
+ root.addView(fixed);
+
+ LinearLayout buttons = new LinearLayout(this);
+ buttons.setOrientation(LinearLayout.HORIZONTAL);
+ buttons.setGravity(Gravity.CENTER);
+ Button connect = new Button(this);
+ connect.setText("CONNECT");
+ Button stop = new Button(this);
+ stop.setText("STOP");
+ buttons.addView(connect, new LinearLayout.LayoutParams(0, dp(52), 1f));
+ buttons.addView(stop, new LinearLayout.LayoutParams(0, dp(52), 1f));
+ root.addView(buttons);
+
+ connect.setOnClickListener(v -> requestConnect());
+ stop.setOnClickListener(v -> {
+ Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_STOP);
+ startService(i);
+ });
+
+ LinearLayout logHeader = new LinearLayout(this);
+ logHeader.setOrientation(LinearLayout.HORIZONTAL);
+ logHeader.setGravity(Gravity.CENTER_VERTICAL);
+ TextView logLabel = new TextView(this);
+ logLabel.setText("Live log");
+ logLabel.setTextSize(16);
+ logLabel.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
+ Button clear = new Button(this);
+ clear.setText("CLEAR");
+ logHeader.addView(logLabel, new LinearLayout.LayoutParams(0, dp(48), 1f));
+ logHeader.addView(clear, new LinearLayout.LayoutParams(dp(100), dp(48)));
+ root.addView(logHeader);
+
+ logScroll = new ScrollView(this);
+ logText = new TextView(this);
+ logText.setTextSize(12);
+ logText.setTypeface(Typeface.MONOSPACE);
+ logText.setTextIsSelectable(true);
+ logText.setPadding(dp(8), dp(8), dp(8), dp(8));
+ logScroll.addView(logText, new ScrollView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ));
+ root.addView(logScroll, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ dp(280)
+ ));
+ clear.setOnClickListener(v -> {
+ AppLog.clear();
+ logText.setText("");
+ });
+
+ TextView note = new TextView(this);
+ note.setText("IPv4 is tunneled. IPv6 is captured and blocked so it cannot bypass the proxy. DNS is sent to 1.1.1.1 through DragonTCP using DNS-over-TCP.");
+ note.setTextSize(11);
+ note.setPadding(0, dp(8), 0, dp(12));
+ root.addView(note);
+
+ setContentView(outer);
}
- private void loadSettings(){server.setText(prefs.getString("server",""));port.setText(prefs.getString("port","53"));token.setText(prefs.getString("token",""));chunkMax.setText(prefs.getString("chunkMax","1048576"));chunkMin.setText(prefs.getString("chunkMin","32"));timeout.setText(prefs.getString("timeout","2s"));}
- private int intValue(EditText e,int d){try{return Integer.parseInt(e.getText().toString().trim());}catch(Exception x){return d;}}
- private boolean validateSettings(){
- if(server.getText().toString().trim().isEmpty()){toast("Enter the server IP or hostname");return false;}
- int p=intValue(port,53),min=intValue(chunkMin,32),max=intValue(chunkMax,1048576);
- if(p<1||p>65535){toast("Port must be 1-65535");return false;}
- if(min<32||max>1048576||min>max){toast("Chunks must satisfy 32 <= Min <= Max <= 1048576");return false;}
- if(timeout.getText().toString().trim().isEmpty()){toast("Enter a timeout such as 2s");return false;}
- return true;
+ private EditText addField(TableLayout table, String label, String defaultValue, boolean numeric, boolean password) {
+ TableRow row = new TableRow(this);
+ row.setPadding(0, dp(2), 0, dp(2));
+ TextView name = new TextView(this);
+ name.setText(label);
+ name.setGravity(Gravity.CENTER_VERTICAL);
+ name.setPadding(0, 0, dp(10), 0);
+ EditText value = new EditText(this);
+ value.setSingleLine(true);
+ value.setText(defaultValue);
+ if (numeric) value.setInputType(InputType.TYPE_CLASS_NUMBER);
+ if (password) value.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+ row.addView(name, new TableRow.LayoutParams(dp(125), dp(50)));
+ row.addView(value, new TableRow.LayoutParams(0, dp(50), 1f));
+ table.addView(row);
+ return value;
}
- private void saveSettings(){prefs.edit().putString("server",server.getText().toString().trim()).putString("port",port.getText().toString().trim()).putString("token",token.getText().toString()).putString("chunkMax",chunkMax.getText().toString().trim()).putString("chunkMin",chunkMin.getText().toString().trim()).putString("timeout",timeout.getText().toString().trim()).apply();}
- private int clientHostId(){
- int id=prefs.getInt("clientHostId",0);if(id>=2&&id<=65534)return id;
- id=2+new SecureRandom().nextInt(65533);prefs.edit().putInt("clientHostId",id).apply();return id;
+ private void requestConnect() {
+ try {
+ validateAndSave();
+ } catch (Exception e) {
+ AppLog.append("CONFIG: " + e.getMessage());
+ return;
+ }
+ Intent prepare = VpnService.prepare(this);
+ if (prepare != null) {
+ startActivityForResult(prepare, VPN_REQUEST);
+ } else {
+ startDragonService();
+ }
}
- private String clientIPv4(int id){return "10.123."+((id>>8)&255)+"."+(id&255);}
- private String clientIPv6(int id){return "fd7a:4472:6167:6f6e::"+Integer.toHexString(id);}
- private Intent buildServiceIntent(){
- int max=intValue(chunkMax,1048576),id=clientHostId();Intent i=new Intent(this,DragonService.class);i.setAction(DragonService.ACTION_CONNECT);
- i.putExtra("server",server.getText().toString().trim());i.putExtra("port",intValue(port,53));i.putExtra("token",token.getText().toString());
- i.putExtra("chunkStart",max);i.putExtra("chunkMax",max);i.putExtra("chunkMin",intValue(chunkMin,32));i.putExtra("timeout",timeout.getText().toString().trim());
- i.putExtra("vpnIPv4",clientIPv4(id));i.putExtra("vpnIPv6",clientIPv6(id));return i;
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == VPN_REQUEST) {
+ if (resultCode == RESULT_OK) startDragonService();
+ else AppLog.append("VPN permission was not granted");
+ }
+ }
+
+ private void startDragonService() {
+ SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
+ Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_CONNECT);
+ i.putExtra(DragonService.EXTRA_SERVER, p.getString("server", ""));
+ i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53));
+ i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", ""));
+ i.putExtra(DragonService.EXTRA_CHUNK_MAX, p.getInt("max", 1048576));
+ i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32));
+ i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1));
+ i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2));
+ if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
+ }
+
+ private void validateAndSave() {
+ String h = server.getText().toString().trim();
+ if (h.isEmpty()) throw new IllegalArgumentException("Server is required");
+ int p = parse(port, 1, 65535, "Port");
+ int max = parse(chunkMax, 32, 1048576, "Max chunk");
+ int min = parse(chunkMin, 32, max, "Min chunk");
+ int rec = parse(reconnect, 1, 1000000, "Reconnect every");
+ int tout = parse(timeout, 1, 120, "Timeout");
+
+ getSharedPreferences(PREFS, MODE_PRIVATE).edit()
+ .putString("server", h)
+ .putInt("port", p)
+ .putString("token", token.getText().toString())
+ .putInt("max", max)
+ .putInt("min", min)
+ .putInt("reconnect", rec)
+ .putInt("timeout", tout)
+ .apply();
+ }
+
+ private int parse(EditText field, int min, int max, String name) {
+ int v;
+ try { v = Integer.parseInt(field.getText().toString().trim()); }
+ catch (Exception e) { throw new IllegalArgumentException(name + " is invalid"); }
+ if (v < min || v > max) throw new IllegalArgumentException(name + " must be " + min + "-" + max);
+ return v;
+ }
+
+ private void loadSettings() {
+ SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
+ server.setText(p.getString("server", ""));
+ port.setText(Integer.toString(p.getInt("port", 53)));
+ token.setText(p.getString("token", ""));
+ chunkMax.setText(Integer.toString(p.getInt("max", 1048576)));
+ chunkMin.setText(Integer.toString(p.getInt("min", 32)));
+ reconnect.setText(Integer.toString(p.getInt("reconnect", 1)));
+ timeout.setText(Integer.toString(p.getInt("timeout", 2)));
+ }
+
+ private void appendLogLine(String line) {
+ // Only auto-scroll when the user was already near the bottom.
+ View child = logScroll.getChildAt(0);
+ int gap = child == null ? 0 : child.getBottom() - (logScroll.getScrollY() + logScroll.getHeight());
+ boolean follow = gap < dp(48);
+ logText.append(line + "\n");
+ if (follow) logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN));
+ }
+
+ private int dp(int value) {
+ return Math.round(value * getResources().getDisplayMetrics().density);
}
- private void startDragon(){if(!validateSettings())return;saveSettings();pendingServiceIntent=buildServiceIntent();DragonService.active=true;DragonService.state="Waiting for VPN permission";refreshStatus();Intent prep=VpnService.prepare(this);if(prep!=null)startActivityForResult(prep,VPN_REQUEST);else{Intent i=pendingServiceIntent;pendingServiceIntent=null;launchService(i);}}
- private void launchService(Intent i){if(i==null)return;DragonService.active=true;DragonService.state="Starting full VPN";refreshStatus();if(Build.VERSION.SDK_INT>=26)startForegroundService(i);else startService(i);toast("Starting DragonTCP VPN...");}
- @Override protected void onActivityResult(int requestCode,int resultCode,Intent data){super.onActivityResult(requestCode,resultCode,data);if(requestCode!=VPN_REQUEST)return;if(resultCode==RESULT_OK&&pendingServiceIntent!=null){Intent i=pendingServiceIntent;pendingServiceIntent=null;launchService(i);}else{pendingServiceIntent=null;DragonService.active=false;DragonService.running=false;DragonService.state="VPN permission denied";refreshStatus();toast("VPN permission is required");}}
- private void stopDragon(){pendingServiceIntent=null;DragonService.state="Stopping...";refreshStatus();Intent s=new Intent(this,DragonService.class);s.setAction(DragonService.ACTION_STOP);try{startService(s);}catch(Exception e){stopService(new Intent(this,DragonService.class));}handler.postDelayed(()->{if(DragonService.active)stopService(new Intent(MainActivity.this,DragonService.class));refreshStatus();},1800);}
- private void clearLog(){try{File f=new File(getFilesDir(),"dragontcp.log");new java.io.FileOutputStream(f,false).close();lastLogText="";logs.setText("");}catch(Exception e){toast("Could not clear log: "+e.getMessage());}}
- private String readTail(File f,int maxBytes){if(!f.exists())return "";try(FileInputStream in=new FileInputStream(f)){long len=f.length();int n=(int)Math.min((long)maxBytes,len);byte[]buf=new byte[n];long skip=len-n;while(skip>0){long s=in.skip(skip);if(s<=0)break;skip-=s;}int off=0;while(offlogScroll.fullScroll(View.FOCUS_DOWN));}}
- private void toast(String s){Toast.makeText(this,s,Toast.LENGTH_LONG).show();}
- @Override protected void onDestroy(){handler.removeCallbacks(refresher);super.onDestroy();}
}
diff --git a/android/src/tech/xvanturing/freeproxy/data/model/ProxyProfile.kt b/android/src/tech/xvanturing/freeproxy/data/model/ProxyProfile.kt
new file mode 100644
index 0000000..da750bd
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/data/model/ProxyProfile.kt
@@ -0,0 +1,10 @@
+package tech.xvanturing.freeproxy.data.model
+
+enum class ProxyType { HTTP, SOCKS5 }
+enum class DnsMode { PROXY, DIRECT }
+
+data class ProxyProfile(
+ val type: ProxyType = ProxyType.HTTP,
+ val dnsMode: DnsMode = DnsMode.PROXY,
+ val udpOverSocks: Boolean = false,
+)
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/AppResolver.kt b/android/src/tech/xvanturing/freeproxy/vpn/AppResolver.kt
new file mode 100644
index 0000000..21b0b2a
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/AppResolver.kt
@@ -0,0 +1,8 @@
+package tech.xvanturing.freeproxy.vpn
+
+import tech.xvanturing.freeproxy.vpn.net.SessionKey
+
+/** App attribution is intentionally disabled in the lightweight build. */
+class AppResolver {
+ fun resolve(protocol: Int, key: SessionKey): String? = null
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/TcpSession.kt b/android/src/tech/xvanturing/freeproxy/vpn/TcpSession.kt
new file mode 100644
index 0000000..bfd402b
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/TcpSession.kt
@@ -0,0 +1,452 @@
+package tech.xvanturing.freeproxy.vpn
+// Modified for DragonTCP Lite compatibility with Kotlin 1.9 (ArrayDeque API).
+
+import android.os.SystemClock
+import android.system.OsConstants
+import android.util.Log
+import tech.xvanturing.freeproxy.vpn.log.LogLevel
+import tech.xvanturing.freeproxy.vpn.log.TunnelLog
+import tech.xvanturing.freeproxy.vpn.net.HostRegistry
+import tech.xvanturing.freeproxy.vpn.net.PacketBuilder
+import tech.xvanturing.freeproxy.vpn.net.SessionKey
+import tech.xvanturing.freeproxy.vpn.net.TcpHeader
+import tech.xvanturing.freeproxy.vpn.net.seqAdvance
+import tech.xvanturing.freeproxy.vpn.net.seqLessOrEqual
+import tech.xvanturing.freeproxy.vpn.net.seqLessThan
+import tech.xvanturing.freeproxy.vpn.net.toInetAddress
+import tech.xvanturing.freeproxy.vpn.proxy.ProxyClient
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import java.io.IOException
+import java.net.Socket
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.random.Random
+
+/**
+ * 一条 TCP 连接的用户态终结点。
+ *
+ * 对本机内核而言,这个对象扮演目标服务器:它回 SYN-ACK、确认数据、发 FIN;
+ * 真实流量则通过 [ProxyClient] 建立的隧道往返。
+ *
+ * 关于可靠性的一个重要简化:写向 TUN 的数据是交给本机内核的,不经过任何有损链路,
+ * 因此不需要拥塞控制。只要严格遵守对端宣告的接收窗口就不会丢包;
+ * 超时重传仅作为极端情况下的兜底。
+ */
+class TcpSession(
+ val key: SessionKey,
+ private val scope: CoroutineScope,
+ private val ioDispatcher: CoroutineDispatcher,
+ private val proxyClient: ProxyClient,
+ private val tun: TunWriter,
+ mtu: Int,
+ private val appResolver: AppResolver?,
+ private val onFinished: (SessionKey) -> Unit,
+) {
+
+ private enum class State { CONNECTING, ESTABLISHED, CLOSED }
+
+ private val mss = (mtu - IPV4_TCP_HEADER_SIZE).coerceIn(536, 1460)
+ private val lock = Object()
+ private val outputBuffer = ByteArray(mtu + 80)
+ private val closed = AtomicBoolean(false)
+
+ /** 上行数据队列;有界,队列压力通过 TCP 接收窗口反馈给应用。 */
+ private val upstream = Channel(capacity = UPSTREAM_QUEUE_SIZE)
+
+ @Volatile
+ private var state = State.CONNECTING
+
+ @Volatile
+ private var socket: Socket? = null
+
+ @Volatile
+ private var job: Job? = null
+
+ @Volatile
+ var lastActivity: Long = SystemClock.elapsedRealtime()
+ private set
+
+ // ---- 发送方向(我们 → 内核)
+ private val initialSequence = Random.nextLong(0, 0xFFFF_FFFFL)
+ private var sendUnacked = initialSequence
+ private var sendNext = initialSequence
+ private var peerWindow = 65535
+ private val retransmitQueue = ArrayDeque()
+ private var finSent = false
+
+ // ---- 接收方向(内核 → 我们)
+ private var receiveNext = 0L
+ private var pendingUpstreamBytes = 0
+ private var upstreamClosed = false
+
+ private class Segment(val sequence: Long, val data: ByteArray)
+
+ /** 收到 SYN:登记序列号并开始异步连接代理。 */
+ fun open(syn: TcpHeader) {
+ synchronized(lock) {
+ receiveNext = seqAdvance(syn.sequence, 1)
+ peerWindow = syn.window
+ }
+ job = scope.launch(ioDispatcher) {
+ // UID 反查要趁 socket 还在,因此放在建立隧道之前
+ val packageName = appResolver?.resolve(OsConstants.IPPROTO_TCP, key)
+ val target = HostRegistry.describe(key.destIp, key.destPort)
+ // 目标是主机名(而非 IP 字面量)时,允许从日志把它加入 DNS 拦截
+ val targetHost = target.substringBeforeLast(':')
+ val targetDomain = targetHost.takeIf { host -> host.any { it.isLetter() } }
+
+ val connected = try {
+ proxyClient.connectTcp(key.destIp.toInetAddress(), key.destPort)
+ } catch (e: Exception) {
+ Log.d(TAG, "连接失败 $key:${e.message}")
+ TunnelLog.connect(
+ target = target,
+ packageName = packageName,
+ status = e.message?.take(48) ?: "失败",
+ level = LogLevel.FAILURE,
+ domain = targetDomain,
+ )
+ // 立刻回 RST,让应用马上得到"连接被拒绝"而不是干等超时
+ sendReset()
+ finish()
+ return@launch
+ }
+ TunnelLog.connect(target, packageName, "OK", LogLevel.SUCCESS, targetDomain)
+
+ val accepted = synchronized(lock) {
+ if (state != State.CONNECTING) {
+ false
+ } else {
+ socket = connected
+ state = State.ESTABLISHED
+ sendSynAck()
+ true
+ }
+ }
+ if (!accepted) {
+ runCatching { connected.close() }
+ return@launch
+ }
+
+ VpnStateHolder.sessionCounter.incrementAndGet()
+ launch(ioDispatcher) { pumpUpstream(connected) }
+ pumpDownstream(connected)
+ }
+ }
+
+ /** 处理来自内核的一个 TCP 报文段。 */
+ fun onPacket(header: TcpHeader, buffer: ByteArray, payloadOffset: Int, payloadLength: Int) {
+ lastActivity = SystemClock.elapsedRealtime()
+
+ if (header.isRst) {
+ finish()
+ return
+ }
+
+ synchronized(lock) {
+ peerWindow = header.window
+ if (header.isAck) releaseAcknowledged(header.acknowledgment)
+ lock.notifyAll()
+ }
+
+ // 重复的 SYN 说明我们的 SYN-ACK 丢了(或那时还没连上代理),补发一次
+ if (header.isSyn) {
+ synchronized(lock) {
+ if (state == State.ESTABLISHED) sendSynAck()
+ }
+ return
+ }
+
+ if (state == State.CLOSED) {
+ sendReset()
+ return
+ }
+
+ val accepted = if (payloadLength > 0) {
+ acceptData(header.sequence, buffer, payloadOffset, payloadLength)
+ } else {
+ 0
+ }
+
+ if (header.isFin) {
+ acceptFin(seqAdvance(header.sequence, accepted))
+ }
+ }
+
+ /** @return 实际被接收的字节数,用于定位随行 FIN 的序列号。 */
+ private fun acceptData(sequence: Long, buffer: ByteArray, offset: Int, length: Int): Int {
+ val chunk = synchronized(lock) {
+ when {
+ sequence == receiveNext -> buffer.copyOfRange(offset, offset + length)
+ // 重传的老数据,或 TUN 上本不该出现的乱序:都用一个 ACK 应答
+ else -> {
+ sendAck()
+ return 0
+ }
+ }
+ }
+
+ // trySend 失败意味着上行积压:不推进 receiveNext,对端会因零窗口暂停,
+ // 等队列腾出空间后由 pumpUpstream 主动通告新窗口。
+ if (!upstream.trySend(chunk).isSuccess) {
+ synchronized(lock) { sendAck() }
+ return 0
+ }
+ synchronized(lock) {
+ receiveNext = seqAdvance(receiveNext, length)
+ pendingUpstreamBytes += length
+ sendAck()
+ }
+ return length
+ }
+
+ private fun acceptFin(finSequence: Long) {
+ synchronized(lock) {
+ if (upstreamClosed) {
+ sendAck()
+ return
+ }
+ if (finSequence != receiveNext) return
+ receiveNext = seqAdvance(receiveNext, 1)
+ upstreamClosed = true
+ sendAck()
+ }
+ // 关闭上行队列,写协程排空后会 shutdownOutput,让代理知道请求已结束
+ upstream.close()
+ }
+
+ // ------------------------------------------------------------ 数据泵
+
+ private suspend fun pumpUpstream(socket: Socket) {
+ try {
+ val output = socket.getOutputStream()
+ for (chunk in upstream) {
+ output.write(chunk)
+ output.flush()
+ VpnStateHolder.uploadCounter.addAndGet(chunk.size.toLong())
+ synchronized(lock) {
+ val before = advertisedWindow()
+ pendingUpstreamBytes = max(0, pendingUpstreamBytes - chunk.size)
+ // 只在窗口刚从"不足一个 MSS"恢复时通告,避免每块数据都回一个冗余 ACK
+ if (state == State.ESTABLISHED && before < mss && advertisedWindow() >= mss) {
+ sendAck()
+ }
+ }
+ }
+ runCatching { socket.shutdownOutput() }
+ } catch (e: Exception) {
+ Log.d(TAG, "上行结束 $key:${e.message}")
+ finish()
+ }
+ }
+
+ private fun pumpDownstream(socket: Socket) {
+ try {
+ val input = socket.getInputStream()
+ val buffer = ByteArray(mss)
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ VpnStateHolder.downloadCounter.addAndGet(read.toLong())
+ sendData(buffer, read)
+ }
+ sendFin()
+ } catch (e: Exception) {
+ Log.d(TAG, "下行结束 $key:${e.message}")
+ if (state == State.ESTABLISHED) sendReset()
+ finish()
+ }
+ }
+
+ /** 把代理返回的数据切成 MSS 大小写回 TUN,并按对端窗口节流。 */
+ private fun sendData(data: ByteArray, length: Int) {
+ var offset = 0
+ while (offset < length) {
+ val chunk = min(mss, length - offset)
+ if (!awaitSendWindow(chunk)) throw IOException("会话已关闭")
+ synchronized(lock) {
+ if (state != State.ESTABLISHED) throw IOException("会话已关闭")
+ val sequence = sendNext
+ val size = PacketBuilder.writeTcp(
+ output = outputBuffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = sequence,
+ acknowledgment = receiveNext,
+ flags = TcpHeader.ACK or TcpHeader.PSH,
+ window = advertisedWindow(),
+ payload = data,
+ payloadOffset = offset,
+ payloadLength = chunk,
+ )
+ tun.enqueue(outputBuffer, size)
+ sendNext = seqAdvance(sequence, chunk)
+ retransmitQueue.add(Segment(sequence, data.copyOfRange(offset, offset + chunk)))
+ }
+ offset += chunk
+ }
+ }
+
+ /** 等到窗口能容下 [needed] 字节;久等不到 ACK 就重传队首。@return false 表示会话已关闭。 */
+ private fun awaitSendWindow(needed: Int): Boolean {
+ synchronized(lock) {
+ var lastRetransmit = SystemClock.elapsedRealtime()
+ while (state == State.ESTABLISHED) {
+ val inflight = (sendNext - sendUnacked).toInt()
+ val allowed = min(max(peerWindow, mss), MAX_INFLIGHT)
+ if (inflight + needed <= allowed) return true
+
+ lock.wait(WINDOW_POLL_MS)
+ val now = SystemClock.elapsedRealtime()
+ if (now - lastRetransmit >= RETRANSMIT_TIMEOUT_MS) {
+ retransmitUnacknowledged()
+ lastRetransmit = now
+ }
+ }
+ return false
+ }
+ }
+
+ // --------------------------------------------------------- 报文发送
+
+ private fun sendSynAck() {
+ val size = PacketBuilder.writeTcp(
+ output = outputBuffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = initialSequence,
+ acknowledgment = receiveNext,
+ flags = TcpHeader.SYN or TcpHeader.ACK,
+ window = advertisedWindow(),
+ mss = mss,
+ )
+ tun.enqueue(outputBuffer, size)
+ // SYN 自身占用一个序列号
+ if (sendNext == initialSequence) sendNext = seqAdvance(initialSequence, 1)
+ }
+
+ private fun sendAck() {
+ val size = PacketBuilder.writeTcp(
+ output = outputBuffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = sendNext,
+ acknowledgment = receiveNext,
+ flags = TcpHeader.ACK,
+ window = advertisedWindow(),
+ )
+ tun.enqueue(outputBuffer, size)
+ }
+
+ private fun sendFin() {
+ synchronized(lock) {
+ if (finSent || state != State.ESTABLISHED) return
+ finSent = true
+ val size = PacketBuilder.writeTcp(
+ output = outputBuffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = sendNext,
+ acknowledgment = receiveNext,
+ flags = TcpHeader.FIN or TcpHeader.ACK,
+ window = advertisedWindow(),
+ )
+ tun.enqueue(outputBuffer, size)
+ sendNext = seqAdvance(sendNext, 1)
+ }
+ }
+
+ private fun sendReset() {
+ // 独立缓冲区:这个方法可能在别的线程正操作 outputBuffer 时被调用
+ val buffer = ByteArray(IPV4_TCP_HEADER_SIZE)
+ val size = synchronized(lock) {
+ PacketBuilder.writeTcp(
+ output = buffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = sendNext,
+ acknowledgment = receiveNext,
+ flags = TcpHeader.RST or TcpHeader.ACK,
+ window = 0,
+ )
+ }
+ tun.enqueue(buffer, size)
+ }
+
+ /** 调用方必须持有 [lock]。 */
+ private fun retransmitUnacknowledged() {
+ val first = retransmitQueue.firstOrNull() ?: return
+ val size = PacketBuilder.writeTcp(
+ output = outputBuffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = first.sequence,
+ acknowledgment = receiveNext,
+ flags = TcpHeader.ACK or TcpHeader.PSH,
+ window = advertisedWindow(),
+ payload = first.data,
+ payloadOffset = 0,
+ payloadLength = first.data.size,
+ )
+ tun.enqueue(outputBuffer, size)
+ }
+
+ /** 丢弃已被确认的段。调用方必须持有 [lock]。 */
+ private fun releaseAcknowledged(acknowledgment: Long) {
+ if (!seqLessThan(sendUnacked, acknowledgment)) return
+ if (!seqLessOrEqual(acknowledgment, sendNext)) return
+ sendUnacked = acknowledgment
+ while (true) {
+ val segment = retransmitQueue.firstOrNull() ?: break
+ val end = seqAdvance(segment.sequence, segment.data.size)
+ if (seqLessOrEqual(end, acknowledgment)) retransmitQueue.removeFirst() else break
+ }
+ }
+
+ /** 剩余可用的接收窗口;上行积压时收缩,必要时通告零窗口让应用暂停发送。 */
+ private fun advertisedWindow(): Int =
+ (RECEIVE_WINDOW - pendingUpstreamBytes).coerceIn(0, RECEIVE_WINDOW)
+
+ fun finish() {
+ if (!closed.compareAndSet(false, true)) return
+ val wasEstablished = synchronized(lock) {
+ val established = state == State.ESTABLISHED
+ state = State.CLOSED
+ lock.notifyAll()
+ established
+ }
+ if (wasEstablished) VpnStateHolder.sessionCounter.decrementAndGet()
+
+ upstream.close()
+ runCatching { socket?.close() }
+ job?.cancel()
+ onFinished(key)
+ }
+
+ private companion object {
+ const val TAG = "TcpSession"
+ const val IPV4_TCP_HEADER_SIZE = 40
+ const val RECEIVE_WINDOW = 65535
+ const val MAX_INFLIGHT = 65535
+ const val UPSTREAM_QUEUE_SIZE = 64
+ const val RETRANSMIT_TIMEOUT_MS = 400L
+ const val WINDOW_POLL_MS = 100L
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/TunWriter.kt b/android/src/tech/xvanturing/freeproxy/vpn/TunWriter.kt
new file mode 100644
index 0000000..8404f4e
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/TunWriter.kt
@@ -0,0 +1,6 @@
+package tech.xvanturing.freeproxy.vpn
+
+/** 把构造好的 IP 包送回 TUN 设备。实现方负责拷贝数据,调用后缓冲区即可复用。 */
+interface TunWriter {
+ fun enqueue(packet: ByteArray, length: Int)
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/TunnelEngine.kt b/android/src/tech/xvanturing/freeproxy/vpn/TunnelEngine.kt
new file mode 100644
index 0000000..d5fc9a0
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/TunnelEngine.kt
@@ -0,0 +1,249 @@
+package tech.xvanturing.freeproxy.vpn
+
+import android.os.ParcelFileDescriptor
+import android.os.SystemClock
+import android.util.Log
+import tech.xvanturing.freeproxy.data.model.ProxyProfile
+import tech.xvanturing.freeproxy.vpn.dns.DnsBlocker
+import tech.xvanturing.freeproxy.vpn.net.Ipv4Header
+import tech.xvanturing.freeproxy.vpn.net.PROTO_TCP
+import tech.xvanturing.freeproxy.vpn.net.PROTO_UDP
+import tech.xvanturing.freeproxy.vpn.net.PacketBuilder
+import tech.xvanturing.freeproxy.vpn.net.SessionKey
+import tech.xvanturing.freeproxy.vpn.net.TcpHeader
+import tech.xvanturing.freeproxy.vpn.net.UdpHeader
+import tech.xvanturing.freeproxy.vpn.net.seqAdvance
+import tech.xvanturing.freeproxy.vpn.proxy.ProxyClient
+import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.net.InetSocketAddress
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * 隧道主循环:从 TUN 读 IP 包,按协议分发给会话,再把响应写回 TUN。
+ *
+ * 读、写各占一个专用线程;每条会话的阻塞式代理 IO 跑在一个可伸缩线程池上。
+ */
+class TunnelEngine(
+ private val tunInterface: ParcelFileDescriptor,
+ private val profile: ProxyProfile,
+ proxyAddress: InetSocketAddress,
+ private val mtu: Int,
+ private val protector: SocketProtector,
+ private val appResolver: AppResolver?,
+ private val dnsBlocker: DnsBlocker?,
+) : TunWriter {
+
+ private val running = AtomicBoolean(false)
+ private val executor = Executors.newCachedThreadPool { runnable ->
+ Thread(runnable, "freeproxy-io").apply { isDaemon = true }
+ }
+ private val ioDispatcher = executor.asCoroutineDispatcher()
+ private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
+
+ private val proxyClient = ProxyClient(profile, proxyAddress, protector)
+
+ private val tcpSessions = ConcurrentHashMap()
+ private val udpSessions = ConcurrentHashMap()
+
+ private val writeQueue = ArrayBlockingQueue(WRITE_QUEUE_SIZE)
+
+ private var readerThread: Thread? = null
+ private var writerThread: Thread? = null
+
+ fun start() {
+ if (!running.compareAndSet(false, true)) return
+ readerThread = Thread(::readLoop, "freeproxy-tun-read").apply { start() }
+ writerThread = Thread(::writeLoop, "freeproxy-tun-write").apply { start() }
+ scope.launch { housekeepingLoop() }
+ }
+
+ fun stop() {
+ if (!running.compareAndSet(true, false)) return
+ tcpSessions.values.toList().forEach { it.finish() }
+ udpSessions.values.toList().forEach { it.finish() }
+ tcpSessions.clear()
+ udpSessions.clear()
+ scope.cancel()
+ readerThread?.interrupt()
+ writerThread?.interrupt()
+ executor.shutdownNow()
+ runCatching { tunInterface.close() }
+ }
+
+ // ------------------------------------------------------------- 读
+
+ private fun readLoop() {
+ val input = FileInputStream(tunInterface.fileDescriptor)
+ val buffer = ByteArray(mtu + HEADROOM)
+ try {
+ while (running.get()) {
+ val length = input.read(buffer)
+ if (length <= 0) continue
+ dispatch(buffer, length)
+ }
+ } catch (e: Exception) {
+ if (running.get()) Log.w(TAG, "TUN 读取中断:${e.message}")
+ } finally {
+ runCatching { input.close() }
+ }
+ }
+
+ private fun dispatch(buffer: ByteArray, length: Int) {
+ // 解析失败的包(含 IPv6、分片包)直接丢弃
+ val ip = Ipv4Header.parse(buffer, length) ?: return
+ when (ip.protocol) {
+ PROTO_TCP -> handleTcp(ip, buffer)
+ PROTO_UDP -> handleUdp(ip, buffer)
+ else -> Unit // ICMP 等不做处理:代理协议本身也承载不了
+ }
+ }
+
+ private fun handleTcp(ip: Ipv4Header, buffer: ByteArray) {
+ val tcp = TcpHeader.parse(buffer, ip.headerLength, ip.payloadLength) ?: return
+ val key = SessionKey(ip.sourceIp, tcp.sourcePort, ip.destIp, tcp.destPort)
+ val payloadOffset = ip.headerLength + tcp.dataOffset
+ val payloadLength = ip.totalLength - payloadOffset
+ if (payloadLength < 0) return
+
+ val existing = tcpSessions[key]
+ if (existing != null) {
+ existing.onPacket(tcp, buffer, payloadOffset, payloadLength)
+ return
+ }
+
+ // 新连接只能由 SYN 发起;其余情况说明会话已过期,回 RST 让对端立即放弃
+ if (!tcp.isSyn) {
+ if (!tcp.isRst) sendReset(key, tcp)
+ return
+ }
+ if (tcpSessions.size >= MAX_TCP_SESSIONS) {
+ Log.w(TAG, "TCP 会话数达到上限,拒绝新连接")
+ sendReset(key, tcp)
+ return
+ }
+
+ val session = TcpSession(
+ key = key,
+ scope = scope,
+ ioDispatcher = ioDispatcher,
+ proxyClient = proxyClient,
+ tun = this,
+ mtu = mtu,
+ appResolver = appResolver,
+ onFinished = { tcpSessions.remove(it) },
+ )
+ // putIfAbsent 防止 SYN 重传时并发建两条会话
+ val raced = tcpSessions.putIfAbsent(key, session)
+ if (raced != null) {
+ raced.onPacket(tcp, buffer, payloadOffset, payloadLength)
+ } else {
+ session.open(tcp)
+ }
+ }
+
+ private fun handleUdp(ip: Ipv4Header, buffer: ByteArray) {
+ val udp = UdpHeader.parse(buffer, ip.headerLength, ip.payloadLength) ?: return
+ val key = SessionKey(ip.sourceIp, udp.sourcePort, ip.destIp, udp.destPort)
+ val payloadOffset = ip.headerLength + UdpHeader.SIZE
+ val payloadLength = udp.payloadLength
+ if (payloadLength <= 0) return
+
+ val session = udpSessions[key] ?: run {
+ if (udpSessions.size >= MAX_UDP_SESSIONS) return
+ val created = UdpSession(
+ key = key,
+ scope = scope,
+ ioDispatcher = ioDispatcher,
+ proxyClient = proxyClient,
+ profile = profile,
+ protector = protector,
+ tun = this,
+ appResolver = appResolver,
+ dnsBlocker = dnsBlocker,
+ onFinished = { udpSessions.remove(it) },
+ )
+ udpSessions.putIfAbsent(key, created) ?: created
+ }
+ session.send(buffer.copyOfRange(payloadOffset, payloadOffset + payloadLength))
+ }
+
+ /** 对没有会话的报文回 RST,避免应用一直卡在连接超时上。 */
+ private fun sendReset(key: SessionKey, tcp: TcpHeader) {
+ val buffer = ByteArray(40)
+ val payloadEnd = if (tcp.isSyn) 1 else 0
+ val size = PacketBuilder.writeTcp(
+ output = buffer,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ sequence = tcp.acknowledgment,
+ acknowledgment = seqAdvance(tcp.sequence, payloadEnd),
+ flags = TcpHeader.RST or TcpHeader.ACK,
+ window = 0,
+ )
+ enqueue(buffer, size)
+ }
+
+ // ------------------------------------------------------------- 写
+
+ override fun enqueue(packet: ByteArray, length: Int) {
+ if (!running.get()) return
+ // 队列满说明内核侧已经跟不上,丢弃比阻塞会话线程更好
+ if (!writeQueue.offer(packet.copyOf(length))) {
+ Log.w(TAG, "TUN 写队列已满,丢弃 1 个包")
+ }
+ }
+
+ private fun writeLoop() {
+ val output = FileOutputStream(tunInterface.fileDescriptor)
+ try {
+ while (running.get()) {
+ val packet = writeQueue.poll(500, TimeUnit.MILLISECONDS) ?: continue
+ output.write(packet)
+ }
+ } catch (e: Exception) {
+ if (running.get()) Log.w(TAG, "TUN 写入中断:${e.message}")
+ } finally {
+ runCatching { output.close() }
+ }
+ }
+
+ // --------------------------------------------------------- 定时维护
+
+ private suspend fun housekeepingLoop() {
+ while (scope.isActive && running.get()) {
+ delay(HOUSEKEEPING_INTERVAL_MS)
+ val now = SystemClock.elapsedRealtime()
+ tcpSessions.values.toList()
+ .filter { now - it.lastActivity > TCP_IDLE_TIMEOUT_MS }
+ .forEach { it.finish() }
+ udpSessions.values.toList()
+ .filter { now - it.lastActivity > it.idleTimeoutMs }
+ .forEach { it.finish() }
+ }
+ }
+
+ private companion object {
+ const val TAG = "TunnelEngine"
+ const val HEADROOM = 80
+ const val WRITE_QUEUE_SIZE = 1024
+ const val MAX_TCP_SESSIONS = 512
+ const val MAX_UDP_SESSIONS = 256
+ const val TCP_IDLE_TIMEOUT_MS = 300_000L
+ const val HOUSEKEEPING_INTERVAL_MS = 5_000L
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/UdpSession.kt b/android/src/tech/xvanturing/freeproxy/vpn/UdpSession.kt
new file mode 100644
index 0000000..26d293e
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/UdpSession.kt
@@ -0,0 +1,353 @@
+package tech.xvanturing.freeproxy.vpn
+// Modified for DragonTCP Lite: HTTP-proxy DNS is forced to Cloudflare 1.1.1.1 over TCP.
+
+import android.os.SystemClock
+import android.system.OsConstants
+import android.util.Log
+import tech.xvanturing.freeproxy.data.model.DnsMode
+import tech.xvanturing.freeproxy.data.model.ProxyProfile
+import tech.xvanturing.freeproxy.data.model.ProxyType
+import tech.xvanturing.freeproxy.vpn.dns.DnsBlocker
+import tech.xvanturing.freeproxy.vpn.log.TunnelLog
+import tech.xvanturing.freeproxy.vpn.net.DnsMessage
+import tech.xvanturing.freeproxy.vpn.net.DnsResponse
+import tech.xvanturing.freeproxy.vpn.net.HostRegistry
+import tech.xvanturing.freeproxy.vpn.net.PacketBuilder
+import tech.xvanturing.freeproxy.vpn.net.SessionKey
+import tech.xvanturing.freeproxy.vpn.net.toInetAddress
+import tech.xvanturing.freeproxy.vpn.net.toIpv4Bytes
+import tech.xvanturing.freeproxy.vpn.net.u8
+import tech.xvanturing.freeproxy.vpn.proxy.ProxyClient
+import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector
+import tech.xvanturing.freeproxy.vpn.proxy.UdpAssociation
+import tech.xvanturing.freeproxy.vpn.proxy.readExactly
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import java.io.ByteArrayOutputStream
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * 一条 UDP "流"(四元组)的转发通道。
+ *
+ * 转发方式取决于配置:
+ * - SOCKS5 且开启 UDP:走 UDP ASSOCIATE,全协议支持;
+ * - 其余情况:只放行 DNS,并自动降级为 DNS over TCP(RFC 7766)经代理查询,
+ * 这样即使上游只有 HTTP CONNECT,域名解析依然可用。
+ *
+ * 每个四元组独占一条转发通道 —— 共享一个中继 socket 会让回程包无法区分本地源端口。
+ */
+class UdpSession(
+ val key: SessionKey,
+ private val scope: CoroutineScope,
+ private val ioDispatcher: CoroutineDispatcher,
+ private val proxyClient: ProxyClient,
+ private val profile: ProxyProfile,
+ private val protector: SocketProtector,
+ private val tun: TunWriter,
+ private val appResolver: AppResolver?,
+ private val dnsBlocker: DnsBlocker?,
+ private val onFinished: (SessionKey) -> Unit,
+) {
+
+ private val closed = AtomicBoolean(false)
+ private val useSocksUdp = profile.type == ProxyType.SOCKS5 && profile.udpOverSocks
+ private val isDns = key.destPort == DNS_PORT
+
+ private var packageResolved = false
+ private var cachedPackage: String? = null
+
+ @Volatile
+ private var association: UdpAssociation? = null
+
+ @Volatile
+ private var relaySocket: DatagramSocket? = null
+
+ @Volatile
+ private var started = false
+
+ @Volatile
+ var lastActivity: Long = SystemClock.elapsedRealtime()
+ private set
+
+ val idleTimeoutMs: Long get() = if (isDns) DNS_IDLE_MS else UDP_IDLE_MS
+
+ /** 转发一个从 TUN 收到的 UDP 载荷。 */
+ fun send(payload: ByteArray) {
+ lastActivity = SystemClock.elapsedRealtime()
+ if (isDns) {
+ logDnsQuery(payload)
+ if (dnsBlocker != null && tryBlockDns(payload)) return
+ }
+ when {
+ // 直连解析优先判断:这条路径完全不碰代理
+ isDns && profile.dnsMode == DnsMode.DIRECT -> sendDnsDirect(payload)
+ useSocksUdp -> sendOverSocks(payload)
+ isDns -> sendDnsOverTcp(payload)
+ else -> {
+ // 代理不支持 UDP:静默丢弃。应用侧通常会自行回退到 TCP。
+ Log.d(TAG, "丢弃 UDP(代理未启用 UDP 转发):$key")
+ finish()
+ }
+ }
+ }
+
+ // ------------------------------------------------------- SOCKS5 UDP
+
+ private fun sendOverSocks(payload: ByteArray) {
+ if (!started) {
+ started = true
+ scope.launch(ioDispatcher) {
+ if (!openAssociation()) {
+ finish()
+ return@launch
+ }
+ forward(payload)
+ receiveLoop()
+ }
+ } else {
+ scope.launch(ioDispatcher) { forward(payload) }
+ }
+ }
+
+ private fun openAssociation(): Boolean = try {
+ val assoc = proxyClient.openUdpAssociate()
+ val socket = DatagramSocket()
+ if (!protector.protect(socket)) {
+ socket.close()
+ assoc.close()
+ false
+ } else {
+ association = assoc
+ relaySocket = socket
+ true
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "UDP ASSOCIATE 失败 $key:${e.message}")
+ false
+ }
+
+ private fun forward(payload: ByteArray) {
+ val socket = relaySocket ?: return
+ val relay = association?.relayAddress ?: return
+ try {
+ // SOCKS5 UDP 请求头:RSV(2) FRAG(1) ATYP ADDR PORT
+ val framed = ByteArrayOutputStream(payload.size + 10).apply {
+ write(0)
+ write(0)
+ write(0)
+ write(ProxyClient.ATYP_IPV4)
+ write(key.destIp.toIpv4Bytes())
+ write((key.destPort ushr 8) and 0xFF)
+ write(key.destPort and 0xFF)
+ write(payload)
+ }.toByteArray()
+ socket.send(DatagramPacket(framed, framed.size, relay))
+ VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
+ } catch (e: Exception) {
+ Log.d(TAG, "UDP 发送失败 $key:${e.message}")
+ finish()
+ }
+ }
+
+ private fun receiveLoop() {
+ val socket = relaySocket ?: return
+ val buffer = ByteArray(MAX_DATAGRAM)
+ val packet = DatagramPacket(buffer, buffer.size)
+ try {
+ while (!closed.get()) {
+ packet.setData(buffer, 0, buffer.size)
+ socket.receive(packet)
+ lastActivity = SystemClock.elapsedRealtime()
+ val payloadOffset = socksPayloadOffset(buffer, packet.length) ?: continue
+ val payloadLength = packet.length - payloadOffset
+ if (payloadLength <= 0) continue
+ VpnStateHolder.downloadCounter.addAndGet(payloadLength.toLong())
+ writeBackToTun(buffer, payloadOffset, payloadLength)
+ }
+ } catch (e: Exception) {
+ if (!closed.get()) Log.d(TAG, "UDP 接收结束 $key:${e.message}")
+ } finally {
+ finish()
+ }
+ }
+
+ /** 跳过 SOCKS5 UDP 应答头,返回真实载荷的起始下标。 */
+ private fun socksPayloadOffset(buffer: ByteArray, length: Int): Int? {
+ if (length < 10) return null
+ var offset = 3 // RSV(2) + FRAG(1)
+ val addressType = buffer.u8(offset)
+ offset += 1
+ offset += when (addressType) {
+ ProxyClient.ATYP_IPV4 -> 4
+ ProxyClient.ATYP_IPV6 -> 16
+ ProxyClient.ATYP_DOMAIN -> {
+ if (offset >= length) return null
+ 1 + buffer.u8(offset)
+ }
+
+ else -> return null
+ }
+ offset += 2 // 端口
+ return if (offset < length) offset else null
+ }
+
+ // -------------------------------------------------------- DNS / TCP
+
+ /**
+ * DNS 是一问一答,直接为每次查询开一条隧道:
+ * TCP 承载的 DNS 报文前面多两个字节的长度前缀。
+ */
+ private fun sendDnsOverTcp(payload: ByteArray) {
+ scope.launch(ioDispatcher) {
+ try {
+ proxyClient.connectTcp(CLOUDFLARE_DNS, DNS_PORT).use { socket ->
+ socket.soTimeout = DNS_TIMEOUT_MS
+ socket.getOutputStream().apply {
+ write((payload.size ushr 8) and 0xFF)
+ write(payload.size and 0xFF)
+ write(payload)
+ flush()
+ }
+ VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
+
+ val input = socket.getInputStream()
+ val header = input.readExactly(2)
+ val length = ((header[0].toInt() and 0xFF) shl 8) or (header[1].toInt() and 0xFF)
+ if (length in 1..MAX_DATAGRAM) {
+ val response = input.readExactly(length)
+ VpnStateHolder.downloadCounter.addAndGet(length.toLong())
+ writeBackToTun(response, 0, length)
+ }
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "DNS over TCP 失败 $key:${e.message}")
+ } finally {
+ finish()
+ }
+ }
+ }
+
+ /**
+ * 本地直连解析:用一个 protect 过的 socket 直接问 DNS 服务器。
+ * 快,但查询内容对所在网络可见 —— 这是用户在配置里明确选择的取舍。
+ */
+ private fun sendDnsDirect(payload: ByteArray) {
+ scope.launch(ioDispatcher) {
+ try {
+ DatagramSocket().use { socket ->
+ if (!protector.protect(socket)) return@launch
+ socket.soTimeout = DNS_TIMEOUT_MS
+ socket.send(
+ DatagramPacket(
+ payload,
+ payload.size,
+ key.destIp.toInetAddress(),
+ key.destPort,
+ ),
+ )
+ VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
+
+ val buffer = ByteArray(MAX_DATAGRAM)
+ val response = DatagramPacket(buffer, buffer.size)
+ socket.receive(response)
+ VpnStateHolder.downloadCounter.addAndGet(response.length.toLong())
+ writeBackToTun(buffer, 0, response.length)
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "直连 DNS 失败 $key:${e.message}")
+ } finally {
+ finish()
+ }
+ }
+ }
+
+ // ------------------------------------------------------------ 日志
+
+ /**
+ * 命中拦截规则的查询不再转发,直接伪造一个应答回给应用。
+ *
+ * @return true 表示已拦截并作答,本次会话到此结束。
+ */
+ private fun tryBlockDns(payload: ByteArray): Boolean {
+ val blocker = dnsBlocker ?: return false
+ val question = DnsMessage.readQuestion(payload, 0, payload.size) ?: return false
+ val packageName = resolvePackage()
+ val ip = blocker.resolve(question, packageName) ?: return false
+ val response = DnsResponse.buildBlockedResponse(payload, payload.size, ip) ?: return false
+ writeBackToTun(response, 0, response.size)
+ val rule = if (blocker.isAppBlocked(packageName)) "应用" else "域名"
+ TunnelLog.dns(
+ "DNS ${question.typeName} ${question.name} → $ip(按$rule 拦截)",
+ packageName,
+ question.name,
+ )
+ finish()
+ return true
+ }
+
+ private fun logDnsQuery(payload: ByteArray) {
+ val question = DnsMessage.readQuestion(payload, 0, payload.size) ?: return
+ TunnelLog.dns(
+ "DNS ${question.typeName} ${question.name}",
+ resolvePackage(),
+ question.name,
+ )
+ }
+
+ /** UID 反查要跨进程,一条会话只做一次。 */
+ private fun resolvePackage(): String? {
+ if (!packageResolved) {
+ cachedPackage = appResolver?.resolve(OsConstants.IPPROTO_UDP, key)
+ packageResolved = true
+ }
+ return cachedPackage
+ }
+
+ /** 把 DNS 应答里的 A 记录喂给反查表,好让后续的连接日志显示域名。 */
+ private fun rememberDnsAnswers(payload: ByteArray, offset: Int, length: Int) {
+ if (!isDns) return
+ DnsMessage.readAnswers(payload, offset, length).forEach { (name, address) ->
+ HostRegistry.remember(address, name)
+ }
+ }
+
+ // ------------------------------------------------------------ 回写
+
+ private fun writeBackToTun(payload: ByteArray, offset: Int, length: Int) {
+ rememberDnsAnswers(payload, offset, length)
+ val output = ByteArray(28 + length)
+ val size = PacketBuilder.writeUdp(
+ output = output,
+ sourceIp = key.destIp,
+ sourcePort = key.destPort,
+ destIp = key.sourceIp,
+ destPort = key.sourcePort,
+ payload = payload,
+ payloadOffset = offset,
+ payloadLength = length,
+ )
+ tun.enqueue(output, size)
+ }
+
+ fun finish() {
+ if (!closed.compareAndSet(false, true)) return
+ // 关闭 socket 即可让阻塞中的 receive() 抛异常退出,无需再取消协程
+ runCatching { relaySocket?.close() }
+ runCatching { association?.close() }
+ onFinished(key)
+ }
+
+ private companion object {
+ const val TAG = "UdpSession"
+ val CLOUDFLARE_DNS: InetAddress = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1))
+ const val DNS_PORT = 53
+ const val DNS_TIMEOUT_MS = 10_000
+ const val MAX_DATAGRAM = 65507
+ const val DNS_IDLE_MS = 20_000L
+ const val UDP_IDLE_MS = 120_000L
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/VpnStateHolder.kt b/android/src/tech/xvanturing/freeproxy/vpn/VpnStateHolder.kt
new file mode 100644
index 0000000..1d7b091
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/VpnStateHolder.kt
@@ -0,0 +1,10 @@
+package tech.xvanturing.freeproxy.vpn
+
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicLong
+
+object VpnStateHolder {
+ val uploadCounter = AtomicLong(0)
+ val downloadCounter = AtomicLong(0)
+ val sessionCounter = AtomicInteger(0)
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/dns/DnsBlocker.kt b/android/src/tech/xvanturing/freeproxy/vpn/dns/DnsBlocker.kt
new file mode 100644
index 0000000..23bdf26
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/dns/DnsBlocker.kt
@@ -0,0 +1,9 @@
+package tech.xvanturing.freeproxy.vpn.dns
+
+import tech.xvanturing.freeproxy.vpn.net.DnsQuestion
+
+/** DNS blocking is not used by DragonTCP Lite; this stub preserves the stack API. */
+class DnsBlocker {
+ fun resolve(question: DnsQuestion, packageName: String?): String? = null
+ fun isAppBlocked(packageName: String?): Boolean = false
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/log/TunnelLog.kt b/android/src/tech/xvanturing/freeproxy/vpn/log/TunnelLog.kt
new file mode 100644
index 0000000..11cadc7
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/log/TunnelLog.kt
@@ -0,0 +1,19 @@
+package tech.xvanturing.freeproxy.vpn.log
+
+enum class LogLevel { SUCCESS, FAILURE }
+
+/**
+ * DragonTCP Lite intentionally keeps the Android UI log focused on DragonTCP
+ * adaptive chunk changes. Per-connection and per-DNS logs are no-ops here.
+ */
+object TunnelLog {
+ fun connect(
+ target: String,
+ packageName: String?,
+ status: String,
+ level: LogLevel,
+ domain: String? = null,
+ ) = Unit
+
+ fun dns(message: String, packageName: String?, domain: String? = null) = Unit
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/ByteCodec.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/ByteCodec.kt
new file mode 100644
index 0000000..7af1aee
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/ByteCodec.kt
@@ -0,0 +1,49 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+import java.net.InetAddress
+
+/** 网络字节序(大端)读写辅助。 */
+
+internal fun ByteArray.u8(index: Int): Int = this[index].toInt() and 0xFF
+
+internal fun ByteArray.u16(index: Int): Int = (u8(index) shl 8) or u8(index + 1)
+
+/** 读 32 位无符号量;用 Long 承载以避开 Kotlin Int 的符号问题。 */
+internal fun ByteArray.u32(index: Int): Long =
+ (u16(index).toLong() shl 16) or u16(index + 2).toLong()
+
+/** IPv4 地址按 32 位整数读出,用作会话表的键既快又省内存。 */
+internal fun ByteArray.ipv4(index: Int): Int =
+ (u8(index) shl 24) or (u8(index + 1) shl 16) or (u8(index + 2) shl 8) or u8(index + 3)
+
+internal fun ByteArray.putU8(index: Int, value: Int) {
+ this[index] = (value and 0xFF).toByte()
+}
+
+internal fun ByteArray.putU16(index: Int, value: Int) {
+ this[index] = ((value ushr 8) and 0xFF).toByte()
+ this[index + 1] = (value and 0xFF).toByte()
+}
+
+internal fun ByteArray.putU32(index: Int, value: Long) {
+ this[index] = ((value ushr 24) and 0xFF).toByte()
+ this[index + 1] = ((value ushr 16) and 0xFF).toByte()
+ this[index + 2] = ((value ushr 8) and 0xFF).toByte()
+ this[index + 3] = (value and 0xFF).toByte()
+}
+
+internal fun ByteArray.putIpv4(index: Int, value: Int) {
+ putU32(index, value.toLong() and 0xFFFFFFFFL)
+}
+
+internal fun Int.toIpv4Bytes(): ByteArray = byteArrayOf(
+ ((this ushr 24) and 0xFF).toByte(),
+ ((this ushr 16) and 0xFF).toByte(),
+ ((this ushr 8) and 0xFF).toByte(),
+ (this and 0xFF).toByte(),
+)
+
+internal fun Int.toInetAddress(): InetAddress = InetAddress.getByAddress(toIpv4Bytes())
+
+internal fun Int.toIpv4String(): String =
+ "${(this ushr 24) and 0xFF}.${(this ushr 16) and 0xFF}.${(this ushr 8) and 0xFF}.${this and 0xFF}"
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/Checksum.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/Checksum.kt
new file mode 100644
index 0000000..fb57f67
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/Checksum.kt
@@ -0,0 +1,32 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+/** RFC 1071 定义的 16 位反码和。 */
+object Checksum {
+
+ /** 对 [length] 字节做反码求和,[initial] 用于把伪头部的和接续进来。 */
+ fun compute(data: ByteArray, offset: Int, length: Int, initial: Long = 0L): Int {
+ var sum = initial
+ var index = offset
+ val end = offset + length
+ while (index + 1 < end) {
+ sum += data.u16(index)
+ index += 2
+ }
+ // 奇数长度时最后一字节按高位对齐补零
+ if (index < end) sum += data.u8(index) shl 8
+ while ((sum ushr 16) != 0L) sum = (sum and 0xFFFF) + (sum ushr 16)
+ return (sum.inv() and 0xFFFF).toInt()
+ }
+
+ /** TCP/UDP 校验和覆盖的伪头部:源地址、目的地址、协议号与传输层长度。 */
+ fun pseudoHeaderSum(sourceIp: Int, destIp: Int, protocol: Int, transportLength: Int): Long {
+ var sum = 0L
+ sum += ((sourceIp ushr 16) and 0xFFFF).toLong()
+ sum += (sourceIp and 0xFFFF).toLong()
+ sum += ((destIp ushr 16) and 0xFFFF).toLong()
+ sum += (destIp and 0xFFFF).toLong()
+ sum += protocol.toLong()
+ sum += transportLength.toLong()
+ return sum
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/DnsMessage.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/DnsMessage.kt
new file mode 100644
index 0000000..5106f97
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/DnsMessage.kt
@@ -0,0 +1,133 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+/** DNS 查询的问题段。 */
+data class DnsQuestion(val name: String, val type: Int) {
+ val typeName: String
+ get() = when (type) {
+ TYPE_A -> "A"
+ TYPE_AAAA -> "AAAA"
+ TYPE_CNAME -> "CNAME"
+ TYPE_HTTPS -> "HTTPS"
+ TYPE_TXT -> "TXT"
+ TYPE_PTR -> "PTR"
+ else -> "TYPE$type"
+ }
+
+ companion object {
+ const val TYPE_A = 1
+ const val TYPE_CNAME = 5
+ const val TYPE_PTR = 12
+ const val TYPE_TXT = 16
+ const val TYPE_AAAA = 28
+ const val TYPE_HTTPS = 65
+ }
+}
+
+/**
+ * 极简 DNS 报文读取器。
+ *
+ * 只取两样东西:查询里问的域名,以及应答里的 A 记录。
+ * 后者用来建立 IP → 域名的反查表,好让连接日志显示域名而不是一串裸 IP。
+ */
+object DnsMessage {
+
+ private const val HEADER_SIZE = 12
+ private const val MAX_POINTER_JUMPS = 16
+
+ /** 读取第一个 Question;不是合法查询时返回 null。 */
+ fun readQuestion(data: ByteArray, offset: Int, length: Int): DnsQuestion? {
+ if (length < HEADER_SIZE + 5) return null
+ val end = offset + length
+ val questionCount = data.u16(offset + 4)
+ if (questionCount < 1) return null
+
+ val (name, afterName) = readName(data, offset + HEADER_SIZE, offset, end) ?: return null
+ if (afterName + 4 > end) return null
+ return DnsQuestion(name, data.u16(afterName))
+ }
+
+ /** 问题段的结束位置(QCLASS 之后);伪造应答时在此截断并续写 Answer。 */
+ internal fun questionEnd(data: ByteArray, offset: Int, length: Int): Int? {
+ if (length < HEADER_SIZE + 5) return null
+ val end = offset + length
+ if (data.u16(offset + 4) < 1) return null
+ val (_, afterName) = readName(data, offset + HEADER_SIZE, offset, end) ?: return null
+ val afterQuestion = afterName + 4 // QTYPE + QCLASS
+ return if (afterQuestion <= end) afterQuestion else null
+ }
+
+ /** 读取应答里的全部 A 记录,返回 域名 → IPv4 的配对。 */
+ fun readAnswers(data: ByteArray, offset: Int, length: Int): List> {
+ if (length < HEADER_SIZE) return emptyList()
+ val end = offset + length
+ val questionCount = data.u16(offset + 4)
+ val answerCount = data.u16(offset + 6)
+ if (answerCount < 1) return emptyList()
+
+ var cursor = offset + HEADER_SIZE
+ repeat(questionCount) {
+ val (_, next) = readName(data, cursor, offset, end) ?: return emptyList()
+ cursor = next + 4 // QTYPE + QCLASS
+ if (cursor > end) return emptyList()
+ }
+
+ val results = mutableListOf>()
+ repeat(answerCount) {
+ val (name, afterName) = readName(data, cursor, offset, end) ?: return results
+ if (afterName + 10 > end) return results
+ val type = data.u16(afterName)
+ val dataLength = data.u16(afterName + 8)
+ val recordStart = afterName + 10
+ if (recordStart + dataLength > end) return results
+ if (type == DnsQuestion.TYPE_A && dataLength == 4) {
+ results += name to data.ipv4(recordStart)
+ }
+ cursor = recordStart + dataLength
+ }
+ return results
+ }
+
+ /**
+ * 读取一个可能被压缩的域名。
+ *
+ * @return 域名与"名字之后的位置";遇到压缩指针时,后者指向指针本身之后而不是跳转目标。
+ */
+ private fun readName(
+ data: ByteArray,
+ start: Int,
+ messageStart: Int,
+ end: Int,
+ ): Pair? {
+ val builder = StringBuilder()
+ var cursor = start
+ var afterName = -1
+ var jumps = 0
+
+ while (cursor < end) {
+ val labelLength = data.u8(cursor)
+ when {
+ labelLength == 0 -> {
+ if (afterName < 0) afterName = cursor + 1
+ return builder.toString() to afterName
+ }
+ // 高两位为 11 表示这是一个指向报文别处的压缩指针
+ (labelLength and 0xC0) == 0xC0 -> {
+ if (cursor + 1 >= end) return null
+ if (++jumps > MAX_POINTER_JUMPS) return null // 防御环形指针
+ if (afterName < 0) afterName = cursor + 2
+ cursor = messageStart + (((labelLength and 0x3F) shl 8) or data.u8(cursor + 1))
+ if (cursor < messageStart || cursor >= end) return null
+ }
+
+ else -> {
+ val labelStart = cursor + 1
+ if (labelStart + labelLength > end) return null
+ if (builder.isNotEmpty()) builder.append('.')
+ builder.append(String(data, labelStart, labelLength, Charsets.US_ASCII))
+ cursor = labelStart + labelLength
+ }
+ }
+ }
+ return null
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/DnsResponse.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/DnsResponse.kt
new file mode 100644
index 0000000..72419ad
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/DnsResponse.kt
@@ -0,0 +1,70 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+/** 伪造 DNS 应答:拦截命中的查询不再转发,直接在隧道内作答。 */
+object DnsResponse {
+
+ private const val HEADER_SIZE = 12
+ private const val ANSWER_TTL = 60
+
+ /** 应答 NAME 用压缩指针指回问题段开头(偏移 12)。 */
+ private const val NAME_POINTER_HI = 0xC0
+ private const val NAME_POINTER_LO = 0x0C
+
+ private const val FLAG_QR = 0x8000
+ private const val FLAG_RD = 0x0100
+ private const val FLAG_RA = 0x0080
+
+ /**
+ * 用 [ip] 给 [query] 造一个 NOERROR 应答。
+ *
+ * A 查询回一条指向 [ip] 的 A 记录;其余类型(AAAA、HTTPS 等)回空应答,
+ * 让查询方立即得到"没有记录",而不是等超时或改走别的解析通道漏出去。
+ *
+ * @return 报文不合法时返回 null,调用方应回退到正常转发。
+ */
+ fun buildBlockedResponse(query: ByteArray, queryLen: Int, ip: String): ByteArray? {
+ if (queryLen < HEADER_SIZE || queryLen > query.size) return null
+ val question = DnsMessage.readQuestion(query, 0, queryLen) ?: return null
+ val questionEnd = DnsMessage.questionEnd(query, 0, queryLen) ?: return null
+ val address = parseIpv4(ip) ?: return null
+
+ val isA = question.type == DnsQuestion.TYPE_A
+ val answerSize = if (isA) 16 else 0 // NAME(2) TYPE CLASS TTL RDLENGTH(各2) RDATA(4)
+ // 只保留头 + 问题段:查询可能带 EDNS 等附加记录,直接续写 Answer 会把它们挤出原位
+ val response = query.copyOf(questionEnd + answerSize)
+
+ // 标志位:QR=1、OPCODE 与 RD 沿用查询、RA=1、RCODE=0
+ val flags = FLAG_QR or (query.u16(2) and (0x7800 or FLAG_RD)) or FLAG_RA
+ response.putU16(2, flags)
+ response.putU16(4, 1) // QDCOUNT
+ response.putU16(6, if (isA) 1 else 0) // ANCOUNT
+ response.putU16(8, 0) // NSCOUNT
+ response.putU16(10, 0) // ARCOUNT
+
+ if (isA) {
+ var cursor = questionEnd
+ response.putU8(cursor, NAME_POINTER_HI)
+ response.putU8(cursor + 1, NAME_POINTER_LO)
+ cursor += 2
+ response.putU16(cursor, DnsQuestion.TYPE_A)
+ response.putU16(cursor + 2, 1) // CLASS IN
+ response.putU32(cursor + 4, ANSWER_TTL.toLong())
+ response.putU16(cursor + 8, 4)
+ response.putIpv4(cursor + 10, address)
+ }
+ return response
+ }
+
+ /** 解析点分四段 IPv4;任何一段非法都视为不可用。 */
+ private fun parseIpv4(ip: String): Int? {
+ val parts = ip.trim().split('.')
+ if (parts.size != 4) return null
+ var address = 0
+ parts.forEach { part ->
+ val octet = part.toIntOrNull() ?: return null
+ if (octet !in 0..255) return null
+ address = (address shl 8) or octet
+ }
+ return address
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/HostRegistry.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/HostRegistry.kt
new file mode 100644
index 0000000..32a686b
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/HostRegistry.kt
@@ -0,0 +1,34 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+/**
+ * IP → 域名的反查表,数据来自流经隧道的 DNS 应答。
+ *
+ * 隧道里看到的目标只有 IP,有了这张表,连接日志才能显示 `github.com:443`
+ * 而不是让人无从判断的 `140.82.121.4:443`。
+ */
+object HostRegistry {
+
+ private const val CAPACITY = 512
+
+ private val lock = Any()
+
+ // accessOrder = true 让 LinkedHashMap 按访问顺序淘汰,即最近用过的域名留得更久
+ private val names = object : LinkedHashMap(64, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean =
+ size > CAPACITY
+ }
+
+ fun remember(address: Int, name: String) {
+ if (name.isEmpty()) return
+ synchronized(lock) { names[address] = name }
+ }
+
+ fun lookup(address: Int): String? = synchronized(lock) { names[address] }
+
+ /** 有域名就用域名,没有就退回点分十进制。 */
+ fun describe(address: Int, port: Int): String = "${lookup(address) ?: address.toIpv4String()}:$port"
+
+ fun clear() {
+ synchronized(lock) { names.clear() }
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/IpHeaders.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/IpHeaders.kt
new file mode 100644
index 0000000..0dfdfdf
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/IpHeaders.kt
@@ -0,0 +1,122 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+const val PROTO_ICMP = 1
+const val PROTO_TCP = 6
+const val PROTO_UDP = 17
+
+/** IPv4 首部。选项字段不解析,但 [headerLength] 已把它算在内。 */
+class Ipv4Header(
+ val headerLength: Int,
+ val totalLength: Int,
+ val protocol: Int,
+ val sourceIp: Int,
+ val destIp: Int,
+) {
+ val payloadLength: Int get() = totalLength - headerLength
+
+ companion object {
+ const val MIN_SIZE = 20
+
+ /** 解析失败返回 null(畸形包直接丢弃,不抛异常 —— 转发热路径上异常代价太高)。 */
+ fun parse(buffer: ByteArray, length: Int): Ipv4Header? {
+ if (length < MIN_SIZE) return null
+ val versionAndIhl = buffer.u8(0)
+ if ((versionAndIhl ushr 4) != 4) return null
+
+ val headerLength = (versionAndIhl and 0x0F) * 4
+ if (headerLength < MIN_SIZE || headerLength > length) return null
+
+ val totalLength = buffer.u16(2)
+ if (totalLength < headerLength || totalLength > length) return null
+
+ // 本栈不做分片重组:MF 置位或分片偏移非零的包一律丢弃。
+ // TUN 的 MTU 由我们自己设定,正常流量不会走到这里。
+ val fragmentField = buffer.u16(6)
+ val moreFragments = (fragmentField and 0x2000) != 0
+ val fragmentOffset = fragmentField and 0x1FFF
+ if (moreFragments || fragmentOffset != 0) return null
+
+ return Ipv4Header(
+ headerLength = headerLength,
+ totalLength = totalLength,
+ protocol = buffer.u8(9),
+ sourceIp = buffer.ipv4(12),
+ destIp = buffer.ipv4(16),
+ )
+ }
+ }
+}
+
+/** TCP 首部。 */
+class TcpHeader(
+ val sourcePort: Int,
+ val destPort: Int,
+ val sequence: Long,
+ val acknowledgment: Long,
+ val dataOffset: Int,
+ val flags: Int,
+ val window: Int,
+) {
+ val isFin: Boolean get() = (flags and FIN) != 0
+ val isSyn: Boolean get() = (flags and SYN) != 0
+ val isRst: Boolean get() = (flags and RST) != 0
+ val isAck: Boolean get() = (flags and ACK) != 0
+
+ override fun toString(): String = buildString {
+ if (isSyn) append("SYN ")
+ if (isAck) append("ACK ")
+ if (isFin) append("FIN ")
+ if (isRst) append("RST ")
+ append("seq=").append(sequence).append(" ack=").append(acknowledgment)
+ }
+
+ companion object {
+ const val MIN_SIZE = 20
+
+ const val FIN = 0x01
+ const val SYN = 0x02
+ const val RST = 0x04
+ const val PSH = 0x08
+ const val ACK = 0x10
+ const val URG = 0x20
+
+ fun parse(buffer: ByteArray, offset: Int, length: Int): TcpHeader? {
+ if (length < MIN_SIZE) return null
+ val dataOffset = ((buffer.u8(offset + 12) ushr 4) and 0x0F) * 4
+ if (dataOffset < MIN_SIZE || dataOffset > length) return null
+ return TcpHeader(
+ sourcePort = buffer.u16(offset),
+ destPort = buffer.u16(offset + 2),
+ sequence = buffer.u32(offset + 4),
+ acknowledgment = buffer.u32(offset + 8),
+ dataOffset = dataOffset,
+ flags = buffer.u8(offset + 13),
+ window = buffer.u16(offset + 14),
+ )
+ }
+ }
+}
+
+/** UDP 首部。 */
+class UdpHeader(
+ val sourcePort: Int,
+ val destPort: Int,
+ val length: Int,
+) {
+ val payloadLength: Int get() = length - SIZE
+
+ companion object {
+ const val SIZE = 8
+
+ fun parse(buffer: ByteArray, offset: Int, available: Int): UdpHeader? {
+ if (available < SIZE) return null
+ val length = buffer.u16(offset + 4)
+ if (length < SIZE || length > available) return null
+ return UdpHeader(
+ sourcePort = buffer.u16(offset),
+ destPort = buffer.u16(offset + 2),
+ length = length,
+ )
+ }
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/PacketBuilder.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/PacketBuilder.kt
new file mode 100644
index 0000000..9bc0b1f
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/PacketBuilder.kt
@@ -0,0 +1,125 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * 构造写回 TUN 的 IPv4 数据包。
+ *
+ * 所有方法都把结果写进调用方提供的缓冲区并返回包长度,热路径上不额外分配。
+ */
+object PacketBuilder {
+
+ private const val DEFAULT_TTL = 64
+ private const val FLAG_DONT_FRAGMENT = 0x4000
+ private val identification = AtomicInteger(1)
+
+ /**
+ * 写入一个 IPv4 + TCP 包。
+ *
+ * [mss] 大于 0 时附加 MSS 选项 —— 只在 SYN-ACK 里需要,用于告诉本机内核
+ * 单个报文段的上限,避免它发出超过隧道 MTU 的数据。
+ */
+ fun writeTcp(
+ output: ByteArray,
+ sourceIp: Int,
+ sourcePort: Int,
+ destIp: Int,
+ destPort: Int,
+ sequence: Long,
+ acknowledgment: Long,
+ flags: Int,
+ window: Int,
+ payload: ByteArray? = null,
+ payloadOffset: Int = 0,
+ payloadLength: Int = 0,
+ mss: Int = 0,
+ ): Int {
+ val optionsLength = if (mss > 0) 4 else 0
+ val tcpLength = TcpHeader.MIN_SIZE + optionsLength + payloadLength
+ val totalLength = Ipv4Header.MIN_SIZE + tcpLength
+
+ writeIpv4Header(output, totalLength, PROTO_TCP, sourceIp, destIp)
+
+ val tcp = Ipv4Header.MIN_SIZE
+ output.putU16(tcp, sourcePort)
+ output.putU16(tcp + 2, destPort)
+ output.putU32(tcp + 4, sequence and 0xFFFFFFFFL)
+ output.putU32(tcp + 8, acknowledgment and 0xFFFFFFFFL)
+ output.putU8(tcp + 12, ((TcpHeader.MIN_SIZE + optionsLength) / 4) shl 4)
+ output.putU8(tcp + 13, flags)
+ output.putU16(tcp + 14, window)
+ output.putU16(tcp + 16, 0) // 校验和占位
+ output.putU16(tcp + 18, 0) // 紧急指针
+
+ if (optionsLength > 0) {
+ output.putU8(tcp + 20, 2) // kind = MSS
+ output.putU8(tcp + 21, 4) // length
+ output.putU16(tcp + 22, mss)
+ }
+
+ if (payload != null && payloadLength > 0) {
+ System.arraycopy(
+ payload,
+ payloadOffset,
+ output,
+ tcp + TcpHeader.MIN_SIZE + optionsLength,
+ payloadLength,
+ )
+ }
+
+ val pseudo = Checksum.pseudoHeaderSum(sourceIp, destIp, PROTO_TCP, tcpLength)
+ output.putU16(tcp + 16, Checksum.compute(output, tcp, tcpLength, pseudo))
+ return totalLength
+ }
+
+ /** 写入一个 IPv4 + UDP 包。 */
+ fun writeUdp(
+ output: ByteArray,
+ sourceIp: Int,
+ sourcePort: Int,
+ destIp: Int,
+ destPort: Int,
+ payload: ByteArray,
+ payloadOffset: Int,
+ payloadLength: Int,
+ ): Int {
+ val udpLength = UdpHeader.SIZE + payloadLength
+ val totalLength = Ipv4Header.MIN_SIZE + udpLength
+
+ writeIpv4Header(output, totalLength, PROTO_UDP, sourceIp, destIp)
+
+ val udp = Ipv4Header.MIN_SIZE
+ output.putU16(udp, sourcePort)
+ output.putU16(udp + 2, destPort)
+ output.putU16(udp + 4, udpLength)
+ output.putU16(udp + 6, 0) // 校验和占位
+
+ System.arraycopy(payload, payloadOffset, output, udp + UdpHeader.SIZE, payloadLength)
+
+ val pseudo = Checksum.pseudoHeaderSum(sourceIp, destIp, PROTO_UDP, udpLength)
+ val checksum = Checksum.compute(output, udp, udpLength, pseudo)
+ // UDP 校验和为 0 表示"未计算",真值为 0 时按 RFC 768 写全 1
+ output.putU16(udp + 6, if (checksum == 0) 0xFFFF else checksum)
+ return totalLength
+ }
+
+ private fun writeIpv4Header(
+ output: ByteArray,
+ totalLength: Int,
+ protocol: Int,
+ sourceIp: Int,
+ destIp: Int,
+ ) {
+ output.putU8(0, 0x45) // 版本 4,首部 5 个 32 位字
+ output.putU8(1, 0) // DSCP / ECN
+ output.putU16(2, totalLength)
+ output.putU16(4, identification.getAndIncrement() and 0xFFFF)
+ output.putU16(6, FLAG_DONT_FRAGMENT)
+ output.putU8(8, DEFAULT_TTL)
+ output.putU8(9, protocol)
+ output.putU16(10, 0) // 校验和占位
+ output.putIpv4(12, sourceIp)
+ output.putIpv4(16, destIp)
+ output.putU16(10, Checksum.compute(output, 0, Ipv4Header.MIN_SIZE))
+ }
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/net/SessionKey.kt b/android/src/tech/xvanturing/freeproxy/vpn/net/SessionKey.kt
new file mode 100644
index 0000000..dcfc654
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/net/SessionKey.kt
@@ -0,0 +1,23 @@
+package tech.xvanturing.freeproxy.vpn.net
+
+/** 四元组,用作会话表的键。 */
+data class SessionKey(
+ val sourceIp: Int,
+ val sourcePort: Int,
+ val destIp: Int,
+ val destPort: Int,
+) {
+ override fun toString(): String =
+ "${sourceIp.toIpv4String()}:$sourcePort → ${destIp.toIpv4String()}:$destPort"
+}
+
+/**
+ * 序列号是模 2^32 的循环量,不能直接比大小。
+ * 这里用 32 位有符号差判断先后,正确处理回绕。
+ */
+internal fun seqLessThan(a: Long, b: Long): Boolean = (a - b).toInt() < 0
+
+internal fun seqLessOrEqual(a: Long, b: Long): Boolean = (a - b).toInt() <= 0
+
+/** 序列号前进 [delta] 字节,保持在 32 位范围内。 */
+internal fun seqAdvance(seq: Long, delta: Int): Long = (seq + delta) and 0xFFFFFFFFL
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/proxy/ProxyClient.kt b/android/src/tech/xvanturing/freeproxy/vpn/proxy/ProxyClient.kt
new file mode 100644
index 0000000..f7824e6
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/proxy/ProxyClient.kt
@@ -0,0 +1,121 @@
+package tech.xvanturing.freeproxy.vpn.proxy
+
+import tech.xvanturing.freeproxy.data.model.ProxyProfile
+import java.io.IOException
+import java.io.InputStream
+import java.net.DatagramSocket
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.Socket
+
+/**
+ * Minimal HTTP CONNECT upstream client used by DragonTCP Lite.
+ *
+ * The upstream proxy is always the local DragonTCP Go process on
+ * 127.0.0.1:8080. DragonTCP then carries the stream over adaptive XOR-framed
+ * TCP/53 to the remote server.
+ */
+class ProxyClient(
+ private val profile: ProxyProfile,
+ private val proxyAddress: InetSocketAddress,
+ private val protector: SocketProtector,
+) {
+ @Throws(IOException::class)
+ fun connectTcp(destination: InetAddress, destinationPort: Int): Socket {
+ val socket = Socket()
+ try {
+ socket.bind(InetSocketAddress(0))
+ if (!protector.protect(socket)) {
+ throw IOException("Unable to protect local proxy socket from VPN")
+ }
+ socket.connect(proxyAddress, CONNECT_TIMEOUT_MS)
+ socket.soTimeout = HANDSHAKE_TIMEOUT_MS
+ socket.tcpNoDelay = true
+
+ val literal = if (destination is Inet6Address) {
+ "[${destination.hostAddress}]:$destinationPort"
+ } else {
+ "${destination.hostAddress}:$destinationPort"
+ }
+ val request = buildString {
+ append("CONNECT ").append(literal).append(" HTTP/1.1\r\n")
+ append("Host: ").append(literal).append("\r\n")
+ append("Proxy-Connection: Keep-Alive\r\n")
+ append("\r\n")
+ }
+ socket.getOutputStream().apply {
+ write(request.toByteArray(Charsets.ISO_8859_1))
+ flush()
+ }
+
+ val input = socket.getInputStream()
+ val status = input.readLineCrLf()
+ while (true) {
+ val line = input.readLineCrLf()
+ if (line.isEmpty()) break
+ }
+ val code = status.split(' ').getOrNull(1)?.toIntOrNull()
+ ?: throw IOException("Local DragonTCP proxy returned invalid response: $status")
+ if (code !in 200..299) {
+ throw IOException("Local DragonTCP CONNECT failed: $status")
+ }
+
+ socket.soTimeout = 0
+ return socket
+ } catch (t: Throwable) {
+ runCatching { socket.close() }
+ throw t
+ }
+ }
+
+ /** HTTP CONNECT does not support SOCKS5 UDP ASSOCIATE. */
+ @Throws(IOException::class)
+ fun openUdpAssociate(): UdpAssociation {
+ throw IOException("UDP ASSOCIATE unavailable with local HTTP CONNECT proxy")
+ }
+
+ companion object {
+ const val ATYP_IPV4 = 0x01
+ const val ATYP_DOMAIN = 0x03
+ const val ATYP_IPV6 = 0x04
+ private const val CONNECT_TIMEOUT_MS = 10_000
+ private const val HANDSHAKE_TIMEOUT_MS = 15_000
+ private const val MAX_HEADER_LINE = 8192
+
+ private fun InputStream.readLineCrLf(): String {
+ val out = StringBuilder()
+ while (true) {
+ val b = read()
+ if (b < 0) {
+ if (out.isEmpty()) throw IOException("Proxy closed connection during handshake")
+ break
+ }
+ if (b == '\n'.code) break
+ if (b != '\r'.code) out.append(b.toChar())
+ if (out.length > MAX_HEADER_LINE) throw IOException("Proxy response header too long")
+ }
+ return out.toString()
+ }
+ }
+}
+
+class UdpAssociation(
+ private val controlSocket: Socket = Socket(),
+ val relayAddress: InetSocketAddress = InetSocketAddress("127.0.0.1", 0),
+) : AutoCloseable {
+ val isAlive: Boolean get() = !controlSocket.isClosed && controlSocket.isConnected
+ override fun close() { runCatching { controlSocket.close() } }
+}
+
+@Throws(IOException::class)
+internal fun InputStream.readExactly(count: Int): ByteArray {
+ val buffer = ByteArray(count)
+ var offset = 0
+ while (offset < count) {
+ val n = read(buffer, offset, count - offset)
+ if (n < 0) throw IOException("Connection closed with ${count - offset} bytes remaining")
+ offset += n
+ }
+ return buffer
+}
diff --git a/android/src/tech/xvanturing/freeproxy/vpn/proxy/SocketProtector.kt b/android/src/tech/xvanturing/freeproxy/vpn/proxy/SocketProtector.kt
new file mode 100644
index 0000000..247c787
--- /dev/null
+++ b/android/src/tech/xvanturing/freeproxy/vpn/proxy/SocketProtector.kt
@@ -0,0 +1,15 @@
+package tech.xvanturing.freeproxy.vpn.proxy
+
+import java.net.DatagramSocket
+import java.net.Socket
+
+/**
+ * 把 socket 排除出隧道。
+ *
+ * 隧道建立后,本应用发往代理服务器的连接如果不加保护,会被系统重新路由回 TUN,
+ * 形成自我循环。[android.net.VpnService.protect] 就是用来打破这个循环的。
+ */
+interface SocketProtector {
+ fun protect(socket: Socket): Boolean
+ fun protect(socket: DatagramSocket): Boolean
+}
diff --git a/bin/dragontcp-vpn-client-linux-amd64 b/bin/dragontcp-vpn-client-linux-amd64
deleted file mode 100644
index 93d4b22..0000000
Binary files a/bin/dragontcp-vpn-client-linux-amd64 and /dev/null differ
diff --git a/bin/dragontcp-vpn-server-linux-amd64 b/bin/dragontcp-vpn-server-linux-amd64
deleted file mode 100644
index 2fb4ef5..0000000
Binary files a/bin/dragontcp-vpn-server-linux-amd64 and /dev/null differ
diff --git a/bin/dragontcp-vpn-server-linux-arm64 b/bin/dragontcp-vpn-server-linux-arm64
deleted file mode 100644
index 80182f4..0000000
Binary files a/bin/dragontcp-vpn-server-linux-arm64 and /dev/null differ
diff --git a/build_core.sh b/build_core.sh
index ab6436f..03be914 100644
--- a/build_core.sh
+++ b/build_core.sh
@@ -1,9 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
-cd "$ROOT/core"
+GO_BIN="${GO_BIN:-go}"
+command -v "$GO_BIN" >/dev/null 2>&1 || { echo "Go compiler not found" >&2; exit 1; }
mkdir -p "$ROOT/bin" "$ROOT/android/lib/arm64-v8a"
-CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/dragontcp-vpn-server-linux-amd64" ./cmd/dragontcp-vpn-server
-CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/dragontcp-vpn-server-linux-arm64" ./cmd/dragontcp-vpn-server
-CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o "$ROOT/android/lib/arm64-v8a/libdragontcp_vpn.so" ./cmd/dragontcp-vpn-client
-echo "Built DragonTCP VPN server + Android core"
+cd "$ROOT/core"
+
+echo "[core] Android ARM64 client..."
+CGO_ENABLED=0 GOOS=android GOARCH=arm64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
+ -o "$ROOT/android/lib/arm64-v8a/libdragontcp_client.so" ./cmd/dragontcp-client
+
+echo "[core] Linux AMD64 server..."
+CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
+ -o "$ROOT/bin/dragontcp-lite-server-linux-amd64" ./cmd/dragontcp-server
+
+echo "[core] Linux ARM64 server..."
+CGO_ENABLED=0 GOOS=linux GOARCH=arm64 "$GO_BIN" build -trimpath -ldflags='-s -w' \
+ -o "$ROOT/bin/dragontcp-lite-server-linux-arm64" ./cmd/dragontcp-server
+
+echo "Core build complete."
diff --git a/core/cmd/dragontcp-client/chunk.go b/core/cmd/dragontcp-client/chunk.go
new file mode 100644
index 0000000..f50da10
--- /dev/null
+++ b/core/cmd/dragontcp-client/chunk.go
@@ -0,0 +1,761 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "dragontcp/internal/protocol"
+)
+
+type chunkClientOptions struct {
+ startSize int
+ minSize int
+ maxSize int
+ adaptive bool
+ adaptSuccesses int
+ adaptLog bool
+ pollers int
+ reconnectEvery int
+ pollDelay time.Duration
+ txnTimeout time.Duration
+ tcpBuffer int
+}
+
+func wireToken(token string) string {
+ if token == "" {
+ return "-"
+ }
+ return token
+}
+
+type adaptiveSizer struct {
+ mu sync.Mutex
+ name string
+ current int
+ min int
+ max int
+ adaptive bool
+ adaptSuccesses int
+ successes int
+ good int
+ bad int
+ logChanges bool
+}
+
+func newAdaptiveSizer(name string, opts chunkClientOptions) *adaptiveSizer {
+ start := opts.startSize
+ if start < opts.minSize {
+ start = opts.minSize
+ }
+ if start > opts.maxSize {
+ start = opts.maxSize
+ }
+ return &adaptiveSizer{
+ name: name,
+ current: start,
+ min: opts.minSize,
+ max: opts.maxSize,
+ adaptive: opts.adaptive,
+ adaptSuccesses: opts.adaptSuccesses,
+ logChanges: opts.adaptLog,
+ }
+}
+
+func (s *adaptiveSizer) Current() int {
+ s.mu.Lock()
+ n := s.current
+ s.mu.Unlock()
+ return n
+}
+
+func (s *adaptiveSizer) Success(attempted int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if !s.adaptive || s.current >= s.max {
+ return
+ }
+ // Ignore stale successes from records that were already in flight when
+ // another worker changed the shared size.
+ if attempted != s.current {
+ return
+ }
+
+ if attempted > s.good {
+ s.good = attempted
+ }
+ s.successes++
+
+ growAfter := s.adaptSuccesses
+ // When we have converged close to a known failure boundary, stay stable
+ // longer before probing again. This also lets us discover later network
+ // improvements without constantly oscillating around the boundary.
+ if s.bad > 0 && s.bad-s.good <= 32 {
+ growAfter *= 8
+ }
+ if s.successes < growAfter {
+ return
+ }
+ s.successes = 0
+
+ old := s.current
+ var next int
+ if s.bad > old+1 {
+ // Binary-search the gap between known-good and known-bad sizes.
+ next = old + (s.bad-old)/2
+ } else {
+ // Either there is no known ceiling, or we have stayed stable long enough
+ // at it to probe the network again in case conditions improved.
+ if s.bad > 0 {
+ s.bad = 0
+ }
+ step := old / 4
+ if step < 32 {
+ step = 32
+ }
+ next = old + step
+ }
+
+ if next > s.max {
+ next = s.max
+ }
+ if next <= old {
+ return
+ }
+ s.current = next
+
+ if s.logChanges {
+ fmt.Printf("adaptive %s chunk: %d -> %d after stable success\n", s.name, old, next)
+ }
+}
+
+func (s *adaptiveSizer) Failure(attempted int) (old, next int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ old = s.current
+
+ if !s.adaptive {
+ return old, old
+ }
+ // Multiple pollers can fail on the same oversized value at once. Only the
+ // first failure for the current value is allowed to reduce it.
+ if attempted != s.current {
+ return old, old
+ }
+ s.successes = 0
+
+ if s.bad == 0 || attempted < s.bad {
+ s.bad = attempted
+ }
+
+ if s.good > 0 && s.good < attempted {
+ // Return directly to the last size that was proven to work.
+ next = s.good
+ } else {
+ // A previously-good value just failed, so conditions worsened. Forget
+ // the old lower bound and use multiplicative decrease.
+ s.good = 0
+ next = attempted / 2
+ }
+ if next < s.min {
+ next = s.min
+ }
+ if next >= attempted && attempted > s.min {
+ next = attempted - 1
+ }
+ if next < s.min {
+ next = s.min
+ }
+ s.current = next
+
+ if s.logChanges && next != old {
+ fmt.Printf("adaptive %s chunk: %d -> %d after transport failure\n", s.name, old, next)
+ }
+ return old, next
+}
+
+type txnLane struct {
+ mu sync.Mutex
+ serverAddr string
+ tcpBuffer int
+ reconnectEvery int
+ timeout time.Duration
+ conn net.Conn
+ count int
+ closed bool
+}
+
+func newTxnLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration) *txnLane {
+ return &txnLane{
+ serverAddr: serverAddr,
+ tcpBuffer: tcpBuffer,
+ reconnectEvery: reconnectEvery,
+ timeout: timeout,
+ }
+}
+
+func (l *txnLane) closeLocked() {
+ if l.conn != nil {
+ _ = l.conn.Close()
+ l.conn = nil
+ }
+ l.count = 0
+}
+
+func (l *txnLane) Close() {
+ l.mu.Lock()
+ l.closed = true
+ l.closeLocked()
+ l.mu.Unlock()
+}
+
+func (l *txnLane) ensureConn() error {
+ if l.closed {
+ return net.ErrClosed
+ }
+ if l.conn != nil && (l.reconnectEvery <= 0 || l.count < l.reconnectEvery) {
+ return nil
+ }
+
+ l.closeLocked()
+ d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
+ conn, err := d.Dial("tcp", l.serverAddr)
+ if err != nil {
+ return err
+ }
+ protocol.TuneTCP(conn)
+ protocol.TuneTCPBuffer(conn, l.tcpBuffer)
+ l.conn = conn
+ return nil
+}
+
+// Do performs exactly one framed transaction. Higher layers decide whether a
+// failed data record should be retried at a smaller adaptive size.
+func (l *txnLane) Do(payload []byte) ([]byte, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if err := l.ensureConn(); err != nil {
+ return nil, err
+ }
+
+ timeout := l.timeout
+ if timeout <= 0 {
+ timeout = 5 * time.Second
+ }
+ _ = l.conn.SetDeadline(time.Now().Add(timeout))
+ requestID := requestCounter.Add(1)
+
+ if err := protocol.WriteRequestFrame(l.conn, requestID, payload); err != nil {
+ l.closeLocked()
+ return nil, err
+ }
+
+ responseID, response, err := protocol.ReadResponseFrame(l.conn)
+ if err != nil {
+ l.closeLocked()
+ return nil, err
+ }
+ if responseID != requestID {
+ l.closeLocked()
+ return nil, fmt.Errorf("request ID mismatch")
+ }
+
+ l.count++
+ _ = l.conn.SetDeadline(time.Time{})
+ if l.reconnectEvery > 0 && l.count >= l.reconnectEvery {
+ // For restrictive TCP/53 networks, reconnectEvery=1 must really mean
+ // one request/response per TCP connection. Close immediately after
+ // receiving the response rather than waiting for the next request.
+ l.closeLocked()
+ }
+ return response, nil
+}
+
+func doControl(lane *txnLane, payload []byte) ([]byte, error) {
+ var lastErr error
+ for attempt := 0; attempt < 5; attempt++ {
+ resp, err := lane.Do(payload)
+ if err == nil {
+ return resp, nil
+ }
+ lastErr = err
+ time.Sleep(time.Duration(attempt+1) * 40 * time.Millisecond)
+ }
+ return nil, lastErr
+}
+
+type chunkResult struct {
+ seq uint64
+ data []byte
+ final uint64
+ eof bool
+ err error
+}
+
+type chunkConn struct {
+ serverAddr string
+ token string
+ sid string
+ opts chunkClientOptions
+
+ pushLane *txnLane
+ pullLanes []*txnLane
+
+ upSizer *adaptiveSizer
+ downSizer *adaptiveSizer
+ serverMax int
+
+ ctx context.Context
+ cancel context.CancelFunc
+ once sync.Once
+
+ writeMu sync.Mutex
+ upSeq uint64
+
+ claim atomic.Uint64
+ ack atomic.Int64
+
+ results chan chunkResult
+ workers sync.WaitGroup
+
+ readMu sync.Mutex
+ pending map[uint64][]byte
+ nextRead uint64
+ current []byte
+ currentSeq uint64
+ finalKnown bool
+ finalSeq uint64
+ terminalErr error
+}
+
+func randomSessionID() (string, error) {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b[:]), nil
+}
+
+func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts chunkClientOptions) (net.Conn, error) {
+ if opts.minSize < 32 {
+ opts.minSize = 32
+ }
+ if opts.maxSize < opts.minSize {
+ opts.maxSize = opts.minSize
+ }
+ if opts.maxSize > protocol.MaxChunkPayload {
+ opts.maxSize = protocol.MaxChunkPayload
+ }
+ if opts.startSize < opts.minSize {
+ opts.startSize = opts.minSize
+ }
+ if opts.startSize > opts.maxSize {
+ opts.startSize = opts.maxSize
+ }
+ if opts.adaptSuccesses < 1 {
+ opts.adaptSuccesses = 64
+ }
+ if opts.pollers < 1 {
+ opts.pollers = 1
+ }
+ if opts.pollers > 128 {
+ opts.pollers = 128
+ }
+ if opts.txnTimeout <= 0 {
+ opts.txnTimeout = 5 * time.Second
+ }
+
+ sid, err := randomSessionID()
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ c := &chunkConn{
+ serverAddr: serverAddr,
+ token: token,
+ sid: sid,
+ opts: opts,
+ ctx: ctx,
+ cancel: cancel,
+ results: make(chan chunkResult, opts.pollers*4),
+ pending: make(map[uint64][]byte, opts.pollers*2),
+ }
+ c.ack.Store(-1)
+ c.upSizer = newAdaptiveSizer("upload", opts)
+ c.downSizer = newAdaptiveSizer("download", opts)
+
+ c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout)
+
+ openPayload := []byte(fmt.Sprintf(
+ "COPEN %s %s %s %d",
+ wireToken(token), sid, targetHost, targetPort,
+ ))
+ resp, err := doControl(c.pushLane, openPayload)
+ if err != nil {
+ c.pushLane.Close()
+ cancel()
+ return nil, err
+ }
+ fields := strings.Fields(string(resp))
+ if len(fields) != 2 || fields[0] != "OPENED" {
+ c.pushLane.Close()
+ cancel()
+ return nil, fmt.Errorf("%s", resp)
+ }
+ serverMax, err := strconv.Atoi(fields[1])
+ if err != nil || serverMax < 32 {
+ c.pushLane.Close()
+ cancel()
+ return nil, fmt.Errorf("bad OPENED response: %q", resp)
+ }
+ c.serverMax = serverMax
+ if serverMax < c.opts.maxSize {
+ c.opts.maxSize = serverMax
+ c.upSizer.max = serverMax
+ c.downSizer.max = serverMax
+ if c.upSizer.current > serverMax {
+ c.upSizer.current = serverMax
+ }
+ if c.downSizer.current > serverMax {
+ c.downSizer.current = serverMax
+ }
+ }
+
+ c.pullLanes = make([]*txnLane, opts.pollers)
+ for i := 0; i < opts.pollers; i++ {
+ lane := newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout)
+ c.pullLanes[i] = lane
+ c.workers.Add(1)
+ go c.pullWorker(lane)
+ }
+
+ return c, nil
+}
+
+func parseDataResponse(resp []byte) (seq uint64, offset int, total int, data []byte, err error) {
+ if len(resp) < 6 || string(resp[:5]) != "DATA " {
+ return 0, 0, 0, nil, fmt.Errorf("not DATA")
+ }
+
+ rest := resp[5:]
+ fields := make([][]byte, 0, 3)
+ start := 0
+ for i := 0; i < len(rest) && len(fields) < 3; i++ {
+ if rest[i] == ' ' {
+ fields = append(fields, rest[start:i])
+ start = i + 1
+ }
+ }
+ if len(fields) != 3 {
+ return 0, 0, 0, nil, fmt.Errorf("bad DATA response")
+ }
+
+ seq, err = strconv.ParseUint(string(fields[0]), 10, 64)
+ if err != nil {
+ return 0, 0, 0, nil, err
+ }
+ offset, err = strconv.Atoi(string(fields[1]))
+ if err != nil || offset < 0 {
+ return 0, 0, 0, nil, fmt.Errorf("bad DATA offset")
+ }
+ total, err = strconv.Atoi(string(fields[2]))
+ if err != nil || total < 0 {
+ return 0, 0, 0, nil, fmt.Errorf("bad DATA total")
+ }
+
+ // start now points immediately after the third separator.
+ return seq, offset, total, rest[start:], nil
+}
+
+func (c *chunkConn) pullWorker(lane *txnLane) {
+ defer c.workers.Done()
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ }
+
+ seq := c.claim.Add(1) - 1
+ offset := 0
+ var assembled []byte
+ consecutiveMinFailures := 0
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ }
+
+ limit := c.downSizer.Current()
+ ack := c.ack.Load()
+ payload := []byte(fmt.Sprintf(
+ "CPULL %s %s %d %d %d %d",
+ wireToken(c.token), c.sid, ack, seq, offset, limit,
+ ))
+
+ resp, err := lane.Do(payload)
+ if err != nil {
+ old, next := c.downSizer.Failure(limit)
+ if next == old && next == c.opts.minSize {
+ consecutiveMinFailures++
+ } else {
+ consecutiveMinFailures = 0
+ }
+ if consecutiveMinFailures >= 8 {
+ select {
+ case c.results <- chunkResult{seq: seq, err: fmt.Errorf("download failed at minimum chunk %d: %w", next, err)}:
+ case <-c.ctx.Done():
+ }
+ return
+ }
+ time.Sleep(30 * time.Millisecond)
+ continue
+ }
+
+ if string(resp) == "WAIT" {
+ if c.opts.pollDelay > 0 {
+ select {
+ case <-time.After(c.opts.pollDelay):
+ case <-c.ctx.Done():
+ return
+ }
+ }
+ continue
+ }
+
+ if strings.HasPrefix(string(resp), "ERR ") {
+ select {
+ case c.results <- chunkResult{seq: seq, err: fmt.Errorf("%s", resp)}:
+ case <-c.ctx.Done():
+ }
+ return
+ }
+
+ if strings.HasPrefix(string(resp), "EOF ") {
+ n, err := strconv.ParseUint(strings.TrimSpace(string(resp[4:])), 10, 64)
+ if err != nil {
+ select {
+ case c.results <- chunkResult{seq: seq, err: err}:
+ case <-c.ctx.Done():
+ }
+ return
+ }
+ select {
+ case c.results <- chunkResult{seq: seq, eof: true, final: n}:
+ case <-c.ctx.Done():
+ }
+ break
+ }
+
+ gotSeq, gotOffset, total, fragment, err := parseDataResponse(resp)
+ if err != nil {
+ select {
+ case c.results <- chunkResult{seq: seq, err: err}:
+ case <-c.ctx.Done():
+ }
+ return
+ }
+ if gotSeq != seq || gotOffset != offset {
+ select {
+ case c.results <- chunkResult{seq: seq, err: fmt.Errorf("DATA position mismatch")}:
+ case <-c.ctx.Done():
+ }
+ return
+ }
+ if total > c.serverMax || total < offset+len(fragment) || len(fragment) == 0 {
+ select {
+ case c.results <- chunkResult{seq: seq, err: fmt.Errorf("invalid DATA fragment size")}:
+ case <-c.ctx.Done():
+ }
+ return
+ }
+
+ if assembled == nil {
+ assembled = make([]byte, 0, total)
+ }
+ assembled = append(assembled, fragment...)
+ offset += len(fragment)
+ consecutiveMinFailures = 0
+ c.downSizer.Success(limit)
+
+ if offset == total {
+ select {
+ case c.results <- chunkResult{seq: seq, data: assembled}:
+ case <-c.ctx.Done():
+ }
+ break
+ }
+ }
+ }
+}
+
+func (c *chunkConn) Read(p []byte) (int, error) {
+ c.readMu.Lock()
+ defer c.readMu.Unlock()
+
+ for {
+ if len(c.current) > 0 {
+ n := copy(p, c.current)
+ c.current = c.current[n:]
+ if len(c.current) == 0 {
+ c.nextRead++
+ c.ack.Store(int64(c.currentSeq))
+ }
+ return n, nil
+ }
+
+ if c.terminalErr != nil {
+ return 0, c.terminalErr
+ }
+
+ if c.finalKnown && c.nextRead >= c.finalSeq {
+ return 0, io.EOF
+ }
+
+ if data, ok := c.pending[c.nextRead]; ok {
+ delete(c.pending, c.nextRead)
+ c.current = data
+ c.currentSeq = c.nextRead
+ continue
+ }
+
+ result, ok := <-c.results
+ if !ok {
+ return 0, io.EOF
+ }
+ if result.err != nil {
+ c.terminalErr = result.err
+ return 0, result.err
+ }
+ if result.eof {
+ if !c.finalKnown || result.final < c.finalSeq {
+ c.finalKnown = true
+ c.finalSeq = result.final
+ }
+ continue
+ }
+ if result.seq < c.nextRead {
+ continue
+ }
+ c.pending[result.seq] = result.data
+ }
+}
+
+func parseAck(resp []byte, expectedSeq uint64) (int, error) {
+ fields := strings.Fields(string(resp))
+ if len(fields) != 3 || fields[0] != "ACK" {
+ return 0, fmt.Errorf("bad CPUSH response: %q", resp)
+ }
+ seq, err := strconv.ParseUint(fields[1], 10, 64)
+ if err != nil || seq != expectedSeq {
+ return 0, fmt.Errorf("bad CPUSH sequence: %q", resp)
+ }
+ n, err := strconv.Atoi(fields[2])
+ if err != nil || n <= 0 {
+ return 0, fmt.Errorf("bad CPUSH length: %q", resp)
+ }
+ return n, nil
+}
+
+func (c *chunkConn) Write(p []byte) (int, error) {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+
+ total := 0
+ consecutiveMinFailures := 0
+
+ for len(p) > 0 {
+ size := c.upSizer.Current()
+ n := size
+ if len(p) < n {
+ n = len(p)
+ }
+
+ seq := c.upSeq
+ prefix := []byte(fmt.Sprintf("CPUSH %s %s %d ", wireToken(c.token), c.sid, seq))
+ payload := make([]byte, len(prefix)+n)
+ copy(payload, prefix)
+ copy(payload[len(prefix):], p[:n])
+
+ resp, err := c.pushLane.Do(payload)
+ if err != nil {
+ old, next := c.upSizer.Failure(size)
+ if next == old && next == c.opts.minSize {
+ consecutiveMinFailures++
+ } else {
+ consecutiveMinFailures = 0
+ }
+ if consecutiveMinFailures >= 8 {
+ return total, fmt.Errorf("upload failed at minimum chunk %d: %w", next, err)
+ }
+ time.Sleep(30 * time.Millisecond)
+ continue
+ }
+
+ if strings.HasPrefix(string(resp), "ERR ") {
+ return total, fmt.Errorf("%s", resp)
+ }
+
+ accepted, err := parseAck(resp, seq)
+ if err != nil {
+ return total, err
+ }
+ if accepted > len(p) {
+ return total, fmt.Errorf("server ACK length %d exceeds pending write %d", accepted, len(p))
+ }
+
+ c.upSeq++
+ total += accepted
+ p = p[accepted:]
+ consecutiveMinFailures = 0
+ c.upSizer.Success(size)
+ }
+
+ return total, nil
+}
+
+func (c *chunkConn) Close() error {
+ c.once.Do(func() {
+ c.cancel()
+
+ lane := newTxnLane(c.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout)
+ _, _ = doControl(lane, []byte(fmt.Sprintf("CCLOSE %s %s", wireToken(c.token), c.sid)))
+ lane.Close()
+
+ if c.pushLane != nil {
+ c.pushLane.Close()
+ }
+ for _, lane := range c.pullLanes {
+ lane.Close()
+ }
+ c.workers.Wait()
+ close(c.results)
+ })
+ return nil
+}
+
+func (c *chunkConn) LocalAddr() net.Addr { return dummyAddr("dragontcp-chunk-local") }
+func (c *chunkConn) RemoteAddr() net.Addr { return dummyAddr("dragontcp-chunk-remote") }
+func (c *chunkConn) SetDeadline(time.Time) error { return nil }
+func (c *chunkConn) SetReadDeadline(time.Time) error { return nil }
+func (c *chunkConn) SetWriteDeadline(time.Time) error { return nil }
+
+type dummyAddr string
+
+func (d dummyAddr) Network() string { return "dragontcp-chunk" }
+func (d dummyAddr) String() string { return string(d) }
diff --git a/core/cmd/dragontcp-client/chunk_test.go b/core/cmd/dragontcp-client/chunk_test.go
new file mode 100644
index 0000000..dd3cb08
--- /dev/null
+++ b/core/cmd/dragontcp-client/chunk_test.go
@@ -0,0 +1,36 @@
+package main
+
+import "testing"
+
+func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
+ opts := chunkClientOptions{
+ startSize: 64,
+ minSize: 32,
+ maxSize: 1024,
+ adaptive: true,
+ adaptSuccesses: 2,
+ }
+ s := newAdaptiveSizer("test", opts)
+ _, next := s.Failure(64)
+ if next != 32 {
+ t.Fatalf("failure should reduce 64 -> 32, got %d", next)
+ }
+
+ // When good=32 and bad=64 are adjacent at the controller's probing
+ // granularity, it deliberately waits 8x longer before testing upward.
+ for i := 0; i < 16; i++ {
+ s.Success(32)
+ }
+ if got := s.Current(); got <= 32 {
+ t.Fatalf("adaptive controller remained stuck at minimum: %d", got)
+ }
+}
+
+func TestWireTokenAllowsEmptyToken(t *testing.T) {
+ if got := wireToken(""); got != "-" {
+ t.Fatalf("empty token wire representation = %q, want '-'", got)
+ }
+ if got := wireToken("secret"); got != "secret" {
+ t.Fatalf("non-empty token changed: %q", got)
+ }
+}
diff --git a/core/cmd/dragontcp-client/main.go b/core/cmd/dragontcp-client/main.go
new file mode 100644
index 0000000..b06b433
--- /dev/null
+++ b/core/cmd/dragontcp-client/main.go
@@ -0,0 +1,478 @@
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "dragontcp/internal/protocol"
+)
+
+const maxHeader = 128 * 1024
+
+var requestCounter atomic.Uint32
+
+func readHTTPHeaders(conn net.Conn) ([]byte, []byte, error) {
+ buf := make([]byte, 0, 8192)
+ tmp := make([]byte, 8192)
+
+ for {
+ n, err := conn.Read(tmp)
+ if n > 0 {
+ buf = append(buf, tmp[:n]...)
+
+ if len(buf) > maxHeader {
+ return nil, nil, fmt.Errorf("HTTP headers too large")
+ }
+
+ if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 {
+ end := i + 4
+ return buf[:end], buf[end:], nil
+ }
+ }
+
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+}
+
+func parseHostPort(authority string, defaultPort int) (string, int, error) {
+ authority = strings.TrimSpace(authority)
+
+ if host, portText, err := net.SplitHostPort(authority); err == nil {
+ port, err := strconv.Atoi(portText)
+ return host, port, err
+ }
+
+ // Host without port.
+ if strings.HasPrefix(authority, "[") && strings.HasSuffix(authority, "]") {
+ return strings.Trim(authority, "[]"), defaultPort, nil
+ }
+
+ if strings.Count(authority, ":") == 0 {
+ return authority, defaultPort, nil
+ }
+
+ // Bare IPv6.
+ if ip := net.ParseIP(authority); ip != nil {
+ return authority, defaultPort, nil
+ }
+
+ return "", 0, fmt.Errorf("invalid authority: %s", authority)
+}
+
+func rewritePlainHTTPRequest(header []byte) (string, int, []byte, error) {
+ text := string(header)
+ lines := strings.Split(text, "\r\n")
+ if len(lines) == 0 {
+ return "", 0, nil, fmt.Errorf("empty request")
+ }
+
+ parts := strings.SplitN(lines[0], " ", 3)
+ if len(parts) != 3 {
+ return "", 0, nil, fmt.Errorf("invalid request line")
+ }
+
+ method, target, version := parts[0], parts[1], parts[2]
+
+ var (
+ hostHeader string
+ headers []string
+ )
+
+ for _, line := range lines[1:] {
+ if line == "" {
+ continue
+ }
+
+ k, v, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+
+ lk := strings.ToLower(strings.TrimSpace(k))
+
+ if lk == "host" {
+ hostHeader = strings.TrimSpace(v)
+ }
+
+ if lk == "connection" ||
+ lk == "proxy-connection" ||
+ lk == "proxy-authorization" {
+ continue
+ }
+
+ headers = append(headers, k+": "+strings.TrimSpace(v))
+ }
+
+ u, err := url.Parse(target)
+ if err != nil {
+ return "", 0, nil, err
+ }
+
+ var host string
+ var port int
+ path := target
+
+ if u.Hostname() != "" {
+ if strings.ToLower(u.Scheme) != "http" {
+ return "", 0, nil, fmt.Errorf("unsupported plain HTTP scheme: %s", u.Scheme)
+ }
+
+ host = u.Hostname()
+ port = 80
+
+ if u.Port() != "" {
+ port, err = strconv.Atoi(u.Port())
+ if err != nil {
+ return "", 0, nil, err
+ }
+ }
+
+ path = u.EscapedPath()
+ if path == "" {
+ path = "/"
+ }
+ if u.RawQuery != "" {
+ path += "?" + u.RawQuery
+ }
+ } else {
+ if hostHeader == "" {
+ return "", 0, nil, fmt.Errorf("missing Host header")
+ }
+
+ host, port, err = parseHostPort(hostHeader, 80)
+ if err != nil {
+ return "", 0, nil, err
+ }
+ if path == "" {
+ path = "/"
+ }
+ }
+
+ var out strings.Builder
+ fmt.Fprintf(&out, "%s %s %s\r\n", method, path, version)
+
+ sawHost := false
+ for _, h := range headers {
+ if strings.HasPrefix(strings.ToLower(h), "host:") {
+ sawHost = true
+ }
+ out.WriteString(h)
+ out.WriteString("\r\n")
+ }
+
+ if !sawHost {
+ if port == 80 {
+ fmt.Fprintf(&out, "Host: %s\r\n", host)
+ } else {
+ fmt.Fprintf(&out, "Host: %s\r\n", net.JoinHostPort(host, strconv.Itoa(port)))
+ }
+ }
+
+ out.WriteString("Connection: close\r\n\r\n")
+
+ return host, port, []byte(out.String()), nil
+}
+
+func openDragonTCPTunnel(serverAddr, token, targetHost string, targetPort int, transport string, tcpBuffer int) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: 10 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+
+ conn, err := d.Dial("tcp", serverAddr)
+ if err != nil {
+ return nil, err
+ }
+
+ protocol.TuneTCP(conn)
+ protocol.TuneTCPBuffer(conn, tcpBuffer)
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
+
+ // Correlation only; cryptographic randomness is unnecessary here.
+ requestID := requestCounter.Add(1)
+
+ var command []byte
+ if transport == "raw" {
+ command = []byte(fmt.Sprintf("TUNNEL2 %s %s %d RAW", token, targetHost, targetPort))
+ } else {
+ // Legacy XOR command remains compatible with the older server.
+ command = []byte(fmt.Sprintf("TUNNEL %s %s %d", token, targetHost, targetPort))
+ }
+
+ if err := protocol.WriteRequestFrame(conn, requestID, command); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ responseID, response, err := protocol.ReadResponseFrame(conn)
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if responseID != requestID {
+ conn.Close()
+ return nil, fmt.Errorf("request ID mismatch")
+ }
+
+ if string(response) != "CONNECTED" {
+ conn.Close()
+ return nil, fmt.Errorf("%s", response)
+ }
+
+ _ = conn.SetDeadline(time.Time{})
+ return conn, nil
+}
+
+func writeHTTPError(conn net.Conn, code int, reason, detail string) {
+ if detail == "" {
+ detail = reason
+ }
+
+ body := []byte(detail)
+
+ fmt.Fprintf(
+ conn,
+ "HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
+ code,
+ reason,
+ len(body),
+ )
+ _, _ = conn.Write(body)
+}
+
+func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, chunkOpts chunkClientOptions, slots chan struct{}) {
+ defer func() {
+ <-slots
+ _ = conn.Close()
+ }()
+
+ protocol.TuneTCP(conn)
+ protocol.TuneTCPBuffer(conn, tcpBuffer)
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
+
+ header, extra, err := readHTTPHeaders(conn)
+ if err != nil {
+ return
+ }
+
+ firstLine := strings.SplitN(string(header), "\r\n", 2)[0]
+ parts := strings.SplitN(firstLine, " ", 3)
+
+ if len(parts) != 3 {
+ writeHTTPError(conn, 400, "Bad Request", "invalid HTTP request line")
+ return
+ }
+
+ method, target := parts[0], parts[1]
+
+ if strings.EqualFold(method, "CONNECT") {
+ host, port, err := parseHostPort(target, 443)
+ if err != nil {
+ writeHTTPError(conn, 400, "Bad Request", err.Error())
+ return
+ }
+
+ var remote net.Conn
+ if transport == "chunk" {
+ remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts)
+ } else {
+ remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
+ }
+ if err != nil {
+ writeHTTPError(conn, 502, "Bad Gateway", err.Error())
+ return
+ }
+ defer remote.Close()
+
+ _, _ = conn.Write([]byte(
+ "HTTP/1.1 200 Connection Established\r\n" +
+ "Proxy-Agent: dragontcp-proxy/2.0\r\n\r\n",
+ ))
+
+ if len(extra) > 0 {
+ if transport == "xor" {
+ protocol.XorInPlace(extra)
+ }
+ if _, err := remote.Write(extra); err != nil {
+ return
+ }
+ }
+
+ _ = conn.SetDeadline(time.Time{})
+ if transport == "xor" {
+ protocol.RelayXOR(conn, remote)
+ } else {
+ // raw and chunk connections expose a normal plaintext net.Conn.
+ protocol.RelayRaw(conn, remote)
+ }
+ return
+ }
+
+ host, port, rewritten, err := rewritePlainHTTPRequest(header)
+ if err != nil {
+ writeHTTPError(conn, 400, "Bad Request", err.Error())
+ return
+ }
+
+ var remote net.Conn
+ if transport == "chunk" {
+ remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts)
+ } else {
+ remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
+ }
+ if err != nil {
+ writeHTTPError(conn, 502, "Bad Gateway", err.Error())
+ return
+ }
+ defer remote.Close()
+
+ initial := make([]byte, 0, len(rewritten)+len(extra))
+ initial = append(initial, rewritten...)
+ initial = append(initial, extra...)
+ if transport == "xor" {
+ protocol.XorInPlace(initial)
+ }
+
+ if _, err := remote.Write(initial); err != nil {
+ return
+ }
+
+ _ = conn.SetDeadline(time.Time{})
+ if transport == "xor" {
+ protocol.RelayXOR(conn, remote)
+ } else {
+ protocol.RelayRaw(conn, remote)
+ }
+}
+
+func main() {
+ var (
+ listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host")
+ listenPort = flag.Int("listen-port", 8080, "local proxy listen port")
+ serverHost = flag.String("server-host", "", "remote DragonTCP server host")
+ serverPort = flag.Int("server-port", 53, "remote DragonTCP server port")
+ token = flag.String("token", "", "optional shared token")
+ maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections")
+ transport = flag.String("transport", "chunk", "transport: chunk (mandatory in LiteVPN build)")
+ tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning")
+ chunkStart = flag.Int("chunk-start", 1048576, "initial adaptive chunk payload bytes")
+ chunkMin = flag.Int("chunk-min", 32, "minimum adaptive chunk payload bytes")
+ chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (up to 1 MiB)")
+ chunkAdaptive = flag.Bool("chunk-adaptive", true, "automatically shrink on failures and grow after stable success")
+ chunkSuccesses = flag.Int("chunk-grow-after", 16, "successful data records required before increasing chunk size")
+ chunkAdaptLog = flag.Bool("chunk-adapt-log", true, "print adaptive chunk size changes")
+ chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
+ chunkPollers = flag.Int("chunk-pollers", 1, "parallel downstream chunk pollers (LiteVPN default 1)")
+ chunkReconnect = flag.Int("chunk-reconnect-every", 1, "reconnect each transaction lane after N requests; 1 = one request per TCP/53 connection")
+ chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
+ chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink")
+ )
+ flag.Parse()
+
+ if *serverHost == "" {
+ fmt.Fprintln(os.Stderr, "--server-host is required")
+ os.Exit(2)
+ }
+
+ *transport = strings.ToLower(*transport)
+ if *transport != "chunk" {
+ fmt.Fprintln(os.Stderr, "DragonTCP LiteVPN requires --transport chunk (adaptive XOR-framed TCP/53)")
+ os.Exit(2)
+ }
+ if *chunkSizeLegacy != 0 {
+ if *chunkSizeLegacy < 32 || *chunkSizeLegacy > protocol.MaxChunkPayload {
+ fmt.Fprintf(os.Stderr, "--chunk-size must be between 32 and %d\n", protocol.MaxChunkPayload)
+ os.Exit(2)
+ }
+ *chunkStart = *chunkSizeLegacy
+ *chunkMin = *chunkSizeLegacy
+ *chunkMax = *chunkSizeLegacy
+ *chunkAdaptive = false
+ }
+ if *chunkMin < 32 || *chunkMax > protocol.MaxChunkPayload || *chunkMin > *chunkStart || *chunkStart > *chunkMax {
+ fmt.Fprintf(os.Stderr, "require 32 <= --chunk-min <= --chunk-start <= --chunk-max <= %d\n", protocol.MaxChunkPayload)
+ os.Exit(2)
+ }
+ if *chunkSuccesses < 1 {
+ fmt.Fprintln(os.Stderr, "--chunk-grow-after must be at least 1")
+ os.Exit(2)
+ }
+ if *chunkPollers < 1 || *chunkPollers > 128 {
+ fmt.Fprintln(os.Stderr, "--chunk-pollers must be between 1 and 128")
+ os.Exit(2)
+ }
+ chunkOpts := chunkClientOptions{
+ startSize: *chunkStart,
+ minSize: *chunkMin,
+ maxSize: *chunkMax,
+ adaptive: *chunkAdaptive,
+ adaptSuccesses: *chunkSuccesses,
+ adaptLog: *chunkAdaptLog,
+ pollers: *chunkPollers,
+ reconnectEvery: *chunkReconnect,
+ pollDelay: *chunkPollDelay,
+ txnTimeout: *chunkTimeout,
+ tcpBuffer: *tcpBuffer,
+ }
+
+ listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
+ serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
+
+ ln, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ defer ln.Close()
+
+ fmt.Printf("local Go HTTP proxy listening on %s\n", listenAddr)
+ fmt.Printf("remote DragonTCP endpoint=%s\n", serverAddr)
+ fmt.Printf("max_connections=%d transport=%s tcp_buffer=%d\n", *maxConnections, *transport, *tcpBuffer)
+ if *transport == "chunk" {
+ fmt.Printf(
+ "adaptive_chunk=%v start=%d min=%d max=%d grow_after=%d pollers=%d reconnect_every=%d timeout=%s\n",
+ *chunkAdaptive,
+ *chunkStart,
+ *chunkMin,
+ *chunkMax,
+ *chunkSuccesses,
+ *chunkPollers,
+ *chunkReconnect,
+ chunkTimeout.String(),
+ )
+ }
+
+ slots := make(chan struct{}, *maxConnections)
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "accept:", err)
+ continue
+ }
+
+ select {
+ case slots <- struct{}{}:
+ go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, chunkOpts, slots)
+ default:
+ writeHTTPError(
+ conn,
+ 503,
+ "Service Unavailable",
+ "proxy connection limit reached",
+ )
+ _ = conn.Close()
+ }
+ }
+}
diff --git a/core/cmd/dragontcp-server/chunk.go b/core/cmd/dragontcp-server/chunk.go
new file mode 100644
index 0000000..5c96d7d
--- /dev/null
+++ b/core/cmd/dragontcp-server/chunk.go
@@ -0,0 +1,505 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "dragontcp/internal/protocol"
+)
+
+type chunkSession struct {
+ id string
+ target net.Conn
+ maxChunk int
+ maxChunks int
+
+ mu sync.Mutex
+ notify chan struct{}
+ chunks map[uint64][]byte
+ nextDown uint64
+ eof bool
+ closed bool
+ lastSeen time.Time
+ debug *serverDebug
+
+ upMu sync.Mutex
+ expectedUp uint64
+ lastUpSeq uint64
+ lastUpLen int
+ haveLastUp bool
+}
+
+func newChunkSession(id string, target net.Conn, maxChunk, maxChunks int, debug *serverDebug) *chunkSession {
+ s := &chunkSession{
+ id: id,
+ target: target,
+ maxChunk: maxChunk,
+ maxChunks: maxChunks,
+ notify: make(chan struct{}),
+ chunks: make(map[uint64][]byte, maxChunks),
+ lastSeen: time.Now(),
+ debug: debug,
+ }
+ go s.readTarget()
+ return s
+}
+
+func (s *chunkSession) signalLocked() {
+ close(s.notify)
+ s.notify = make(chan struct{})
+}
+
+func (s *chunkSession) touchLocked() {
+ s.lastSeen = time.Now()
+}
+
+func (s *chunkSession) touch() {
+ s.mu.Lock()
+ s.touchLocked()
+ s.mu.Unlock()
+}
+
+func (s *chunkSession) readTarget() {
+ buf := make([]byte, s.maxChunk)
+
+ for {
+ n, err := s.target.Read(buf)
+ if n > 0 {
+ data := append([]byte(nil), buf[:n]...)
+ if s.debug != nil && s.debug.enabled {
+ s.debug.bytesDown.Add(uint64(n))
+ }
+
+ for {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ if len(s.chunks) < s.maxChunks {
+ seq := s.nextDown
+ s.nextDown++
+ s.chunks[seq] = data
+ s.touchLocked()
+ s.signalLocked()
+ s.mu.Unlock()
+ break
+ }
+ ch := s.notify
+ s.mu.Unlock()
+ <-ch
+ }
+ }
+
+ if err != nil {
+ if s.debug != nil && s.debug.enabled {
+ s.debug.logf("TARGET EOF session=%s err=%v", s.id, err)
+ }
+ s.mu.Lock()
+ if !s.closed {
+ s.eof = true
+ s.touchLocked()
+ s.signalLocked()
+ }
+ s.mu.Unlock()
+ return
+ }
+ }
+}
+
+// push is idempotent for the most recently accepted sequence. This matters
+// when the server receives a record but the tiny ACK is lost: the client can
+// retry the same sequence at a smaller adaptive size without duplicating bytes
+// in the target stream. The ACK reports the length that was actually accepted.
+func (s *chunkSession) push(seq uint64, data []byte) (int, error) {
+ s.upMu.Lock()
+ defer s.upMu.Unlock()
+
+ if len(data) == 0 || len(data) > s.maxChunk {
+ return 0, fmt.Errorf("upload record size %d is invalid", len(data))
+ }
+
+ if s.haveLastUp && seq == s.lastUpSeq {
+ s.touch()
+ return s.lastUpLen, nil
+ }
+
+ if seq < s.expectedUp {
+ return 0, fmt.Errorf("upload sequence %d is too old", seq)
+ }
+ if seq > s.expectedUp {
+ return 0, fmt.Errorf("unexpected upload sequence %d, expected %d", seq, s.expectedUp)
+ }
+
+ if _, err := s.target.Write(data); err != nil {
+ return 0, err
+ }
+
+ if s.debug != nil && s.debug.enabled {
+ s.debug.bytesUp.Add(uint64(len(data)))
+ s.debug.pushRecords.Add(1)
+ }
+
+ s.lastUpSeq = seq
+ s.lastUpLen = len(data)
+ s.haveLastUp = true
+ s.expectedUp++
+ s.touch()
+ return len(data), nil
+}
+
+// pull returns at most limit bytes from the requested stored chunk, beginning
+// at offset. The chunk sequence stays stable while the client retries smaller
+// fragments, so a large queued chunk can always be recovered after an MTU-like
+// failure without reopening the proxied destination connection.
+func (s *chunkSession) pull(want uint64, ack int64, offset, limit int, wait time.Duration) (data []byte, total int, eof bool, final uint64, waitExpired bool, err error) {
+ if offset < 0 || limit <= 0 || limit > s.maxChunk {
+ return nil, 0, false, 0, false, fmt.Errorf("invalid pull offset/limit")
+ }
+
+ timer := time.NewTimer(wait)
+ defer timer.Stop()
+
+ for {
+ s.mu.Lock()
+ s.touchLocked()
+
+ if ack >= 0 {
+ removed := false
+ for seq := range s.chunks {
+ if seq <= uint64(ack) {
+ delete(s.chunks, seq)
+ removed = true
+ }
+ }
+ if removed {
+ s.signalLocked()
+ }
+ }
+
+ if chunk, ok := s.chunks[want]; ok {
+ if offset >= len(chunk) {
+ s.mu.Unlock()
+ return nil, len(chunk), false, 0, false, fmt.Errorf("pull offset %d beyond chunk size %d", offset, len(chunk))
+ }
+ end := offset + limit
+ if end > len(chunk) {
+ end = len(chunk)
+ }
+ out := append([]byte(nil), chunk[offset:end]...)
+ total = len(chunk)
+ s.mu.Unlock()
+ return out, total, false, 0, false, nil
+ }
+
+ if s.eof && want >= s.nextDown {
+ final = s.nextDown
+ s.mu.Unlock()
+ return nil, 0, true, final, false, nil
+ }
+
+ if s.closed {
+ final = s.nextDown
+ s.mu.Unlock()
+ return nil, 0, true, final, false, nil
+ }
+
+ ch := s.notify
+ s.mu.Unlock()
+
+ select {
+ case <-ch:
+ continue
+ case <-timer.C:
+ return nil, 0, false, 0, true, nil
+ }
+ }
+}
+
+func (s *chunkSession) close() {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ s.closed = true
+ s.signalLocked()
+ s.mu.Unlock()
+ _ = s.target.Close()
+}
+
+type chunkManager struct {
+ mu sync.RWMutex
+ sessions map[string]*chunkSession
+ timeout time.Duration
+ debug *serverDebug
+}
+
+func newChunkManager(timeout time.Duration, debug *serverDebug) *chunkManager {
+ m := &chunkManager{
+ sessions: make(map[string]*chunkSession),
+ timeout: timeout,
+ debug: debug,
+ }
+ go m.cleanupLoop()
+ return m
+}
+
+func (m *chunkManager) get(id string) *chunkSession {
+ m.mu.RLock()
+ s := m.sessions[id]
+ m.mu.RUnlock()
+ return s
+}
+
+func (m *chunkManager) count() int {
+ m.mu.RLock()
+ n := len(m.sessions)
+ m.mu.RUnlock()
+ return n
+}
+
+func (m *chunkManager) add(id string, s *chunkSession) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, exists := m.sessions[id]; exists {
+ return fmt.Errorf("session already exists")
+ }
+ m.sessions[id] = s
+ return nil
+}
+
+func (m *chunkManager) remove(id string) {
+ m.mu.Lock()
+ s := m.sessions[id]
+ delete(m.sessions, id)
+ m.mu.Unlock()
+ if s != nil {
+ s.close()
+ }
+}
+
+func (m *chunkManager) cleanupLoop() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ cutoff := time.Now().Add(-m.timeout)
+ var stale []string
+
+ m.mu.RLock()
+ for id, s := range m.sessions {
+ s.mu.Lock()
+ last := s.lastSeen
+ closed := s.closed
+ s.mu.Unlock()
+ if closed || last.Before(cutoff) {
+ stale = append(stale, id)
+ }
+ }
+ m.mu.RUnlock()
+
+ for _, id := range stale {
+ if m.debug != nil && m.debug.enabled {
+ m.debug.logf("SESSION timeout-close id=%s active_sessions=%d", id, m.count())
+ }
+ m.remove(id)
+ if m.debug != nil && m.debug.enabled {
+ m.debug.sessionsClosed.Add(1)
+ m.debug.activeSessions.Add(-1)
+ }
+ }
+ }
+}
+
+func decodeWireToken(token string) string {
+ if token == "-" {
+ return ""
+ }
+ return token
+}
+
+func isChunkCommand(payload []byte) bool {
+ return bytes.HasPrefix(payload, []byte("COPEN ")) ||
+ bytes.HasPrefix(payload, []byte("CPUSH ")) ||
+ bytes.HasPrefix(payload, []byte("CPULL ")) ||
+ bytes.HasPrefix(payload, []byte("CCLOSE "))
+}
+
+func processChunkCommand(
+ conn net.Conn,
+ requestID uint32,
+ payload []byte,
+ token string,
+ allowPrivate bool,
+ cache *dnsCache,
+ tcpBuffer int,
+ manager *chunkManager,
+ maxChunk int,
+ maxBufferedChunks int,
+ pollWait time.Duration,
+ debug *serverDebug,
+) error {
+ if bytes.HasPrefix(payload, []byte("COPEN ")) {
+ parts := strings.Fields(string(payload))
+ if len(parts) != 5 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad COPEN"))
+ }
+ if !tokenEqual(decodeWireToken(parts[1]), token) {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
+ }
+ sid := parts[2]
+ if len(sid) < 16 || len(sid) > 64 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid session id"))
+ }
+ host := parts[3]
+ port, err := strconv.Atoi(parts[4])
+ if err != nil || port < 1 || port > 65535 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid port"))
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ target, err := dialTarget(ctx, host, port, allowPrivate, cache, tcpBuffer)
+ cancel()
+ if err != nil {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
+ }
+
+ session := newChunkSession(sid, target, maxChunk, maxBufferedChunks, debug)
+ if err := manager.add(sid, session); err != nil {
+ session.close()
+ if debug != nil && debug.enabled {
+ debug.errorf("COPEN session=%s target=%s:%d failed: %v", sid, host, port, err)
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
+ }
+ if debug != nil && debug.enabled {
+ debug.sessionsOpened.Add(1)
+ debug.activeSessions.Add(1)
+ debug.logf("SESSION OPEN id=%s peer=%v target=%s:%d max_chunk=%d active_sessions=%d", sid, conn.RemoteAddr(), host, port, maxChunk, manager.count())
+ debug.chunkf("COPEN id=%s target=%s:%d -> OPENED max=%d", sid, host, port, maxChunk)
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("OPENED %d", maxChunk)))
+ }
+
+ if bytes.HasPrefix(payload, []byte("CPUSH ")) {
+ parts := bytes.SplitN(payload, []byte(" "), 5)
+ if len(parts) != 5 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPUSH"))
+ }
+ if !tokenEqual(decodeWireToken(string(parts[1])), token) {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
+ }
+ sid := string(parts[2])
+ seq, err := strconv.ParseUint(string(parts[3]), 10, 64)
+ if err != nil {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid sequence"))
+ }
+ s := manager.get(sid)
+ if s == nil {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown session"))
+ }
+ accepted, err := s.push(seq, parts[4])
+ if err != nil {
+ if debug != nil && debug.enabled {
+ debug.errorf("CPUSH id=%s seq=%d bytes=%d: %v", sid, seq, len(parts[4]), err)
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
+ }
+ if debug != nil {
+ debug.chunkf("CPUSH id=%s seq=%d bytes=%d -> ACK accepted=%d", sid, seq, len(parts[4]), accepted)
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("ACK %d %d", seq, accepted)))
+ }
+
+ if bytes.HasPrefix(payload, []byte("CPULL ")) {
+ parts := strings.Fields(string(payload))
+ if len(parts) != 7 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPULL"))
+ }
+ if !tokenEqual(decodeWireToken(parts[1]), token) {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
+ }
+ s := manager.get(parts[2])
+ if s == nil {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown session"))
+ }
+ ack, err := strconv.ParseInt(parts[3], 10, 64)
+ if err != nil || ack < -1 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid ack"))
+ }
+ want, err := strconv.ParseUint(parts[4], 10, 64)
+ if err != nil {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid want"))
+ }
+ offset, err := strconv.Atoi(parts[5])
+ if err != nil || offset < 0 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid offset"))
+ }
+ limit, err := strconv.Atoi(parts[6])
+ if err != nil || limit < 1 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid limit"))
+ }
+ if limit > maxChunk {
+ limit = maxChunk
+ }
+ if debug != nil && debug.enabled {
+ debug.pullRequests.Add(1)
+ debug.chunkf("CPULL id=%s ack=%d want=%d offset=%d limit=%d", parts[2], ack, want, offset, limit)
+ }
+
+ data, total, eof, final, waitExpired, err := s.pull(want, ack, offset, limit, pollWait)
+ if err != nil {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
+ }
+ if waitExpired {
+ if debug != nil && debug.enabled {
+ debug.waitRecords.Add(1)
+ debug.chunkf("CPULL id=%s want=%d -> WAIT", parts[2], want)
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte("WAIT"))
+ }
+ if eof {
+ if debug != nil {
+ debug.chunkf("CPULL id=%s want=%d -> EOF final=%d", parts[2], want, final)
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("EOF %d", final)))
+ }
+
+ if debug != nil && debug.enabled {
+ debug.dataRecords.Add(1)
+ debug.chunkf("DATA id=%s seq=%d offset=%d bytes=%d total=%d", parts[2], want, offset, len(data), total)
+ }
+ prefix := []byte(fmt.Sprintf("DATA %d %d %d ", want, offset, total))
+ out := make([]byte, len(prefix)+len(data))
+ copy(out, prefix)
+ copy(out[len(prefix):], data)
+ return protocol.WriteResponseFrame(conn, requestID, out)
+ }
+
+ if bytes.HasPrefix(payload, []byte("CCLOSE ")) {
+ parts := strings.Fields(string(payload))
+ if len(parts) != 3 {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CCLOSE"))
+ }
+ if !tokenEqual(decodeWireToken(parts[1]), token) {
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
+ }
+ manager.remove(parts[2])
+ if debug != nil && debug.enabled {
+ debug.sessionsClosed.Add(1)
+ debug.activeSessions.Add(-1)
+ debug.logf("SESSION CLOSE id=%s peer=%v active_sessions=%d", parts[2], conn.RemoteAddr(), manager.count())
+ debug.chunkf("CCLOSE id=%s -> CLOSED", parts[2])
+ }
+ return protocol.WriteResponseFrame(conn, requestID, []byte("CLOSED"))
+ }
+
+ return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown chunk command"))
+}
diff --git a/core/cmd/dragontcp-server/chunk_test.go b/core/cmd/dragontcp-server/chunk_test.go
new file mode 100644
index 0000000..a0a192b
--- /dev/null
+++ b/core/cmd/dragontcp-server/chunk_test.go
@@ -0,0 +1,12 @@
+package main
+
+import "testing"
+
+func TestDecodeWireTokenAllowsEmptyToken(t *testing.T) {
+ if got := decodeWireToken("-"); got != "" {
+ t.Fatalf("empty wire token decoded as %q", got)
+ }
+ if got := decodeWireToken("secret"); got != "secret" {
+ t.Fatalf("non-empty token changed: %q", got)
+ }
+}
diff --git a/core/cmd/dragontcp-server/debug.go b/core/cmd/dragontcp-server/debug.go
new file mode 100644
index 0000000..f23f94b
--- /dev/null
+++ b/core/cmd/dragontcp-server/debug.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "sync/atomic"
+ "time"
+)
+
+type serverDebug struct {
+ enabled bool
+ chunks bool
+ statsEvery time.Duration
+ started time.Time
+
+ sessionsOpened atomic.Uint64
+ sessionsClosed atomic.Uint64
+ activeSessions atomic.Int64
+ bytesUp atomic.Uint64
+ bytesDown atomic.Uint64
+ pushRecords atomic.Uint64
+ pullRequests atomic.Uint64
+ dataRecords atomic.Uint64
+ waitRecords atomic.Uint64
+ errors atomic.Uint64
+}
+
+func newServerDebug(enabled, chunks bool, statsEvery time.Duration) *serverDebug {
+ d := &serverDebug{
+ enabled: enabled || chunks,
+ chunks: chunks,
+ statsEvery: statsEvery,
+ started: time.Now(),
+ }
+ if d.enabled && d.statsEvery > 0 {
+ go d.statsLoop()
+ }
+ return d
+}
+
+func (d *serverDebug) logf(format string, args ...any) {
+ if d == nil || !d.enabled {
+ return
+ }
+ fmt.Fprintf(os.Stderr, "%s [DEBUG] "+format+"\n", append([]any{time.Now().Format("2006-01-02 15:04:05.000")}, args...)...)
+}
+
+func (d *serverDebug) chunkf(format string, args ...any) {
+ if d == nil || !d.chunks {
+ return
+ }
+ fmt.Fprintf(os.Stderr, "%s [CHUNK] "+format+"\n", append([]any{time.Now().Format("2006-01-02 15:04:05.000")}, args...)...)
+}
+
+func (d *serverDebug) errorf(format string, args ...any) {
+ if d == nil || !d.enabled {
+ return
+ }
+ d.errors.Add(1)
+ fmt.Fprintf(os.Stderr, "%s [ERROR] "+format+"\n", append([]any{time.Now().Format("2006-01-02 15:04:05.000")}, args...)...)
+}
+
+func (d *serverDebug) statsLoop() {
+ ticker := time.NewTicker(d.statsEvery)
+ defer ticker.Stop()
+ for range ticker.C {
+ d.logf(
+ "STATS uptime=%s active_connections=%d active_sessions=%d sessions_opened=%d sessions_closed=%d bytes_up=%d bytes_down=%d push_records=%d pull_requests=%d data_records=%d waits=%d errors=%d",
+ time.Since(d.started).Round(time.Second),
+ atomic.LoadInt64(&active),
+ d.activeSessions.Load(),
+ d.sessionsOpened.Load(),
+ d.sessionsClosed.Load(),
+ d.bytesUp.Load(),
+ d.bytesDown.Load(),
+ d.pushRecords.Load(),
+ d.pullRequests.Load(),
+ d.dataRecords.Load(),
+ d.waitRecords.Load(),
+ d.errors.Load(),
+ )
+ }
+}
diff --git a/core/cmd/dragontcp-server/main.go b/core/cmd/dragontcp-server/main.go
new file mode 100644
index 0000000..0c153be
--- /dev/null
+++ b/core/cmd/dragontcp-server/main.go
@@ -0,0 +1,367 @@
+package main
+
+import (
+ "context"
+ "crypto/subtle"
+ "flag"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "dragontcp/internal/protocol"
+)
+
+var active int64
+
+type dnsEntry struct {
+ ips []netip.Addr
+ expires time.Time
+}
+
+type dnsCache struct {
+ mu sync.RWMutex
+ entries map[string]dnsEntry
+ ttl time.Duration
+ max int
+}
+
+func newDNSCache(ttl time.Duration, max int) *dnsCache {
+ return &dnsCache{
+ entries: make(map[string]dnsEntry),
+ ttl: ttl,
+ max: max,
+ }
+}
+
+func (c *dnsCache) resolve(ctx context.Context, host string) ([]netip.Addr, error) {
+ if ip, err := netip.ParseAddr(host); err == nil {
+ return []netip.Addr{ip}, nil
+ }
+
+ now := time.Now()
+ c.mu.RLock()
+ entry, ok := c.entries[host]
+ c.mu.RUnlock()
+ if ok && now.Before(entry.expires) {
+ return entry.ips, nil
+ }
+
+ ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
+ if err != nil {
+ return nil, err
+ }
+
+ c.mu.Lock()
+ if len(c.entries) >= c.max {
+ // Simple bounded reset keeps the hot cache cheap and prevents growth.
+ c.entries = make(map[string]dnsEntry, c.max)
+ }
+ c.entries[host] = dnsEntry{ips: ips, expires: now.Add(c.ttl)}
+ c.mu.Unlock()
+
+ return ips, nil
+}
+
+func tokenEqual(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
+}
+
+var blockedSpecial = []netip.Prefix{
+ netip.MustParsePrefix("0.0.0.0/8"),
+ netip.MustParsePrefix("100.64.0.0/10"),
+ netip.MustParsePrefix("192.0.0.0/24"),
+ netip.MustParsePrefix("192.0.2.0/24"),
+ netip.MustParsePrefix("198.18.0.0/15"),
+ netip.MustParsePrefix("198.51.100.0/24"),
+ netip.MustParsePrefix("203.0.113.0/24"),
+ netip.MustParsePrefix("240.0.0.0/4"),
+ netip.MustParsePrefix("2001:db8::/32"),
+}
+
+func addressAllowed(addr netip.Addr, allowPrivate bool) bool {
+ if addr.IsUnspecified() || addr.IsMulticast() {
+ return false
+ }
+
+ if allowPrivate {
+ return true
+ }
+
+ if !addr.IsGlobalUnicast() ||
+ addr.IsPrivate() ||
+ addr.IsLoopback() ||
+ addr.IsLinkLocalUnicast() {
+ return false
+ }
+
+ for _, prefix := range blockedSpecial {
+ if prefix.Contains(addr) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func dialTarget(ctx context.Context, host string, port int, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Conn, error) {
+ ips, err := cache.resolve(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+
+ var lastErr error
+ var blocked []string
+
+ d := net.Dialer{
+ Timeout: 10 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+
+ for _, ip := range ips {
+ if !addressAllowed(ip, allowPrivate) {
+ blocked = append(blocked, ip.String())
+ continue
+ }
+
+ addr := net.JoinHostPort(ip.String(), strconv.Itoa(port))
+ conn, err := d.DialContext(ctx, "tcp", addr)
+ if err == nil {
+ protocol.TuneTCP(conn)
+ protocol.TuneTCPBuffer(conn, tcpBuffer)
+ return conn, nil
+ }
+ lastErr = err
+ }
+
+ if lastErr != nil {
+ return nil, lastErr
+ }
+ if len(blocked) > 0 {
+ return nil, fmt.Errorf("target resolves only to blocked addresses: %s", strings.Join(blocked, ","))
+ }
+ return nil, fmt.Errorf("no usable target address")
+}
+
+func handle(
+ conn net.Conn,
+ token string,
+ allowPrivate bool,
+ cache *dnsCache,
+ tcpBuffer int,
+ slots chan struct{},
+ manager *chunkManager,
+ chunkMax int,
+ chunkBuffered int,
+ chunkPollWait time.Duration,
+ debug *serverDebug,
+) {
+ defer func() {
+ <-slots
+ atomic.AddInt64(&active, -1)
+ _ = conn.Close()
+ }()
+
+ protocol.TuneTCP(conn)
+ protocol.TuneTCPBuffer(conn, tcpBuffer)
+
+ for {
+ _ = conn.SetDeadline(time.Now().Add(20 * time.Second))
+
+ requestID, _, payload, err := protocol.ReadRequestFrame(conn)
+ if err != nil {
+ if debug != nil && debug.enabled && err != io.EOF {
+ debug.errorf("peer=%v read request: %v", conn.RemoteAddr(), err)
+ }
+ return
+ }
+
+ if isChunkCommand(payload) {
+ if err := processChunkCommand(
+ conn,
+ requestID,
+ payload,
+ token,
+ allowPrivate,
+ cache,
+ tcpBuffer,
+ manager,
+ chunkMax,
+ chunkBuffered,
+ chunkPollWait,
+ debug,
+ ); err != nil {
+ return
+ }
+ continue
+ }
+
+ parts := strings.Fields(string(payload))
+ transport := "xor"
+
+ if len(parts) == 4 && parts[0] == "TUNNEL" {
+ transport = "xor"
+ } else if len(parts) == 5 && parts[0] == "TUNNEL2" {
+ transport = strings.ToLower(parts[4])
+ if transport != "raw" && transport != "xor" {
+ _ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR transport must be RAW or XOR"))
+ return
+ }
+ } else {
+ _ = protocol.WriteResponseFrame(
+ conn,
+ requestID,
+ []byte("ERR expected TUNNEL, TUNNEL2, or chunk command"),
+ )
+ return
+ }
+
+ if !tokenEqual(parts[1], token) {
+ _ = protocol.WriteResponseFrame(
+ conn,
+ requestID,
+ []byte("ERR authentication failed"),
+ )
+ return
+ }
+
+ port, err := strconv.Atoi(parts[3])
+ if err != nil || port < 1 || port > 65535 {
+ _ = protocol.WriteResponseFrame(
+ conn,
+ requestID,
+ []byte("ERR invalid port"),
+ )
+ return
+ }
+
+ if debug != nil && debug.enabled {
+ debug.logf("TUNNEL peer=%v target=%s:%d transport=%s", conn.RemoteAddr(), parts[2], port, transport)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ target, err := dialTarget(ctx, parts[2], port, allowPrivate, cache, tcpBuffer)
+ cancel()
+
+ if err != nil {
+ if debug != nil && debug.enabled {
+ debug.errorf("TUNNEL target=%s:%d connect failed: %v", parts[2], port, err)
+ }
+ _ = protocol.WriteResponseFrame(
+ conn,
+ requestID,
+ []byte("ERR "+err.Error()),
+ )
+ return
+ }
+ defer target.Close()
+
+ if err := protocol.WriteResponseFrame(conn, requestID, []byte("CONNECTED")); err != nil {
+ return
+ }
+
+ _ = conn.SetDeadline(time.Time{})
+ if transport == "raw" {
+ protocol.RelayRaw(conn, target)
+ } else {
+ protocol.RelayXOR(conn, target)
+ }
+ if debug != nil && debug.enabled {
+ debug.logf("TUNNEL closed peer=%v target=%s:%d transport=%s", conn.RemoteAddr(), parts[2], port, transport)
+ }
+ return
+ }
+}
+
+func main() {
+ var (
+ host = flag.String("host", "0.0.0.0", "listen host")
+ port = flag.Int("port", 53, "listen port")
+ token = flag.String("token", "", "optional shared token")
+ maxConnections = flag.Int("max-connections", 20000, "max simultaneous tunnels")
+ allowPrivate = flag.Bool("allow-private", false, "allow private/loopback targets")
+ dnsCacheTTL = flag.Duration("dns-cache-ttl", 30*time.Second, "server DNS cache TTL")
+ dnsCacheSize = flag.Int("dns-cache-size", 4096, "maximum cached DNS hostnames")
+ tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning")
+ chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (32 bytes to 1 MiB)")
+ chunkBuffered = flag.Int("chunk-buffered", 256, "maximum buffered destination chunks per session")
+ chunkPollWait = flag.Duration("chunk-poll-wait", 200*time.Millisecond, "server long-poll wait for chunk data")
+ sessionTimeout = flag.Duration("chunk-session-timeout", 2*time.Minute, "idle chunk session timeout")
+ debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics")
+ debugChunks = flag.Bool("debug-chunks", false, "log every chunk protocol record; very verbose")
+ debugStats = flag.Duration("debug-stats-interval", 5*time.Second, "periodic debug statistics interval; 0 disables")
+ )
+ flag.Parse()
+
+ if *chunkMax < 32 || *chunkMax > protocol.MaxChunkPayload {
+ fmt.Fprintf(os.Stderr, "--chunk-max must be between 32 and %d\n", protocol.MaxChunkPayload)
+ os.Exit(2)
+ }
+ if *chunkBuffered < 8 {
+ fmt.Fprintln(os.Stderr, "--chunk-buffered must be at least 8")
+ os.Exit(2)
+ }
+
+ listenAddr := net.JoinHostPort(*host, strconv.Itoa(*port))
+ ln, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ defer ln.Close()
+
+ fmt.Printf("DragonTCP Go server listening on %s\n", listenAddr)
+ fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer)
+
+ slots := make(chan struct{}, *maxConnections)
+ cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
+ debug := newServerDebug(*debugEnabled, *debugChunks, *debugStats)
+ manager := newChunkManager(*sessionTimeout, debug)
+ fmt.Printf("adaptive_chunk_max=%d buffered_chunks=%d poll_wait=%s\n", *chunkMax, *chunkBuffered, chunkPollWait.String())
+ if debug.enabled {
+ fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery)
+ }
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "accept:", err)
+ continue
+ }
+
+ select {
+ case slots <- struct{}{}:
+ atomic.AddInt64(&active, 1)
+ if debug.enabled {
+ debug.logf("ACCEPT peer=%v active_connections=%d", conn.RemoteAddr(), atomic.LoadInt64(&active))
+ }
+ go handle(
+ conn,
+ *token,
+ *allowPrivate,
+ cache,
+ *tcpBuffer,
+ slots,
+ manager,
+ *chunkMax,
+ *chunkBuffered,
+ *chunkPollWait,
+ debug,
+ )
+ default:
+ if debug.enabled {
+ debug.errorf("REJECT peer=%v reason=max-connections", conn.RemoteAddr())
+ }
+ _ = conn.Close()
+ }
+ }
+}
diff --git a/core/cmd/dragontcp-vpn-client/main.go b/core/cmd/dragontcp-vpn-client/main.go
deleted file mode 100644
index 8e0f3cc..0000000
--- a/core/cmd/dragontcp-vpn-client/main.go
+++ /dev/null
@@ -1,624 +0,0 @@
-package main
-
-import (
- "crypto/rand"
- "errors"
- "flag"
- "fmt"
- "io"
- "net"
- "net/netip"
- "os"
- "os/signal"
- "strconv"
- "sync"
- "sync/atomic"
- "syscall"
- "time"
-
- "dragontcpvpn/internal/protocol"
-)
-
-var requestCounter atomic.Uint32
-
-type txnLane struct {
- mu sync.Mutex
- serverAddr string
- timeout time.Duration
- reconnectEvery int
- conn net.Conn
- count int
- closed bool
-}
-
-func newTxnLane(addr string, timeout time.Duration, reconnectEvery int) *txnLane {
- return &txnLane{serverAddr: addr, timeout: timeout, reconnectEvery: reconnectEvery}
-}
-func (l *txnLane) closeLocked() {
- if l.conn != nil {
- _ = l.conn.Close()
- l.conn = nil
- }
- l.count = 0
-}
-func (l *txnLane) Close() { l.mu.Lock(); l.closed = true; l.closeLocked(); l.mu.Unlock() }
-func (l *txnLane) ensureConn() error {
- if l.closed {
- return net.ErrClosed
- }
- if l.conn != nil && (l.reconnectEvery <= 0 || l.count < l.reconnectEvery) {
- return nil
- }
- l.closeLocked()
- d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
- c, err := d.Dial("tcp", l.serverAddr)
- if err != nil {
- return err
- }
- protocol.TuneTCP(c)
- l.conn = c
- return nil
-}
-func (l *txnLane) Do(payload []byte) ([]byte, error) {
- l.mu.Lock()
- defer l.mu.Unlock()
- if err := l.ensureConn(); err != nil {
- return nil, err
- }
- timeout := l.timeout
- if timeout <= 0 {
- timeout = 3 * time.Second
- }
- _ = l.conn.SetDeadline(time.Now().Add(timeout))
- id := requestCounter.Add(1)
- if err := protocol.WriteRequestFrame(l.conn, id, payload); err != nil {
- l.closeLocked()
- return nil, err
- }
- rid, resp, err := protocol.ReadResponseFrame(l.conn)
- if err != nil {
- l.closeLocked()
- return nil, err
- }
- if rid != id {
- l.closeLocked()
- return nil, errors.New("request ID mismatch")
- }
- l.count++
- _ = l.conn.SetDeadline(time.Time{})
- return resp, nil
-}
-func doControl(l *txnLane, payload []byte) ([]byte, error) {
- var last error
- for i := 0; i < 6; i++ {
- r, e := l.Do(payload)
- if e == nil {
- return r, nil
- }
- last = e
- time.Sleep(time.Duration(i+1) * 50 * time.Millisecond)
- }
- return nil, last
-}
-
-type adaptiveSizer struct {
- mu sync.Mutex
- name string
- current, min, max int
- successes int
- growAfter int
- log bool
-}
-
-func newSizer(name string, start, min, max, growAfter int, log bool) *adaptiveSizer {
- if min < 32 {
- min = 32
- }
- if max > protocol.VPNMaxFragment {
- max = protocol.VPNMaxFragment
- }
- if max < min {
- max = min
- }
- if start < min {
- start = min
- }
- if start > max {
- start = max
- }
- if growAfter < 1 {
- growAfter = 32
- }
- return &adaptiveSizer{name: name, current: start, min: min, max: max, growAfter: growAfter, log: log}
-}
-func (s *adaptiveSizer) Current() int { s.mu.Lock(); v := s.current; s.mu.Unlock(); return v }
-func (s *adaptiveSizer) Failure(actual int) {
- s.mu.Lock()
- defer s.mu.Unlock()
- old := s.current
- s.successes = 0
- basis := actual
- if basis <= 0 || basis > old {
- basis = old
- }
- next := basis / 2
- if next < s.min {
- next = s.min
- }
- if next >= old && old > s.min {
- next = old / 2
- if next < s.min {
- next = s.min
- }
- }
- if next < old {
- s.current = next
- if s.log {
- fmt.Printf("adaptive %s chunk: %d -> %d after transport failure (record=%d)\n", s.name, old, next, actual)
- }
- }
-}
-func (s *adaptiveSizer) Success(actual int, full bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.current >= s.max || !full {
- return
- }
- s.successes++
- if s.successes < s.growAfter {
- return
- }
- s.successes = 0
- old := s.current
- step := old / 4
- if step < 32 {
- step = 32
- }
- next := old + step
- if next > s.max {
- next = s.max
- }
- if next > old {
- s.current = next
- if s.log {
- fmt.Printf("adaptive %s chunk: %d -> %d after stable success\n", s.name, old, next)
- }
- }
-}
-
-func receiveTunFD(path string, timeout time.Duration) (*os.File, error) {
- _ = os.Remove(path)
- addr := &net.UnixAddr{Name: path, Net: "unix"}
- ln, err := net.ListenUnix("unix", addr)
- if err != nil {
- return nil, err
- }
- defer func() { ln.Close(); os.Remove(path) }()
- _ = os.Chmod(path, 0600)
- fmt.Printf("TUNFD READY %s\n", path)
- _ = ln.SetDeadline(time.Now().Add(timeout))
- c, err := ln.AcceptUnix()
- if err != nil {
- return nil, err
- }
- defer c.Close()
- buf := make([]byte, 1)
- oob := make([]byte, 128)
- n, oobn, _, _, err := c.ReadMsgUnix(buf, oob)
- if err != nil {
- return nil, err
- }
- if n < 1 {
- return nil, errors.New("missing TUN fd marker")
- }
- msgs, err := syscall.ParseSocketControlMessage(oob[:oobn])
- if err != nil {
- return nil, err
- }
- for _, m := range msgs {
- fds, e := syscall.ParseUnixRights(&m)
- if e == nil && len(fds) > 0 {
- return os.NewFile(uintptr(fds[0]), "android-tun"), nil
- }
- }
- return nil, errors.New("TUN file descriptor was not received")
-}
-
-func randomSID() (protocol.VPNSessionID, error) {
- var sid protocol.VPNSessionID
- _, err := io.ReadFull(rand.Reader, sid[:])
- return sid, err
-}
-
-type vpnClient struct {
- tun *os.File
- sid protocol.VPNSessionID
- serverAddr string
- token string
- ipv4, ipv6 netip.Addr
- mtu int
- timeout time.Duration
- batchDelay time.Duration
- reconnectEvery int
- upSizer, downSizer *adaptiveSizer
- control, upload, download *txnLane
- upPackets, downPackets, upBytes, downBytes atomic.Uint64
- upBatches, downBatches, localDropped atomic.Uint64
- stopped chan struct{}
- stopOnce sync.Once
-}
-
-func newVPNClient(tun *os.File, addr, token string, v4, v6 netip.Addr, mtu, start, min, max, growAfter, reconnectEvery int, timeout, batchDelay time.Duration, adaptLog bool) (*vpnClient, error) {
- sid, err := randomSID()
- if err != nil {
- return nil, err
- }
- if batchDelay < 0 {
- batchDelay = 0
- }
- return &vpnClient{tun: tun, sid: sid, serverAddr: addr, token: token, ipv4: v4, ipv6: v6, mtu: mtu, timeout: timeout, batchDelay: batchDelay, reconnectEvery: reconnectEvery,
- upSizer: newSizer("upload", start, min, max, growAfter, adaptLog), downSizer: newSizer("download", start, min, max, growAfter, adaptLog),
- control: newTxnLane(addr, timeout, reconnectEvery), upload: newTxnLane(addr, timeout, reconnectEvery), download: newTxnLane(addr, timeout, reconnectEvery), stopped: make(chan struct{})}, nil
-}
-func (v *vpnClient) open() error {
- req, err := protocol.BuildVPNOpen(v.sid, v.token, v.ipv4, v.ipv6, v.mtu)
- if err != nil {
- return err
- }
- resp, err := doControl(v.control, req)
- if err != nil {
- return err
- }
- max, err := protocol.ParseVPNOpened(resp)
- if err != nil {
- return err
- }
- if max < v.upSizer.max {
- v.upSizer.max = max
- if v.upSizer.current > max {
- v.upSizer.current = max
- }
- }
- if max < v.downSizer.max {
- v.downSizer.max = max
- if v.downSizer.current > max {
- v.downSizer.current = max
- }
- }
- fmt.Printf("VPN SESSION OPEN ipv4=%s ipv6=%s mtu=%d server_chunk_max=%d\n", v.ipv4, v.ipv6, v.mtu, max)
- return nil
-}
-func (v *vpnClient) close() {
- v.stopOnce.Do(func() {
- close(v.stopped)
- if p, err := protocol.BuildVPNClose(v.sid), error(nil); err == nil {
- _, _ = v.control.Do(p)
- }
- v.control.Close()
- v.upload.Close()
- v.download.Close()
- _ = v.tun.Close()
- })
-}
-
-func (v *vpnClient) logLocalDrop(reason string) {
- n := v.localDropped.Add(1)
- // Link-local/control traffic can be noisy. Keep it visible without filling
- // the Android live log or making a harmless packet fatal to the VPN.
- if n <= 8 || n%256 == 0 {
- fmt.Printf("VPN DROP local packet (%s) dropped=%d\n", reason, n)
- }
-}
-
-func (v *vpnClient) tunReadLoop(out chan<- []byte, errs chan<- error) {
- buf := make([]byte, protocol.VPNMaxPacket)
- for {
- n, err := v.tun.Read(buf)
- if err != nil {
- errs <- err
- return
- }
- if n < 1 || n > protocol.VPNMaxPacket {
- continue
- }
- packet := append([]byte(nil), buf[:n]...)
- src, _, err := protocol.PacketAddresses(packet)
- if err != nil {
- v.logLocalDrop(err.Error())
- continue
- }
- if src != v.ipv4 && src != v.ipv6 {
- v.logLocalDrop(fmt.Sprintf("source %s is not assigned VPN address", src))
- continue
- }
- select {
- case out <- packet:
- case <-v.stopped:
- return
- }
- }
-}
-
-func batchWireSize(packets [][]byte) int {
- n := 1
- for _, p := range packets {
- n += 2 + len(p)
- }
- return n
-}
-
-func (v *vpnClient) uploadLoop(in <-chan []byte, errs chan<- error) {
- var seq uint32
- var carry []byte
- for {
- var first []byte
- if carry != nil {
- first, carry = carry, nil
- } else {
- select {
- case first = <-in:
- case <-v.stopped:
- return
- }
- }
-
- packets := [][]byte{first}
- encodedSize := 1 + 2 + len(first)
- timer := time.NewTimer(v.batchDelay)
- collect:
- for encodedSize < protocol.VPNMaxBatch {
- select {
- case p := <-in:
- need := 2 + len(p)
- if encodedSize+need > protocol.VPNMaxBatch {
- carry = p
- break collect
- }
- packets = append(packets, p)
- encodedSize += need
- case <-timer.C:
- break collect
- case <-v.stopped:
- if !timer.Stop() {
- select {
- case <-timer.C:
- default:
- }
- }
- return
- }
- }
- if !timer.Stop() {
- select {
- case <-timer.C:
- default:
- }
- }
-
- batch, err := protocol.BuildVPNBatch(packets)
- if err != nil {
- errs <- err
- return
- }
- offset := 0
- for offset < len(batch) {
- limit := v.upSizer.Current()
- size := len(batch) - offset
- if size > limit {
- size = limit
- }
- req, e := protocol.BuildVPNPush(v.sid, seq, offset, len(batch), batch[offset:offset+size])
- if e != nil {
- errs <- e
- return
- }
- resp, e := v.upload.Do(req)
- if e != nil {
- v.upSizer.Failure(size)
- continue
- }
- rseq, accepted, e := protocol.ParseVPNAck(resp)
- if e != nil {
- errs <- e
- return
- }
- if rseq != seq || accepted < offset || accepted > len(batch) {
- errs <- errors.New("bad server upload ACK")
- return
- }
- v.upSizer.Success(size, size == limit)
- offset = accepted
- }
- var rawBytes uint64
- for _, p := range packets {
- rawBytes += uint64(len(p))
- }
- v.upPackets.Add(uint64(len(packets)))
- v.upBytes.Add(rawBytes)
- v.upBatches.Add(1)
- seq++
- }
-}
-
-func (v *vpnClient) downloadLoop(errs chan<- error) {
- var want uint32
- ack := protocol.VPNNoAck
- offset := 0
- var transfer []byte
- total := 0
- for {
- limit := v.downSizer.Current()
- req, e := protocol.BuildVPNPull(v.sid, ack, want, offset, limit)
- if e != nil {
- errs <- e
- return
- }
- resp, e := v.download.Do(req)
- if e != nil {
- v.downSizer.Failure(limit)
- continue
- }
- seq, roff, rtotal, data, wait, e := protocol.ParseVPNData(resp)
- if e != nil {
- errs <- e
- return
- }
- if wait {
- continue
- }
- if seq != want || roff != offset || rtotal < 1 || rtotal > protocol.VPNMaxBatch {
- errs <- errors.New("bad server download sequence")
- return
- }
- if offset == 0 {
- total = rtotal
- transfer = make([]byte, 0, total)
- } else if rtotal != total {
- errs <- errors.New("download transfer size changed")
- return
- }
- transfer = append(transfer, data...)
- offset += len(data)
- v.downSizer.Success(len(data), len(data) == limit)
- if offset < total {
- continue
- }
- if offset != total {
- errs <- errors.New("download transfer overflow")
- return
- }
- packets, e := protocol.ParseVPNBatch(transfer)
- if e != nil {
- // Compatibility with the first packet-VPN build, which used one raw
- // IP packet as each transfer object.
- if len(transfer) > 0 && (transfer[0]>>4 == 4 || transfer[0]>>4 == 6) {
- packets = [][]byte{transfer}
- } else {
- errs <- e
- return
- }
- }
- var rawBytes uint64
- for _, packet := range packets {
- n, e := v.tun.Write(packet)
- if e != nil {
- errs <- e
- return
- }
- if n != len(packet) {
- errs <- io.ErrShortWrite
- return
- }
- rawBytes += uint64(n)
- }
- v.downPackets.Add(uint64(len(packets)))
- v.downBytes.Add(rawBytes)
- v.downBatches.Add(1)
- ack = want
- want++
- offset = 0
- transfer = nil
- total = 0
- }
-}
-
-func (v *vpnClient) run() error {
- if err := v.open(); err != nil {
- return err
- }
- fmt.Println("VPN READY")
- errs := make(chan error, 3)
- packets := make(chan []byte, 256)
- go v.tunReadLoop(packets, errs)
- go v.uploadLoop(packets, errs)
- go v.downloadLoop(errs)
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case err := <-errs:
- return err
- case <-ticker.C:
- fmt.Printf("STATS up_packets=%d down_packets=%d up_batches=%d down_batches=%d up_bytes=%d down_bytes=%d local_dropped=%d upload_chunk=%d download_chunk=%d pollers=1\n", v.upPackets.Load(), v.downPackets.Load(), v.upBatches.Load(), v.downBatches.Load(), v.upBytes.Load(), v.downBytes.Load(), v.localDropped.Load(), v.upSizer.Current(), v.downSizer.Current())
- case <-v.stopped:
- return nil
- }
- }
-}
-
-func main() {
- serverHost := flag.String("server-host", "", "DragonTCP VPN server host/IP")
- serverPort := flag.Int("server-port", 53, "DragonTCP VPN server TCP port")
- token := flag.String("token", "change-this-token", "shared token")
- tunFDSocket := flag.String("tun-fd-socket", "", "Unix socket path used by Android to pass the VpnService TUN fd")
- tunFD := flag.Int("tun-fd", -1, "existing TUN fd for testing/non-Android use")
- ipv4Text := flag.String("vpn-ipv4", "10.123.0.2", "client VPN IPv4 address")
- ipv6Text := flag.String("vpn-ipv6", "fd7a:4472:6167:6f6e::2", "client VPN IPv6 address")
- mtu := flag.Int("vpn-mtu", 1280, "VPN interface MTU")
- chunkMax := flag.Int("chunk-max", protocol.VPNMaxFragment, "maximum adaptive record bytes (up to 1 MiB)")
- chunkMin := flag.Int("chunk-min", 32, "minimum adaptive record bytes")
- chunkStart := flag.Int("chunk-start", protocol.VPNMaxFragment, "starting record bytes; app sets this equal to max")
- growAfter := flag.Int("chunk-grow-after", 64, "full successful records before increasing chunk size")
- timeout := flag.Duration("chunk-timeout", 2*time.Second, "framed transaction timeout")
- batchDelay := flag.Duration("batch-delay", time.Millisecond, "maximum delay used to combine adjacent TUN packets into one transfer object")
- reconnectEvery := flag.Int("chunk-reconnect-every", 32, "reconnect a TCP/53 lane after this many transactions; 0 keeps it open")
- adaptLog := flag.Bool("chunk-adapt-log", false, "log adaptive chunk changes")
- flag.Parse()
- if *serverHost == "" {
- fmt.Fprintln(os.Stderr, "--server-host is required")
- os.Exit(2)
- }
- if *serverPort < 1 || *serverPort > 65535 {
- fmt.Fprintln(os.Stderr, "invalid server port")
- os.Exit(2)
- }
- if *chunkMin < 32 || *chunkMax > protocol.VPNMaxFragment || *chunkMin > *chunkMax {
- fmt.Fprintf(os.Stderr, "chunks must satisfy 32 <= min <= max <= %d\n", protocol.VPNMaxFragment)
- os.Exit(2)
- }
- if *chunkStart < *chunkMin {
- *chunkStart = *chunkMin
- }
- if *chunkStart > *chunkMax {
- *chunkStart = *chunkMax
- }
- v4, err := netip.ParseAddr(*ipv4Text)
- if err != nil || !v4.Is4() {
- fmt.Fprintln(os.Stderr, "invalid --vpn-ipv4")
- os.Exit(2)
- }
- v6, err := netip.ParseAddr(*ipv6Text)
- if err != nil || !v6.Is6() {
- fmt.Fprintln(os.Stderr, "invalid --vpn-ipv6")
- os.Exit(2)
- }
- var tun *os.File
- if *tunFD >= 0 {
- tun = os.NewFile(uintptr(*tunFD), "tun")
- } else {
- if *tunFDSocket == "" {
- fmt.Fprintln(os.Stderr, "--tun-fd-socket is required on Android")
- os.Exit(2)
- }
- tun, err = receiveTunFD(*tunFDSocket, 10*time.Second)
- if err != nil {
- fmt.Fprintln(os.Stderr, "receive TUN fd:", err)
- os.Exit(1)
- }
- }
- addr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
- client, err := newVPNClient(tun, addr, *token, v4, v6, *mtu, *chunkStart, *chunkMin, *chunkMax, *growAfter, *reconnectEvery, *timeout, *batchDelay, *adaptLog)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- sig := make(chan os.Signal, 1)
- signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
- go func() { <-sig; client.close() }()
- if err := client.run(); err != nil && !errors.Is(err, os.ErrClosed) && !errors.Is(err, net.ErrClosed) {
- fmt.Fprintln(os.Stderr, "VPN stopped:", err)
- client.close()
- os.Exit(1)
- }
- client.close()
-}
diff --git a/core/cmd/dragontcp-vpn-server/main.go b/core/cmd/dragontcp-vpn-server/main.go
deleted file mode 100644
index 4487dbf..0000000
--- a/core/cmd/dragontcp-vpn-server/main.go
+++ /dev/null
@@ -1,841 +0,0 @@
-package main
-
-import (
- "bytes"
- "crypto/subtle"
- "encoding/hex"
- "errors"
- "flag"
- "fmt"
- "io"
- "net"
- "net/netip"
- "os"
- "os/exec"
- "os/signal"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "syscall"
- "time"
- "unsafe"
-
- "dragontcpvpn/internal/protocol"
-)
-
-const (
- defaultVPNv4Prefix = "10.123.0.0/16"
- defaultVPNv6Prefix = "fd7a:4472:6167:6f6e::/64"
-)
-
-type debugStats struct {
- enabled bool
- packets bool
- started time.Time
- activeConns atomic.Int64
- activeSessions atomic.Int64
- upPackets atomic.Uint64
- downPackets atomic.Uint64
- upBatches atomic.Uint64
- downBatches atomic.Uint64
- upBytes atomic.Uint64
- downBytes atomic.Uint64
- dropped atomic.Uint64
- errors atomic.Uint64
-}
-
-func (d *debugStats) logf(format string, args ...any) {
- if d != nil && d.enabled {
- fmt.Printf("[DEBUG] "+format+"\n", args...)
- }
-}
-func (d *debugStats) packetf(format string, args ...any) {
- if d != nil && d.packets {
- fmt.Printf("[PACKET] "+format+"\n", args...)
- }
-}
-func (d *debugStats) errorf(format string, args ...any) {
- if d != nil {
- d.errors.Add(1)
- if d.enabled {
- fmt.Printf("[ERROR] "+format+"\n", args...)
- }
- }
-}
-
-func tokenEqual(a, b string) bool {
- if len(a) != len(b) {
- return false
- }
- return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
-}
-
-type vpnSession struct {
- sid protocol.VPNSessionID
- ipv4 netip.Addr
- ipv6 netip.Addr
- mtu int
- maxChunk int
- maxPackets int
- maxQueueBytes int
- batchDelay time.Duration
- manager *vpnManager
-
- mu sync.Mutex
- notify chan struct{}
- packets map[uint32]*downTransfer
- nextDown uint32
- closed bool
- lastSeen time.Time
- pendingPackets [][]byte
- pendingEncoded int
- pendingTimer *time.Timer
- queuedPacketCount int
- queuedBytes int
-
- upMu sync.Mutex
- expectedUp uint32
- currentSeq uint32
- currentTotal int
- currentBuf []byte
- haveCurrent bool
- lastComplete uint32
- lastCompleteTotal int
- haveLastComplete bool
-}
-
-type downTransfer struct {
- data []byte
- packetCount int
- rawBytes int
-}
-
-func newVPNSession(m *vpnManager, sid protocol.VPNSessionID, v4, v6 netip.Addr, mtu, maxChunk, maxPackets, maxQueueBytes int, batchDelay time.Duration) *vpnSession {
- return &vpnSession{
- sid: sid, ipv4: v4, ipv6: v6, mtu: mtu, maxChunk: maxChunk, maxPackets: maxPackets, maxQueueBytes: maxQueueBytes, batchDelay: batchDelay,
- manager: m, notify: make(chan struct{}), packets: make(map[uint32]*downTransfer, maxPackets), lastSeen: time.Now(),
- }
-}
-
-func (s *vpnSession) signalLocked() {
- close(s.notify)
- s.notify = make(chan struct{})
-}
-func (s *vpnSession) touchLocked() { s.lastSeen = time.Now() }
-func (s *vpnSession) touch() { s.mu.Lock(); s.touchLocked(); s.mu.Unlock() }
-
-func (s *vpnSession) flushPendingLocked() {
- if len(s.pendingPackets) == 0 {
- return
- }
- if s.pendingTimer != nil {
- s.pendingTimer.Stop()
- s.pendingTimer = nil
- }
- batch, err := protocol.BuildVPNBatch(s.pendingPackets)
- if err != nil {
- if s.manager.debug != nil {
- s.manager.debug.dropped.Add(uint64(len(s.pendingPackets)))
- s.manager.debug.errorf("BATCH sid=%s: %v", shortSID(s.sid), err)
- }
- s.queuedPacketCount -= len(s.pendingPackets)
- for _, p := range s.pendingPackets {
- s.queuedBytes -= len(p)
- }
- s.pendingPackets = nil
- s.pendingEncoded = 0
- return
- }
- rawBytes := 0
- for _, p := range s.pendingPackets {
- rawBytes += len(p)
- }
- seq := s.nextDown
- s.nextDown++
- s.packets[seq] = &downTransfer{data: batch, packetCount: len(s.pendingPackets), rawBytes: rawBytes}
- if s.manager.debug != nil {
- s.manager.debug.downBatches.Add(1)
- s.manager.debug.packetf("BATCH QUEUE sid=%s seq=%d packets=%d raw_bytes=%d transfer_bytes=%d", shortSID(s.sid), seq, len(s.pendingPackets), rawBytes, len(batch))
- }
- s.pendingPackets = nil
- s.pendingEncoded = 0
- s.signalLocked()
-}
-
-func (s *vpnSession) flushPending() {
- s.mu.Lock()
- if !s.closed {
- s.flushPendingLocked()
- }
- s.mu.Unlock()
-}
-
-func (s *vpnSession) enqueue(packet []byte) bool {
- if len(packet) == 0 || len(packet) > protocol.VPNMaxPacket {
- return false
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.closed {
- return false
- }
- need := 2 + len(packet)
- if len(s.pendingPackets) > 0 && s.pendingEncoded+need > protocol.VPNMaxBatch {
- s.flushPendingLocked()
- }
- if s.queuedPacketCount >= s.maxPackets || s.queuedBytes+len(packet) > s.maxQueueBytes {
- if s.manager.debug != nil {
- s.manager.debug.dropped.Add(1)
- }
- return false
- }
- p := append([]byte(nil), packet...)
- if len(s.pendingPackets) == 0 {
- s.pendingEncoded = 1
- }
- s.pendingPackets = append(s.pendingPackets, p)
- s.pendingEncoded += 2 + len(p)
- s.queuedPacketCount++
- s.queuedBytes += len(p)
- s.touchLocked()
- if s.manager.debug != nil {
- s.manager.debug.downPackets.Add(1)
- s.manager.debug.downBytes.Add(uint64(len(packet)))
- s.manager.debug.packetf("QUEUE sid=%s bytes=%d pending_packets=%d pending_transfer=%d", shortSID(s.sid), len(packet), len(s.pendingPackets), s.pendingEncoded)
- }
- if s.pendingEncoded >= protocol.VPNMaxBatch {
- s.flushPendingLocked()
- } else if s.pendingTimer == nil {
- delay := s.batchDelay
- if delay <= 0 {
- s.flushPendingLocked()
- } else {
- s.pendingTimer = time.AfterFunc(delay, s.flushPending)
- }
- }
- return true
-}
-
-func (s *vpnSession) push(seq uint32, offset, total int, data []byte) (int, error) {
- s.upMu.Lock()
- defer s.upMu.Unlock()
- if total < 1 || total > protocol.VPNMaxBatch || len(data) < 1 || len(data) > s.maxChunk || offset < 0 || offset+len(data) > total {
- return 0, errors.New("invalid transfer fragment")
- }
-
- if s.haveLastComplete && seq == s.lastComplete {
- s.touch()
- return s.lastCompleteTotal, nil
- }
- if seq < s.expectedUp {
- return 0, fmt.Errorf("old upload sequence %d", seq)
- }
- if seq > s.expectedUp {
- return 0, fmt.Errorf("upload sequence %d expected %d", seq, s.expectedUp)
- }
-
- if !s.haveCurrent {
- if offset != 0 {
- return 0, errors.New("first fragment offset must be zero")
- }
- s.haveCurrent = true
- s.currentSeq = seq
- s.currentTotal = total
- s.currentBuf = make([]byte, 0, total)
- }
- if s.currentSeq != seq || s.currentTotal != total {
- return 0, errors.New("transfer fragment metadata changed")
- }
-
- // Idempotent retry: if this exact offset was already accepted, acknowledge
- // the existing bytes instead of appending duplicate data.
- if offset < len(s.currentBuf) {
- end := offset + len(data)
- if end <= len(s.currentBuf) && bytes.Equal(s.currentBuf[offset:end], data) {
- return len(s.currentBuf), nil
- }
- return 0, errors.New("retry fragment does not match accepted data")
- }
- if offset != len(s.currentBuf) {
- return 0, fmt.Errorf("fragment offset %d expected %d", offset, len(s.currentBuf))
- }
-
- s.currentBuf = append(s.currentBuf, data...)
- accepted := len(s.currentBuf)
- if accepted < total {
- s.touch()
- return accepted, nil
- }
-
- transfer := append([]byte(nil), s.currentBuf...)
- s.haveCurrent = false
- s.currentBuf = nil
-
- if err := s.manager.acceptClientTransfer(s, transfer); err != nil {
- return 0, err
- }
-
- s.lastComplete = seq
- s.lastCompleteTotal = total
- s.haveLastComplete = true
- s.expectedUp++
- s.touch()
- if s.manager.debug != nil {
- s.manager.debug.upBatches.Add(1)
- s.manager.debug.packetf("UP BATCH sid=%s seq=%d transfer_bytes=%d", shortSID(s.sid), seq, len(transfer))
- }
- return accepted, nil
-}
-
-func (s *vpnSession) pull(ack, want uint32, offset, limit int, wait time.Duration) ([]byte, int, bool, error) {
- if offset < 0 || limit < 1 || limit > s.maxChunk {
- return nil, 0, false, errors.New("invalid pull")
- }
- timer := time.NewTimer(wait)
- defer timer.Stop()
- for {
- s.mu.Lock()
- s.touchLocked()
- if ack != protocol.VPNNoAck {
- for seq := range s.packets {
- if seq <= ack {
- rec := s.packets[seq]
- if rec != nil {
- s.queuedPacketCount -= rec.packetCount
- s.queuedBytes -= rec.rawBytes
- }
- delete(s.packets, seq)
- }
- }
- }
- if rec, ok := s.packets[want]; ok {
- if offset >= len(rec.data) {
- s.mu.Unlock()
- return nil, len(rec.data), false, errors.New("pull offset beyond transfer")
- }
- end := offset + limit
- if end > len(rec.data) {
- end = len(rec.data)
- }
- out := append([]byte(nil), rec.data[offset:end]...)
- total := len(rec.data)
- s.mu.Unlock()
- return out, total, false, nil
- }
- if s.closed {
- s.mu.Unlock()
- return nil, 0, false, net.ErrClosed
- }
- ch := s.notify
- s.mu.Unlock()
- select {
- case <-ch:
- case <-timer.C:
- return nil, 0, true, nil
- }
- }
-}
-
-func (s *vpnSession) close() {
- s.mu.Lock()
- if !s.closed {
- s.closed = true
- if s.pendingTimer != nil {
- s.pendingTimer.Stop()
- s.pendingTimer = nil
- }
- s.signalLocked()
- }
- s.mu.Unlock()
-}
-
-type vpnManager struct {
- mu sync.RWMutex
- sessions map[protocol.VPNSessionID]*vpnSession
- byIPv4 map[netip.Addr]*vpnSession
- byIPv6 map[netip.Addr]*vpnSession
- maxChunk int
- maxPackets int
- maxQueueBytes int
- batchDelay time.Duration
- pollWait time.Duration
- timeout time.Duration
- tun *os.File
- tunWriteMu sync.Mutex
- mockEcho bool
- allowPrivate bool
- debug *debugStats
- v4Prefix netip.Prefix
- v6Prefix netip.Prefix
-}
-
-func newVPNManager(tun *os.File, mockEcho bool, maxChunk, maxPackets, maxQueueBytes int, pollWait, timeout, batchDelay time.Duration, allowPrivate bool, debug *debugStats) *vpnManager {
- v4p := netip.MustParsePrefix(defaultVPNv4Prefix)
- v6p := netip.MustParsePrefix(defaultVPNv6Prefix)
- m := &vpnManager{
- sessions: make(map[protocol.VPNSessionID]*vpnSession), byIPv4: make(map[netip.Addr]*vpnSession), byIPv6: make(map[netip.Addr]*vpnSession),
- maxChunk: maxChunk, maxPackets: maxPackets, maxQueueBytes: maxQueueBytes, pollWait: pollWait, timeout: timeout, batchDelay: batchDelay, tun: tun, mockEcho: mockEcho, allowPrivate: allowPrivate, debug: debug,
- v4Prefix: v4p, v6Prefix: v6p,
- }
- if tun != nil {
- go m.tunReadLoop()
- }
- go m.cleanupLoop()
- return m
-}
-
-func (m *vpnManager) addOrGet(sid protocol.VPNSessionID, v4, v6 netip.Addr, mtu int) (*vpnSession, error) {
- if !m.v4Prefix.Contains(v4) || v4 == netip.MustParseAddr("10.123.0.1") {
- return nil, errors.New("client IPv4 outside DragonTCP subnet")
- }
- if !m.v6Prefix.Contains(v6) || v6 == netip.MustParseAddr("fd7a:4472:6167:6f6e::1") {
- return nil, errors.New("client IPv6 outside DragonTCP subnet")
- }
- if mtu < 576 || mtu > 9000 {
- return nil, errors.New("invalid client MTU")
- }
-
- m.mu.Lock()
- defer m.mu.Unlock()
- if old := m.sessions[sid]; old != nil {
- if old.ipv4 != v4 || old.ipv6 != v6 {
- return nil, errors.New("session address mismatch")
- }
- old.touch()
- return old, nil
- }
- if m.byIPv4[v4] != nil || m.byIPv6[v6] != nil {
- return nil, errors.New("client VPN address already in use")
- }
- s := newVPNSession(m, sid, v4, v6, mtu, m.maxChunk, m.maxPackets, m.maxQueueBytes, m.batchDelay)
- m.sessions[sid] = s
- m.byIPv4[v4] = s
- m.byIPv6[v6] = s
- if m.debug != nil {
- m.debug.activeSessions.Add(1)
- m.debug.logf("SESSION OPEN sid=%s ipv4=%s ipv6=%s mtu=%d", shortSID(sid), v4, v6, mtu)
- }
- return s, nil
-}
-
-func (m *vpnManager) get(sid protocol.VPNSessionID) *vpnSession {
- m.mu.RLock()
- s := m.sessions[sid]
- m.mu.RUnlock()
- return s
-}
-func (m *vpnManager) remove(sid protocol.VPNSessionID) {
- m.mu.Lock()
- s := m.sessions[sid]
- if s != nil {
- delete(m.sessions, sid)
- delete(m.byIPv4, s.ipv4)
- delete(m.byIPv6, s.ipv6)
- }
- m.mu.Unlock()
- if s != nil {
- s.close()
- if m.debug != nil {
- m.debug.activeSessions.Add(-1)
- m.debug.logf("SESSION CLOSE sid=%s", shortSID(sid))
- }
- }
-}
-
-func (m *vpnManager) cleanupLoop() {
- ticker := time.NewTicker(30 * time.Second)
- defer ticker.Stop()
- for range ticker.C {
- cutoff := time.Now().Add(-m.timeout)
- var stale []protocol.VPNSessionID
- m.mu.RLock()
- for sid, s := range m.sessions {
- s.mu.Lock()
- last := s.lastSeen
- closed := s.closed
- s.mu.Unlock()
- if closed || last.Before(cutoff) {
- stale = append(stale, sid)
- }
- }
- m.mu.RUnlock()
- for _, sid := range stale {
- m.remove(sid)
- }
- }
-}
-
-func destinationAllowed(dst netip.Addr, allowPrivate bool) bool {
- if dst.IsUnspecified() || dst.IsMulticast() {
- return false
- }
- if allowPrivate {
- return true
- }
- if dst.IsLoopback() || dst.IsLinkLocalUnicast() || dst.IsPrivate() {
- return false
- }
- return true
-}
-
-func (m *vpnManager) dropClientPacket(s *vpnSession, packet []byte, reason string) {
- if m.debug != nil {
- m.debug.dropped.Add(1)
- m.debug.packetf("DROP sid=%s bytes=%d reason=%s", shortSID(s.sid), len(packet), reason)
- // A source mismatch can be normal Android link-local/control traffic.
- // Never tear down the whole VPN session for one such packet.
- m.debug.logf("DROP sid=%s reason=%s", shortSID(s.sid), reason)
- }
-}
-
-func (m *vpnManager) acceptClientPacket(s *vpnSession, packet []byte) (bool, error) {
- src, dst, err := protocol.PacketAddresses(packet)
- if err != nil {
- m.dropClientPacket(s, packet, err.Error())
- return false, nil
- }
- if src != s.ipv4 && src != s.ipv6 {
- m.dropClientPacket(s, packet, fmt.Sprintf("source %s does not match session address", src))
- return false, nil
- }
- if !destinationAllowed(dst, m.allowPrivate) {
- m.dropClientPacket(s, packet, fmt.Sprintf("destination %s is blocked", dst))
- return false, nil
- }
- if m.mockEcho {
- s.enqueue(packet)
- return true, nil
- }
- if m.tun == nil {
- return false, errors.New("VPN TUN is unavailable")
- }
- m.tunWriteMu.Lock()
- n, err := m.tun.Write(packet)
- m.tunWriteMu.Unlock()
- if err != nil {
- return false, err
- }
- if n != len(packet) {
- return false, io.ErrShortWrite
- }
- return true, nil
-}
-
-func (m *vpnManager) acceptClientTransfer(s *vpnSession, transfer []byte) error {
- packets, err := protocol.ParseVPNBatch(transfer)
- if err != nil {
- // Compatibility with the first packet-VPN build.
- if len(transfer) > 0 && (transfer[0]>>4 == 4 || transfer[0]>>4 == 6) {
- packets = [][]byte{transfer}
- } else {
- return err
- }
- }
- for _, packet := range packets {
- accepted, err := m.acceptClientPacket(s, packet)
- if err != nil {
- return err
- }
- if accepted && m.debug != nil {
- m.debug.upPackets.Add(1)
- m.debug.upBytes.Add(uint64(len(packet)))
- }
- }
- return nil
-}
-
-func (m *vpnManager) tunReadLoop() {
- buf := make([]byte, 65535)
- for {
- n, err := m.tun.Read(buf)
- if err != nil {
- if m.debug != nil {
- m.debug.errorf("TUN read: %v", err)
- }
- return
- }
- if n < 1 {
- continue
- }
- packet := append([]byte(nil), buf[:n]...)
- _, dst, e := protocol.PacketAddresses(packet)
- if e != nil {
- continue
- }
- m.mu.RLock()
- var s *vpnSession
- if dst.Is4() {
- s = m.byIPv4[dst]
- } else {
- s = m.byIPv6[dst]
- }
- m.mu.RUnlock()
- if s != nil {
- s.enqueue(packet)
- }
- }
-}
-
-func shortSID(sid protocol.VPNSessionID) string { return hex.EncodeToString(sid[:4]) }
-
-func processVPN(conn net.Conn, requestID uint32, payload []byte, token string, m *vpnManager) error {
- switch payload[0] {
- case protocol.VPNCmdOpen:
- sid, tok, v4, v6, mtu, err := protocol.ParseVPNOpen(payload)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- if !tokenEqual(tok, token) {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("authentication failed"))
- }
- _, err = m.addOrGet(sid, v4, v6, mtu)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- return protocol.WriteResponseFrame(conn, requestID, protocol.BuildVPNOpened(m.maxChunk))
- case protocol.VPNCmdPush:
- sid, seq, offset, total, data, err := protocol.ParseVPNPush(payload)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- s := m.get(sid)
- if s == nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("unknown VPN session"))
- }
- accepted, err := s.push(seq, offset, total, data)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- return protocol.WriteResponseFrame(conn, requestID, protocol.BuildVPNAck(seq, accepted))
- case protocol.VPNCmdPull:
- sid, ack, want, offset, limit, err := protocol.ParseVPNPull(payload)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- s := m.get(sid)
- if s == nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("unknown VPN session"))
- }
- if limit > s.maxChunk {
- limit = s.maxChunk
- }
- data, total, wait, err := s.pull(ack, want, offset, limit, m.pollWait)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- if wait {
- return protocol.WriteResponseFrame(conn, requestID, []byte{protocol.VPNRespWait})
- }
- m.debug.packetf("DOWN sid=%s seq=%d offset=%d bytes=%d total=%d", shortSID(sid), want, offset, len(data), total)
- return protocol.WriteResponseFrame(conn, requestID, protocol.BuildVPNData(want, offset, total, data))
- case protocol.VPNCmdClose:
- sid, err := protocol.ParseVPNClose(payload)
- if err != nil {
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error()))
- }
- m.remove(sid)
- return protocol.WriteResponseFrame(conn, requestID, []byte{protocol.VPNRespClosed})
- default:
- return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("unknown VPN command"))
- }
-}
-
-func handleConn(conn net.Conn, token string, m *vpnManager, slots chan struct{}, debug *debugStats) {
- defer func() { <-slots; debug.activeConns.Add(-1); _ = conn.Close() }()
- protocol.TuneTCP(conn)
- for {
- _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
- requestID, _, payload, err := protocol.ReadRequestFrame(conn)
- if err != nil {
- if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
- debug.errorf("peer=%v read: %v", conn.RemoteAddr(), err)
- }
- return
- }
- if !protocol.IsVPNCommand(payload) {
- _ = protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("this binary accepts DragonTCP VPN packet commands only"))
- continue
- }
- if err := processVPN(conn, requestID, payload, token, m); err != nil {
- return
- }
- }
-}
-
-// Linux TUN setup.
-type ifreq struct {
- Name [16]byte
- Flags uint16
- _ [22]byte
-}
-
-const tunSetIFF = 0x400454ca
-const iffTun = 0x0001
-const iffNoPI = 0x1000
-
-func openTun(name string) (*os.File, error) {
- fd, err := syscall.Open("/dev/net/tun", syscall.O_RDWR|syscall.O_CLOEXEC, 0)
- if err != nil {
- return nil, err
- }
- var req ifreq
- copy(req.Name[:], []byte(name))
- req.Flags = iffTun | iffNoPI
- _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tunSetIFF), uintptr(unsafe.Pointer(&req)))
- if errno != 0 {
- syscall.Close(fd)
- return nil, errno
- }
- return os.NewFile(uintptr(fd), name), nil
-}
-
-func run(cmd string, args ...string) error {
- c := exec.Command(cmd, args...)
- out, err := c.CombinedOutput()
- if err != nil {
- return fmt.Errorf("%s %s: %v: %s", cmd, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
- }
- return nil
-}
-func runOptional(debug *debugStats, cmd string, args ...string) {
- if err := run(cmd, args...); err != nil {
- debug.logf("optional command failed: %v", err)
- }
-}
-func ensureRule(debug *debugStats, binary string, argsCheck, argsAdd []string) {
- if err := exec.Command(binary, argsCheck...).Run(); err == nil {
- return
- }
- if err := run(binary, argsAdd...); err != nil {
- debug.logf("NAT rule warning: %v", err)
- }
-}
-func setupLinuxVPN(tunName string, mtu int, autoNAT bool, debug *debugStats) (*os.File, error) {
- tun, err := openTun(tunName)
- if err != nil {
- return nil, fmt.Errorf("open /dev/net/tun: %w", err)
- }
- fail := func(e error) (*os.File, error) { tun.Close(); return nil, e }
- if err := run("ip", "link", "set", "dev", tunName, "mtu", strconv.Itoa(mtu)); err != nil {
- return fail(err)
- }
- if err := run("ip", "addr", "replace", "10.123.0.1/16", "dev", tunName); err != nil {
- return fail(err)
- }
- // IPv6 may be disabled on some hosts; report clearly instead of silently bypassing it.
- if err := run("ip", "-6", "addr", "replace", "fd7a:4472:6167:6f6e::1/64", "dev", tunName); err != nil {
- return fail(err)
- }
- if err := run("ip", "link", "set", "dev", tunName, "up"); err != nil {
- return fail(err)
- }
- if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0644); err != nil {
- return fail(fmt.Errorf("enable IPv4 forwarding: %w", err))
- }
- if err := os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1\n"), 0644); err != nil {
- return fail(fmt.Errorf("enable IPv6 forwarding: %w", err))
- }
- if autoNAT {
- if _, err := exec.LookPath("iptables"); err != nil {
- return fail(errors.New("iptables not found; install iptables or start with --auto-nat=false and configure NAT yourself"))
- }
- ensureRule(debug, "iptables", []string{"-t", "nat", "-C", "POSTROUTING", "-s", "10.123.0.0/16", "-j", "MASQUERADE"}, []string{"-t", "nat", "-A", "POSTROUTING", "-s", "10.123.0.0/16", "-j", "MASQUERADE"})
- ensureRule(debug, "iptables", []string{"-C", "FORWARD", "-i", tunName, "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-i", tunName, "-j", "ACCEPT"})
- ensureRule(debug, "iptables", []string{"-C", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"})
- if _, err := exec.LookPath("ip6tables"); err == nil {
- ensureRule(debug, "ip6tables", []string{"-t", "nat", "-C", "POSTROUTING", "-s", "fd7a:4472:6167:6f6e::/64", "-j", "MASQUERADE"}, []string{"-t", "nat", "-A", "POSTROUTING", "-s", "fd7a:4472:6167:6f6e::/64", "-j", "MASQUERADE"})
- ensureRule(debug, "ip6tables", []string{"-C", "FORWARD", "-i", tunName, "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-i", tunName, "-j", "ACCEPT"})
- ensureRule(debug, "ip6tables", []string{"-C", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"})
- } else {
- debug.logf("WARNING: ip6tables not found; IPv6 Internet access needs manual routing/NAT")
- }
- }
- return tun, nil
-}
-
-func main() {
- host := flag.String("host", "0.0.0.0", "listen host")
- port := flag.Int("port", 53, "listen TCP port")
- token := flag.String("token", "change-this-token", "shared token")
- maxConnections := flag.Int("max-connections", 20000, "maximum simultaneous TCP/53 connections")
- maxChunk := flag.Int("chunk-max", protocol.VPNMaxFragment, "maximum DragonTCP transport fragment bytes (32-1048576)")
- maxPackets := flag.Int("vpn-buffered-packets", 2048, "maximum queued return IP packets per client")
- maxQueueBytes := flag.Int("vpn-buffer-bytes", 8*1024*1024, "maximum queued raw return bytes per client")
- batchDelay := flag.Duration("batch-delay", time.Millisecond, "maximum delay to combine adjacent TUN packets into one transfer object")
- pollWait := flag.Duration("poll-wait", 100*time.Millisecond, "long-poll wait for a return packet")
- sessionTimeout := flag.Duration("session-timeout", 5*time.Minute, "idle VPN session timeout")
- tunName := flag.String("tun", "dragontcp0", "Linux TUN interface name")
- mtu := flag.Int("mtu", 1280, "server TUN MTU")
- autoNAT := flag.Bool("auto-nat", true, "configure IPv4/IPv6 forwarding and iptables MASQUERADE")
- allowPrivate := flag.Bool("allow-private", false, "allow VPN clients to access private/link-local destinations")
- mockEcho := flag.Bool("mock-echo", false, "test mode: echo client IP packets back instead of using Linux TUN/NAT")
- debugOn := flag.Bool("debug", false, "debug sessions and statistics")
- debugPackets := flag.Bool("debug-packets", false, "very verbose per-IP-packet logging")
- statsEvery := flag.Duration("debug-stats-interval", 10*time.Second, "debug statistics interval; 0 disables")
- flag.Parse()
- if *maxChunk < 32 || *maxChunk > protocol.VPNMaxFragment {
- fmt.Fprintf(os.Stderr, "--chunk-max must be 32-%d\n", protocol.VPNMaxFragment)
- os.Exit(2)
- }
- if *maxPackets < 1 || *maxQueueBytes < protocol.VPNMaxPacket {
- fmt.Fprintln(os.Stderr, "invalid VPN buffer limits")
- os.Exit(2)
- }
- if *mtu < 576 || *mtu > 9000 {
- fmt.Fprintln(os.Stderr, "--mtu must be 576-9000")
- os.Exit(2)
- }
- debug := &debugStats{enabled: *debugOn, packets: *debugPackets, started: time.Now()}
- var tun *os.File
- var err error
- if !*mockEcho {
- tun, err = setupLinuxVPN(*tunName, *mtu, *autoNAT, debug)
- if err != nil {
- fmt.Fprintln(os.Stderr, "VPN setup failed:", err)
- os.Exit(1)
- }
- defer tun.Close()
- }
- manager := newVPNManager(tun, *mockEcho, *maxChunk, *maxPackets, *maxQueueBytes, *pollWait, *sessionTimeout, *batchDelay, *allowPrivate, debug)
- addr := net.JoinHostPort(*host, strconv.Itoa(*port))
- ln, err := net.Listen("tcp", addr)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- defer ln.Close()
- fmt.Printf("DragonTCP VPN server listening on %s\n", addr)
- if *mockEcho {
- fmt.Println("mode=mock-echo (no Internet forwarding)")
- } else {
- fmt.Printf("tun=%s mtu=%d IPv4=10.123.0.1/16 IPv6=fd7a:4472:6167:6f6e::1/64 auto_nat=%t\n", *tunName, *mtu, *autoNAT)
- }
- fmt.Printf("chunk_max=%d batch_max=%d batch_delay=%s poll_wait=%s buffered_packets=%d buffer_bytes=%d\n", *maxChunk, protocol.VPNMaxBatch, batchDelay.String(), pollWait.String(), *maxPackets, *maxQueueBytes)
- if debug.enabled && *statsEvery > 0 {
- go func() {
- t := time.NewTicker(*statsEvery)
- defer t.Stop()
- for range t.C {
- fmt.Printf("[DEBUG] STATS uptime=%s conns=%d sessions=%d up_packets=%d down_packets=%d up_batches=%d down_batches=%d up_bytes=%d down_bytes=%d dropped=%d errors=%d\n", time.Since(debug.started).Round(time.Second), debug.activeConns.Load(), debug.activeSessions.Load(), debug.upPackets.Load(), debug.downPackets.Load(), debug.upBatches.Load(), debug.downBatches.Load(), debug.upBytes.Load(), debug.downBytes.Load(), debug.dropped.Load(), debug.errors.Load())
- }
- }()
- }
- sig := make(chan os.Signal, 1)
- signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
- go func() { <-sig; fmt.Println("Stopping DragonTCP VPN server..."); ln.Close() }()
- slots := make(chan struct{}, *maxConnections)
- for {
- conn, err := ln.Accept()
- if err != nil {
- break
- }
- select {
- case slots <- struct{}{}:
- debug.activeConns.Add(1)
- go handleConn(conn, *token, manager, slots, debug)
- default:
- _ = conn.Close()
- }
- }
-}
diff --git a/core/go.mod b/core/go.mod
index 5de27cd..a8d2a13 100644
--- a/core/go.mod
+++ b/core/go.mod
@@ -1,3 +1,3 @@
-module dragontcpvpn
+module dragontcp
go 1.22
diff --git a/core/internal/protocol/vpnproto.go b/core/internal/protocol/vpnproto.go
deleted file mode 100644
index 377de9f..0000000
--- a/core/internal/protocol/vpnproto.go
+++ /dev/null
@@ -1,376 +0,0 @@
-package protocol
-
-import (
- "encoding/binary"
- "errors"
- "fmt"
- "net/netip"
-)
-
-const (
- VPNCmdOpen byte = 0x30
- VPNCmdPush byte = 0x31
- VPNCmdPull byte = 0x32
- VPNCmdClose byte = 0x33
-
- VPNRespOpened byte = 0x40
- VPNRespAck byte = 0x41
- VPNRespData byte = 0x42
- VPNRespWait byte = 0x43
- VPNRespClosed byte = 0x44
- VPNRespError byte = 0x7f
-
- VPNNoAck uint32 = 0xffffffff
-
- // Raw IP packets remain bounded by the IPv4/IPv6 packet-length model.
- VPNMaxPacket = 65535
-
- // DragonTCP transfer objects/records are independent of IP packet size.
- // Multiple IP packets may be batched into one transfer object.
- VPNMaxFragment = 1024 * 1024
- VPNMaxBatch = 1024 * 1024
-
- VPNBatchVersion byte = 1
-)
-
-type VPNSessionID [16]byte
-
-func VPNError(message string) []byte {
- b := []byte(message)
- if len(b) > 4096 {
- b = b[:4096]
- }
- out := make([]byte, 1+len(b))
- out[0] = VPNRespError
- copy(out[1:], b)
- return out
-}
-
-func ParseVPNError(payload []byte) error {
- if len(payload) == 0 {
- return errors.New("empty DragonTCP VPN response")
- }
- if payload[0] == VPNRespError {
- return errors.New(string(payload[1:]))
- }
- return nil
-}
-
-// OPEN request:
-// cmd(1) sid(16) tokenLen(2) token(N) ipv4(4) ipv6(16) mtu(2)
-func BuildVPNOpen(sid VPNSessionID, token string, ipv4, ipv6 netip.Addr, mtu int) ([]byte, error) {
- if len(token) > 4096 {
- return nil, errors.New("token too long")
- }
- if !ipv4.Is4() || !ipv6.Is6() {
- return nil, errors.New("invalid VPN client addresses")
- }
- if mtu < 576 || mtu > VPNMaxPacket {
- return nil, errors.New("invalid VPN MTU")
- }
- out := make([]byte, 1+16+2+len(token)+4+16+2)
- out[0] = VPNCmdOpen
- copy(out[1:17], sid[:])
- binary.BigEndian.PutUint16(out[17:19], uint16(len(token)))
- pos := 19
- copy(out[pos:pos+len(token)], token)
- pos += len(token)
- v4 := ipv4.As4()
- copy(out[pos:pos+4], v4[:])
- pos += 4
- v6 := ipv6.As16()
- copy(out[pos:pos+16], v6[:])
- pos += 16
- binary.BigEndian.PutUint16(out[pos:pos+2], uint16(mtu))
- return out, nil
-}
-
-func ParseVPNOpen(payload []byte) (sid VPNSessionID, token string, ipv4, ipv6 netip.Addr, mtu int, err error) {
- if len(payload) < 1+16+2+4+16+2 || payload[0] != VPNCmdOpen {
- err = errors.New("bad VPN OPEN")
- return
- }
- copy(sid[:], payload[1:17])
- tokenLen := int(binary.BigEndian.Uint16(payload[17:19]))
- need := 1 + 16 + 2 + tokenLen + 4 + 16 + 2
- if len(payload) != need {
- err = errors.New("bad VPN OPEN length")
- return
- }
- pos := 19
- token = string(payload[pos : pos+tokenLen])
- pos += tokenLen
- var a4 [4]byte
- copy(a4[:], payload[pos:pos+4])
- ipv4 = netip.AddrFrom4(a4)
- pos += 4
- var a6 [16]byte
- copy(a6[:], payload[pos:pos+16])
- ipv6 = netip.AddrFrom16(a6)
- pos += 16
- mtu = int(binary.BigEndian.Uint16(payload[pos : pos+2]))
- return
-}
-
-// OPENED v2 response: cmd(1) maxChunk(4).
-// ParseVPNOpened also accepts the old 3-byte/uint16 response for compatibility.
-func BuildVPNOpened(maxChunk int) []byte {
- if maxChunk > VPNMaxFragment {
- maxChunk = VPNMaxFragment
- }
- if maxChunk < 1 {
- maxChunk = 1
- }
- out := make([]byte, 5)
- out[0] = VPNRespOpened
- binary.BigEndian.PutUint32(out[1:5], uint32(maxChunk))
- return out
-}
-
-func ParseVPNOpened(payload []byte) (int, error) {
- if err := ParseVPNError(payload); err != nil {
- return 0, err
- }
- if len(payload) == 5 && payload[0] == VPNRespOpened {
- v := int(binary.BigEndian.Uint32(payload[1:5]))
- if v < 1 || v > VPNMaxFragment {
- return 0, errors.New("bad VPN OPENED max chunk")
- }
- return v, nil
- }
- if len(payload) == 3 && payload[0] == VPNRespOpened {
- return int(binary.BigEndian.Uint16(payload[1:3])), nil
- }
- return 0, errors.New("bad VPN OPENED response")
-}
-
-// PUSH v2 request: cmd(1) sid(16) seq(4) offset(4) total(4) data(N)
-func BuildVPNPush(sid VPNSessionID, seq uint32, offset, total int, data []byte) ([]byte, error) {
- if total < 1 || total > VPNMaxBatch || offset < 0 || offset > total || len(data) < 1 || offset+len(data) > total || len(data) > VPNMaxFragment {
- return nil, errors.New("invalid VPN PUSH fragment")
- }
- out := make([]byte, 29+len(data))
- out[0] = VPNCmdPush
- copy(out[1:17], sid[:])
- binary.BigEndian.PutUint32(out[17:21], seq)
- binary.BigEndian.PutUint32(out[21:25], uint32(offset))
- binary.BigEndian.PutUint32(out[25:29], uint32(total))
- copy(out[29:], data)
- return out, nil
-}
-
-func ParseVPNPush(payload []byte) (sid VPNSessionID, seq uint32, offset, total int, data []byte, err error) {
- if len(payload) < 30 || payload[0] != VPNCmdPush {
- err = errors.New("bad VPN PUSH")
- return
- }
- copy(sid[:], payload[1:17])
- seq = binary.BigEndian.Uint32(payload[17:21])
- offset = int(binary.BigEndian.Uint32(payload[21:25]))
- total = int(binary.BigEndian.Uint32(payload[25:29]))
- data = payload[29:]
- if total < 1 || total > VPNMaxBatch || offset < 0 || offset > total || len(data) < 1 || len(data) > VPNMaxFragment || offset+len(data) > total {
- err = errors.New("bad VPN PUSH fragment bounds")
- }
- return
-}
-
-func BuildVPNAck(seq uint32, accepted int) []byte {
- out := make([]byte, 9)
- out[0] = VPNRespAck
- binary.BigEndian.PutUint32(out[1:5], seq)
- binary.BigEndian.PutUint32(out[5:9], uint32(accepted))
- return out
-}
-
-func ParseVPNAck(payload []byte) (seq uint32, accepted int, err error) {
- if e := ParseVPNError(payload); e != nil {
- err = e
- return
- }
- if len(payload) != 9 || payload[0] != VPNRespAck {
- err = errors.New("bad VPN ACK")
- return
- }
- seq = binary.BigEndian.Uint32(payload[1:5])
- accepted = int(binary.BigEndian.Uint32(payload[5:9]))
- return
-}
-
-// PULL v2 request: cmd(1) sid(16) ack(4) want(4) offset(4) limit(4)
-func BuildVPNPull(sid VPNSessionID, ack, want uint32, offset, limit int) ([]byte, error) {
- if offset < 0 || offset > VPNMaxBatch || limit < 1 || limit > VPNMaxFragment {
- return nil, errors.New("invalid VPN PULL")
- }
- out := make([]byte, 33)
- out[0] = VPNCmdPull
- copy(out[1:17], sid[:])
- binary.BigEndian.PutUint32(out[17:21], ack)
- binary.BigEndian.PutUint32(out[21:25], want)
- binary.BigEndian.PutUint32(out[25:29], uint32(offset))
- binary.BigEndian.PutUint32(out[29:33], uint32(limit))
- return out, nil
-}
-
-func ParseVPNPull(payload []byte) (sid VPNSessionID, ack, want uint32, offset, limit int, err error) {
- if len(payload) != 33 || payload[0] != VPNCmdPull {
- err = errors.New("bad VPN PULL")
- return
- }
- copy(sid[:], payload[1:17])
- ack = binary.BigEndian.Uint32(payload[17:21])
- want = binary.BigEndian.Uint32(payload[21:25])
- offset = int(binary.BigEndian.Uint32(payload[25:29]))
- limit = int(binary.BigEndian.Uint32(payload[29:33]))
- if offset < 0 || offset > VPNMaxBatch || limit < 1 || limit > VPNMaxFragment {
- err = errors.New("bad VPN PULL bounds")
- }
- return
-}
-
-// DATA v2 response: cmd(1) seq(4) offset(4) total(4) data(N)
-func BuildVPNData(seq uint32, offset, total int, data []byte) []byte {
- out := make([]byte, 13+len(data))
- out[0] = VPNRespData
- binary.BigEndian.PutUint32(out[1:5], seq)
- binary.BigEndian.PutUint32(out[5:9], uint32(offset))
- binary.BigEndian.PutUint32(out[9:13], uint32(total))
- copy(out[13:], data)
- return out
-}
-
-func ParseVPNData(payload []byte) (seq uint32, offset, total int, data []byte, wait bool, err error) {
- if e := ParseVPNError(payload); e != nil {
- err = e
- return
- }
- if len(payload) == 1 && payload[0] == VPNRespWait {
- wait = true
- return
- }
- if len(payload) < 14 || payload[0] != VPNRespData {
- err = fmt.Errorf("bad VPN DATA response type/length")
- return
- }
- seq = binary.BigEndian.Uint32(payload[1:5])
- offset = int(binary.BigEndian.Uint32(payload[5:9]))
- total = int(binary.BigEndian.Uint32(payload[9:13]))
- data = payload[13:]
- if total < 1 || total > VPNMaxBatch || offset < 0 || offset+len(data) > total || len(data) < 1 || len(data) > VPNMaxFragment {
- err = errors.New("bad VPN DATA bounds")
- }
- return
-}
-
-// A transfer object is a batch of raw IP packets:
-// version(1), then repeated packetLen(2) + packet bytes.
-func BuildVPNBatch(packets [][]byte) ([]byte, error) {
- if len(packets) == 0 {
- return nil, errors.New("empty VPN batch")
- }
- total := 1
- for _, packet := range packets {
- if len(packet) < 1 || len(packet) > VPNMaxPacket {
- return nil, errors.New("invalid IP packet length in VPN batch")
- }
- total += 2 + len(packet)
- if total > VPNMaxBatch {
- return nil, errors.New("VPN batch exceeds maximum")
- }
- }
- out := make([]byte, total)
- out[0] = VPNBatchVersion
- pos := 1
- for _, packet := range packets {
- binary.BigEndian.PutUint16(out[pos:pos+2], uint16(len(packet)))
- pos += 2
- copy(out[pos:pos+len(packet)], packet)
- pos += len(packet)
- }
- return out, nil
-}
-
-func ParseVPNBatch(batch []byte) ([][]byte, error) {
- if len(batch) < 4 || len(batch) > VPNMaxBatch || batch[0] != VPNBatchVersion {
- return nil, errors.New("bad VPN batch")
- }
- packets := make([][]byte, 0, 8)
- pos := 1
- for pos < len(batch) {
- if pos+2 > len(batch) {
- return nil, errors.New("truncated VPN batch packet length")
- }
- n := int(binary.BigEndian.Uint16(batch[pos : pos+2]))
- pos += 2
- if n < 1 || n > VPNMaxPacket || pos+n > len(batch) {
- return nil, errors.New("invalid VPN batch packet")
- }
- packet := make([]byte, n)
- copy(packet, batch[pos:pos+n])
- packets = append(packets, packet)
- pos += n
- }
- if len(packets) == 0 {
- return nil, errors.New("VPN batch contains no packets")
- }
- return packets, nil
-}
-
-// PacketAddresses returns the source and destination addresses from a raw
-// IPv4/IPv6 packet. The packet may contain trailing bytes; the IP header's own
-// length field is validated against the supplied buffer.
-func PacketAddresses(packet []byte) (src, dst netip.Addr, err error) {
- if len(packet) < 1 {
- return src, dst, errors.New("empty IP packet")
- }
- switch packet[0] >> 4 {
- case 4:
- if len(packet) < 20 {
- return src, dst, errors.New("short IPv4 packet")
- }
- total := int(packet[2])<<8 | int(packet[3])
- if total < 20 || total > len(packet) {
- return src, dst, errors.New("invalid IPv4 total length")
- }
- var a, b [4]byte
- copy(a[:], packet[12:16])
- copy(b[:], packet[16:20])
- return netip.AddrFrom4(a), netip.AddrFrom4(b), nil
- case 6:
- if len(packet) < 40 {
- return src, dst, errors.New("short IPv6 packet")
- }
- total := 40 + (int(packet[4])<<8 | int(packet[5]))
- if total > len(packet) {
- return src, dst, errors.New("invalid IPv6 payload length")
- }
- var a, b [16]byte
- copy(a[:], packet[8:24])
- copy(b[:], packet[24:40])
- return netip.AddrFrom16(a), netip.AddrFrom16(b), nil
- default:
- return src, dst, errors.New("unsupported IP version")
- }
-}
-
-func BuildVPNClose(sid VPNSessionID) []byte {
- out := make([]byte, 17)
- out[0] = VPNCmdClose
- copy(out[1:17], sid[:])
- return out
-}
-
-func ParseVPNClose(payload []byte) (sid VPNSessionID, err error) {
- if len(payload) != 17 || payload[0] != VPNCmdClose {
- return sid, errors.New("bad VPN CLOSE")
- }
- copy(sid[:], payload[1:17])
- return sid, nil
-}
-
-func IsVPNCommand(payload []byte) bool {
- if len(payload) == 0 {
- return false
- }
- return payload[0] >= VPNCmdOpen && payload[0] <= VPNCmdClose
-}
diff --git a/licenses/FreeProxy-APACHE-2.0.txt b/licenses/FreeProxy-APACHE-2.0.txt
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/licenses/FreeProxy-APACHE-2.0.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.