v1.0.0: AWG -> VLESS migration — ReVoltVPN debut

This commit is contained in:
esefxdz committed 2026-07-24 20:56:13 +03:00
1 parent b679ba4d8f
commit aaf9887f49
29 files changed
+1063 -1393

No files matched your search

+4 -2
View File
@@ -66,12 +66,14 @@ local_packages/*/.dart_tool/
android/key.properties android/key.properties
android/admob.properties android/admob.properties
SECURITY_HARDENING.md SECURITY_HARDENING.md
VLESS_APP_CONTRACT.md
AGENTS.md AGENTS.md
SERVER_NOTES.md SERVER_NOTES.md
lib/logic/backup/ lib/logic/backup/
00-FULL-REPORT.md 00-FULL-REPORT.md
server full picture.txt server full picture.txt
technical_debt_1_answer.md technical_debt_*.md
technical_debt_1.md
server_full_picture_2.txt server_full_picture_2.txt
hivemind_backup.py hivemind_backup.py
AGENTS.md
00-FULL-REPORT.md
+467
View File
@@ -0,0 +1,467 @@
# Xray Configuration Reference — PaladinVPN
> Minimal production config with annotated explanations and optional upgrades.
> File lives at `/usr/local/etc/xray/config.json` on the Hetzner box.
---
## Minimal Working Config
This is what you need to deploy. Copy-paste whole.
```json
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"tag": "vless-in",
"port": 8443,
"listen": "127.0.0.1",
"protocol": "vless",
"settings": {
"clients": [],
"decryption": "none"
},
"streamSettings": {
"network": "ws",
"security": "none",
"wsSettings": { "path": "/tunnel" }
}
},
{
"tag": "api-in",
"port": 10085,
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": { "address": "127.0.0.1" }
}
],
"outbounds": [
{ "protocol": "freedom", "tag": "direct" }
],
"stats": {},
"policy": {
"system": {
"statsInboundUplink": true,
"statsInboundDownlink": true
},
"levels": {
"0": { "uplinkOnly": 0, "downlinkOnly": 0 },
"1": { "uplinkOnly": 192, "downlinkOnly": 192 }
}
},
"routing": {
"rules": [
{
"type": "field",
"inboundTag": ["api-in"],
"outboundTag": "direct"
}
]
}
}
```
---
## What Every Section Does
### `log`
```json
"log": { "loglevel": "warning" }
```
| Value | Behavior |
|-------|----------|
| `"none"` | Silent. Use once everything is stable. |
| `"warning"` | Only problems. Good for production. |
| `"info"` | Shows connects/disconnects. Useful for debugging. |
| `"debug"` | Every packet, every DNS query, very noisy. Use only when tracing a specific bug. |
Xray writes to stdout, which systemd captures. View with `journalctl -u xray -f`.
---
### `inbounds[0]` — VLESS tunnel entry (`vless-in`)
```json
{
"tag": "vless-in",
"port": 8443,
"listen": "127.0.0.1",
"protocol": "vless",
"settings": {
"clients": [],
"decryption": "none"
},
"streamSettings": {
"network": "ws",
"security": "none",
"wsSettings": { "path": "/tunnel" }
}
}
```
| Field | What it does |
|-------|--------------|
| `tag: "vless-in"` | Name used by routing rules and Python code. Must match `VLESS_INBOUND_TAG` in the server. |
| `port: 8443` | Xray listens here. nginx forwards WebSocket traffic from :443 to this port. Not exposed to the internet directly. |
| `listen: "127.0.0.1"` | Localhost only. The internet can't reach this port — nginx is the gatekeeper. |
| `protocol: "vless"` | VLESS protocol — lightweight, no built-in encryption (TLS is handled by nginx). |
| `clients: []` | Starts empty. Your Python script adds UUIDs when users watch ads via `add_vless_client()`. |
| `decryption: "none"` | VLESS-specific. Encryption is handled by the outer TLS layer (nginx + certbot). |
| `network: "ws"` | WebSocket transport — this is what makes VLESS look like regular HTTPS to DPI. |
| `security: "none"` | No internal TLS. nginx already terminated TLS. |
| `path: "/tunnel"` | The WebSocket path. Must match `VLESS_PATH` in Python and `vlessPath` in Dart. |
**Why localhost + nginx instead of exposing Xray directly?**
- nginx handles Let's Encrypt certs. Xray never touches certs.
- VLESS over WebSocket on :443 looks identical to a normal website visitor. DPI can't tell the difference.
- If Xray crashes, nginx returns 502 instead of a connection refused — cleaner for clients.
---
### `inbounds[1]` — Stats API (`api-in`)
```json
{
"tag": "api-in",
"port": 10085,
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": { "address": "127.0.0.1" }
}
```
| Field | What it does |
|-------|--------------|
| `tag: "api-in"` | Used by the routing rule to direct stats traffic. |
| `port: 10085` | Must match `XRAY_API = "http://127.0.0.1:10085"` in Python. |
| `protocol: "dokodemo-door"` | "Accept anything, just pass it through." Used because the stats API is a simple HTTP endpoint, not a proxy protocol. |
| `listen: "127.0.0.1"` | Stats API is internal only. Never expose this to the internet. |
Your Python code hits `http://127.0.0.1:10085/stats/query` to ask "how many bytes has user X used?" The response drives quota enforcement. If this port is unreachable, `get_xray_stats()` returns `{}` and quota enforcement silently stops.
---
### `outbounds`
```json
"outbounds": [
{ "protocol": "freedom", "tag": "direct" }
]
```
| Field | What it does |
|-------|--------------|
| `protocol: "freedom"` | "Send traffic to the real internet." The default outbound for all user traffic. |
| `tag: "direct"` | Name referenced by routing rules. |
This is the exit door. User traffic comes in through `vless-in`, gets processed, and leaves through `direct` to the internet. Simple setups only need one outbound. Advanced setups might have multiple (see "additions" below).
---
### `stats`
```json
"stats": {}
```
Enables Xray's internal byte counter. Without this, `get_xray_stats()` always returns `{"stat":[]}` — no quota enforcement, no per-user tracking. Required.
---
### `policy`
```json
"policy": {
"system": {
"statsInboundUplink": true,
"statsInboundDownlink": true
},
"levels": {
"0": { "uplinkOnly": 0, "downlinkOnly": 0 },
"1": { "uplinkOnly": 192, "downlinkOnly": 192 }
}
}
```
| Field | What it does |
|-------|--------------|
| `statsInboundUplink` | Count upload bytes per user. Required for quota. |
| `statsInboundDownlink` | Count download bytes per user. Required for quota. |
| `levels.0` | Unlimited (0 = no cap). Default for new sessions. |
| `levels.1` | 192 KB/s up + down ≈ 1.5 Mbps. Applied when user exceeds 2 GB. |
**This is your throttling system.** When a user hits their quota, Python calls `set_vless_client_level(device_id, 1)`. Xray enforces the cap instantly at the protocol level — no `tc` commands, no per-IP kernel rules. When they watch a bonus ad, Python calls `set_vless_client_level(device_id, 0)` to restore full speed.
Each user gets their `level` field set when `add_vless_client()` creates their entry in the `clients` array. Level changes take effect on the next Xray hot-reload (`xray api adi`).
---
### `routing`
```json
"routing": {
"rules": [
{
"type": "field",
"inboundTag": ["api-in"],
"outboundTag": "direct"
}
]
}
```
| Field | What it does |
|-------|--------------|
| `type: "field"` | Match traffic by field (inbound tag, domain, protocol, etc). |
| `inboundTag: ["api-in"]` | "This rule applies to traffic arriving on the api-in inbound." |
| `outboundTag: "direct"` | "Send it out through the direct outbound." |
Traffic on `vless-in` (user VPN traffic) uses the default route — also `direct`, since it's the only outbound. This explicit rule for `api-in` prevents Xray from getting confused about where to send stats queries.
---
## Verification Commands
After deploying the config:
```bash
# Restart Xray
sudo systemctl restart xray
# Check it's running
sudo systemctl status xray
# Test stats API
curl http://127.0.0.1:10085/stats/query -d '{"pattern":"","reset":false}'
# Should return: {"stat":[]}
# Watch logs
sudo journalctl -u xray -f
# Test WebSocket upgrade through nginx (run after nginx config is updated)
curl -v -H "Upgrade: websocket" -H "Connection: Upgrade" https://YOURDOMAIN.com/tunnel
# Should return 101 Switching Protocols
```
---
## Optional Additions — Cherry-Pick What You Want
### 1. DNS — Stop DNS Leaks
```json
"dns": {
"servers": [
"1.1.1.1",
"8.8.8.8",
"localhost"
]
}
```
Xray resolves domains itself instead of using the server's system DNS. Prevents DNS leaks. The `"localhost"` fallback means "use the server's DNS if Cloudflare/Google are unreachable."
---
### 2. Connection Limits — Prevent Dead Sessions
Add to `policy.levels` entries:
```json
"0": {
"uplinkOnly": 0,
"downlinkOnly": 0,
"connIdle": 300
},
"1": {
"uplinkOnly": 192,
"downlinkOnly": 192,
"connIdle": 300
}
```
`connIdle: 300` = close the connection if no traffic for 5 minutes. Prevents sessions from lingering forever with the WebSocket open but idle. Saves server resources.
---
### 3. Buffer Size — Smoother Throttled Browsing
Add to throttled level:
```json
"1": {
"uplinkOnly": 192,
"downlinkOnly": 192,
"bufferSize": 512
}
```
Allows short bursts up to 512 bytes above the cap before throttling kicks in. Makes throttled browsing feel less sluggish — pages load in small bursts. Without this, every single packet is capped, which feels awful at 192 KB/s.
---
### 4. Sniffing — Better App Compatibility
```json
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls"]
}
```
Xray inspects the first few bytes of each connection to detect the real protocol (HTTP, TLS, etc.) and routes accordingly. Fixes issues with apps that don't play nice with transparent proxying. Low overhead, generally safe to enable.
---
### 5. Per-Outbound Metrics — Server-Wide Dashboard
```json
"metrics": {
"tag": "metrics-out"
}
```
Adds total server-wide upload/download to the stats API response. Not per-user, just aggregate. Useful for a dashboard like "served 42 GB today across all users."
---
### 6. Multiple Outbounds — Future-Proofing
```json
"outbounds": [
{ "protocol": "freedom", "tag": "direct" },
{ "protocol": "blackhole", "tag": "block" }
]
```
Adds a `block` outbound that silently drops traffic. Later you could add routing rules to block specific domains (torrent trackers, malware, etc.) by routing them to `block` instead of `direct`.
With a routing rule like:
```json
{
"type": "field",
"domain": ["geosite:torrent"],
"outboundTag": "block"
}
```
---
### 7. Log to File Instead of stdout
```json
"log": {
"loglevel": "warning",
"access": "/var/log/xray/access.log",
"error": "/var/log/xray/error.log"
}
```
Writes access and error logs to files instead of systemd journal. Easier to grep. Make sure the directory exists and Xray can write to it:
```bash
sudo mkdir -p /var/log/xray
sudo chown xray:xray /var/log/xray
```
---
### 8. IPv6 Outbound
```json
"outbounds": [
{
"protocol": "freedom",
"tag": "direct",
"settings": {
"domainStrategy": "UseIP"
}
}
]
```
`domainStrategy: "UseIP"` means "use whichever IP version the DNS returns." If you enable IPv6 on the server later, this lets Xray use it automatically. Options: `"UseIP"`, `"UseIPv4"`, `"UseIPv6"`.
---
### 9. Full Config with All Cherry-Picks Applied
```json
{
"log": {
"loglevel": "warning",
"access": "/var/log/xray/access.log",
"error": "/var/log/xray/error.log"
},
"dns": {
"servers": ["1.1.1.1", "8.8.8.8", "localhost"]
},
"inbounds": [
{
"tag": "vless-in",
"port": 8443,
"listen": "127.0.0.1",
"protocol": "vless",
"settings": {
"clients": [],
"decryption": "none"
},
"streamSettings": {
"network": "ws",
"security": "none",
"wsSettings": { "path": "/tunnel" }
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls"]
}
},
{
"tag": "api-in",
"port": 10085,
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": { "address": "127.0.0.1" }
}
],
"outbounds": [
{
"protocol": "freedom",
"tag": "direct",
"settings": { "domainStrategy": "UseIP" }
},
{ "protocol": "blackhole", "tag": "block" }
],
"stats": {},
"metrics": { "tag": "metrics-out" },
"policy": {
"system": {
"statsInboundUplink": true,
"statsInboundDownlink": true
},
"levels": {
"0": {
"uplinkOnly": 0,
"downlinkOnly": 0,
"connIdle": 300
},
"1": {
"uplinkOnly": 192,
"downlinkOnly": 192,
"connIdle": 300,
"bufferSize": 512
}
}
},
"routing": {
"rules": [
{
"type": "field",
"inboundTag": ["api-in"],
"outboundTag": "direct"
}
]
}
}
```
---
## Config ↔ Python Code Reference
| Config value | Python constant | What breaks if mismatched |
|---|---|---|
| `inbounds[0].tag = "vless-in"` | `VLESS_INBOUND_TAG = "vless-in"` | `add_vless_client()` can't find the inbound |
| `inbounds[0].port = 8443` | nginx `proxy_pass http://127.0.0.1:8443` | nginx can't forward to Xray |
| `inbounds[1].port = 10085` | `XRAY_API = "http://127.0.0.1:10085"` | Stats queries fail silently |
| `wsSettings.path = "/tunnel"` | `VLESS_PATH = "/tunnel"` | nginx 404s because path doesn't match |
| `policy.levels.1 = 192` | `THROTTLE_RATE_KBPS = 192` | Throttle speed doesn't match documentation |
| Config file path | `XRAY_CONFIG = "/usr/local/etc/xray/config.json"` | Server can't find the config at all |
---
## Troubleshooting Cheat Sheet
| Symptom | Check |
|---------|-------|
| `[CMD FAIL] xray api adi` | Is Xray running? `systemctl status xray` |
| Stats always return `{}` | `curl http://127.0.0.1:10085/stats/query -d '{"pattern":"","reset":false}'` |
| Clients can't connect | `curl -v -H "Upgrade: websocket" -H "Connection: Upgrade" https://DOMAIN/tunnel` |
| Clients connect but no internet | `sudo journalctl -u xray -f`, check for routing errors |
| Config changes not taking effect | `sudo systemctl restart xray` (hot-reload via API sometimes needs a restart) |
| "Address already in use" on Xray start | Port 8443 or 10085 is taken. `sudo ss -tlnp \| grep -E '8443\|10085'` |
+5 -14
View File
@@ -4,17 +4,11 @@
# via JNI (Java Native Interface) or reflection, which would cause # via JNI (Java Native Interface) or reflection, which would cause
# UnsatisfiedLinkError / ClassNotFoundException at runtime. # UnsatisfiedLinkError / ClassNotFoundException at runtime.
# ── AmneziaWG Native Backend (JNI) ──────────────────────────────────────────── # ── VLESS / Xray Native Backend (flutter_vless) ───────────────────────────────
# The wireguard_flutter plugin uses org.amnezia.awg for all tunnel operations. # The flutter_vless plugin embeds Xray core via JNI. Keep all JNI entry points.
# These classes are loaded via JNI — stripping them will crash the VPN. -keep class com.github.tfox.flutter_vless.** { *; }
-keep class org.amnezia.awg.backend.** { *; } -keep class libv2ray.** { *; }
-keep class org.amnezia.awg.crypto.** { *; } -keep class xray.** { *; }
-keep class org.amnezia.awg.config.** { *; }
-keep class com.wireguard.** { *; }
# ── Kotlin Coroutines (used by wireguard_flutter plugin) ──────────────────────
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
# ── Flutter / Platform Channels ─────────────────────────────────────────────── # ── Flutter / Platform Channels ───────────────────────────────────────────────
-keep class io.flutter.** { *; } -keep class io.flutter.** { *; }
@@ -25,9 +19,6 @@
-keep class com.google.android.gms.ads.** { *; } -keep class com.google.android.gms.ads.** { *; }
-dontwarn com.google.android.gms.ads.** -dontwarn com.google.android.gms.ads.**
# ── Klaxon JSON parser (used by wireguard_flutter plugin) ─────────────────────
-keep class com.beust.klaxon.** { *; }
# ── General Android / Kotlin ────────────────────────────────────────────────── # ── General Android / Kotlin ──────────────────────────────────────────────────
-keepattributes *Annotation* -keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-30
View File
@@ -1,30 +0,0 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.11.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.20"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
}
+5 -3
View File
@@ -13,10 +13,12 @@ allprojects {
} }
} }
rootProject.buildDir = "../build" rootProject.layout.buildDirectory.set(layout.projectDirectory.dir("../build"))
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.layout.buildDirectory.set(
rootProject.layout.buildDirectory.dir(project.name)
)
} }
subprojects { subprojects {
@@ -24,5 +26,5 @@ subprojects {
} }
tasks.register("clean", Delete::class) { tasks.register("clean", Delete::class) {
delete(rootProject.buildDir) delete(rootProject.layout.buildDirectory)
} }
-1
View File
@@ -1,4 +1,3 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
-25
View File
@@ -1,25 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.11.1" apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
}
include ":app"
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")
+1 -4
View File
@@ -98,10 +98,8 @@ class AdManager extends ChangeNotifier {
} }
} }
// 2. Fetch device credentials for Server-Side Verification (SSV) // 2. Fetch device ID for Server-Side Verification (SSV)
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
final keys = await CryptoService.getOrCreateKeys();
final pubKey = keys['publicKey']!;
// 3. Generate a connect nonce so the subsequent poll can verify // 3. Generate a connect nonce so the subsequent poll can verify
// it's talking to the session created by *this* ad. // it's talking to the session created by *this* ad.
@@ -113,7 +111,6 @@ class AdManager extends ChangeNotifier {
final ssvOptions = ServerSideVerificationOptions( final ssvOptions = ServerSideVerificationOptions(
customData: jsonEncode({ customData: jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
'public_key': pubKey,
'ad_type': adType, 'ad_type': adType,
'nonce': nonce, 'nonce': nonce,
}), }),
+15 -36
View File
@@ -1,47 +1,26 @@
// ============================================================================= // =============================================================================
// app_config.example.dart — TEMPLATE FOR SERVER SETTINGS // app_config.example.dart — TEMPLATE FOR SERVER SETTINGS
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Rename this file to `app_config.dart` and fill in your real values below. // Rename this file to `app_config.dart` and fill in your real values.
// Do not commit your real `app_config.dart` to GitHub! // Do not commit your real `app_config.dart` to GitHub!
// ============================================================================= // =============================================================================
class AppConfig { abstract final class AppConfig {
// ── STEP 1: Paste your Hetzner server IP here ───────────────────────────── AppConfig._();
static const String serverIp = '127.0.0.1'; // REPLACE WITH REAL IP
// ── STEP 2: WireGuard UDP traffic port (default: 51820) ─────────────────── // ── Server ─────────────────────────────────────────────────────────────
static const String wgPort = '51820'; // REPLACE WITH REAL PORT static const String serverDomain = 'yourdomain.com'; // REPLACE
// ── STEP 3: AmneziaWG DPI Obfuscation Settings ─────────────────────────── static const String vlessPath = '/tunnel'; // REPLACE if different
// Copy these from your real app_config.dart or your amnezia-wg-easy server.
// All zeros = AmneziaWG obfuscation disabled (no DPI protection).
static const int awgJc = 0; // REPLACE
static const int awgJmin = 0; // REPLACE
static const int awgJmax = 0; // REPLACE
static const int awgS1 = 0; // REPLACE
static const int awgS2 = 0; // REPLACE
static const int awgH1 = 0; // REPLACE
static const int awgH2 = 0; // REPLACE
static const int awgH3 = 0; // REPLACE
static const int awgH4 = 0; // REPLACE
// ── STEP 4: AdMob IDs ─────────────────────────────────────────────────── static String get hivemindApiBase => 'https://$serverDomain/api';
// Also create android/admob.properties from the template in that folder.
static const String admobAppId = 'ca-app-pub-0000000000000000~0000000000'; // REPLACE // ── VLESS ──────────────────────────────────────────────────────────────
static const int serverPort = 443;
static const String vlessFlow = 'xtls-rprx-vision';
static const String vlessSecurity = 'tls';
static const String vlessType = 'ws';
// ── AdMob ──────────────────────────────────────────────────────────────
static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE
// ── STEP 5: Network & Tunnel Settings ────────────────────────────────────
static const String dnsServers = '1.1.1.1, 1.0.0.1, 2606:4700:4700::1111, 2606:4700:4700::1001';
static const String allowedIps = '0.0.0.0/0, ::/0';
static const int mtu = 1280;
static const int persistentKeepalive = 25;
static const int hivemindPort = 5000;
// ─────────────────────────────────────────────────────────────────────────
// Nothing below this line needs to be changed.
// ── STEP 1: Hivemind Server API ──────────────────────────────────────────
static String get hivemindApiBase => 'https://yourdomain.duckdns.org/api'; // REPLACE WITH REAL DOMAIN
/// The full server address passed to the WireGuard tunnel (IP:port).
static String get serverEndpoint => '$serverIp:$wgPort';
} }
+1 -30
View File
@@ -1,16 +1,11 @@
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class CryptoService { class CryptoService {
static const String _privKeyPref = 'wg_private_key';
static const String _pubKeyPref = 'wg_public_key';
static const String _deviceIdPref = 'device_uuid'; static const String _deviceIdPref = 'device_uuid';
static const _storage = FlutterSecureStorage(); static const _storage = FlutterSecureStorage();
// ── Get or create the persistent device UUID ────────────────────────────── /// Get or create a persistent device UUID for server-side session tracking.
static Future<String> getDeviceId() async { static Future<String> getDeviceId() async {
final existing = await _storage.read(key: _deviceIdPref); final existing = await _storage.read(key: _deviceIdPref);
if (existing != null) return existing; if (existing != null) return existing;
@@ -19,28 +14,4 @@ class CryptoService {
await _storage.write(key: _deviceIdPref, value: newId); await _storage.write(key: _deviceIdPref, value: newId);
return newId; return newId;
} }
// ── Get or create the X25519 keypair for WireGuard ────────────────────────
static Future<Map<String, String>> getOrCreateKeys() async {
String? privBase64 = await _storage.read(key: _privKeyPref);
String? pubBase64 = await _storage.read(key: _pubKeyPref);
if (privBase64 != null && pubBase64 != null) {
return {'privateKey': privBase64, 'publicKey': pubBase64};
}
final algorithm = X25519();
final keyPair = await algorithm.newKeyPair();
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
final publicKey = await keyPair.extractPublicKey();
privBase64 = base64Encode(privateKeyBytes);
pubBase64 = base64Encode(publicKey.bytes);
await _storage.write(key: _privKeyPref, value: privBase64);
await _storage.write(key: _pubKeyPref, value: pubBase64);
return {'privateKey': privBase64, 'publicKey': pubBase64};
}
} }
+16 -50
View File
@@ -20,7 +20,7 @@ class HivemindService {
} }
/// Polls the Hivemind server until the session is ready or timeout occurs. /// Polls the Hivemind server until the session is ready or timeout occurs.
/// Returns the WireGuard config string. /// Returns a VLESS connection URL string.
/// ///
/// [onAttempt] is called after each poll with (current, total) so the UI /// [onAttempt] is called after each poll with (current, total) so the UI
/// can show progress (e.g. "Contacting server (3/15)…"). /// can show progress (e.g. "Contacting server (3/15)…").
@@ -29,15 +29,8 @@ class HivemindService {
void Function(int attempt, int total)? onAttempt, void Function(int attempt, int total)? onAttempt,
}) async { }) async {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
final keys = await CryptoService.getOrCreateKeys();
final privKey = keys['privateKey']!;
// ── Generate a connect nonce ────────────────────────────────────── // ── Generate a connect nonce ──────────────────────────────────────
// Every fresh connect cycle gets a new random token. The server
// stores it in the session and echoes it back in /session/status.
// If the poll returns a session whose nonce differs (e.g. a stale
// leftover from a failed disconnectAndCleanup), we skip it and keep
// polling until the right session appears or the timeout fires.
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
_expectedNonce = nonce; _expectedNonce = nonce;
debugPrint('[HivemindService] Connect nonce: $nonce'); debugPrint('[HivemindService] Connect nonce: $nonce');
@@ -45,14 +38,10 @@ class HivemindService {
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
// ── AD BYPASS (debug only) ──────────────────────────────────────── // ── AD BYPASS (debug only) ────────────────────────────────────────
// In debug mode, send a fake AdMob callback so the server creates
// a session without a real ad. In release mode this code is
// stripped — sessions are only created by real AdMob SSV callbacks.
if (kDebugMode) { if (kDebugMode) {
try { try {
final customData = jsonEncode({ final customData = jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
'public_key': keys['publicKey']!,
'ad_type': 'main_ad', 'ad_type': 'main_ad',
'nonce': nonce, 'nonce': nonce,
}); });
@@ -75,48 +64,27 @@ class HivemindService {
debugPrint('[HivemindService] Session found, checking nonce…'); debugPrint('[HivemindService] Session found, checking nonce…');
// ── Nonce gate ────────────────────────────────────────────── // ── Nonce gate ──────────────────────────────────────────────
// Reject any session whose nonce doesn't match what we sent.
// If the server is old and doesn't return a nonce we still
// accept it (backwards-compat), but a mismatch is a hard skip.
final serverNonce = data['nonce'] as String?; final serverNonce = data['nonce'] as String?;
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) { if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
debugPrint('[HivemindService] Nonce mismatch (expected $_expectedNonce, got $serverNonce) — stale session, retrying…'); debugPrint('[HivemindService] Nonce mismatch (expected $_expectedNonce, got $serverNonce) — stale session, retrying…');
// Fall through to retry — do NOT accept this session. } else if (data['active'] == true && data['vless_uuid'] != null) {
} else if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) { final vlessUuid = data['vless_uuid'];
final clientIp = data['client_ip'];
final clientIpv6 = data['client_ipv6'] ?? '';
final serverPubkey = data['server_pubkey'];
// Build dual-stack Address: IPv4/16 + optional IPv6/80 // Build VLESS connection URL
final addresses = clientIpv6.isNotEmpty final vlessUrl = 'vless://$vlessUuid@${AppConfig.serverDomain}:${AppConfig.serverPort}'
? '$clientIp/16, $clientIpv6/80' '?path=${Uri.encodeComponent(AppConfig.vlessPath)}'
: '$clientIp/16'; '&security=${AppConfig.vlessSecurity}'
'&type=${AppConfig.vlessType}'
'&flow=${AppConfig.vlessFlow}'
'#PaladinVPN';
// Construct the WireGuard config locally
final configText = '''
[Interface]
PrivateKey = $privKey
Address = $addresses
DNS = ${AppConfig.dnsServers}
MTU = ${AppConfig.mtu}
Jc = ${AppConfig.awgJc}
Jmin = ${AppConfig.awgJmin}
Jmax = ${AppConfig.awgJmax}
S1 = ${AppConfig.awgS1}
S2 = ${AppConfig.awgS2}
H1 = ${AppConfig.awgH1}
H2 = ${AppConfig.awgH2}
H3 = ${AppConfig.awgH3}
H4 = ${AppConfig.awgH4}
[Peer]
PublicKey = $serverPubkey
AllowedIPs = ${AppConfig.allowedIps}
Endpoint = ${AppConfig.serverEndpoint}
PersistentKeepalive = ${AppConfig.persistentKeepalive}
''';
_expectedNonce = null; // consumed successfully _expectedNonce = null; // consumed successfully
return configText; return vlessUrl;
} else if (data['active'] == true && data['client_ip'] != null) {
// Server is still running AWG — VLESS client cannot use this.
debugPrint('[HivemindService] Server returned AWG format (client_ip) — server migration needed.');
throw Exception('Server is running the old AWG protocol. '
'Please update the server to VLESS.');
} }
} else { } else {
debugPrint('[HivemindService] Non-200 status: ${response.statusCode}'); debugPrint('[HivemindService] Non-200 status: ${response.statusCode}');
@@ -134,8 +102,6 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive}
} }
/// Quick one-shot check: does the server have a live session for this device? /// Quick one-shot check: does the server have a live session for this device?
/// Used by VpnConnection after the tunnel starts to confirm the server-side
/// session exists before reporting "connected" to the UI.
static Future<bool> verifySession() async { static Future<bool> verifySession() async {
try { try {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
+76 -187
View File
@@ -1,106 +1,71 @@
// ============================================================================= // =============================================================================
// vpn_connection.dart — WireGuard Tunnel Manager // vpn_connection.dart — VLESS VPN Tunnel Manager
// ───────────────────────────────────────────────────────────────────────────── // =============================================================================
// This file manages the WireGuard tunnel on the Android device.
// It is the bridge between the UI (the big connect button) and the actual
// VPN tunnel running inside the Android operating system.
//
// HOW IT WORKS:
// 1. When connect() is called, it first asks WgApiService to fetch the config
// file for this device from the server (see wg_api_service.dart).
// 2. It passes that config text to the wireguard_flutter plugin, which handles
// all the native Android VPN stuff automatically.
// 3. It listens to the native tunnel state and updates the UI accordingly.
//
// You never need to edit this file.
// Server settings → app_config.dart
// Server communication → wg_api_service.dart
// =============================================================================
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:wireguard_flutter/wireguard_flutter.dart'; import 'package:flutter_vless/flutter_vless.dart';
import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/hivemind_service.dart'; import 'package:paladinvpn/logic/hivemind_service.dart';
// ── VPN States ──────────────────────────────────────────────────────────────── // ── VPN States ────────────────────────────────────────────────────────────────
// These are the possible states the tunnel can be in.
// The UI uses these to decide what to display (spinner, shield icon, etc.)
enum VpnStatus { enum VpnStatus {
disconnected, // No tunnel active — idle state disconnected,
connecting, // Fetching config from server + starting tunnel connecting,
connected, // Tunnel is up and routing traffic connected,
disconnecting, // Tearing down the tunnel disconnecting,
error, // Something went wrong (server unreachable, etc.) error,
} }
// ── VpnConnection ─────────────────────────────────────────────────────────────
// This is a "ChangeNotifier" — it works like a Python observable/event emitter.
// Whenever the state changes, it automatically notifies all widgets that are
// listening, and they re-render themselves. You never call setState() manually.
class VpnConnection extends ChangeNotifier { class VpnConnection extends ChangeNotifier {
// Current tunnel state (starts disconnected)
VpnStatus _status = VpnStatus.disconnected; VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status; VpnStatus get status => _status;
bool _pluginAvailable = false;
// True only during the brief window after startup where we detected an
// already-running tunnel — lets SessionTimer know to auto-resume.
bool _isStartupRestoration = false;
bool get isStartupRestoration => _isStartupRestoration;
// Short message shown to the user under the connect button
String _statusMessage = 'Tap to connect'; String _statusMessage = 'Tap to connect';
String get statusMessage => _statusMessage; String get statusMessage => _statusMessage;
// Optional error message shown if something goes wrong
String? _errorMessage; String? _errorMessage;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
// The internal WireGuard IP assigned to this device (e.g. 10.8.0.2) // True when the app was killed and reopened while the VPN was running.
// Populated after a successful config fetch and used by SessionTimer. bool _isStartupRestoration = false;
String? internalIp; bool get isStartupRestoration => _isStartupRestoration;
// The wireguard_flutter plugin instance — this is what actually talks to Android late final FlutterVless _vless;
final _wireguard = WireGuardFlutter.instance; bool _initialized = false;
// Subscription to the native tunnel state stream
StreamSubscription? _stageSub;
// ── Initialization ────────────────────────────────────────────────────────
// Called automatically when the app starts (via Provider in main.dart)
VpnConnection() { VpnConnection() {
_init(); _init();
} }
Future<void> _init() async { Future<void> _init() async {
// WireGuard doesn't work in a web browser — skip on web
if (kIsWeb) return; if (kIsWeb) return;
_vless = FlutterVless(
onStatusChanged: (status) {
debugPrint('[VPN] Status: state=${status.state} '
'connection=${status.connectionState.name}');
_mapStatus(status);
},
);
try { try {
// Register our tunnel interface with Android. await _vless.initializeVless(
// 'Paladin0' is just the name of the network interface — like "eth0" providerBundleIdentifier: 'com.paladinvpn.app',
await _wireguard.initialize(interfaceName: 'Paladin0'); );
_pluginAvailable = true; _initialized = true;
} catch (e) { } catch (e) {
debugPrint('WireGuard init error (expected on emulator): $e'); debugPrint('[VPN] VLESS init error (expected on emulator): $e');
} }
// Start listening to native tunnel state changes from Android. // Check if VPN was already running (app killed and reopened)
// When Android reports the tunnel went up/down, _mapStage() is called.
try { try {
_stageSub = _wireguard.vpnStageSnapshot.listen(_mapStage); final coreVersion = await _vless.getCoreVersion();
} catch (_) {} debugPrint('[VPN] Xray core version: $coreVersion');
// Check if the VPN was already running before the app started. final delay = await _vless.getConnectedServerDelay();
// This handles the case where the user swiped the app away and reopened it. if (delay > 0) {
try {
final currentStage = await _wireguard.stage();
if (currentStage == VpnStage.connected) {
_isStartupRestoration = true; _isStartupRestoration = true;
_mapStage(currentStage); _setStatus(VpnStatus.connected, 'Secured');
// Clear the flag after a few seconds — only needed during app startup
Future.delayed(const Duration(seconds: 5), () { Future.delayed(const Duration(seconds: 5), () {
_isStartupRestoration = false; _isStartupRestoration = false;
}); });
@@ -108,52 +73,34 @@ class VpnConnection extends ChangeNotifier {
} catch (_) {} } catch (_) {}
} }
// ── Native State Mapping ────────────────────────────────────────────────── void _mapStatus(VlessStatus status) {
// Android sends back low-level states like "authenticating", "preparing", etc. switch (status.connectionState) {
// This function translates those into our simpler 5-state enum above. case VlessConnectionState.connected:
void _mapStage(VpnStage stage) {
switch (stage) {
case VpnStage.connected:
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, 'Secured');
break; case VlessConnectionState.disconnected:
case VpnStage.disconnected:
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
break; case VlessConnectionState.connecting:
case VpnStage.connecting:
case VpnStage.waitingConnection:
case VpnStage.authenticating:
case VpnStage.preparing:
case VpnStage.reconnect:
_setStatus(VpnStatus.connecting, 'Establishing tunnel…'); _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break; case VlessConnectionState.disconnecting:
case VpnStage.disconnecting:
case VpnStage.exiting:
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
break; case VlessConnectionState.unknown:
default: // Native backend emitted an unrecognized state — treat as error
_setStatus(VpnStatus.disconnected, 'Tap to connect'); // only if we aren't already in a terminal state.
break; if (_status != VpnStatus.connected &&
_status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed');
}
} }
} }
// ── Connect ─────────────────────────────────────────────────────────────── // ── Connect ───────────────────────────────────────────────────────────────
// This is the main function — called after the user watches the rewarded ad.
//
// Step 1: Call the WG-Easy API to get the .conf for this device
// Step 2: Pass that .conf to the WireGuard plugin to start the tunnel
//
// Returns true if the connection was successful, false if it failed.
// The connect button uses this to decide whether to start the timer.
Future<bool> connect() async { Future<bool> connect() async {
// Don't do anything if we're already connected or currently connecting
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
return false; return false;
} }
// Guard: native plugin never loaded (wrong arch, missing .so) if (!kIsWeb && !_initialized) {
if (!kIsWeb && !_pluginAvailable) { _errorMessage = 'VPN service unavailable.';
_errorMessage = 'VPN service unavailable. The native tunnel library '
'could not be loaded on this device.';
_setStatus(VpnStatus.error, 'Service unavailable'); _setStatus(VpnStatus.error, 'Service unavailable');
return false; return false;
} }
@@ -161,40 +108,31 @@ class VpnConnection extends ChangeNotifier {
_setStatus(VpnStatus.connecting, 'Fetching config…'); _setStatus(VpnStatus.connecting, 'Fetching config…');
_errorMessage = null; _errorMessage = null;
// ── Web / emulator fallback ────────────────────────────────────────────
if (kIsWeb) { if (kIsWeb) {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
_setStatus(VpnStatus.connected, 'Secured (dev mode)'); _setStatus(VpnStatus.connected, 'Secured (dev mode)');
return true; return true;
} }
// ── Step 1: Fetch WireGuard config from Hivemind ──────────────────── // ── Step 1: Fetch VLESS URL from Hivemind ──────────────────────────
String configText; String vlessUrl;
try { try {
configText = await HivemindService.fetchConfigWithPolling( vlessUrl = await HivemindService.fetchConfigWithPolling(
onAttempt: (attempt, total) { onAttempt: (attempt, total) {
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…'); _setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
}, },
); );
// Parse the internal IP (e.g. "Address = 10.8.0.2/16" or "/32")
final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/');
final match = regex.firstMatch(configText);
if (match != null) {
internalIp = match.group(1);
}
} catch (e) { } catch (e) {
debugPrint('Hivemind config error: $e'); debugPrint('[VPN] Hivemind config error: $e');
final raw = e.toString().replaceAll('Exception: ', ''); final raw = e.toString().replaceAll('Exception: ', '');
// Map the known failures to something the user can act on if (raw.contains('AWG protocol') || raw.contains('old AWG')) {
if (raw.contains('timed out') || raw.contains('Session not activated')) { _errorMessage = 'Server update required.\nThe VPN server needs to be upgraded to VLESS.';
_errorMessage = 'The server did not respond in time.\n' _setStatus(VpnStatus.error, 'Server outdated');
'Check your internet connection and try again.'; } else if (raw.contains('timed out') || raw.contains('Session not activated')) {
_errorMessage = 'The server did not respond in time.\nCheck your connection and try again.';
_setStatus(VpnStatus.error, 'Server unreachable'); _setStatus(VpnStatus.error, 'Server unreachable');
} else if (raw.contains('SocketException') || } else if (raw.contains('SocketException') || raw.contains('Failed host lookup')) {
raw.contains('Failed host lookup')) { _errorMessage = 'Cannot reach the server.\nMake sure you have internet access.';
_errorMessage = 'Cannot reach the VPN server.\n'
'Make sure you have an active internet connection.';
_setStatus(VpnStatus.error, 'No internet'); _setStatus(VpnStatus.error, 'No internet');
} else { } else {
_errorMessage = raw; _errorMessage = raw;
@@ -204,24 +142,20 @@ class VpnConnection extends ChangeNotifier {
return false; return false;
} }
// ── Step 2: Start the WireGuard tunnel with the fetched config ────── // ── Step 2: Parse and start VLESS tunnel ───────────────────────────
try { try {
// Re-initialize to ensure VPN permission is granted before starting final parsed = FlutterVless.parse(vlessUrl);
await _wireguard.initialize(interfaceName: 'Paladin0'); final config = parsed.getFullConfiguration();
await _wireguard.startVpn(
serverAddress: AppConfig.serverEndpoint, await _vless.startVless(
wgQuickConfig: configText, remark: parsed.remark.isNotEmpty ? parsed.remark : 'PaladinVPN',
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider', config: config,
); );
// ── Step 3: Verify the server session is alive ────────────────── // Give the tunnel a moment to stabilise
// The tunnel just came up — give it a moment to stabilise before
// making an HTTP call through it, otherwise DNS/resolution can fail.
_setStatus(VpnStatus.connecting, 'Verifying session…'); _setStatus(VpnStatus.connecting, 'Verifying session…');
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
// Retry once if the first attempt fails — the tunnel may need a
// moment to settle before outbound HTTP works reliably.
bool sessionOk = false; bool sessionOk = false;
for (int attempt = 0; attempt < 2; attempt++) { for (int attempt = 0; attempt < 2; attempt++) {
sessionOk = await HivemindService.verifySession(); sessionOk = await HivemindService.verifySession();
@@ -235,10 +169,9 @@ class VpnConnection extends ChangeNotifier {
if (!sessionOk) { if (!sessionOk) {
debugPrint('[VPN] Tunnel started but no server session — tearing down.'); debugPrint('[VPN] Tunnel started but no server session — tearing down.');
try { try {
await _wireguard.stopVpn().timeout(const Duration(seconds: 3)); await _vless.stopVless();
} catch (_) {} } catch (_) {}
_errorMessage = 'Session not confirmed by server.\n' _errorMessage = 'Session not confirmed by server.\nPlease try again.';
'Please try again.';
_setStatus(VpnStatus.error, 'Session rejected'); _setStatus(VpnStatus.error, 'Session rejected');
return false; return false;
} }
@@ -246,49 +179,17 @@ class VpnConnection extends ChangeNotifier {
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, 'Secured');
return true; return true;
} catch (e) { } catch (e) {
debugPrint('WireGuard tunnel error: $e'); debugPrint('[VPN] VLESS tunnel error: $e');
final raw = e.toString(); _errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
if (raw.contains('VPN_PERMISSION_REQUESTED')) { _setStatus(VpnStatus.error, 'Connection failed');
// Native plugin showed the VPN permission dialog — wait and retry
_setStatus(VpnStatus.connecting, 'Waiting for permission…');
await Future.delayed(const Duration(seconds: 2));
// Retry the tunnel start once the user has had time to grant permission
try {
await _wireguard.startVpn(
serverAddress: AppConfig.serverEndpoint,
wgQuickConfig: configText,
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
);
_setStatus(VpnStatus.connected, 'Secured');
return true;
} catch (retryError) {
debugPrint('WireGuard retry error: $retryError');
_errorMessage = 'VPN permission was denied.\n'
'Please grant the VPN permission and try again.';
_setStatus(VpnStatus.error, 'Permission denied');
return false;
}
} else if (raw.contains('Permission') || raw.contains('permissions')) {
_errorMessage = 'VPN permission was denied.\n'
'Please grant the VPN permission and try again.';
_setStatus(VpnStatus.error, 'Permission denied');
} else if (raw.contains('NameInvalid') || raw.contains('invalid')) {
_errorMessage = 'The WireGuard configuration is invalid.\n'
'Please contact support.';
_setStatus(VpnStatus.error, 'Bad config');
} else {
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Tunnel error');
}
return false; return false;
} }
} }
// ── Disconnect ──────────────────────────────────────────────────────────── // ── Disconnect ────────────────────────────────────────────────────────────
// Tears down the active tunnel and returns Android to normal networking.
Future<void> disconnect() async { Future<void> disconnect() async {
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) { if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
return; // Already disconnecting or disconnected — don't double-fire return;
} }
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
@@ -299,39 +200,27 @@ class VpnConnection extends ChangeNotifier {
return; return;
} }
// Stop the VPN tunnel with a hard timeout.
// stopVpn() can hang indefinitely if the native Go backend is stuck —
// we give it 5 seconds max, then force through.
try { try {
await _wireguard.stopVpn().timeout( await _vless.stopVless().timeout(
const Duration(seconds: 5), const Duration(seconds: 5),
onTimeout: () { onTimeout: () => debugPrint('[VPN] stopVless() timed out.'),
debugPrint('[VPN] stopVpn() timed out — forcing disconnect state.');
},
); );
} catch (e) { } catch (e) {
debugPrint('WireGuard stop error: $e'); debugPrint('[VPN] VLESS stop error: $e');
} }
// Clean up the peer from the server so we don't leave dead connections lying around.
// Fire-and-forget with a short timeout — server cleanup failing should never
// block the local disconnect from completing.
HivemindService.disconnectAndCleanup(); HivemindService.disconnectAndCleanup();
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
} }
// ── Internal state updater ────────────────────────────────────────────────
// Updates state and triggers a UI re-render (like calling setState() in Python/Qt)
void _setStatus(VpnStatus s, String msg) { void _setStatus(VpnStatus s, String msg) {
_status = s; _status = s;
_statusMessage = msg; _statusMessage = msg;
notifyListeners(); // tells all listening widgets to rebuild notifyListeners();
} }
@override @override
void dispose() { void dispose() {
_stageSub?.cancel();
super.dispose(); super.dispose();
} }
} }
-1
View File
@@ -7,7 +7,6 @@ import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/session_timer.dart'; import 'package:paladinvpn/logic/session_timer.dart';
import 'package:paladinvpn/logic/ad_manager.dart'; import 'package:paladinvpn/logic/ad_manager.dart';
import 'package:paladinvpn/logic/consent_manager.dart'; import 'package:paladinvpn/logic/consent_manager.dart';
import 'package:paladinvpn/screens/main_screen.dart';
import 'package:paladinvpn/screens/intro.dart'; import 'package:paladinvpn/screens/intro.dart';
/// The entry point for the Paladin VPN application. /// The entry point for the Paladin VPN application.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 PT BILLION JAYA GROUP
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,66 +0,0 @@
group 'billion.group.wireguard_flutter'
version '1.0-SNAPSHOT'
buildscript {
ext.kotlin_version = '1.8.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 31
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
minSdkVersion 21
}
namespace 'billion.group.wireguard_flutter'
packaging {
resources {
excludes += ['META-INF/**']
}
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.zaneschepke:amneziawg-android:2.3.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2"
implementation 'com.beust:klaxon:5.5'
implementation 'androidx.appcompat:appcompat:1.6.1'
/// to run wireguard on Android 25 or lower
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.3"
}
@@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1 +0,0 @@
rootProject.name = 'wireguard_dart'
@@ -1,8 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The AmneziaWG GoBackend internally creates a VpnService.
FOREGROUND_SERVICE permission must be present so the
VPN notification and lifecycle work correctly. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</manifest>
@@ -1,325 +0,0 @@
package billion.group.wireguard_flutter
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import android.app.Activity
import io.flutter.embedding.android.FlutterActivity
import android.content.Intent
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.util.Log
import com.beust.klaxon.Klaxon
import org.amnezia.awg.backend.*
import org.amnezia.awg.crypto.Key
import org.amnezia.awg.crypto.KeyPair
import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.*
import java.util.*
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
/** WireguardFlutterPlugin */
const val PERMISSIONS_REQUEST_CODE = 10014
const val METHOD_CHANNEL_NAME = "billion.group.wireguard_flutter/wgcontrol"
const val METHOD_EVENT_NAME = "billion.group.wireguard_flutter/wgstage"
class WireguardFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
PluginRegistry.ActivityResultListener {
private lateinit var channel: MethodChannel
private lateinit var events: EventChannel
private lateinit var tunnelName: String
private val futureBackend = CompletableDeferred<Backend>()
private var vpnStageSink: EventChannel.EventSink? = null
private val scope = CoroutineScope(Job() + Dispatchers.Main.immediate)
private var backend: Backend? = null
private var havePermission = false
private lateinit var context: Context
private var activity: Activity? = null
private var config: org.amnezia.awg.config.Config? = null
private var tunnel: WireGuardTunnel? = null
private val TAG = "NVPN"
var isVpnChecked = false
companion object {
private var state: String = "no_connection"
fun getStatus(): String {
return state
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
this.havePermission =
(requestCode == PERMISSIONS_REQUEST_CODE) && (resultCode == Activity.RESULT_OK)
return havePermission
}
override fun onAttachedToActivity(activityPluginBinding: ActivityPluginBinding) {
this.activity = activityPluginBinding.activity as FlutterActivity
}
override fun onDetachedFromActivityForConfigChanges() {
this.activity = null
}
override fun onReattachedToActivityForConfigChanges(activityPluginBinding: ActivityPluginBinding) {
this.activity = activityPluginBinding.activity as FlutterActivity
}
override fun onDetachedFromActivity() {
this.activity = null
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL_NAME)
events = EventChannel(flutterPluginBinding.binaryMessenger, METHOD_EVENT_NAME)
context = flutterPluginBinding.applicationContext
scope.launch(Dispatchers.IO) {
try {
backend = createBackend()
futureBackend.complete(backend!!)
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
}
}
channel.setMethodCallHandler(this)
events.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
isVpnChecked = false
vpnStageSink = events
}
override fun onCancel(arguments: Any?) {
isVpnChecked = false
vpnStageSink = null
}
})
}
private fun createBackend(): Backend {
if (backend == null) {
backend = GoBackend(context, object : TunnelActionHandler {
override fun runPreUp(commands: Collection<String>) {}
override fun runPostUp(commands: Collection<String>) {}
override fun runPreDown(commands: Collection<String>) {}
override fun runPostDown(commands: Collection<String>) {}
})
}
return backend as Backend
}
private fun flutterSuccess(result: Result, o: Any) {
scope.launch(Dispatchers.Main) {
result.success(o)
}
}
private fun flutterError(result: Result, error: String) {
scope.launch(Dispatchers.Main) {
result.error(error, null, null)
}
}
private fun flutterNotImplemented(result: Result) {
scope.launch(Dispatchers.Main) {
result.notImplemented()
}
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"initialize" -> setupTunnel(call.argument<String>("localizedDescription").toString(), result)
"start" -> {
connect(call.argument<String>("wgQuickConfig").toString(), result)
if (!isVpnChecked) {
if (isVpnActive()) {
state = "connected"
isVpnChecked = true
println("VPN is active")
} else {
state = "disconnected"
isVpnChecked = true
println("VPN is not active")
}
}
}
"stop" -> {
disconnect(result)
}
"stage" -> {
result.success(getStatus())
}
"checkPermission" -> {
checkPermission()
result.success(null)
}
else -> flutterNotImplemented(result)
}
}
private fun isVpnActive(): Boolean {
try {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activeNetwork = connectivityManager.activeNetwork
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
return networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
} else {
return false
}
} catch (e: Exception) {
Log.e(TAG, "isVpnActive - ERROR - ${e.message}")
return false
}
}
private fun updateStage(stage: String?) {
scope.launch(Dispatchers.Main) {
val updatedStage = stage ?: "no_connection"
state = updatedStage
vpnStageSink?.success(updatedStage.lowercase(Locale.ROOT))
}
}
private fun updateStageFromState(state: Tunnel.State) {
scope.launch(Dispatchers.Main) {
when (state) {
Tunnel.State.UP -> updateStage("connected")
Tunnel.State.DOWN -> updateStage("disconnected")
else -> updateStage("wait_connection")
}
}
}
private fun disconnect(result: Result) {
scope.launch(Dispatchers.IO) {
try {
if (futureBackend.await().getRunningTunnelNames().isEmpty()) {
updateStage("disconnected")
throw Exception("Tunnel is not running")
}
updateStage("disconnecting")
futureBackend.await().setState(
tunnel(tunnelName) { state ->
scope.launch(Dispatchers.Main) {
Log.i(TAG, "onStateChange - $state")
updateStageFromState(state)
}
}, Tunnel.State.DOWN, config
)
Log.i(TAG, "Disconnect - success!")
flutterSuccess(result, "")
} catch (e: BackendException) {
Log.e(TAG, "Disconnect - BackendException - ERROR - ${e.reason}", e)
flutterError(result, e.reason.toString())
} catch (e: Throwable) {
Log.e(TAG, "Disconnect - Can't disconnect from tunnel: ${e.message}")
flutterError(result, e.message.toString())
}
}
}
private fun connect(wgQuickConfig: String, result: Result) {
scope.launch(Dispatchers.IO) {
try {
if (!havePermission) {
// The permission dialog was already shown by setupTunnel /
// checkPermission. Tell Flutter to wait and retry rather
// than throwing a confusing error while the system dialog
// is still on screen.
checkPermission()
flutterError(result, "VPN_PERMISSION_REQUESTED")
return@launch
}
updateStage("prepare")
val inputStream = ByteArrayInputStream(wgQuickConfig.toByteArray())
config = org.amnezia.awg.config.Config.parse(inputStream)
updateStage("connecting")
futureBackend.await().setState(
tunnel(tunnelName) { state ->
scope.launch(Dispatchers.Main) {
Log.i(TAG, "onStateChange - $state")
updateStageFromState(state)
}
}, Tunnel.State.UP, config
)
Log.i(TAG, "Connect - success!")
flutterSuccess(result, "")
} catch (e: BackendException) {
Log.e(TAG, "Connect - BackendException - ERROR - ${e.reason}", e)
flutterError(result, e.reason.toString())
} catch (e: Throwable) {
Log.e(TAG, "Connect - Can't connect to tunnel: $e", e)
flutterError(result, e.message.toString())
}
}
}
private fun setupTunnel(localizedDescription: String, result: Result) {
scope.launch(Dispatchers.IO) {
if (Tunnel.isNameInvalid(localizedDescription)) {
flutterError(result, "Invalid Name")
return@launch
}
tunnelName = localizedDescription
checkPermission()
result.success(null)
}
}
private fun checkPermission() {
val intent = android.net.VpnService.prepare(this.activity)
if (intent != null) {
havePermission = false
this.activity?.startActivityForResult(intent, PERMISSIONS_REQUEST_CODE)
} else {
havePermission = true
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
events.setStreamHandler(null)
isVpnChecked = false
}
private fun tunnel(name: String, callback: StateChangeCallback? = null): WireGuardTunnel {
if (tunnel == null) {
tunnel = WireGuardTunnel(name, callback)
}
return tunnel as WireGuardTunnel
}
}
typealias StateChangeCallback = (Tunnel.State) -> Unit
class WireGuardTunnel(
private val name: String, private val onStateChanged: StateChangeCallback? = null
) : Tunnel {
override fun getName() = name
override fun onStateChange(newState: Tunnel.State) {
onStateChanged?.invoke(newState)
}
override fun isIpv4ResolutionPreferred(): Boolean? = true
override fun isMetered(): Boolean? = false
}
@@ -1,117 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:process_run/shell.dart';
import '../wireguard_flutter_platform_interface.dart';
class WireGuardFlutterLinux extends WireGuardFlutterInterface {
String? name;
File? configFile;
VpnStage _stage = VpnStage.noConnection;
final _stageController = StreamController<VpnStage>.broadcast();
void _setStage(VpnStage stage) {
_stage = stage;
_stageController.add(stage);
}
final shell = Shell(runInShell: true, verbose: kDebugMode);
@override
Future<void> initialize({required String interfaceName}) async {
name = interfaceName.replaceAll(' ', '_');
await refreshStage();
}
Future<String> get filePath async {
final tempDir = await getTemporaryDirectory();
return '${tempDir.path}${Platform.pathSeparator}$name.conf';
}
@override
Future<void> startVpn({
required String serverAddress,
required String wgQuickConfig,
required String providerBundleIdentifier,
}) async {
final isAlreadyConnected = await isConnected();
if (!isAlreadyConnected) {
_setStage(VpnStage.preparing);
} else {
debugPrint('Already connected');
}
try {
configFile = await File(await filePath).create();
await configFile!.writeAsString(wgQuickConfig);
} on PathAccessException {
debugPrint('Denied to write file. Trying to start interface');
if (isAlreadyConnected) {
return _setStage(VpnStage.connected);
}
try {
await shell.run('sudo wg-quick up $name');
} catch (_) {
} finally {
_setStage(VpnStage.denied);
}
}
if (!isAlreadyConnected) {
_setStage(VpnStage.connecting);
await shell.run('sudo wg-quick up ${configFile?.path ?? await filePath}');
_setStage(VpnStage.connected);
}
}
@override
Future<void> stopVpn() async {
assert(
(await isConnected()),
'Bad state: vpn has not been started. Call startVpn',
);
_setStage(VpnStage.disconnecting);
try {
await shell
.run('sudo wg-quick down ${configFile?.path ?? (await filePath)}');
} catch (e) {
await refreshStage();
rethrow;
}
await refreshStage();
}
@override
Future<VpnStage> stage() async => _stage;
@override
Stream<VpnStage> get vpnStageSnapshot => _stageController.stream;
@override
Future<void> refreshStage() async {
if (await isConnected()) {
_setStage(VpnStage.connected);
} else if (name == null) {
_setStage(VpnStage.waitingConnection);
} else if (configFile == null) {
_setStage(VpnStage.noConnection);
} else {
_setStage(VpnStage.disconnected);
}
}
@override
Future<bool> isConnected() async {
assert(
name != null,
'Bad state: not initialized. Call "initialize" before calling this command',
);
final processResultList = await shell.run('sudo wg');
final process = processResultList.first;
return process.outLines.any((line) => line.trim() == 'interface: $name');
}
}
@@ -1,62 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:wireguard_flutter/linux/wireguard_flutter_linux.dart';
import 'package:wireguard_flutter/wireguard_flutter_method_channel.dart';
import 'wireguard_flutter_platform_interface.dart';
export 'wireguard_flutter_platform_interface.dart' show VpnStage;
class WireGuardFlutter extends WireGuardFlutterInterface {
static WireGuardFlutterInterface? __instance;
static WireGuardFlutterInterface get _instance => __instance!;
static WireGuardFlutterInterface get instance {
registerWith();
return _instance;
}
static void registerWith() {
if (__instance == null) {
if (kIsWeb) {
throw UnsupportedError('The web platform is not supported');
} else if (Platform.isLinux) {
__instance = WireGuardFlutterLinux();
} else {
__instance = WireGuardFlutterMethodChannel();
}
}
}
WireGuardFlutter._();
@override
Stream<VpnStage> get vpnStageSnapshot => _instance.vpnStageSnapshot;
@override
Future<void> initialize({required String interfaceName}) {
return _instance.initialize(interfaceName: interfaceName);
}
@override
Future<void> startVpn({
required String serverAddress,
required String wgQuickConfig,
required String providerBundleIdentifier,
}) async {
return _instance.startVpn(
serverAddress: serverAddress,
wgQuickConfig: wgQuickConfig,
providerBundleIdentifier: providerBundleIdentifier,
);
}
@override
Future<void> stopVpn() => _instance.stopVpn();
@override
Future<void> refreshStage() => _instance.refreshStage();
@override
Future<VpnStage> stage() => _instance.stage();
}
@@ -1,60 +0,0 @@
import 'package:flutter/services.dart';
import 'wireguard_flutter_platform_interface.dart';
class WireGuardFlutterMethodChannel extends WireGuardFlutterInterface {
static const _methodChannelVpnControl =
"billion.group.wireguard_flutter/wgcontrol";
static const _methodChannel = MethodChannel(_methodChannelVpnControl);
static const _eventChannelVpnStage =
'billion.group.wireguard_flutter/wgstage';
static const _eventChannel = EventChannel(_eventChannelVpnStage);
@override
Stream<VpnStage> get vpnStageSnapshot =>
_eventChannel.receiveBroadcastStream().map(
(event) => event == VpnStage.denied.code
? VpnStage.disconnected
: VpnStage.values.firstWhere(
(stage) => stage.code == event,
orElse: () => VpnStage.noConnection,
),
);
@override
Future<void> initialize({required String interfaceName}) {
return _methodChannel.invokeMethod("initialize", {
"localizedDescription": interfaceName,
"win32ServiceName": interfaceName,
});
}
@override
Future<void> startVpn({
required String serverAddress,
required String wgQuickConfig,
required String providerBundleIdentifier,
}) async {
return _methodChannel.invokeMethod("start", {
"serverAddress": serverAddress,
"wgQuickConfig": wgQuickConfig,
"providerBundleIdentifier": providerBundleIdentifier,
});
}
@override
Future<void> stopVpn() => _methodChannel.invokeMethod('stop');
@override
Future<void> refreshStage() => _methodChannel.invokeMethod("refresh");
@override
Future<VpnStage> stage() => _methodChannel.invokeMethod("stage").then(
(value) => value != null
? VpnStage.values.firstWhere(
(stage) => stage.code == value.toString(),
orElse: () => VpnStage.disconnected,
)
: VpnStage.disconnected,
);
}
@@ -1,36 +0,0 @@
abstract class WireGuardFlutterInterface {
Stream<VpnStage> get vpnStageSnapshot;
Future<void> initialize({required String interfaceName});
Future<void> startVpn({
required String serverAddress,
required String wgQuickConfig,
required String providerBundleIdentifier,
});
Future<void> stopVpn();
Future<void> refreshStage();
Future<VpnStage> stage();
Future<bool> isConnected() =>
stage().then((stage) => stage == VpnStage.connected);
}
enum VpnStage {
connected('connected'),
connecting('connecting'),
disconnecting('disconnecting'),
disconnected('disconnected'),
waitingConnection('wait_connection'),
authenticating('authenticating'),
reconnect('reconnect'),
noConnection('no_connection'),
preparing('prepare'),
denied('denied'),
exiting('exiting');
final String code;
const VpnStage(this.code);
}
@@ -1,28 +0,0 @@
name: wireguard_flutter
description: Wireguard Flutter SDK
version: 0.1.3
homepage: https://flutterflux.com
repository: https://github.com/Caqil/wireguard_flutter
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: '>=3.13.7'
dependencies:
flutter:
sdk: flutter
path_provider: ^2.1.2
plugin_platform_interface: ^2.0.2
process_run: ^0.14.0+1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
plugin:
platforms:
android:
package: billion.group.wireguard_flutter
pluginClass: WireguardFlutterPlugin
+41 -32
View File
@@ -65,14 +65,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
cryptography:
dependency: "direct main"
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -219,6 +211,46 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_vless:
dependency: "direct main"
description:
name: flutter_vless
sha256: aa66ea904e7593cc9b2f8f12c291a3a1f404377125d5cd9faa6399d444f511ab
url: "https://pub.dev"
source: hosted
version: "1.1.4"
flutter_vless_android:
dependency: transitive
description:
name: flutter_vless_android
sha256: f2f2c81a410a7341ea05dfe3643b213530be44d6574bc91fa29c0bd417d97ac2
url: "https://pub.dev"
source: hosted
version: "1.1.4"
flutter_vless_macos:
dependency: transitive
description:
name: flutter_vless_macos
sha256: "096c21bd9728ee2856325c85f60bc7075dda44f99205dd0baa82e5a1223a62a2"
url: "https://pub.dev"
source: hosted
version: "1.1.4"
flutter_vless_platform_interface:
dependency: transitive
description:
name: flutter_vless_platform_interface
sha256: "5f14b7c0052a3457ec1eff1ab5f77a48963b96ea83895f0337a1f5bfc5346137"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_vless_windows:
dependency: transitive
description:
name: flutter_vless_windows
sha256: aa436a361c5933f10d0c179b0db0c7d1c7565cbd43863c3d436e1e4daaaed33a
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -448,14 +480,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
process_run:
dependency: transitive
description:
name: process_run
sha256: "8d9c6198b98fbbfb511edd42e7364e24d85c163e47398919871b952dc86a423e"
url: "https://pub.dev"
source: hosted
version: "0.14.2"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -573,14 +597,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -749,13 +765,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.3.0"
wireguard_flutter:
dependency: "direct main"
description:
path: "local_packages/wireguard_flutter"
relative: true
source: path
version: "0.1.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -781,5 +790,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.4"
+2 -4
View File
@@ -1,5 +1,5 @@
name: paladinvpn name: paladinvpn
description: Paladin VPN — Minimalist high-performance WireGuard tunnel description: Paladin VPN — Secure VLESS tunnel for Android
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `pub publish`. This is preferred for private packages. # pub.dev using `pub publish`. This is preferred for private packages.
@@ -30,11 +30,9 @@ dependencies:
google_fonts: ^6.1.0 google_fonts: ^6.1.0
provider: ^6.1.1 provider: ^6.1.1
google_mobile_ads: ^4.0.0 google_mobile_ads: ^4.0.0
wireguard_flutter: flutter_vless: ^1.1.4
path: local_packages/wireguard_flutter
http: ^1.6.0 http: ^1.6.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
cryptography: ^2.9.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
flutter_secure_storage: ^10.3.1 flutter_secure_storage: ^10.3.1
uuid: ^4.5.3 uuid: ^4.5.3
+228 -243
View File
@@ -1,19 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Hivemind VPN Session Manager Hivemind VPN Session Manager
Manages time + data quotas for AmneziaWG peers via ad rewards. Manages time + data quotas for VLESS clients via ad rewards.
Designed for Android-only clients. Designed for Android-only clients.
=== TABLE OF CONTENTS =========================================================== === TABLE OF CONTENTS ===========================================================
LINE SECTION LINE SECTION
44 Configuration — ad rewards, throttle rate, check interval 37 Configuration — ad rewards, Xray paths, throttle levels
60 State & Cache — sessions dict, IP pool, server pubkey 75 State & Cache — sessions dict, stats failure counter
93 Flask Routes — /admob/callback, /session/status, /session/stop 97 Flask Routes — /admob/callback, /session/status, /session/stop
220 Session Logic — _start_or_extend_session (create / extend) 202 Session Logic — _start_or_extend_session (create / extend / revive)
295 AdMob SSV Verification — Google ECDSA signature checks 270 AdMob SSV Verification — Google ECDSA signature checks
380 Management Loop — expiry + data-quota watchdog (60 s tick) 355 Management Loop — expiry + data-quota watchdog (60 s tick)
426 Low-Level Plumbing — awg commands, tc throttling, IP pool, peers 403 Low-Level Plumbing — add/remove/set_level clients, Xray stats
567 Entry Point — daemon thread + gunicorn bootstrap 551 Entry Point — daemon thread + gunicorn bootstrap
================================================================================ ================================================================================
""" """
@@ -23,9 +23,8 @@ import threading
import json import json
import requests import requests
import shlex import shlex
import re
import urllib.parse import urllib.parse
import ipaddress import uuid
import base64 import base64
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric import ec
@@ -34,18 +33,10 @@ from cryptography.hazmat.primitives.serialization import load_der_public_key, lo
app = Flask(__name__) app = Flask(__name__)
# Compiled once at startup for performance
_WG_PUBKEY_RE = re.compile(r'^[A-Za-z0-9+/]{43}=$')
def validate_wg_pubkey(pubkey: str) -> bool:
"""Rejects any string that isn't a valid 44-char base64 WireGuard public key."""
return bool(pubkey and _WG_PUBKEY_RE.match(pubkey))
# ============================================================================== # ==============================================================================
# CONFIGURATION # CONFIGURATION
# ============================================================================== # ==============================================================================
CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and uses 0% CPU. CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and uses 0% CPU.
WG_INTERFACE = "awg0"
# Main Ad Rewards # Main Ad Rewards
MAIN_AD_MINUTES = 60 MAIN_AD_MINUTES = 60
@@ -55,7 +46,21 @@ MAIN_AD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
BONUS_AD_MINUTES = 30 BONUS_AD_MINUTES = 30
BONUS_AD_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB BONUS_AD_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB
THROTTLE_RATE = "1.5mbit" # VLESS server info — returned to app in /session/status
VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain
VLESS_PATH = "/tunnel"
# Xray paths
XRAY_CONFIG = "/usr/local/etc/xray/config.json"
XRAY_API = "http://127.0.0.1:10085"
VLESS_INBOUND_TAG = "vless-in"
# VLESS policy levels for bandwidth throttling
# Level 0 = unlimited (default for new sessions)
# Level 1 = 192 KB/s (~1.5 Mbps) — matches the old AWG THROTTLE_RATE
# The Xray config.json must define matching policy.levels entries.
THROTTLE_LEVEL = 1
THROTTLE_RATE_KBPS = 192
# AdMob SSV verification toggle # AdMob SSV verification toggle
# True = dev mode, accept any callback (no signature check) # True = dev mode, accept any callback (no signature check)
@@ -70,31 +75,23 @@ ADMOB_BYPASS = True
# STATE & CACHE # STATE & CACHE
# ============================================================================== # ==============================================================================
sessions_lock = threading.Lock() sessions_lock = threading.Lock()
# Paste your Server Public Key here if dynamic fetching keeps failing
# Example: SERVER_PUBKEY_FALLBACK = "abc123xyz..." # Consecutive Xray stats API failures — reset to 0 on first success.
SERVER_PUBKEY_FALLBACK = None # Used to rate-limit log spam when the stats endpoint is unreachable.
SERVER_PUBKEY = None _xray_stats_failures = 0
# Keyed by device_id (e.g. "esf-18fc2...") # Keyed by device_id (e.g. "esf-18fc2...")
# { # {
# "device_id": { # "device_id": {
# "ip": "10.8.0.100", # "vless_uuid": "550e8400-...",
# "pubkey": "abc123xyz...", # "expires_at": 1716480000,
# "expires_at": 1716480000, # "quota_bytes": 2147483648,
# "quota_bytes": 2147483648, # "is_throttled": False,
# "baseline_bytes": 0, # "nonce": "a7f3b2-1716480000000",
# "is_throttled": False,
# "nonce": "a7f3b2-1716480000000",
# } # }
# } # }
active_sessions = {} active_sessions = {}
# Pre-computed pool of all available IPv4 addresses in the 10.8.0.0/16 subnet
# (10.8.0.0 through 10.8.255.255 = 65,534 host addresses).
# Initialised once at module load; allocate_ip() pops from it, release_ip() returns to it.
AVAILABLE_IPS = {str(ip) for ip in ipaddress.IPv4Network('10.8.0.0/16').hosts()}
AVAILABLE_IPS.discard('10.8.0.1') # reserved for gateway
# ============================================================================== # ==============================================================================
# FLASK ROUTES (API surface — what the outside world sees) # FLASK ROUTES (API surface — what the outside world sees)
@@ -107,7 +104,7 @@ def admob_callback():
The Android client never touches this endpoint; Google calls it directly. The Android client never touches this endpoint; Google calls it directly.
custom_data (JSON, set by the app before showing the ad): custom_data (JSON, set by the app before showing the ad):
{ "device_id": "...", "public_key": "...", "ad_type": "main_ad"|"bonus_ad", "nonce": "..." } { "device_id": "...", "ad_type": "main_ad"|"bonus_ad", "nonce": "..." }
""" """
try: try:
if ADMOB_BYPASS: if ADMOB_BYPASS:
@@ -135,15 +132,14 @@ def admob_callback():
custom_data = json.loads(custom_data_str) custom_data = json.loads(custom_data_str)
device_id = custom_data.get('device_id') device_id = custom_data.get('device_id')
client_pubkey = custom_data.get('public_key')
if not device_id or not client_pubkey: if not device_id:
return "Invalid custom_data payload", 400 return "Invalid custom_data payload", 400
ad_type = custom_data.get('ad_type', 'main_ad') ad_type = custom_data.get('ad_type', 'main_ad')
nonce = custom_data.get('nonce', None) nonce = custom_data.get('nonce', None)
success, msg = _start_or_extend_session(device_id, client_pubkey, ad_type, nonce) success, msg = _start_or_extend_session(device_id, ad_type, nonce)
return ("OK", 200) if success else (msg, 500) return ("OK", 200) if success else (msg, 500)
except Exception as e: except Exception as e:
@@ -155,7 +151,7 @@ def admob_callback():
def session_status(): def session_status():
""" """
Query: ?device_id=esf-xxx Query: ?device_id=esf-xxx
Returns current session state so the Android app can construct its AmneziaWG config. Returns current VLESS session state so the app can connect.
""" """
device_id = request.args.get('device_id') device_id = request.args.get('device_id')
if not device_id: if not device_id:
@@ -167,27 +163,18 @@ def session_status():
if not session: if not session:
return jsonify({"active": False, "message": "No session found."}), 404 return jsonify({"active": False, "message": "No session found."}), 404
# Bulletproof fallback: If server public key is missing, try fetching it right now stats = get_xray_stats()
global SERVER_PUBKEY used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
if not SERVER_PUBKEY:
pubkey = run_cmd("awg show awg0 public-key")
if pubkey:
SERVER_PUBKEY = pubkey
print(f"[AWG] Dynamic Server Public Key cached from awg0: {SERVER_PUBKEY}")
else:
SERVER_PUBKEY = SERVER_PUBKEY_FALLBACK or "server key="
stats = get_wireguard_stats()
current_bytes = stats.get(session["ip"], {}).get("total_bytes", 0)
used_bytes = max(0, current_bytes - session["baseline_bytes"])
remaining_data = max(0, session["quota_bytes"] - used_bytes) remaining_data = max(0, session["quota_bytes"] - used_bytes)
remaining_secs = max(0, session["expires_at"] - time.time()) remaining_secs = max(0, session["expires_at"] - time.time())
return jsonify({ return jsonify({
"active": remaining_secs > 0, "active": remaining_secs > 0,
"client_ip": session["ip"], "vless_uuid": session["vless_uuid"],
"client_ipv6": session.get("ipv6", ""), "server_domain": VLESS_DOMAIN,
"server_pubkey": SERVER_PUBKEY, "server_port": 443,
"vless_path": VLESS_PATH,
"vless_flow": "xtls-rprx-vision",
"is_throttled": session["is_throttled"], "is_throttled": session["is_throttled"],
"expires_in_seconds": int(remaining_secs), "expires_in_seconds": int(remaining_secs),
"quota_bytes": session["quota_bytes"], "quota_bytes": session["quota_bytes"],
@@ -206,86 +193,75 @@ def session_stop():
if device_id: if device_id:
with sessions_lock: with sessions_lock:
session = active_sessions.pop(device_id, None) session = active_sessions.pop(device_id, None)
if session: # Lock released — file I/O and subprocess happen outside.
remove_wg_peer(session["pubkey"]) if session:
if session["is_throttled"]: remove_vless_client(device_id)
remove_throttle(session["ip"]) print(f"[API] Explicit stop for {device_id}.")
release_ip(session["ip"])
print(f"[API] Explicit stop for {device_id}.")
return jsonify({"success": True})
# ============================================================================== # ==============================================================================
# SESSION LOGIC # SESSION LOGIC
# ============================================================================== # ==============================================================================
def _start_or_extend_session(device_id, client_pubkey, ad_type, nonce=None): def _start_or_extend_session(device_id, ad_type, nonce=None):
"""Core logic to provision or extend a session. """Core logic to provision or extend a session.
nonce -- client-generated random token; stored in session and echoed nonce -- client-generated random token; stored in session and echoed
back in /session/status so the client can reject stale sessions. back in /session/status so the client can reject stale sessions.
""" """
if not re.match(r"^[A-Za-z0-9+/=]{43,44}$", client_pubkey):
return False, "Invalid WireGuard public key format"
now = time.time() now = time.time()
# ── Phase 1: update in-memory state under lock ────────────────────
xray_action = None # ("add", uuid) | ("remove", None) | ("set_level", level) | None
with sessions_lock: with sessions_lock:
if device_id in active_sessions and ad_type == 'bonus_ad': if device_id in active_sessions and ad_type == 'bonus_ad':
# Extend existing session
session = active_sessions[device_id] session = active_sessions[device_id]
session["expires_at"] += (BONUS_AD_MINUTES * 60) session["expires_at"] += (BONUS_AD_MINUTES * 60)
session["quota_bytes"] += BONUS_AD_BYTES session["quota_bytes"] += BONUS_AD_BYTES
if session["is_throttled"]: if session["is_throttled"]:
remove_throttle(session["ip"])
session["is_throttled"] = False session["is_throttled"] = False
xray_action = ("set_level", 0)
stats = get_wireguard_stats() print(f"[SESSION] Bonus time added for {device_id}.")
current_bytes = stats.get(session["ip"], {}).get("total_bytes", 0) # Fall through to Phase 2 outside the lock
session["baseline_bytes"] = current_bytes
print(f"[SESSION] Bonus time added for {device_id} (IP: {session['ip']}).")
return True, "Bonus time and data added."
else: else:
# Create a brand new session (Main Ad) # Create a brand new session (Main Ad)
if not validate_wg_pubkey(client_pubkey):
print(f"[SESSION] Rejected invalid pubkey from {device_id[:8]}...")
return False, "Invalid public key format."
existing = active_sessions.get(device_id) existing = active_sessions.get(device_id)
if existing: vless_uuid = str(uuid.uuid4())
if existing["is_throttled"]:
remove_throttle(existing["ip"])
remove_wg_peer(existing["pubkey"])
release_ip(existing["ip"])
ipv4 = allocate_ip()
if not ipv4:
return False, "Server full! No available IP addresses."
ipv6 = ipv4_to_ipv6(ipv4)
add_wg_peer(client_pubkey, ipv4, ipv6)
# Fetch immediate stats to set baseline
# (Though brand new peers usually start at 0)
stats = get_wireguard_stats()
current_bytes = stats.get(ipv4, {}).get("total_bytes", 0)
active_sessions[device_id] = { active_sessions[device_id] = {
"ip": ipv4, "vless_uuid": vless_uuid,
"ipv6": ipv6, "expires_at": now + (MAIN_AD_MINUTES * 60),
"pubkey": client_pubkey, "quota_bytes": MAIN_AD_BYTES,
"expires_at": now + (MAIN_AD_MINUTES * 60), "is_throttled": False,
"quota_bytes": MAIN_AD_BYTES, "nonce": nonce,
"baseline_bytes": current_bytes,
"is_throttled": False,
"nonce": nonce,
} }
print(f"[SESSION] Main session started for {device_id} (IP: {ipv4} / {ipv6}).")
return True, "Main session active." if existing:
xray_action = ("replace", vless_uuid)
else:
xray_action = ("add", vless_uuid)
print(f"[SESSION] Main session started for {device_id} (UUID: {vless_uuid[:8]}...).")
# ── Phase 2: Xray file I/O + subprocess OUTSIDE the lock ──────────
if xray_action is None:
return True, "Bonus time and data added."
op, arg = xray_action[0], xray_action[1]
if op == "set_level":
set_vless_client_level(device_id, arg)
return True, "Bonus time and data added."
elif op == "replace":
remove_vless_client(device_id)
add_vless_client(arg, device_id)
return True, "Main session active."
elif op == "add":
add_vless_client(arg, device_id)
return True, "Main session active."
@@ -381,41 +357,43 @@ def _verify_admob_signature(raw_query_string: str, signature_b64url: str, key_id
# ============================================================================== # ==============================================================================
def management_loop(): def management_loop():
print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.") print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.")
init_wireguard()
while True: while True:
try: try:
stats = get_wireguard_stats() stats = get_xray_stats()
now = time.time() now = time.time()
# ── Phase 1: decide what to do under the lock ────────────────
with sessions_lock: with sessions_lock:
expired_devices = [] to_remove = [] # (device_id,) — expired, remove from Xray
to_throttle = [] # (device_id,) — over quota, set level 1
for device_id, session in list(active_sessions.items()): for device_id, session in list(active_sessions.items()):
ip = session["ip"]
# ── 1. Check expiry ─────────────────────────────────────── # ── 1. Check expiry ───────────────────────────────────────
if now > session["expires_at"]: if now > session["expires_at"]:
print(f"[HIVEMIND] Session expired for {device_id} ({ip}). Removing peer.") print(f"[HIVEMIND] Session expired for {device_id}.")
if session["is_throttled"]: to_remove.append(device_id)
remove_throttle(ip)
remove_wg_peer(session["pubkey"])
release_ip(ip)
expired_devices.append(device_id)
continue continue
# ── 2. Check data quota ─────────────────────────────────── # ── 2. Check data quota — throttle when exceeded ──────────
if ip in stats: used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
used_bytes = stats[ip]["total_bytes"] - session["baseline_bytes"]
if used_bytes > session["quota_bytes"] and not session["is_throttled"]: if used_bytes > session["quota_bytes"] and not session["is_throttled"]:
print(f"[HIVEMIND] Data cap reached for {ip} ({used_bytes/1e9:.2f} GB used). Throttling.") print(f"[HIVEMIND] Data cap reached for {device_id} ({used_bytes/1e9:.2f} GB used). Throttling to ~1.5 Mbps.")
apply_throttle(ip) session["is_throttled"] = True
session["is_throttled"] = True to_throttle.append(device_id)
for device_id in expired_devices: for device_id in to_remove:
del active_sessions[device_id] del active_sessions[device_id]
# ── Phase 2: file I/O + subprocess OUTSIDE the lock ───────
for device_id in to_remove:
remove_vless_client(device_id)
for device_id in to_throttle:
set_vless_client_level(device_id, THROTTLE_LEVEL)
except Exception as e: except Exception as e:
print(f"[HIVEMIND] Unhandled error: {e}") print(f"[HIVEMIND] Unhandled error: {e}")
@@ -423,15 +401,11 @@ def management_loop():
# ============================================================================== # ==============================================================================
# LOW-LEVEL PLUMBING (awg, tc, IP pool, peer management) # LOW-LEVEL PLUMBING (Xray client management + stats)
# ============================================================================== # ==============================================================================
def run_cmd(cmd, ignore_errors=False): def run_cmd(cmd, ignore_errors=False):
"""Executes a shell command directly on the host (no Docker middleman).""" """Executes a shell command directly on the host."""
# All commands run directly on the host against awg0.
# The only sudo requirement is for awg/tc/iptables — already handled via
# the hivemind systemd unit running as root or via sudoers rules.
args = shlex.split(cmd) args = shlex.split(cmd)
try: try:
result = subprocess.run( result = subprocess.run(
@@ -439,130 +413,141 @@ def run_cmd(cmd, ignore_errors=False):
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
) )
return result.stdout.strip() return result.stdout.strip()
except FileNotFoundError:
print(f"[CMD FAIL] executable not found: {args[0]}")
return None
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if not ignore_errors: if not ignore_errors:
print(f"[CMD FAIL] {cmd}\n stderr: {e.stderr.strip()}") print(f"[CMD FAIL] {cmd}\n stderr: {e.stderr.strip()}")
return None return None
except OSError as e:
print(f"[CMD FAIL] {cmd}\n os error: {e}")
return None
def init_wireguard(): def add_vless_client(uuid, email, level=0):
"""Initialises traffic control and fetches the server's public key.""" """Add a client UUID to the Xray inbound config and hot-reload.
global SERVER_PUBKEY
print(f"[TC] Initialising traffic control on {WG_INTERFACE}...")
run_cmd(f"tc qdisc del dev {WG_INTERFACE} root", ignore_errors=True)
run_cmd(f"tc qdisc add dev {WG_INTERFACE} root handle 1: htb default 999")
print("[TC] Root qdisc ready.")
print(f"[IP] Expanding routing table for /16 subnet...") level -- Xray policy level for bandwidth limits:
run_cmd(f"ip route add 10.8.0.0/16 dev {WG_INTERFACE}", ignore_errors=True) 0 = unlimited, 1 = throttled (~1.5 Mbps).
print(f"[NAT] Applying masquerade for /16 subnet...")
run_cmd(f"iptables -t nat -A POSTROUTING -s 10.8.0.0/16 -j MASQUERADE", ignore_errors=True)
print(f"[IP] Adding IPv6 route for fd42::/80...")
run_cmd(f"ip -6 route add fd42::/80 dev {WG_INTERFACE}", ignore_errors=True)
print(f"[NAT] Applying IPv6 masquerade for fd42::/80...")
run_cmd(f"ip6tables -t nat -A POSTROUTING -s fd42::/80 -j MASQUERADE", ignore_errors=True)
# The phone connects to awg0 on port 4433.
pubkey = run_cmd("awg show awg0 public-key")
if pubkey:
SERVER_PUBKEY = pubkey
print(f"[AWG] Server Public Key cached from awg0: {SERVER_PUBKEY}")
else:
print("[AWG] WARNING: Could not fetch awg0 public key! Check that awg is installed on host.")
def _class_id(ip):
# Combine 3rd and 4th octet for uniqueness across /16
# e.g. "10.8.3.5" -> "773" (3*256+5), avoids collisions
parts = ip.split('.')
return str(int(parts[2]) * 256 + int(parts[3]))
def apply_throttle(ip):
cid = _class_id(ip)
print(f"[TC] Throttling {ip} -> class 1:{cid} @ {THROTTLE_RATE}")
run_cmd(f"tc class add dev {WG_INTERFACE} parent 1: classid 1:{cid} htb rate {THROTTLE_RATE}")
run_cmd(f"tc filter add dev {WG_INTERFACE} protocol ip parent 1:0 prio 1 u32 match ip dst {ip}/32 flowid 1:{cid}")
def remove_throttle(ip):
cid = _class_id(ip)
print(f"[TC] Removing throttle from {ip} (class 1:{cid})")
run_cmd(f"tc class del dev {WG_INTERFACE} classid 1:{cid}", ignore_errors=True)
def get_wireguard_stats():
"""Returns { "10.8.X.Y": {"pubkey": "...", "total_bytes": 1234}, ... }
Reads directly from the host's awg0 interface.
Uses two separate commands to avoid AmneziaWG dump column-layout differences.
""" """
# Step 1: pubkey -> (rx_bytes, tx_bytes) with open(XRAY_CONFIG, 'r') as f:
transfer_out = run_cmd("awg show awg0 transfer") config = json.load(f)
# Step 2: pubkey -> ip/32
allowed_out = run_cmd("awg show awg0 allowed-ips")
if not transfer_out or not allowed_out: for inbound in config["inbounds"]:
if inbound.get("tag") == VLESS_INBOUND_TAG:
inbound["settings"]["clients"].append({
"id": uuid,
"email": email,
"level": level,
"flow": "xtls-rprx-vision"
})
break
with open(XRAY_CONFIG, 'w') as f:
json.dump(config, f, indent=2)
run_cmd("xray api adi --server=127.0.0.1:10085")
print(f"[VLESS] Client added: {email[:16]}...")
def set_vless_client_level(email, level):
"""Change a client's policy level in the Xray config and hot-reload.
Used for throttling (level 1) and un-throttling (level 0) without
removing and re-adding the client. Silently no-ops if the client
is not found (e.g. already expired and removed).
"""
with open(XRAY_CONFIG, 'r') as f:
config = json.load(f)
found = False
for inbound in config["inbounds"]:
if inbound.get("tag") == VLESS_INBOUND_TAG:
for client in inbound["settings"]["clients"]:
if client.get("email") == email:
client["level"] = level
found = True
break
break
if not found:
print(f"[VLESS] set_level: client {email[:16]}... not found — skipped.")
return
with open(XRAY_CONFIG, 'w') as f:
json.dump(config, f, indent=2)
run_cmd("xray api adi --server=127.0.0.1:10085")
print(f"[VLESS] Client {email[:16]}... level -> {level}.")
def remove_vless_client(email):
"""Remove a client from the Xray inbound config and hot-reload."""
with open(XRAY_CONFIG, 'r') as f:
config = json.load(f)
for inbound in config["inbounds"]:
if inbound.get("tag") == VLESS_INBOUND_TAG:
clients = inbound["settings"]["clients"]
inbound["settings"]["clients"] = [
c for c in clients if c.get("email") != email
]
break
with open(XRAY_CONFIG, 'w') as f:
json.dump(config, f, indent=2)
run_cmd("xray api adi --server=127.0.0.1:10085")
print(f"[VLESS] Client removed: {email[:16]}...")
def get_xray_stats():
"""Query Xray stats API for per-user upload+download bytes.
Returns: { "device_id": {"total_bytes": 12345}, ... }
"""
global _xray_stats_failures
try:
resp = requests.get(
f"{XRAY_API}/stats/query",
json={"pattern": "", "reset": False},
timeout=5
)
data = resp.json()
except Exception as e:
# Rate-limit log spam: warn on first failure, then every 10th.
_xray_stats_failures += 1
if _xray_stats_failures == 1:
print(f"[XRAY_STATS] WARNING: Cannot reach Xray stats API: {e}")
elif _xray_stats_failures % 10 == 0:
print(f"[XRAY_STATS] ERROR: {_xray_stats_failures} consecutive failures. "
f"Quota enforcement is NOT running. Last error: {e}")
return {} return {}
# Build pubkey -> IP map from allowed-ips # Success — reset the failure counter.
pubkey_to_ip = {} if _xray_stats_failures > 0:
for line in allowed_out.splitlines(): print(f"[XRAY_STATS] Recovered after {_xray_stats_failures} failure(s).")
parts = line.split('\t') _xray_stats_failures = 0
if len(parts) >= 2:
pubkey_to_ip[parts[0]] = parts[1].split('/')[0]
# Build IP -> stats from transfer bytes
stats = {} stats = {}
for line in transfer_out.splitlines(): for entry in data.get("stat", []):
parts = line.split('\t') name = entry.get("name", "")
if len(parts) >= 3: value = int(entry.get("value", 0))
pubkey = parts[0]
ip = pubkey_to_ip.get(pubkey) # Xray stats names look like: "user>>>email>>>traffic>>>uplink"
if ip: if ">>>traffic>>>" in name:
stats[ip] = { parts = name.split(">>>")
"pubkey": pubkey, email = parts[1] if len(parts) > 1 else None
"total_bytes": int(parts[1]) + int(parts[2]) if email and email != "api":
} if email not in stats:
stats[email] = {"total_bytes": 0}
stats[email]["total_bytes"] += value
return stats return stats
def ipv4_to_ipv6(ipv4):
"""Map 10.8.X.Y → fd42::X:Y (ULA for internal VPN routing, no NAT64 needed)."""
parts = ipv4.split('.')
return f"fd42::{parts[2]}:{parts[3]}"
def allocate_ip():
"""Pops an available IP from the pre-generated pool."""
if not AVAILABLE_IPS:
return None
return AVAILABLE_IPS.pop()
def release_ip(ip):
"""Returns an IP back to the available pool."""
if ip:
AVAILABLE_IPS.add(ip)
def add_wg_peer(pubkey, ipv4, ipv6):
"""Dynamically adds an ephemeral dual-stack peer to AmneziaWG."""
# Remove it first just in case it exists to avoid conflicts
run_cmd(f"awg set awg0 peer {pubkey} remove", ignore_errors=True)
res = run_cmd(f"awg set awg0 peer {pubkey} allowed-ips {ipv4}/32,{ipv6}/128")
time.sleep(0.2) # Let AmneziaWG register the peer before baseline snapshot
if res is not None:
print(f"[AWG] Ephemeral peer added: {pubkey[:8]}... -> {ipv4} / {ipv6}")
def remove_wg_peer(pubkey):
"""Removes an ephemeral peer from AmneziaWG."""
if pubkey:
run_cmd(f"awg set awg0 peer {pubkey} remove", ignore_errors=True)
print(f"[AWG] Ephemeral peer removed: {pubkey[:8]}...")
# ============================================================================== # ==============================================================================
# ENTRY POINT # ENTRY POINT
# ============================================================================== # ==============================================================================
+200
View File
@@ -0,0 +1,200 @@
#!/bin/bash
# PaladinVPN — Full Server Picture (VLESS era)
# Copy-paste this entire block into your SSH terminal on the Hetzner Debian box.
# Read-only — makes no changes to the system.
# Run as root or with sudo.
bash <<'REPORT'
echo "============================================================"
echo " PaladinVPN — Server State Report"
echo " $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "============================================================"
# ── 1. System ────────────────────────────────────────────────
echo ""; echo "=== SYSTEM ==="
echo "Hostname : $(hostname)"
echo "Uptime : $(uptime -p)"
echo "Debian : $(cat /etc/debian_version 2>/dev/null || echo 'N/A')"
echo "Kernel : $(uname -r)"
echo "Arch : $(uname -m)"
echo "Load : $(uptime | awk -F'load average:' '{print $2}' | xargs)"
echo ""
echo "CPU:"
echo "$(lscpu | grep -E 'Model name|CPU\(s\)|Thread' | sed 's/^/ /')" 2>/dev/null
echo ""
echo "Memory:"
free -h | grep -E '^Mem:|^Swap:' | sed 's/^/ /'
echo ""
echo "Disk:"
df -h / | tail -1 | awk '{print " Root: " $3 "/" $2 " (" $5 ")"}'
# ── 2. Network Interfaces ────────────────────────────────────
echo ""; echo "=== NETWORK INTERFACES ==="
ip -br addr 2>/dev/null || ifconfig -a 2>/dev/null
echo ""
echo "Routes:"
ip route show 2>/dev/null | grep -v '^fe80' || route -n
echo ""
echo "DNS:"
cat /etc/resolv.conf 2>/dev/null | grep -v '^#' | grep -v '^$' | sed 's/^/ /'
# ── 3. Open Ports ────────────────────────────────────────────
echo ""; echo "=== LISTENING PORTS ==="
ss -tlnp 2>/dev/null | head -30 || netstat -tlnp 2>/dev/null | head -30
# ── 4. IPv6 Status ───────────────────────────────────────────
echo ""; echo "=== IPv6 ==="
echo "Forwarding:"
sysctl net.ipv6.conf.all.forwarding 2>/dev/null
echo ""
echo "IPv6 routes:"
ip -6 route show 2>/dev/null | head -10
echo ""
echo "IPv6 NAT (ip6tables):"
ip6tables -t nat -L POSTROUTING -n 2>/dev/null | head -10 || echo " ip6tables not available"
# ── 5. Firewall (iptables) ───────────────────────────────────
echo ""; echo "=== IPTABLES NAT ==="
iptables -t nat -L POSTROUTING -n -v 2>/dev/null | head -20 || echo " iptables not available"
# ── 6. Services ──────────────────────────────────────────────
echo ""; echo "=== SERVICES ==="
for svc in hivemind xray nginx docker certbot; do
if systemctl is-enabled "$svc" &>/dev/null; then
printf " %-12s : %s (enabled)\n" "$svc" "$(systemctl is-active "$svc")"
elif systemctl is-active "$svc" &>/dev/null; then
printf " %-12s : %s (not enabled)\n" "$svc" "$(systemctl is-active "$svc")"
else
printf " %-12s : not found\n" "$svc"
fi
done
echo ""
echo "Certbot timer:"
systemctl list-timers --all 2>/dev/null | grep -i certbot || echo " No certbot timer found"
# ── 7. Xray ──────────────────────────────────────────────────
echo ""; echo "=== XRAY ==="
if command -v xray &>/dev/null; then
echo "Binary : $(which xray)"
echo "Version : $(xray version 2>&1 | head -1)"
echo "Status : $(systemctl is-active xray 2>/dev/null || echo 'N/A')"
echo ""
echo "Config file exists?"
if [ -f /usr/local/etc/xray/config.json ]; then
echo " YES — $(wc -c < /usr/local/etc/xray/config.json) bytes"
echo " Clients: $(python3 -c "import json; c=json.load(open('/usr/local/etc/xray/config.json')); print(len(c['inbounds'][0]['settings']['clients']))" 2>/dev/null || echo 'ERROR reading config')"
else
echo " NO — /usr/local/etc/xray/config.json not found"
fi
echo ""
echo "Stats API:"
curl -s --max-time 3 http://127.0.0.1:10085/stats/query -d '{"pattern":"","reset":false}' 2>/dev/null | python3 -m json.tool 2>/dev/null | head -10 || echo " Stats API unreachable"
echo ""
echo "Last 5 Xray log lines:"
journalctl -u xray --no-pager -n 5 2>/dev/null | sed 's/^/ /' || echo " No journal entries"
else
echo " Xray NOT INSTALLED"
fi
# ── 8. Hivemind ──────────────────────────────────────────────
echo ""; echo "=== HIVEMIND ==="
echo "Status : $(systemctl is-active hivemind 2>/dev/null || echo 'N/A')"
echo "Config : $(systemctl cat hivemind 2>/dev/null | grep -i execstart | sed 's/^/ /')"
echo ""
echo "Active sessions:"
python3 -c "
import sys; sys.path.insert(0, '/root')
try:
from server_hivemind_5_0 import active_sessions, sessions_lock
with sessions_lock:
count = len(active_sessions)
throttled = sum(1 for s in active_sessions.values() if s.get('is_throttled'))
print(f' Total: {count} Throttled: {throttled}')
except Exception as e:
print(f' Could not read sessions: {e}')
" 2>/dev/null || echo " Could not import hivemind module"
echo ""
echo "Hivemind script:"
if [ -f /root/server_hivemind_5_0.py ]; then
echo " /root/server_hivemind_5_0.py — $(wc -l < /root/server_hivemind_5_0.py) lines"
elif [ -f /root/server_hivemind_* ]; then
ls -la /root/server_hivemind_* 2>/dev/null | sed 's/^/ /'
else
echo " No hivemind script found in /root"
fi
echo ""
echo "Last 10 Hivemind log lines:"
journalctl -u hivemind --no-pager -n 10 2>/dev/null | sed 's/^/ /' || echo " No journal entries"
# ── 9. Nginx ─────────────────────────────────────────────────
echo ""; echo "=== NGINX ==="
if command -v nginx &>/dev/null; then
echo "Binary : $(which nginx)"
echo "Version : $(nginx -v 2>&1)"
echo "Status : $(systemctl is-active nginx 2>/dev/null || echo 'N/A')"
echo ""
echo "Config test:"
nginx -t 2>&1 | sed 's/^/ /'
echo ""
echo "Sites enabled:"
ls -la /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
echo ""
echo "/tunnel location block exists?"
grep -r "location /tunnel" /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /' || echo " NOT FOUND"
echo ""
echo "/api location block exists?"
grep -r "location /api" /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /' || echo " NOT FOUND"
else
echo " NGINX NOT INSTALLED"
fi
# ── 10. SSL ──────────────────────────────────────────────────
echo ""; echo "=== SSL CERTIFICATES ==="
if command -v certbot &>/dev/null; then
certbot certificates 2>/dev/null | sed 's/^/ /' || echo " No certificates found"
echo ""
echo "Certificate files:"
ls -la /etc/letsencrypt/live/ 2>/dev/null | sed 's/^/ /' || echo " /etc/letsencrypt/live/ not found"
else
echo " certbot not installed"
fi
# ── 11. Docker ────────────────────────────────────────────────
echo ""; echo "=== DOCKER ==="
if command -v docker &>/dev/null; then
echo "Running containers:"
docker ps --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " None"
else
echo " Docker not installed"
fi
# ── 12. Traffic Control (tc) ─────────────────────────────────
echo ""; echo "=== TRAFFIC CONTROL (tc) ==="
if command -v tc &>/dev/null; then
for iface in $(ip -br link 2>/dev/null | awk '{print $1}'); do
rules=$(tc qdisc show dev "$iface" 2>/dev/null | grep -v "noqueue\|fq_codel\|pfifo_fast" | head -5)
if [ -n "$rules" ]; then
echo " Interface $iface:"
echo "$rules" | sed 's/^/ /'
fi
done
echo " (no custom tc rules found unless listed above)"
else
echo " tc not available"
fi
# ── 13. AWG leftovers ────────────────────────────────────────
echo ""; echo "=== AWG LEFTOVERS ==="
echo "awg0 interface : $([ -d /sys/class/net/awg0 ] && echo 'STILL EXISTS' || echo 'gone ✓')"
echo "awg binary : $(command -v awg 2>/dev/null && echo 'STILL INSTALLED' || echo 'gone ✓')"
echo "wg-easy docker : $(docker ps -a --filter 'name=wg-easy' --format '{{.Names}}' 2>/dev/null || echo 'gone ✓')"
echo "iptables 10.8.x: $(iptables -t nat -L POSTROUTING 2>/dev/null | grep '10\.8\.' | head -3 || echo 'gone ✓')"
# ── 14. Recent Errors ────────────────────────────────────────
echo ""; echo "=== RECENT ERRORS (last 50 journal lines, filtered) ==="
journalctl -u hivemind -u xray -u nginx --no-pager -n 50 --since "1 hour ago" 2>/dev/null | grep -iE 'error|fail|warn|exception|cannot|denied|timeout' | tail -10 | sed 's/^/ /' || echo " No recent errors"
echo ""; echo "============================================================"
echo " Report complete — $(date '+%H:%M:%S')"
echo "============================================================"
REPORT