fix: session nonce to prevent stale session reuse, reorganize hivemind, clean AdMob bypass toggle

This commit is contained in:
esefxdz committed 2026-07-18 13:02:04 +03:00
1 parent 08f08fbf3e
commit 0943506404
4 files changed
+404 -333

No files matched your search

+1
View File
@@ -65,6 +65,7 @@ local_packages/*/.dart_tool/
# Secrets # Secrets
android/key.properties android/key.properties
android/admob.properties android/admob.properties
AGENTS.md
SERVER_NOTES.md SERVER_NOTES.md
lib/logic/backup/ lib/logic/backup/
00-FULL-REPORT.md 00-FULL-REPORT.md
+9 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:paladinvpn/logic/crypto_service.dart'; import 'package:paladinvpn/logic/crypto_service.dart';
@@ -102,12 +103,19 @@ class AdManager extends ChangeNotifier {
final keys = await CryptoService.getOrCreateKeys(); final keys = await CryptoService.getOrCreateKeys();
final pubKey = keys['publicKey']!; final pubKey = keys['publicKey']!;
// 3. Build SSV options to send to our Hivemind server via Google's callback // 3. Generate a connect nonce so the subsequent poll can verify
// it's talking to the session created by *this* ad.
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
HivemindService.setExpectedNonce(nonce);
debugPrint('[AdManager] Ad nonce: $nonce');
// 4. Build SSV options to send to our Hivemind server via Google's callback
final ssvOptions = ServerSideVerificationOptions( final ssvOptions = ServerSideVerificationOptions(
customData: jsonEncode({ customData: jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
'public_key': pubKey, 'public_key': pubKey,
'ad_type': adType, 'ad_type': adType,
'nonce': nonce,
}), }),
); );
+36 -2
View File
@@ -1,16 +1,30 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:paladinvpn/logic/app_config.dart'; import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/crypto_service.dart'; import 'package:paladinvpn/logic/crypto_service.dart';
class HivemindService { class HivemindService {
/// Nonce generated at the start of a connect cycle.
/// Sent to the server during session creation and verified in every poll
/// response so we never accidentally resume a stale session.
static String? _expectedNonce;
/// Stores a nonce so that fetchConfigWithPolling can verify it.
/// Called by AdManager.showAd() before showing a rewarded ad so the
/// subsequent poll accepts only the session created by *this* ad.
static void setExpectedNonce(String nonce) {
_expectedNonce = nonce;
}
/// 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 the WireGuard config 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)…").
static Future<String> fetchConfigWithPolling({ static Future<String> fetchConfigWithPolling({
void Function(int attempt, int total)? onAttempt, void Function(int attempt, int total)? onAttempt,
}) async { }) async {
@@ -18,6 +32,16 @@ class HivemindService {
final keys = await CryptoService.getOrCreateKeys(); final keys = await CryptoService.getOrCreateKeys();
final privKey = keys['privateKey']!; final privKey = keys['privateKey']!;
// ── 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}';
_expectedNonce = nonce;
debugPrint('[HivemindService] Connect nonce: $nonce');
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) ────────────────────────────────────────
@@ -30,6 +54,7 @@ class HivemindService {
'device_id': deviceId, 'device_id': deviceId,
'public_key': keys['publicKey']!, 'public_key': keys['publicKey']!,
'ad_type': 'main_ad', 'ad_type': 'main_ad',
'nonce': nonce,
}); });
final fakeAdmobPingUrl = Uri.parse( final fakeAdmobPingUrl = Uri.parse(
'${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}'); '${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
@@ -47,9 +72,17 @@ class HivemindService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
debugPrint('[HivemindService] Session active, constructing config…'); debugPrint('[HivemindService] Session found, checking nonce…');
if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) { // ── 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?;
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
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['client_ip'] != null && data['server_pubkey'] != null) {
final clientIp = data['client_ip']; final clientIp = data['client_ip'];
final clientIpv6 = data['client_ipv6'] ?? ''; final clientIpv6 = data['client_ipv6'] ?? '';
final serverPubkey = data['server_pubkey']; final serverPubkey = data['server_pubkey'];
@@ -82,6 +115,7 @@ AllowedIPs = ${AppConfig.allowedIps}
Endpoint = ${AppConfig.serverEndpoint} Endpoint = ${AppConfig.serverEndpoint}
PersistentKeepalive = ${AppConfig.persistentKeepalive} PersistentKeepalive = ${AppConfig.persistentKeepalive}
'''; ''';
_expectedNonce = null; // consumed successfully
return configText; return configText;
} }
} else { } else {
+358 -330
View File
@@ -3,6 +3,18 @@
Hivemind VPN Session Manager Hivemind VPN Session Manager
Manages time + data quotas for AmneziaWG peers via ad rewards. Manages time + data quotas for AmneziaWG peers via ad rewards.
Designed for Android-only clients. Designed for Android-only clients.
=== TABLE OF CONTENTS ===========================================================
LINE SECTION
44 Configuration — ad rewards, throttle rate, check interval
60 State & Cache — sessions dict, IP pool, server pubkey
93 Flask Routes — /admob/callback, /session/status, /session/stop
220 Session Logic — _start_or_extend_session (create / extend)
295 AdMob SSV Verification — Google ECDSA signature checks
380 Management Loop — expiry + data-quota watchdog (60 s tick)
426 Low-Level Plumbing — awg commands, tc throttling, IP pool, peers
567 Entry Point — daemon thread + gunicorn bootstrap
================================================================================
""" """
import time import time
@@ -13,11 +25,12 @@ import requests
import shlex import shlex
import re import re
import urllib.parse import urllib.parse
import ipaddress
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
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_der_public_key, load_pem_public_key from cryptography.hazmat.primitives.serialization import load_der_public_key, load_pem_public_key
import base64
app = Flask(__name__) app = Flask(__name__)
@@ -44,6 +57,15 @@ BONUS_AD_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB
THROTTLE_RATE = "1.5mbit" THROTTLE_RATE = "1.5mbit"
# AdMob SSV verification toggle
# True = dev mode, accept any callback (no signature check)
# False = prod mode, enforce Google ECDSA signature verification
#
# ⚠️ Set to False AFTER Google Play verifies your AdMob implementation is
# working correctly with real rewarded ads. Until then, keep True so
# the debug fake-callback path in the Flutter app still functions.
ADMOB_BYPASS = True
# ============================================================================== # ==============================================================================
# STATE & CACHE # STATE & CACHE
# ============================================================================== # ==============================================================================
@@ -61,7 +83,8 @@ SERVER_PUBKEY = None
# "expires_at": 1716480000, # "expires_at": 1716480000,
# "quota_bytes": 2147483648, # "quota_bytes": 2147483648,
# "baseline_bytes": 0, # "baseline_bytes": 0,
# "is_throttled": False # "is_throttled": False,
# "nonce": "a7f3b2-1716480000000",
# } # }
# } # }
active_sessions = {} active_sessions = {}
@@ -70,10 +93,342 @@ active_sessions = {}
IP_POOL_START = 100 IP_POOL_START = 100
IP_POOL_END = 250 IP_POOL_END = 250
# Pre-computed pool of all available IPv4 addresses in the 10.8.0.0/16 subnet.
# 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')
# ============================================================================== # ==============================================================================
# SYSTEM COMMAND HELPERS # FLASK ROUTES (API surface — what the outside world sees)
# ============================================================================== # ==============================================================================
@app.route('/api/admob/callback', methods=['GET'])
def admob_callback():
"""
Google AdMob SSV callback — called server-to-server when a rewarded ad completes.
The Android client never touches this endpoint; Google calls it directly.
custom_data (JSON, set by the app before showing the ad):
{ "device_id": "...", "public_key": "...", "ad_type": "main_ad"|"bonus_ad", "nonce": "..." }
"""
try:
if ADMOB_BYPASS:
# ── DEV: accept any callback without signature verification ──
custom_data_str = request.args.get('custom_data')
if not custom_data_str:
return "OK", 200
else:
# ── PROD: enforce Google ECDSA signature verification ────────
signature = request.args.get('signature')
key_id = request.args.get('key_id')
custom_data_str = request.args.get('custom_data')
# Google sends a verification ping with signature+key_id but no custom_data
if not custom_data_str:
return "OK", 200
if not all([signature, key_id]):
return "Missing parameters", 400
raw_qs = request.query_string.decode("utf-8")
if not _verify_admob_signature(raw_qs, signature, key_id):
print("[ADMOB] Rejected callback — invalid signature.")
return "Forbidden", 403
custom_data = json.loads(custom_data_str)
device_id = custom_data.get('device_id')
client_pubkey = custom_data.get('public_key')
if not device_id or not client_pubkey:
return "Invalid custom_data payload", 400
ad_type = custom_data.get('ad_type', 'main_ad')
nonce = custom_data.get('nonce', None)
success, msg = _start_or_extend_session(device_id, client_pubkey, ad_type, nonce)
return ("OK", 200) if success else (msg, 500)
except Exception as e:
print(f"[ADMOB] SSV Error: {e}")
return "Internal Error", 500
@app.route('/api/session/status', methods=['GET'])
def session_status():
"""
Query: ?device_id=esf-xxx
Returns current session state so the Android app can construct its AmneziaWG config.
"""
device_id = request.args.get('device_id')
if not device_id:
return jsonify({"error": "Missing query parameter: device_id"}), 400
with sessions_lock:
session = active_sessions.get(device_id)
if not session:
return jsonify({"active": False, "message": "No session found."}), 404
# Bulletproof fallback: If server public key is missing, try fetching it right now
global SERVER_PUBKEY
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_secs = max(0, session["expires_at"] - time.time())
return jsonify({
"active": remaining_secs > 0,
"client_ip": session["ip"],
"client_ipv6": session.get("ipv6", ""),
"server_pubkey": SERVER_PUBKEY,
"is_throttled": session["is_throttled"],
"expires_in_seconds": int(remaining_secs),
"quota_bytes": session["quota_bytes"],
"used_bytes": used_bytes,
"remaining_bytes": remaining_data,
"nonce": session.get("nonce", ""),
})
@app.route('/api/session/stop', methods=['POST'])
def session_stop():
"""Allows the app to explicitly tear down its session on disconnect."""
data = request.get_json(silent=True)
device_id = data.get('device_id') if data else None
if device_id:
with sessions_lock:
session = active_sessions.pop(device_id, None)
if session:
remove_wg_peer(session["pubkey"])
if session["is_throttled"]:
remove_throttle(session["ip"])
release_ip(session["ip"])
print(f"[API] Explicit stop for {device_id}.")
return jsonify({"success": True})
# ==============================================================================
# SESSION LOGIC
# ==============================================================================
def _start_or_extend_session(device_id, client_pubkey, ad_type, nonce=None):
"""Core logic to provision or extend a session.
nonce -- client-generated random token; stored in session and echoed
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()
with sessions_lock:
if device_id in active_sessions and ad_type == 'bonus_ad':
# Extend existing session
session = active_sessions[device_id]
session["expires_at"] += (BONUS_AD_MINUTES * 60)
session["quota_bytes"] += BONUS_AD_BYTES
if session["is_throttled"]:
remove_throttle(session["ip"])
session["is_throttled"] = False
stats = get_wireguard_stats()
current_bytes = stats.get(session["ip"], {}).get("total_bytes", 0)
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:
# 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)
if existing:
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] = {
"ip": ipv4,
"ipv6": ipv6,
"pubkey": client_pubkey,
"expires_at": now + (MAIN_AD_MINUTES * 60),
"quota_bytes": MAIN_AD_BYTES,
"baseline_bytes": current_bytes,
"is_throttled": False,
"nonce": nonce,
}
print(f"[SESSION] Main session started for {device_id} (IP: {ipv4} / {ipv6}).")
return True, "Main session active."
# ==============================================================================
# ADMOB SERVER-TO-SERVER (SSV) VERIFICATION
# ==============================================================================
ADMOB_KEYS_URL = "https://www.gstatic.com/admob/reward/verifier-keys.json"
# Cache: { key_id_str: EllipticCurvePublicKey }
# Refreshed at most once every 24 hours so we don't hammer Google's CDN.
_admob_key_cache = {}
_admob_keys_fetched_at = 0.0
_ADMOB_KEY_TTL = 86400 # seconds
def _get_admob_public_key(key_id: str):
"""
Returns the Google ECDSA public-key object for the given key_id.
Fetches and caches the key list from gstatic; refreshes every 24h.
Returns None if the key_id is unknown.
"""
global _admob_key_cache, _admob_keys_fetched_at
now = time.time()
if now - _admob_keys_fetched_at > _ADMOB_KEY_TTL or not _admob_key_cache:
try:
resp = requests.get(ADMOB_KEYS_URL, timeout=5)
resp.raise_for_status()
data = resp.json()
new_cache = {}
for entry in data.get("keys", []):
kid = str(entry["keyId"])
pem = entry["pem"].encode()
new_cache[kid] = load_pem_public_key(pem)
_admob_key_cache = new_cache
_admob_keys_fetched_at = now
print(f"[ADMOB] Refreshed {len(new_cache)} public key(s) from Google.")
except Exception as e:
print(f"[ADMOB] WARNING: Could not refresh public keys: {e}")
# Fall through with stale cache rather than rejecting all valid callbacks
return _admob_key_cache.get(key_id)
def _verify_admob_signature(raw_query_string: str, signature_b64url: str, key_id: str) -> bool:
"""
Verifies an AdMob SSV ECDSA-SHA256 signature.
Google signs all query params EXCEPT 'signature' and 'key_id', in original order.
"""
try:
public_key = _get_admob_public_key(key_id)
if not public_key:
print("[ADMOB] Unknown key_id — rejecting callback.")
return False
# Reconstruct the signed payload by stripping signature + key_id from raw qs
parsed = urllib.parse.parse_qs(raw_query_string, keep_blank_values=True)
# Google signs query params in the exact order they appear in the URL.
# Use the raw string to preserve ordering — parse_qs loses it.
ordered_pairs = []
for pair in raw_query_string.split('&'):
if '=' not in pair:
continue
k, v = pair.split('=', 1)
if k in ('signature', 'key_id'):
continue
ordered_pairs.append(f"{k}={v}")
payload = '&'.join(ordered_pairs).encode('utf-8')
# Google uses URL-safe base64 (no padding) for the signature
sig_bytes = base64.urlsafe_b64decode(signature_b64url + '==')
public_key.verify(
sig_bytes,
payload,
ec.ECDSA(hashes.SHA256())
)
return True
except Exception as e:
print(f"[ADMOB] Signature verification failed: {e}")
return False
# ==============================================================================
# MANAGEMENT LOOP (The Hivemind — expiry + data-quota watchdog)
# ==============================================================================
def management_loop():
print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.")
init_wireguard()
while True:
try:
stats = get_wireguard_stats()
now = time.time()
with sessions_lock:
expired_devices = []
for device_id, session in list(active_sessions.items()):
ip = session["ip"]
# ── 1. Check expiry ───────────────────────────────────────
if now > session["expires_at"]:
print(f"[HIVEMIND] Session expired for {device_id} ({ip}). Removing peer.")
if session["is_throttled"]:
remove_throttle(ip)
remove_wg_peer(session["pubkey"])
release_ip(ip)
expired_devices.append(device_id)
continue
# ── 2. Check data quota ───────────────────────────────────
if ip in stats:
used_bytes = stats[ip]["total_bytes"] - session["baseline_bytes"]
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.")
apply_throttle(ip)
session["is_throttled"] = True
for device_id in expired_devices:
del active_sessions[device_id]
except Exception as e:
print(f"[HIVEMIND] Unhandled error: {e}")
time.sleep(CHECK_INTERVAL_SECONDS)
# ==============================================================================
# LOW-LEVEL PLUMBING (awg, tc, IP pool, peer management)
# ==============================================================================
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 (no Docker middleman)."""
# All commands run directly on the host against awg0. # All commands run directly on the host against awg0.
@@ -177,25 +532,12 @@ def get_wireguard_stats():
return stats return stats
import ipaddress
# ==============================================================================
# IPv6 HELPERS
# ==============================================================================
def ipv4_to_ipv6(ipv4): def ipv4_to_ipv6(ipv4):
"""Map 10.8.X.Y → fd42::X:Y (ULA for internal VPN routing, no NAT64 needed).""" """Map 10.8.X.Y → fd42::X:Y (ULA for internal VPN routing, no NAT64 needed)."""
parts = ipv4.split('.') parts = ipv4.split('.')
return f"fd42::{parts[2]}:{parts[3]}" return f"fd42::{parts[2]}:{parts[3]}"
# ==============================================================================
# IP POOL MANAGEMENT
# ==============================================================================
# The server subnet is 10.8.0.0/16, giving ~65,530 IPs for public use.
#60 THOUSAND OR 64 THOUSAND ARGUEMENT GOES HERE
AVAILABLE_IPS = {str(ip) for ip in ipaddress.IPv4Network('10.8.0.0/16').hosts()}
AVAILABLE_IPS.discard('10.8.0.1')
# We need a fallback if we somehow exhaust them, though unlikely
def allocate_ip(): def allocate_ip():
"""Pops an available IP from the pre-generated pool.""" """Pops an available IP from the pre-generated pool."""
if not AVAILABLE_IPS: if not AVAILABLE_IPS:
@@ -224,320 +566,6 @@ def remove_wg_peer(pubkey):
print(f"[AWG] Ephemeral peer removed: {pubkey[:8]}...") print(f"[AWG] Ephemeral peer removed: {pubkey[:8]}...")
# ==============================================================================
# BACKGROUND MANAGEMENT LOOP (The Hivemind)
# ==============================================================================
def management_loop():
print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.")
init_wireguard()
while True:
try:
stats = get_wireguard_stats()
now = time.time()
with sessions_lock:
expired_devices = []
for device_id, session in list(active_sessions.items()):
ip = session["ip"]
# ── 1. Check expiry ───────────────────────────────────────
if now > session["expires_at"]:
print(f"[HIVEMIND] Session expired for {device_id} ({ip}). Removing peer.")
if session["is_throttled"]:
remove_throttle(ip)
remove_wg_peer(session["pubkey"])
release_ip(ip)
expired_devices.append(device_id)
continue
# ── 2. Check data quota ───────────────────────────────────
if ip in stats:
used_bytes = stats[ip]["total_bytes"] - session["baseline_bytes"]
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.")
apply_throttle(ip)
session["is_throttled"] = True
for device_id in expired_devices:
del active_sessions[device_id]
except Exception as e:
print(f"[HIVEMIND] Unhandled error: {e}")
time.sleep(CHECK_INTERVAL_SECONDS)
# ==============================================================================
# SESSION LOGIC
# ==============================================================================
def _start_or_extend_session(device_id, client_pubkey, ad_type):
"""Core logic to provision or extend a session."""
if not re.match(r"^[A-Za-z0-9+/=]{43,44}$", client_pubkey):
return False, "Invalid WireGuard public key format"
now = time.time()
with sessions_lock:
if device_id in active_sessions and ad_type == 'bonus_ad':
# Extend existing session
session = active_sessions[device_id]
session["expires_at"] += (BONUS_AD_MINUTES * 60)
session["quota_bytes"] += BONUS_AD_BYTES
if session["is_throttled"]:
remove_throttle(session["ip"])
session["is_throttled"] = False
stats = get_wireguard_stats()
current_bytes = stats.get(session["ip"], {}).get("total_bytes", 0)
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:
# 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)
if existing:
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] = {
"ip": ipv4,
"ipv6": ipv6,
"pubkey": client_pubkey,
"expires_at": now + (MAIN_AD_MINUTES * 60),
"quota_bytes": MAIN_AD_BYTES,
"baseline_bytes": current_bytes,
"is_throttled": False,
}
print(f"[SESSION] Main session started for {device_id} (IP: {ipv4} / {ipv6}).")
return True, "Main session active."
# ==============================================================================
# ADMOB SERVER-TO-SERVER (SSV) VERIFICATION
# ==============================================================================
ADMOB_KEYS_URL = "https://www.gstatic.com/admob/reward/verifier-keys.json"
# Cache: { key_id_str: EllipticCurvePublicKey }
# Refreshed at most once every 24 hours so we don't hammer Google's CDN.
_admob_key_cache = {}
_admob_keys_fetched_at = 0.0
_ADMOB_KEY_TTL = 86400 # seconds
def _get_admob_public_key(key_id: str):
"""
Returns the Google ECDSA public-key object for the given key_id.
Fetches and caches the key list from gstatic; refreshes every 24h.
Returns None if the key_id is unknown.
"""
global _admob_key_cache, _admob_keys_fetched_at
now = time.time()
if now - _admob_keys_fetched_at > _ADMOB_KEY_TTL or not _admob_key_cache:
try:
resp = requests.get(ADMOB_KEYS_URL, timeout=5)
resp.raise_for_status()
data = resp.json()
new_cache = {}
for entry in data.get("keys", []):
kid = str(entry["keyId"])
pem = entry["pem"].encode()
new_cache[kid] = load_pem_public_key(pem)
_admob_key_cache = new_cache
_admob_keys_fetched_at = now
print(f"[ADMOB] Refreshed {len(new_cache)} public key(s) from Google.")
except Exception as e:
print(f"[ADMOB] WARNING: Could not refresh public keys: {e}")
# Fall through with stale cache rather than rejecting all valid callbacks
return _admob_key_cache.get(key_id)
def _verify_admob_signature(raw_query_string: str, signature_b64url: str, key_id: str) -> bool:
"""
Verifies an AdMob SSV ECDSA-SHA256 signature.
Google signs all query params EXCEPT 'signature' and 'key_id', in original order.
"""
try:
# 1. Rebuild the signed message: all params except signature & key_id
params = urllib.parse.parse_qsl(raw_query_string, keep_blank_values=True)
filtered = [(k, v) for k, v in params if k not in ("signature", "key_id")]
message = urllib.parse.urlencode(filtered).encode("utf-8")
# 2. Base64url-decode the signature (add missing padding if needed)
padding = 4 - len(signature_b64url) % 4
sig_bytes = base64.urlsafe_b64decode(signature_b64url + ("=" * (padding % 4)))
# 3. Fetch the matching public key
pub_key = _get_admob_public_key(key_id)
if pub_key is None:
print(f"[ADMOB] Unknown key_id: {key_id}. Rejecting.")
return False
# 4. Verify — raises InvalidSignature on failure
pub_key.verify(sig_bytes, message, ec.ECDSA(hashes.SHA256()))
return True
except Exception as e:
print(f"[ADMOB] Signature verification failed: {e}")
return False
@app.route('/api/admob/callback', methods=['GET'])
def admob_callback():
"""
Google AdMob SSV callback — called server-to-server when a rewarded ad completes.
The Android client never touches this endpoint; Google calls it directly.
custom_data (JSON, set by the app before showing the ad):
{ "device_id": "...", "public_key": "...", "ad_type": "main_ad"|"bonus_ad" }
"""
try:
# ======================================================================
# ⚠️ ADMOB BYPASS (TEMPORARY FOR APP REVIEW)
# DELETE THIS ENTIRE BLOCK WHEN YOU WANT TO ENABLE REAL ADS
# ======================================================================
custom_data_str = request.args.get('custom_data')
if not custom_data_str:
return "OK", 200
# ======================================================================
# ======================================================================
# ✅ REAL ADMOB CODE
# UNCOMMENT THIS ENTIRE BLOCK WHEN YOU WANT TO ENABLE REAL ADS
# ======================================================================
# signature = request.args.get('signature')
# key_id = request.args.get('key_id')
# custom_data_str = request.args.get('custom_data')
#
# # Google sends a verification ping with signature+key_id but no custom_data
# if not custom_data_str:
# return "OK", 200 # Verification ping from Google dashboard
#
# if not all([signature, key_id]):
# return "Missing parameters", 400
#
# raw_qs = request.query_string.decode("utf-8")
# if not _verify_admob_signature(raw_qs, signature, key_id):
# print("[ADMOB] Rejected callback — invalid signature.")
# return "Forbidden", 403
# ======================================================================
custom_data = json.loads(custom_data_str)
device_id = custom_data.get('device_id')
client_pubkey = custom_data.get('public_key')
if not device_id or not client_pubkey:
return "Invalid custom_data payload", 400
ad_type = custom_data.get('ad_type', 'main_ad')
success, msg = _start_or_extend_session(device_id, client_pubkey, ad_type)
return ("OK", 200) if success else (msg, 500)
except Exception as e:
print(f"[ADMOB] SSV Error: {e}")
return "Internal Error", 500
# ==============================================================================
# FLASK API (APP POLLING)
# ==============================================================================
@app.route('/api/session/status', methods=['GET'])
def session_status():
"""
Query: ?device_id=esf-xxx
Returns current session state so the Android app can construct its AmneziaWG config.
"""
device_id = request.args.get('device_id')
if not device_id:
return jsonify({"error": "Missing query parameter: device_id"}), 400
with sessions_lock:
session = active_sessions.get(device_id)
if not session:
return jsonify({"active": False, "message": "No session found."}), 404
# Bulletproof fallback: If server public key is missing, try fetching it right now
global SERVER_PUBKEY
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_secs = max(0, session["expires_at"] - time.time())
return jsonify({
"active": remaining_secs > 0,
"client_ip": session["ip"],
"client_ipv6": session.get("ipv6", ""),
"server_pubkey": SERVER_PUBKEY,
"is_throttled": session["is_throttled"],
"expires_in_seconds": int(remaining_secs),
"quota_bytes": session["quota_bytes"],
"used_bytes": used_bytes,
"remaining_bytes": remaining_data,
})
@app.route('/api/session/stop', methods=['POST'])
def session_stop():
"""Allows the app to explicitly tear down its session on disconnect."""
data = request.get_json(silent=True)
device_id = data.get('device_id') if data else None
if device_id:
with sessions_lock:
session = active_sessions.pop(device_id, None)
if session:
remove_wg_peer(session["pubkey"])
if session["is_throttled"]:
remove_throttle(session["ip"])
release_ip(session["ip"])
print(f"[API] Explicit stop for {device_id}.")
return jsonify({"success": True})
# ============================================================================== # ==============================================================================
# ENTRY POINT # ENTRY POINT
# ============================================================================== # ==============================================================================