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.