diff --git a/.gitignore b/.gitignore index 8e36791..0a3503e 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ UI_POSITIONING.md SERVER_SETUP.md REALITY_MIGRATION_PLAN.md xray_config_reality.json +SERVER_DEPLOYMENT.md +swarm_watch.py diff --git a/lib/components/clock_display.dart b/lib/components/clock_display.dart index 8b76934..ce4dee8 100644 --- a/lib/components/clock_display.dart +++ b/lib/components/clock_display.dart @@ -1,92 +1,115 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:revoltvpn/logic/app_colors.dart'; -import 'package:revoltvpn/logic/session_timer.dart'; +// ============================================================================= +// clock_display.dart — EDUCATIONAL REFERENCE (not used in production) +// ============================================================================= +// +// This widget was the original combined timer + speed + syncing display. +// It was replaced by the separate TimerBox and SpeedBox widgets in the +// Reality-era UI refactor. +// +// What's worth studying here: +// +// 1. TEXT GLOW EFFECT — the yellow glow around the countdown timer: +// ``` +// shadows: [Shadow(color: AppColors.accent50, blurRadius: 16)] +// ``` +// This exact effect was ported to timer_box.dart and is what gives +// the countdown its signature pulsing-yellow look. +// +// 2. SYNCING PLACEHOLDER — conditional spinner + label when the first +// server poll hasn't returned yet. Shows "Syncing…" with a small +// CircularProgressIndicator instead of flashing 00:00:00. +// +// 3. COMBINED DISPLAY — clock + speed stacked in a Column inside a +// fixed 200×200 SizedBox. The modern UI split these into separate +// boxes for the stacked layout, but the stacking approach here is +// a good reference for compact combined displays. +// +// Kept in the repo for reference. Safe to delete if you're pruning. +// ============================================================================= -class ClockDisplay extends StatelessWidget { - const ClockDisplay({super.key}); +// import 'package:flutter/material.dart'; +// import 'package:provider/provider.dart'; +// import 'package:revoltvpn/logic/app_colors.dart'; +// import 'package:revoltvpn/logic/session_timer.dart'; +// class ClockDisplay extends StatelessWidget { +// const ClockDisplay({super.key}); - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, timer, _) { - final bool showSyncing = !timer.hasSyncedOnce; +// @override +// Widget build(BuildContext context) { +// return Consumer( +// builder: (context, timer, _) { +// final bool showSyncing = !timer.hasSyncedOnce; - return SizedBox( - width: 200, - height: 200, - child: Stack( - alignment: Alignment.center, - children: [ - // Center content: countdown + speed (or syncing placeholder) - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showSyncing) ...[ - // Syncing placeholder — spinner + label - const SizedBox( - width: 32, - height: 32, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.accent50, - ), - ), - const SizedBox(height: 10), - const Text( - 'Syncing…', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.accent60, - letterSpacing: 1.5, - ), - ), - ] else ...[ - // Glowing countdown timer - Text( - timer.formatted, - style: const TextStyle( - fontSize: 40, - fontWeight: FontWeight.w800, - color: AppColors.textWhite, - letterSpacing: 2, - shadows: [ - Shadow( - color: AppColors.accent50, - blurRadius: 16, - ), - ], - ), - ), - const SizedBox(height: 6), - - // Speed indicator - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.speed, color: AppColors.textDim, size: 14), - const SizedBox(width: 4), - Text( - timer.currentSpeedKBps > 0.5 - ? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s' - : 'Idle', - style: const TextStyle( - fontSize: 12, - color: AppColors.textDim, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ], - ), - ], - ), - ); - }, - ); - } -} +// return SizedBox( +// width: 200, +// height: 200, +// child: Stack( +// alignment: Alignment.center, +// children: [ +// Column( +// mainAxisSize: MainAxisSize.min, +// children: [ +// if (showSyncing) ...[ +// const SizedBox( +// width: 32, +// height: 32, +// child: CircularProgressIndicator( +// strokeWidth: 2, +// color: AppColors.accent50, +// ), +// ), +// const SizedBox(height: 10), +// const Text( +// 'Syncing…', +// style: TextStyle( +// fontSize: 16, +// fontWeight: FontWeight.w600, +// color: AppColors.accent60, +// letterSpacing: 1.5, +// ), +// ), +// ] else ...[ +// Text( +// timer.formatted, +// style: const TextStyle( +// fontSize: 40, +// fontWeight: FontWeight.w800, +// color: AppColors.textWhite, +// letterSpacing: 2, +// shadows: [ +// Shadow( +// color: AppColors.accent50, +// blurRadius: 16, +// ), +// ], +// ), +// ), +// const SizedBox(height: 6), +// Row( +// mainAxisSize: MainAxisSize.min, +// children: [ +// const Icon(Icons.speed, color: AppColors.textDim, size: 14), +// const SizedBox(width: 4), +// Text( +// timer.currentSpeedKBps > 0.5 +// ? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s' +// : 'Idle', +// style: const TextStyle( +// fontSize: 12, +// color: AppColors.textDim, +// fontWeight: FontWeight.w500, +// ), +// ), +// ], +// ), +// ], +// ], +// ), +// ], +// ), +// ); +// }, +// ); +// } +// } diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 8346c6d..ee23475 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -21,7 +21,6 @@ class SessionTimer extends ChangeNotifier { bool _isDisconnecting = false; bool _userInitiatedStop = false; - bool _isThrottled = false; int? _currentPort; bool _portSwitchInProgress = false; @@ -66,7 +65,7 @@ class SessionTimer extends ChangeNotifier { if (vpnConnection.status == VpnStatus.connected && !isRunning && vpnConnection.isStartupRestoration) { - start('resume'); + _resumeTicking(); return; } @@ -225,7 +224,6 @@ class SessionTimer extends ChangeNotifier { !_portSwitchInProgress) { debugPrint('[Timer] Port changed $_currentPort → $serverPort — reconnecting…'); _currentPort = serverPort; - _isThrottled = data['is_throttled'] ?? false; _portSwitchInProgress = true; _timer?.cancel(); @@ -238,17 +236,12 @@ class SessionTimer extends ChangeNotifier { return; } _currentPort = serverPort; - _isThrottled = data['is_throttled'] ?? false; _consecutiveFailures = 0; _offlineSeconds = 0; _hasSyncedOnce = true; notifyListeners(); - } else if (response.statusCode == 404) { - // Peer not found on server (expired / deleted natively) - stop(); - await vpnConnection.disconnect(); } else { _markSyncFailure(); } diff --git a/nginx_hivemind_reality.conf b/nginx_hivemind_reality.conf index 8f9cf1f..1cd1431 100644 --- a/nginx_hivemind_reality.conf +++ b/nginx_hivemind_reality.conf @@ -14,6 +14,31 @@ server { ssl_certificate /etc/letsencrypt/live/getrevolt.app/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/getrevolt.app/privkey.pem; + # ── Swarm monitor endpoints (lock behind basic auth) ──────────────── + # Generate password file on server: + # apt install apache2-utils + # htpasswd -c /etc/nginx/.htpasswd admin + location /api/swarm { + auth_basic "Hivemind Monitor"; + auth_basic_user_file /etc/nginx/.htpasswd; + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 30s; + } + location /api/log { + auth_basic "Hivemind Monitor"; + auth_basic_user_file /etc/nginx/.htpasswd; + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 30s; + } + # ── API proxy to Hivemind (Flask) ──────────────────────────────────── location /api/ { proxy_pass http://127.0.0.1:5000; diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index e327096..596da24 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -35,6 +35,30 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key app = Flask(__name__) +# ── Log buffer for TUI / remote monitoring ──────────────────────────────── +# Captures every print() into a ring buffer so the TUI dashboard can pull +# recent log lines without SSH. /api/log returns the last 200 lines. +import sys +from collections import deque + +_log_buffer = deque(maxlen=200) + +class _TeeStdout: + def __init__(self, original): + self.original = original + def write(self, s): + self.original.write(s) + self.original.flush() + for line in s.splitlines(): + stripped = line.rstrip() + if stripped: + _log_buffer.append(stripped) + def flush(self): + self.original.flush() + +sys.stdout = _TeeStdout(sys.stdout) + + def _is_valid_device_id(device_id): """Validate that device_id is a well-formed UUID (Flutter uuid.v4() format). @@ -86,6 +110,11 @@ VLESS_SERVER_IP = "204.168.246.88" VLESS_REALITY_PORT = 8443 REALITY_PUBLIC_KEY = "GENERATE_ME_WITH_xray_x25519" REALITY_SHORT_ID = "abc123" + +# ── Persistent swarm total (survives restarts) ────────────────────────── +SWARM_TOTAL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "swarm_total.json") +_persistent_total_gb = 0.0 REALITY_FINGERPRINT = "chrome" # ── Xray paths ─────────────────────────────────────────────────────────── @@ -105,6 +134,25 @@ THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB # the debug fake-callback path in the Flutter app still functions. ADMOB_BYPASS = True +def _load_persistent_total(): + """Read the running total GB from disk. Returns 0.0 if file missing/corrupt.""" + try: + with open(SWARM_TOTAL_FILE, 'r') as f: + data = json.load(f) + return float(data.get("total_gb", 0.0)) + except Exception: + return 0.0 + + +def _save_persistent_total(): + """Write the running total GB to disk atomically.""" + _atomic_write_json(SWARM_TOTAL_FILE, {"total_gb": _persistent_total_gb}) + + +# Load at module init — survives Hivemind restarts. +_persistent_total_gb = _load_persistent_total() + + # ============================================================================== # STATE & CACHE # ============================================================================== @@ -187,6 +235,18 @@ def health(): return jsonify({"ok": True}) +@app.route('/api/swarm') +def swarm_status(): + """Returns sorted drone list — consumed by TUI dashboards and CLI tools.""" + return jsonify(get_swarm_status()) + + +@app.route('/api/log') +def log_tail(): + """Returns recent log lines — consumed by TUI dashboards.""" + return jsonify(list(_log_buffer)) + + @app.route('/api/session/status', methods=['GET']) def session_status(): """ @@ -243,6 +303,13 @@ def session_stop(): session = active_sessions.pop(device_id, None) # Lock released — file I/O and subprocess happen outside. if session: + # Add final bytes to persistent odometer. + stats = get_xray_stats() + used = stats.get(device_id, {}).get("total_bytes", 0) + if used > 0: + global _persistent_total_gb + _persistent_total_gb += used / 1_000_000_000 + _save_persistent_total() remove_vless_client(device_id) print(f"[API] Explicit stop for {device_id}.") @@ -271,6 +338,8 @@ def _start_or_extend_session(device_id, nonce=None): "expires_at": now + (MAIN_AD_MINUTES * 60), "nonce": nonce, "throttled": False, + "_created_at": now, + "_prev_bytes": 0, } if existing: @@ -406,6 +475,7 @@ def _verify_admob_signature(raw_query_string: str, signature_b64url: str, key_id # MANAGEMENT LOOP (The Hivemind — expiry + throttle watchdog) # ============================================================================== def management_loop(): + global _persistent_total_gb print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.") while True: @@ -426,6 +496,7 @@ def management_loop(): continue used_bytes = stats.get(device_id, {}).get("total_bytes", 0) + session["_prev_bytes"] = used_bytes just_throttled = False if used_bytes > THROTTLE_BYTES and not session.get("throttled", False): @@ -440,6 +511,11 @@ def management_loop(): session["_cleaned_normal"] = True for device_id in to_remove: + # Add final used bytes to the persistent odometer before culling. + used = stats.get(device_id, {}).get("total_bytes", 0) + if used > 0: + _persistent_total_gb += used / 1_000_000_000 + _save_persistent_total() del active_sessions[device_id] for device_id in to_remove: @@ -457,6 +533,69 @@ def management_loop(): time.sleep(CHECK_INTERVAL_SECONDS) +# ############################################################################## +# SWARM INTELLIGENCE (drone status — sorted data for TUI / API consumers) +# ############################################################################## +# get_swarm_status() returns a list of dicts sorted by GB used (highest first). +# Callers (CLI tools, TUI dashboards, /api/swarm endpoint) consume this. +# It snapshots active_sessions under lock and reads Xray stats — no side effects. +# ############################################################################## + +def get_swarm_status(): + """Return sorted drone list + aggregate totals for external monitoring. + + Returns a dict: + { + "drones": [ {...}, {...}, ... ], # sorted by gb descending + "swarm_gb": 7.3, # total GB across all drones + "active_count": 3, # sessions still alive + } + + Each drone dict: + { + "id": "a1b2c3d4", # first 8 chars of device_id + "uptime_min": 23, # minutes since session created + "gb": 2.14, # data used (GB, 2 decimal places) + "throttled": false, # over quota? + "active": true, # had traffic in the last tick? + "remaining_min": 37, # minutes until expiry + } + """ + now = time.time() + stats = get_xray_stats() + + with sessions_lock: + drones = [] + for device_id, session in active_sessions.items(): + used_bytes = stats.get(device_id, {}).get("total_bytes", 0) + gb = round(used_bytes / 1_000_000_000, 2) + uptime_min = int((now - session.get("_created_at", session.get("expires_at", now) - MAIN_AD_MINUTES * 60)) / 60) + remaining_min = max(0, int((session["expires_at"] - now) / 60)) + prev_bytes = session.get("_prev_bytes", 0) + active = used_bytes > prev_bytes + + drones.append({ + "id": device_id[:8], + "uptime_min": max(0, uptime_min), + "gb": gb, + "throttled": session.get("throttled", False), + "active": active, + "remaining_min": remaining_min, + }) + + # Sort: highest data usage first — abusers float to the top. + drones.sort(key=lambda d: d["gb"], reverse=True) + + swarm_gb = round(sum(d["gb"] for d in drones), 2) + + return { + "drones": drones, + "swarm_gb": swarm_gb, + "total_gb": round(_persistent_total_gb + swarm_gb, 2), + "active_count": len(drones), + } + + # ============================================================================== # LOW-LEVEL PLUMBING (Xray client management + stats) # ==============================================================================