V7
This commit is contained in:
@@ -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
|
This version deliberately returns to the lightweight DragonTCP architecture.
|
||||||
TCP transport. It captures IPv4 and IPv6 through Android `VpnService`, passes
|
DragonTCP itself is an HTTP/HTTPS CONNECT proxy tunnel; Android's `VpnService`
|
||||||
the TUN file descriptor to the Go core, and transfers raw IP packets to a Linux
|
is only the local adapter that feeds normal app TCP traffic into that proxy.
|
||||||
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.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Android apps
|
Android apps
|
||||||
|
|
|
|
||||||
| IPv4 + IPv6 default routes
|
| IPv4 TCP / DNS packets
|
||||||
v
|
v
|
||||||
Android VpnService TUN (MTU 1280)
|
Android VpnService TUN
|
||||||
|
|
|
|
||||||
| raw IPv4/IPv6 packets
|
| lightweight userspace TCP adapter
|
||||||
v
|
v
|
||||||
DragonTCP Android Go core
|
127.0.0.1:8080 HTTP CONNECT
|
||||||
|
|
|
|
||||||
| packet batching (up to 1 MiB transfer objects)
|
v
|
||||||
| adaptive fragmentation 32 B .. 1 MiB
|
DragonTCP Go client
|
||||||
| XOR 0xAD framing
|
|
|
||||||
v
|
| adaptive records + mandatory XOR 0xAD
|
||||||
TCP/53
|
| TCP/53
|
||||||
|
|
v
|
||||||
v
|
DragonTCP Lite server
|
||||||
DragonTCP Linux server
|
|
|
||||||
|
|
v
|
||||||
v
|
Internet destination
|
||||||
Linux TUN dragontcp0
|
|
||||||
|
|
|
||||||
| forwarding / NAT
|
|
||||||
v
|
|
||||||
Internet
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Because the tunnel carries raw IP packets, it can carry TCP, UDP, DNS, ICMP,
|
There is **no Linux TUN**, no server NAT, no raw-IP DragonTCP protocol, and no
|
||||||
IPv4 and IPv6. It does not depend on applications supporting an HTTP proxy.
|
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
|
```text
|
||||||
bin/dragontcp-vpn-server-linux-amd64
|
Server port: 53/TCP
|
||||||
bin/dragontcp-vpn-server-linux-arm64
|
Token: optional / empty allowed
|
||||||
bin/dragontcp-vpn-client-linux-amd64 # test/debug client
|
Local proxy: 127.0.0.1:8080
|
||||||
android/build/DragonTCP-VPN.apk
|
Pollers: 1 (fixed)
|
||||||
android/lib/arm64-v8a/libdragontcp_vpn.so
|
Reconnect Every: 1
|
||||||
core/ # complete Go source
|
Chunk Start: Max Chunk
|
||||||
android/src/ # complete Android Java source
|
Chunk Min: 32
|
||||||
build_core.sh
|
Chunk Max: 1,048,576 bytes
|
||||||
build_all.sh
|
Chunk Grow After: 16 successes
|
||||||
android/build_apk.sh
|
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
|
```text
|
||||||
creates a Linux TUN and configures forwarding/NAT.
|
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
|
```bash
|
||||||
sudo apt-get update
|
sudo ./dragontcp-lite-server-linux-amd64 --chunk-max 1048576
|
||||||
sudo apt-get install -y iproute2 iptables
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Start:
|
No token is required by default. To require one:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./dragontcp-vpn-server-linux-amd64 \
|
sudo ./dragontcp-lite-server-linux-amd64 --token 'SECRET' --chunk-max 1048576
|
||||||
--token 'YOUR_SECRET' \
|
|
||||||
--debug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Defaults:
|
The Android Token field must contain the same value.
|
||||||
|
|
||||||
```text
|
If you do not want to run the binary as root:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./dragontcp-vpn-server-linux-amd64 \
|
sudo setcap cap_net_bind_service=+ep ./dragontcp-lite-server-linux-amd64
|
||||||
--token 'YOUR_SECRET' \
|
./dragontcp-lite-server-linux-amd64
|
||||||
--chunk-max 1048576 \
|
|
||||||
--vpn-buffer-bytes 8388608 \
|
|
||||||
--batch-delay 1ms \
|
|
||||||
--debug \
|
|
||||||
--debug-stats-interval 5s
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-packet/batch diagnostics are very verbose:
|
If TCP/53 is already occupied by a DNS resolver, free that port first.
|
||||||
|
|
||||||
```bash
|
## Android usage
|
||||||
--debug-packets
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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:
|
## Build everything from source
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- Go 1.22+
|
- Go 1.22+
|
||||||
- JDK 17+
|
- JDK 17+
|
||||||
- Android SDK platform/build-tools
|
- Android SDK platform + build-tools (35 works)
|
||||||
- zip
|
- 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.
|
Example environment:
|
||||||
|
|
||||||
Set the SDK directory:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANDROID_SDK_ROOT="$HOME/Android/Sdk"
|
export ANDROID_SDK_ROOT="$HOME/Android/Sdk"
|
||||||
|
export KOTLIN_HOME="$HOME/.sdkman/candidates/kotlin/current"
|
||||||
```
|
```
|
||||||
|
|
||||||
Build native components and APK:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build_all.sh
|
./build_all.sh
|
||||||
@@ -212,37 +139,62 @@ Build native components and APK:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
bin/dragontcp-vpn-server-linux-amd64
|
bin/dragontcp-lite-server-linux-amd64
|
||||||
bin/dragontcp-vpn-server-linux-arm64
|
bin/dragontcp-lite-server-linux-arm64
|
||||||
android/lib/arm64-v8a/libdragontcp_vpn.so
|
android/lib/arm64-v8a/libdragontcp_client.so
|
||||||
android/build/DragonTCP-VPN.apk
|
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
|
```bash
|
||||||
./build_core.sh
|
./build_core.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Build only the APK after the native core exists:
|
## Build only the APK
|
||||||
|
|
||||||
|
After the core has been built:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd android
|
cd android
|
||||||
./build_apk.sh
|
./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;
|
## Third-party stack
|
||||||
- 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**.
|
|
||||||
|
|
||||||
A physical Android phone is still required to validate device/vendor-specific
|
The userspace Android TUN/TCP adapter is adapted from the Apache-2.0-licensed
|
||||||
`VpnService` behavior and the real mobile-network TCP/53 path.
|
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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
@@ -1,17 +1,37 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.dragontcp.client"
|
package="com.dragontcp.client"
|
||||||
android:versionCode="4"
|
android:versionCode="8"
|
||||||
android:versionName="3.0">
|
android:versionName="8.0-lite">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="29" android:targetSdkVersion="29" />
|
<uses-sdk android:minSdkVersion="29" android:targetSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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">
|
<application
|
||||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
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>
|
</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>
|
</service>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -2,22 +2,74 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||||
[[ -n "$SDK" ]] || { echo "Set ANDROID_SDK_ROOT" >&2; exit 1; }
|
KOTLIN_HOME="${KOTLIN_HOME:-$HOME/.sdkman/candidates/kotlin/current}"
|
||||||
BUILD_TOOLS="${BUILD_TOOLS:-35.0.0}"; PLATFORM="${PLATFORM:-android-35}"
|
BUILD_TOOLS="${BUILD_TOOLS:-35.0.0}"
|
||||||
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
|
PLATFORM="${PLATFORM:-android-35}"
|
||||||
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"
|
[[ -n "$SDK" ]] || { echo "Set ANDROID_SDK_ROOT (or ANDROID_HOME)." >&2; exit 1; }
|
||||||
for tool in aapt d8 apksigner; do [[ -x "$BT/$tool" ]] || { echo "Missing $BT/$tool" >&2; exit 1; }; done
|
[[ -d "$KOTLIN_HOME" ]] || { echo "Set KOTLIN_HOME to a Kotlin compiler distribution." >&2; exit 1; }
|
||||||
CORE="$ROOT/lib/arm64-v8a/libdragontcp_vpn.so"; [[ -f "$CORE" ]] || { echo "Run ../build_core.sh first" >&2; exit 1; }
|
BT="$SDK/build-tools/$BUILD_TOOLS"
|
||||||
B="$ROOT/build"; rm -rf "$B"; mkdir -p "$B/classes" "$B/dex"
|
AJ="$SDK/platforms/$PLATFORM/android.jar"
|
||||||
"$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)
|
if [[ ! -d "$BT" ]]; then
|
||||||
"$BT/d8" --lib "$AJ" --min-api 29 --output "$B/dex" $(find "$B/classes" -name '*.class' -print)
|
BUILD_TOOLS="$(find "$SDK/build-tools" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"
|
||||||
cp "$B/resources.ap_" "$B/DragonTCP-VPN-unsigned.apk"
|
BT="$SDK/build-tools/$BUILD_TOOLS"
|
||||||
(cd "$B/dex" && zip -q "$B/DragonTCP-VPN-unsigned.apk" classes.dex)
|
fi
|
||||||
(cd "$ROOT" && zip -q -r "$B/DragonTCP-VPN-unsigned.apk" lib)
|
if [[ ! -f "$AJ" ]]; then
|
||||||
KEYSTORE="$ROOT/dragontcp-debug.jks"
|
PLATFORM="$(find "$SDK/platforms" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"
|
||||||
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
|
AJ="$SDK/platforms/$PLATFORM/android.jar"
|
||||||
"$BT/apksigner" sign --ks "$KEYSTORE" --ks-pass pass:dragontcp --key-pass pass:dragontcp --out "$B/DragonTCP-VPN.apk" "$B/DragonTCP-VPN-unsigned.apk"
|
fi
|
||||||
"$BT/apksigner" verify --verbose "$B/DragonTCP-VPN.apk"
|
|
||||||
echo "Built APK: $B/DragonTCP-VPN.apk"
|
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.
@@ -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 +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.app.PendingIntent;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.net.LocalSocket;
|
|
||||||
import android.net.LocalSocketAddress;
|
|
||||||
import android.net.VpnService;
|
import android.net.VpnService;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
import android.os.ParcelFileDescriptor;
|
import android.os.ParcelFileDescriptor;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.FileDescriptor;
|
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.OutputStreamWriter;
|
import java.net.DatagramSocket;
|
||||||
import java.io.PrintWriter;
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
public class DragonService extends VpnService {
|
import tech.xvanturing.freeproxy.data.model.DnsMode;
|
||||||
public static final String ACTION_CONNECT="com.dragontcp.client.CONNECT";
|
import tech.xvanturing.freeproxy.data.model.ProxyProfile;
|
||||||
public static final String ACTION_STOP="com.dragontcp.client.STOP";
|
import tech.xvanturing.freeproxy.data.model.ProxyType;
|
||||||
public static volatile boolean active=false,running=false;
|
import tech.xvanturing.freeproxy.vpn.TunnelEngine;
|
||||||
public static volatile String state="Stopped";
|
import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector;
|
||||||
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;
|
|
||||||
|
|
||||||
@Override public void onCreate(){super.onCreate();createNotificationChannel();}
|
public class DragonService extends VpnService {
|
||||||
@Override public int onStartCommand(Intent intent,int flags,int startId){
|
public static final String ACTION_CONNECT = "com.dragontcp.client.CONNECT";
|
||||||
if(intent==null)return START_NOT_STICKY;String action=intent.getAction();
|
public static final String ACTION_STOP = "com.dragontcp.client.STOP";
|
||||||
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;
|
public static final String EXTRA_SERVER = "server";
|
||||||
cleanupResources(true);clearLog();active=true;running=false;state="Starting VPN";startForeground(NOTIFICATION_ID,buildNotification("Starting full VPN"));
|
public static final String EXTRA_PORT = "port";
|
||||||
String server=intent.getStringExtra("server"),token=intent.getStringExtra("token"),timeout=intent.getStringExtra("timeout"),v4=intent.getStringExtra("vpnIPv4"),v6=intent.getStringExtra("vpnIPv6");
|
public static final String EXTRA_TOKEN = "token";
|
||||||
int port=intent.getIntExtra("port",53),max=intent.getIntExtra("chunkMax",1048576),min=intent.getIntExtra("chunkMin",32),start=intent.getIntExtra("chunkStart",max);
|
public static final String EXTRA_CHUNK_MAX = "chunkMax";
|
||||||
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;}
|
public static final String EXTRA_CHUNK_MIN = "chunkMin";
|
||||||
start=max;
|
public static final String EXTRA_RECONNECT = "reconnect";
|
||||||
try{
|
public static final String EXTRA_TIMEOUT = "timeout";
|
||||||
establishPacketVpn(v4,v6);
|
|
||||||
state="Starting DragonTCP core";
|
private static final int NOTIFICATION_ID = 53;
|
||||||
startCore(server.trim(),port,token,start,min,max,timeout.trim(),v4,v6);
|
private static final String CHANNEL_ID = "dragontcp-lite";
|
||||||
state="Connecting to DragonTCP server";
|
private static final int LOCAL_PROXY_PORT = 8080;
|
||||||
updateNotification("Connecting • TCP/"+port);
|
private static final int TUN_MTU = 1400;
|
||||||
}catch(Exception e){failStart(e.getMessage()==null?e.toString():e.getMessage());}
|
|
||||||
|
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;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void establishPacketVpn(String v4,String v6)throws Exception{
|
private void startEverything(Intent intent) {
|
||||||
VpnService.Builder b=new VpnService.Builder();b.setSession("DragonTCP VPN");b.setMtu(1280);
|
synchronized (stateLock) {
|
||||||
b.addAddress(v4,32);b.addAddress(v6,128);b.addRoute("0.0.0.0",0);b.addRoute("::",0);
|
if (connected || coreProcess != null || tunnelEngine != null) {
|
||||||
b.addDnsServer("1.1.1.1");b.addDnsServer("2606:4700:4700::1111");
|
// Restart in-place without stopSelf(); this avoids a race where
|
||||||
try{b.addDisallowedApplication(getPackageName());}catch(PackageManager.NameNotFoundException e){throw new Exception("Cannot exclude DragonTCP from its own VPN",e);}
|
// Android destroys the service just after a new CONNECT begins.
|
||||||
Intent open=new Intent(this,MainActivity.class);open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP|Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
stopping = true;
|
||||||
PendingIntent pi=PendingIntent.getActivity(this,1,open,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);b.setConfigureIntent(pi);
|
cleanupComponentsLocked();
|
||||||
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");
|
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{
|
private Process startDragonCore(
|
||||||
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");
|
String server,
|
||||||
fdSocketFile=new File(getFilesDir(),"dragontcp-tunfd.sock");if(fdSocketFile.exists())fdSocketFile.delete();
|
int port,
|
||||||
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);
|
String 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");
|
int chunkMax,
|
||||||
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");
|
int chunkMin,
|
||||||
appendLog("Server: "+server+":"+port);appendLog("Transport chunks: start=max="+max+" min="+min+" pollers=1 timeout="+timeout);
|
int reconnect,
|
||||||
ProcessBuilder pb=new ProcessBuilder(cmd);pb.redirectErrorStream(true);pb.directory(getFilesDir());final Process p=pb.start();synchronized(lifecycleLock){process=p;}
|
int timeout
|
||||||
outputThread=new Thread(()->readCoreOutput(p),"DragonTCP-output");outputThread.setDaemon(true);outputThread.start();
|
) throws Exception {
|
||||||
passTunFdWhenReady();
|
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{
|
private void startCoreLogReader(Process process) {
|
||||||
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);}
|
Thread reader = new Thread(() -> {
|
||||||
if(fdSocketFile==null||!fdSocketFile.exists())throw new Exception("DragonTCP core did not create its TUN-fd socket");
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
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){}}
|
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){
|
private void waitForLocalProxy(Process process) throws Exception {
|
||||||
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");}}
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
int code=p.waitFor();handleCoreExit(p,code);
|
Throwable last = null;
|
||||||
}catch(Exception e){appendLog("Core reader: "+e);handleCoreExit(p,-1);}
|
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 ParcelFileDescriptor establishVpn() throws Exception {
|
||||||
private void updateNotification(String m){NotificationManager nm=(NotificationManager)getSystemService(NOTIFICATION_SERVICE);if(nm!=null)nm.notify(NOTIFICATION_ID,buildNotification(m));}
|
Builder builder = new Builder()
|
||||||
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);}}
|
.setSession("DragonTCP Lite")
|
||||||
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){}}
|
.setMtu(TUN_MTU)
|
||||||
private void clearLog(){try{new FileOutputStream(new File(getFilesDir(),"dragontcp.log"),false).close();}catch(Exception ignored){}}
|
.addAddress("10.77.0.2", 32)
|
||||||
private void failStart(String m){appendLog("START ERROR: "+m);running=false;active=false;state="Start failed: "+m;cleanupResources(true);stopForeground(true);stopSelf();}
|
.addRoute("0.0.0.0", 0)
|
||||||
private void shutdown(String reason,boolean stop){state="Stopping";running=false;appendLog(reason);cleanupResources(true);active=false;state="Stopped";stopForeground(true);if(stop)stopSelf();}
|
.addDnsServer("1.1.1.1")
|
||||||
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;}
|
// The embedded userspace adapter is intentionally IPv4-only.
|
||||||
private void closeVpn(){ParcelFileDescriptor v=vpnInterface;vpnInterface=null;if(v!=null){try{v.close();}catch(Exception ignored){}}}
|
// Capturing ::/0 blocks IPv6 instead of leaking it outside the VPN.
|
||||||
@Override public void onRevoke(){appendLog("VPN permission revoked");shutdown("VPN revoked",true);super.onRevoke();}
|
.addAddress("fd77:6472:6167:6f6e::2", 128)
|
||||||
@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();}
|
.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;
|
package com.dragontcp.client;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.Typeface;
|
import android.graphics.Typeface;
|
||||||
import android.net.VpnService;
|
import android.net.VpnService;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
|
||||||
import android.text.InputType;
|
import android.text.InputType;
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
@@ -17,117 +16,254 @@ import android.widget.Button;
|
|||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
import android.widget.ScrollView;
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TableLayout;
|
||||||
|
import android.widget.TableRow;
|
||||||
import android.widget.TextView;
|
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 {
|
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 EditText server;
|
||||||
private TextView status, logs;
|
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 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() {
|
private final AppLog.Listener logListener = line -> runOnUiThread(() -> appendLogLine(line));
|
||||||
@Override public void run() {
|
|
||||||
refreshStatus();
|
|
||||||
handler.postDelayed(this, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@Override protected void onCreate(Bundle savedInstanceState) {
|
@Override
|
||||||
super.onCreate(savedInstanceState);
|
public void onCreate(Bundle state) {
|
||||||
prefs = getSharedPreferences("dragontcp", MODE_PRIVATE);
|
super.onCreate(state);
|
||||||
setTitle("DragonTCP VPN");
|
|
||||||
buildUi();
|
buildUi();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
handler.post(refresher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int dp(int v) { return (int)(v * getResources().getDisplayMetrics().density + 0.5f); }
|
@Override
|
||||||
private TextView text(String s, float sp, boolean bold) {
|
protected void onStart() {
|
||||||
TextView v = new TextView(this); v.setText(s); v.setTextSize(sp); v.setTextColor(Color.rgb(232,236,241));
|
super.onStart();
|
||||||
if (bold) v.setTypeface(Typeface.DEFAULT, Typeface.BOLD); return v;
|
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);
|
@Override
|
||||||
EditText e=new EditText(this);e.setSingleLine(true);e.setTextColor(Color.WHITE);e.setHintTextColor(Color.GRAY);e.setInputType(type);
|
protected void onStop() {
|
||||||
e.setBackgroundColor(Color.rgb(42,47,54));e.setPadding(dp(12),dp(9),dp(12),dp(9));
|
AppLog.removeListener(logListener);
|
||||||
root.addView(e,new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT));return e;
|
super.onStop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildUi() {
|
private void buildUi() {
|
||||||
ScrollView page=new ScrollView(this);page.setFillViewport(true);page.setBackgroundColor(Color.rgb(20,23,27));
|
int pad = dp(14);
|
||||||
LinearLayout root=new LinearLayout(this);root.setOrientation(LinearLayout.VERTICAL);root.setPadding(dp(18),dp(18),dp(18),dp(24));page.addView(root);
|
ScrollView outer = new ScrollView(this);
|
||||||
TextView title=text("DragonTCP VPN",27f,true);title.setTextColor(Color.rgb(104,207,255));root.addView(title);
|
LinearLayout root = new LinearLayout(this);
|
||||||
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);
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
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);
|
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);
|
TextView title = new TextView(this);
|
||||||
port=field(root,"TCP port",InputType.TYPE_CLASS_NUMBER);
|
title.setText("DragonTCP Lite VPN");
|
||||||
token=field(root,"Token",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
title.setTextSize(24);
|
||||||
chunkMax=field(root,"Maximum transport fragment bytes (start = max)",InputType.TYPE_CLASS_NUMBER);
|
title.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||||
chunkMin=field(root,"Minimum transport fragment bytes",InputType.TYPE_CLASS_NUMBER);
|
root.addView(title);
|
||||||
timeout=field(root,"Transaction timeout (example: 2s)",InputType.TYPE_CLASS_TEXT);
|
|
||||||
|
|
||||||
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);
|
TextView subtitle = new TextView(this);
|
||||||
note.setTextColor(Color.rgb(160,170,180));note.setPadding(0,dp(10),0,dp(8));root.addView(note);
|
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);
|
TableLayout table = new TableLayout(this);
|
||||||
connectButton=new Button(this);connectButton.setText("CONNECT");buttons.addView(connectButton,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f));
|
table.setStretchAllColumns(false);
|
||||||
stopButton=new Button(this);stopButton.setText("STOP");buttons.addView(stopButton,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f));
|
table.setColumnStretchable(1, true);
|
||||||
connectButton.setOnClickListener(v -> startDragon()); stopButton.setOnClickListener(v -> stopDragon());
|
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);
|
server = addField(table, "Server", "", false, false);
|
||||||
TextView lt=text("Live log",17f,true);lh.addView(lt,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f));
|
port = addField(table, "Port", "53", true, false);
|
||||||
Button clear=new Button(this);clear.setText("CLEAR");lh.addView(clear);clear.setOnClickListener(v -> clearLog());
|
token = addField(table, "Token", "", false, true);
|
||||||
logScroll=new ScrollView(this);logScroll.setFillViewport(true);logScroll.setVerticalScrollBarEnabled(true);logScroll.setBackgroundColor(Color.BLACK);
|
chunkMax = addField(table, "Max chunk", "1048576", true, false);
|
||||||
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);
|
chunkMin = addField(table, "Min chunk", "32", true, false);
|
||||||
logScroll.addView(logs,new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT));
|
reconnect = addField(table, "Reconnect every", "1", true, false);
|
||||||
root.addView(logScroll,new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,dp(320)));
|
timeout = addField(table, "Timeout (s)", "2", true, false);
|
||||||
setContentView(page);
|
|
||||||
|
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 EditText addField(TableLayout table, String label, String defaultValue, boolean numeric, boolean password) {
|
||||||
private int intValue(EditText e,int d){try{return Integer.parseInt(e.getText().toString().trim());}catch(Exception x){return d;}}
|
TableRow row = new TableRow(this);
|
||||||
private boolean validateSettings(){
|
row.setPadding(0, dp(2), 0, dp(2));
|
||||||
if(server.getText().toString().trim().isEmpty()){toast("Enter the server IP or hostname");return false;}
|
TextView name = new TextView(this);
|
||||||
int p=intValue(port,53),min=intValue(chunkMin,32),max=intValue(chunkMax,1048576);
|
name.setText(label);
|
||||||
if(p<1||p>65535){toast("Port must be 1-65535");return false;}
|
name.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
if(min<32||max>1048576||min>max){toast("Chunks must satisfy 32 <= Min <= Max <= 1048576");return false;}
|
name.setPadding(0, 0, dp(10), 0);
|
||||||
if(timeout.getText().toString().trim().isEmpty()){toast("Enter a timeout such as 2s");return false;}
|
EditText value = new EditText(this);
|
||||||
return true;
|
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(){
|
private void requestConnect() {
|
||||||
int id=prefs.getInt("clientHostId",0);if(id>=2&&id<=65534)return id;
|
try {
|
||||||
id=2+new SecureRandom().nextInt(65533);prefs.edit().putInt("clientHostId",id).apply();return id;
|
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(){
|
@Override
|
||||||
int max=intValue(chunkMax,1048576),id=clientHostId();Intent i=new Intent(this,DragonService.class);i.setAction(DragonService.ACTION_CONNECT);
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
i.putExtra("server",server.getText().toString().trim());i.putExtra("port",intValue(port,53));i.putExtra("token",token.getText().toString());
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
i.putExtra("chunkStart",max);i.putExtra("chunkMax",max);i.putExtra("chunkMin",intValue(chunkMin,32));i.putExtra("timeout",timeout.getText().toString().trim());
|
if (requestCode == VPN_REQUEST) {
|
||||||
i.putExtra("vpnIPv4",clientIPv4(id));i.putExtra("vpnIPv6",clientIPv6(id));return i;
|
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 TCP(RFC 7766)经代理查询,
|
||||||
|
* 这样即使上游只有 HTTP CONNECT,域名解析依然可用。
|
||||||
|
*
|
||||||
|
* 每个四元组独占一条转发通道 —— 共享一个中继 socket 会让回程包无法区分本地源端口。
|
||||||
|
*/
|
||||||
|
class UdpSession(
|
||||||
|
val key: SessionKey,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
private val proxyClient: ProxyClient,
|
||||||
|
private val profile: ProxyProfile,
|
||||||
|
private val protector: SocketProtector,
|
||||||
|
private val tun: TunWriter,
|
||||||
|
private val appResolver: AppResolver?,
|
||||||
|
private val dnsBlocker: DnsBlocker?,
|
||||||
|
private val onFinished: (SessionKey) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val closed = AtomicBoolean(false)
|
||||||
|
private val useSocksUdp = profile.type == ProxyType.SOCKS5 && profile.udpOverSocks
|
||||||
|
private val isDns = key.destPort == DNS_PORT
|
||||||
|
|
||||||
|
private var packageResolved = false
|
||||||
|
private var cachedPackage: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var association: UdpAssociation? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var relaySocket: DatagramSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastActivity: Long = SystemClock.elapsedRealtime()
|
||||||
|
private set
|
||||||
|
|
||||||
|
val idleTimeoutMs: Long get() = if (isDns) DNS_IDLE_MS else UDP_IDLE_MS
|
||||||
|
|
||||||
|
/** 转发一个从 TUN 收到的 UDP 载荷。 */
|
||||||
|
fun send(payload: ByteArray) {
|
||||||
|
lastActivity = SystemClock.elapsedRealtime()
|
||||||
|
if (isDns) {
|
||||||
|
logDnsQuery(payload)
|
||||||
|
if (dnsBlocker != null && tryBlockDns(payload)) return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
// 直连解析优先判断:这条路径完全不碰代理
|
||||||
|
isDns && profile.dnsMode == DnsMode.DIRECT -> sendDnsDirect(payload)
|
||||||
|
useSocksUdp -> sendOverSocks(payload)
|
||||||
|
isDns -> sendDnsOverTcp(payload)
|
||||||
|
else -> {
|
||||||
|
// 代理不支持 UDP:静默丢弃。应用侧通常会自行回退到 TCP。
|
||||||
|
Log.d(TAG, "丢弃 UDP(代理未启用 UDP 转发):$key")
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- SOCKS5 UDP
|
||||||
|
|
||||||
|
private fun sendOverSocks(payload: ByteArray) {
|
||||||
|
if (!started) {
|
||||||
|
started = true
|
||||||
|
scope.launch(ioDispatcher) {
|
||||||
|
if (!openAssociation()) {
|
||||||
|
finish()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
forward(payload)
|
||||||
|
receiveLoop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scope.launch(ioDispatcher) { forward(payload) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAssociation(): Boolean = try {
|
||||||
|
val assoc = proxyClient.openUdpAssociate()
|
||||||
|
val socket = DatagramSocket()
|
||||||
|
if (!protector.protect(socket)) {
|
||||||
|
socket.close()
|
||||||
|
assoc.close()
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
association = assoc
|
||||||
|
relaySocket = socket
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "UDP ASSOCIATE 失败 $key:${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forward(payload: ByteArray) {
|
||||||
|
val socket = relaySocket ?: return
|
||||||
|
val relay = association?.relayAddress ?: return
|
||||||
|
try {
|
||||||
|
// SOCKS5 UDP 请求头:RSV(2) FRAG(1) ATYP ADDR PORT
|
||||||
|
val framed = ByteArrayOutputStream(payload.size + 10).apply {
|
||||||
|
write(0)
|
||||||
|
write(0)
|
||||||
|
write(0)
|
||||||
|
write(ProxyClient.ATYP_IPV4)
|
||||||
|
write(key.destIp.toIpv4Bytes())
|
||||||
|
write((key.destPort ushr 8) and 0xFF)
|
||||||
|
write(key.destPort and 0xFF)
|
||||||
|
write(payload)
|
||||||
|
}.toByteArray()
|
||||||
|
socket.send(DatagramPacket(framed, framed.size, relay))
|
||||||
|
VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "UDP 发送失败 $key:${e.message}")
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun receiveLoop() {
|
||||||
|
val socket = relaySocket ?: return
|
||||||
|
val buffer = ByteArray(MAX_DATAGRAM)
|
||||||
|
val packet = DatagramPacket(buffer, buffer.size)
|
||||||
|
try {
|
||||||
|
while (!closed.get()) {
|
||||||
|
packet.setData(buffer, 0, buffer.size)
|
||||||
|
socket.receive(packet)
|
||||||
|
lastActivity = SystemClock.elapsedRealtime()
|
||||||
|
val payloadOffset = socksPayloadOffset(buffer, packet.length) ?: continue
|
||||||
|
val payloadLength = packet.length - payloadOffset
|
||||||
|
if (payloadLength <= 0) continue
|
||||||
|
VpnStateHolder.downloadCounter.addAndGet(payloadLength.toLong())
|
||||||
|
writeBackToTun(buffer, payloadOffset, payloadLength)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (!closed.get()) Log.d(TAG, "UDP 接收结束 $key:${e.message}")
|
||||||
|
} finally {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳过 SOCKS5 UDP 应答头,返回真实载荷的起始下标。 */
|
||||||
|
private fun socksPayloadOffset(buffer: ByteArray, length: Int): Int? {
|
||||||
|
if (length < 10) return null
|
||||||
|
var offset = 3 // RSV(2) + FRAG(1)
|
||||||
|
val addressType = buffer.u8(offset)
|
||||||
|
offset += 1
|
||||||
|
offset += when (addressType) {
|
||||||
|
ProxyClient.ATYP_IPV4 -> 4
|
||||||
|
ProxyClient.ATYP_IPV6 -> 16
|
||||||
|
ProxyClient.ATYP_DOMAIN -> {
|
||||||
|
if (offset >= length) return null
|
||||||
|
1 + buffer.u8(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
offset += 2 // 端口
|
||||||
|
return if (offset < length) offset else null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------- DNS / TCP
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS 是一问一答,直接为每次查询开一条隧道:
|
||||||
|
* TCP 承载的 DNS 报文前面多两个字节的长度前缀。
|
||||||
|
*/
|
||||||
|
private fun sendDnsOverTcp(payload: ByteArray) {
|
||||||
|
scope.launch(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
proxyClient.connectTcp(CLOUDFLARE_DNS, DNS_PORT).use { socket ->
|
||||||
|
socket.soTimeout = DNS_TIMEOUT_MS
|
||||||
|
socket.getOutputStream().apply {
|
||||||
|
write((payload.size ushr 8) and 0xFF)
|
||||||
|
write(payload.size and 0xFF)
|
||||||
|
write(payload)
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
|
||||||
|
|
||||||
|
val input = socket.getInputStream()
|
||||||
|
val header = input.readExactly(2)
|
||||||
|
val length = ((header[0].toInt() and 0xFF) shl 8) or (header[1].toInt() and 0xFF)
|
||||||
|
if (length in 1..MAX_DATAGRAM) {
|
||||||
|
val response = input.readExactly(length)
|
||||||
|
VpnStateHolder.downloadCounter.addAndGet(length.toLong())
|
||||||
|
writeBackToTun(response, 0, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "DNS over TCP 失败 $key:${e.message}")
|
||||||
|
} finally {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地直连解析:用一个 protect 过的 socket 直接问 DNS 服务器。
|
||||||
|
* 快,但查询内容对所在网络可见 —— 这是用户在配置里明确选择的取舍。
|
||||||
|
*/
|
||||||
|
private fun sendDnsDirect(payload: ByteArray) {
|
||||||
|
scope.launch(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
DatagramSocket().use { socket ->
|
||||||
|
if (!protector.protect(socket)) return@launch
|
||||||
|
socket.soTimeout = DNS_TIMEOUT_MS
|
||||||
|
socket.send(
|
||||||
|
DatagramPacket(
|
||||||
|
payload,
|
||||||
|
payload.size,
|
||||||
|
key.destIp.toInetAddress(),
|
||||||
|
key.destPort,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
|
||||||
|
|
||||||
|
val buffer = ByteArray(MAX_DATAGRAM)
|
||||||
|
val response = DatagramPacket(buffer, buffer.size)
|
||||||
|
socket.receive(response)
|
||||||
|
VpnStateHolder.downloadCounter.addAndGet(response.length.toLong())
|
||||||
|
writeBackToTun(buffer, 0, response.length)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "直连 DNS 失败 $key:${e.message}")
|
||||||
|
} finally {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 日志
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命中拦截规则的查询不再转发,直接伪造一个应答回给应用。
|
||||||
|
*
|
||||||
|
* @return true 表示已拦截并作答,本次会话到此结束。
|
||||||
|
*/
|
||||||
|
private fun tryBlockDns(payload: ByteArray): Boolean {
|
||||||
|
val blocker = dnsBlocker ?: return false
|
||||||
|
val question = DnsMessage.readQuestion(payload, 0, payload.size) ?: return false
|
||||||
|
val packageName = resolvePackage()
|
||||||
|
val ip = blocker.resolve(question, packageName) ?: return false
|
||||||
|
val response = DnsResponse.buildBlockedResponse(payload, payload.size, ip) ?: return false
|
||||||
|
writeBackToTun(response, 0, response.size)
|
||||||
|
val rule = if (blocker.isAppBlocked(packageName)) "应用" else "域名"
|
||||||
|
TunnelLog.dns(
|
||||||
|
"DNS ${question.typeName} ${question.name} → $ip(按$rule 拦截)",
|
||||||
|
packageName,
|
||||||
|
question.name,
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logDnsQuery(payload: ByteArray) {
|
||||||
|
val question = DnsMessage.readQuestion(payload, 0, payload.size) ?: return
|
||||||
|
TunnelLog.dns(
|
||||||
|
"DNS ${question.typeName} ${question.name}",
|
||||||
|
resolvePackage(),
|
||||||
|
question.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UID 反查要跨进程,一条会话只做一次。 */
|
||||||
|
private fun resolvePackage(): String? {
|
||||||
|
if (!packageResolved) {
|
||||||
|
cachedPackage = appResolver?.resolve(OsConstants.IPPROTO_UDP, key)
|
||||||
|
packageResolved = true
|
||||||
|
}
|
||||||
|
return cachedPackage
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 DNS 应答里的 A 记录喂给反查表,好让后续的连接日志显示域名。 */
|
||||||
|
private fun rememberDnsAnswers(payload: ByteArray, offset: Int, length: Int) {
|
||||||
|
if (!isDns) return
|
||||||
|
DnsMessage.readAnswers(payload, offset, length).forEach { (name, address) ->
|
||||||
|
HostRegistry.remember(address, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 回写
|
||||||
|
|
||||||
|
private fun writeBackToTun(payload: ByteArray, offset: Int, length: Int) {
|
||||||
|
rememberDnsAnswers(payload, offset, length)
|
||||||
|
val output = ByteArray(28 + length)
|
||||||
|
val size = PacketBuilder.writeUdp(
|
||||||
|
output = output,
|
||||||
|
sourceIp = key.destIp,
|
||||||
|
sourcePort = key.destPort,
|
||||||
|
destIp = key.sourceIp,
|
||||||
|
destPort = key.sourcePort,
|
||||||
|
payload = payload,
|
||||||
|
payloadOffset = offset,
|
||||||
|
payloadLength = length,
|
||||||
|
)
|
||||||
|
tun.enqueue(output, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
if (!closed.compareAndSet(false, true)) return
|
||||||
|
// 关闭 socket 即可让阻塞中的 receive() 抛异常退出,无需再取消协程
|
||||||
|
runCatching { relaySocket?.close() }
|
||||||
|
runCatching { association?.close() }
|
||||||
|
onFinished(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "UdpSession"
|
||||||
|
val CLOUDFLARE_DNS: InetAddress = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1))
|
||||||
|
const val DNS_PORT = 53
|
||||||
|
const val DNS_TIMEOUT_MS = 10_000
|
||||||
|
const val MAX_DATAGRAM = 65507
|
||||||
|
const val DNS_IDLE_MS = 20_000L
|
||||||
|
const val UDP_IDLE_MS = 120_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,9 +1,21 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
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"
|
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
|
cd "$ROOT/core"
|
||||||
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 "[core] Android ARM64 client..."
|
||||||
echo "Built DragonTCP VPN server + Android core"
|
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."
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
module dragontcpvpn
|
module dragontcp
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user