This commit is contained in:
2026-08-16 03:48:57 -03:00
parent 5621de243a
commit c02b36c83d
48 changed files with 5398 additions and 2221 deletions
+132 -180
View File
@@ -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.
-4
View File
@@ -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
+20
View File
@@ -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.
+27 -7
View File
@@ -1,17 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.dragontcp.client"
android:versionCode="4"
android:versionName="3.0">
android:versionCode="8"
android:versionName="8.0-lite">
<uses-sdk android:minSdkVersion="29" android:targetSdkVersion="29" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application android:allowBackup="false" android:extractNativeLibs="true" android:label="DragonTCP VPN" android:usesCleartextTraffic="true">
<activity android:name=".MainActivity" android:exported="true" android:screenOrientation="portrait">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
<application
android:allowBackup="false"
android:extractNativeLibs="true"
android:label="DragonTCP Lite"
android:icon="@drawable/ic_dragontcp"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".DragonService" android:exported="true" android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter><action android:name="android.net.VpnService"/></intent-filter>
<service
android:name=".DragonService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
</manifest>
+202
View File
@@ -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.
+20
View File
@@ -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.
+71 -19
View File
@@ -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"
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#263238" android:pathData="M24,2 L42,12 L42,36 L24,46 L6,36 L6,12 Z"/>
<path android:fillColor="#FFFFFF" android:pathData="M13,15 L26,15 C34,15 38,19 38,24 C38,29 34,33 26,33 L22,33 L22,39 L13,39 Z M22,22 L22,27 L26,27 C29,27 30,26 30,24 C30,23 29,22 26,22 Z"/>
</vector>
-1
View File
@@ -1 +0,0 @@
<resources><string name="app_name">DragonTCP VPN</string></resources>
@@ -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<String> lines = new ArrayDeque<>();
private static final CopyOnWriteArrayList<Listener> 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); }
}
@@ -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<String> cmd=new ArrayList<String>();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<String> 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()<deadline){if(fdSocketFile!=null&&fdSocketFile.exists())break;Process p; synchronized(lifecycleLock){p=process;}if(p==null||!p.isAlive())throw new Exception("DragonTCP core exited before TUN handoff");Thread.sleep(25);}
if(fdSocketFile==null||!fdSocketFile.exists())throw new Exception("DragonTCP core did not create its TUN-fd socket");
LocalSocket s=new LocalSocket();try{s.connect(new LocalSocketAddress(fdSocketFile.getAbsolutePath(),LocalSocketAddress.Namespace.FILESYSTEM));FileDescriptor fd=vpnInterface.getFileDescriptor();s.setFileDescriptorsForSend(new FileDescriptor[]{fd});s.getOutputStream().write(0x44);s.getOutputStream().flush();appendLog("TUN file descriptor passed to DragonTCP core");}finally{try{s.close();}catch(Exception ignored){}}
private void startCoreLogReader(Process process) {
Thread reader = new Thread(() -> {
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));
}
}
@@ -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(off<n){int r=in.read(buf,off,n-off);if(r<0)break;off+=r;}return new String(buf,0,off,"UTF-8");}catch(Exception e){return "log error: "+e.getMessage();}}
private void refreshStatus(){boolean a=DragonService.active,r=DragonService.running;status.setText((r?"":a?"":"")+DragonService.state);status.setTextColor(r?Color.rgb(115,235,145):a?Color.rgb(255,205,95):Color.rgb(232,236,241));connectButton.setEnabled(!a);stopButton.setEnabled(a);String current=readTail(new File(getFilesDir(),"dragontcp.log"),131072);if(!current.equals(lastLogText)){lastLogText=current;logs.setText(current);logScroll.post(()->logScroll.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();}
}
@@ -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,
)
@@ -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
}
@@ -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<ByteArray>(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<Segment>()
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
}
}
@@ -0,0 +1,6 @@
package tech.xvanturing.freeproxy.vpn
/** 把构造好的 IP 包送回 TUN 设备。实现方负责拷贝数据,调用后缓冲区即可复用。 */
interface TunWriter {
fun enqueue(packet: ByteArray, length: Int)
}
@@ -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<SessionKey, TcpSession>()
private val udpSessions = ConcurrentHashMap<SessionKey, UdpSession>()
private val writeQueue = ArrayBlockingQueue<ByteArray>(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
}
}
@@ -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 TCPRFC 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
}
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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}"
@@ -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
}
}
@@ -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<Pair<String, Int>> {
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<Pair<String, Int>>()
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<String, Int>? {
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
}
}
@@ -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
}
}
@@ -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<Int, String>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>): 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() }
}
}
@@ -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,
)
}
}
}
@@ -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))
}
}
@@ -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
@@ -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
}
@@ -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
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17 -5
View File
@@ -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."
+761
View File
@@ -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) }
+36
View File
@@ -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)
}
}
+478
View File
@@ -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()
}
}
}
+505
View File
@@ -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"))
}
+12
View File
@@ -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)
}
}
+83
View File
@@ -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(),
)
}
}
+367
View File
@@ -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()
}
}
}
-624
View File
@@ -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()
}
-841
View File
@@ -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()
}
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
module dragontcpvpn
module dragontcp
go 1.22
-376
View File
@@ -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
}
+202
View File
@@ -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.