v1.0.0: AWG -> VLESS migration — ReVoltVPN debut
This commit is contained in:
1 parent
b679ba4d8f
commit
aaf9887f49
29 files changed
+1046
-1376
No files matched your search
+4
-2
@@ -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
|
||||||
@@ -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'` |
|
||||||
Vendored
+5
-14
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,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};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
+74
-185
@@ -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();
|
|
||||||
if (raw.contains('VPN_PERMISSION_REQUESTED')) {
|
|
||||||
// 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.';
|
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||||
_setStatus(VpnStatus.error, 'Tunnel error');
|
_setStatus(VpnStatus.error, 'Connection failed');
|
||||||
}
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
-5
@@ -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>
|
|
||||||
-325
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+213
-228
@@ -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,
|
||||||
# "baseline_bytes": 0,
|
|
||||||
# "is_throttled": False,
|
# "is_throttled": False,
|
||||||
# "nonce": "a7f3b2-1716480000000",
|
# "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,85 +193,74 @@ 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)
|
||||||
|
# Lock released — file I/O and subprocess happen outside.
|
||||||
if session:
|
if session:
|
||||||
remove_wg_peer(session["pubkey"])
|
remove_vless_client(device_id)
|
||||||
if session["is_throttled"]:
|
|
||||||
remove_throttle(session["ip"])
|
|
||||||
release_ip(session["ip"])
|
|
||||||
print(f"[API] Explicit stop for {device_id}.")
|
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,
|
|
||||||
"pubkey": client_pubkey,
|
|
||||||
"expires_at": now + (MAIN_AD_MINUTES * 60),
|
"expires_at": now + (MAIN_AD_MINUTES * 60),
|
||||||
"quota_bytes": MAIN_AD_BYTES,
|
"quota_bytes": MAIN_AD_BYTES,
|
||||||
"baseline_bytes": current_bytes,
|
|
||||||
"is_throttled": False,
|
"is_throttled": False,
|
||||||
"nonce": nonce,
|
"nonce": nonce,
|
||||||
}
|
}
|
||||||
print(f"[SESSION] Main session started for {device_id} (IP: {ipv4} / {ipv6}).")
|
|
||||||
|
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."
|
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
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user