Add Peer Health Monitor feature

Problem:
Mobile VPN clients behind NAT frequently change IP addresses when switching
between networks (WiFi/mobile data). The WireGuard server loses track of
their current endpoint, causing connection drops until the client initiates
new traffic. This is especially problematic for always-on VPN configurations.

Solution:
Peer Health Monitor actively monitors peer connectivity and helps maintain
stable connections by:
- Pinging peers to detect connectivity issues early
- Tracking endpoint changes and connection status
- Auto-configuring PersistentKeepalive on server side
- Providing real-time visibility into peer health status

Features:
- Real-time peer health dashboard with auto-refresh (10s)
- Per-interface monitoring configuration
- ICMP ping with configurable intervals
- Success rate tracking and statistics
- API endpoints for integration
- Bulgarian and Russian translations

Files changed:
- src/modules/PeerHealthMonitor.py (new)
- src/modules/DashboardConfig.py (Health config section)
- src/dashboard.py (API endpoints)
- src/static/app/src/components/settingsComponent/peerHealthMonitor.vue (new)
- src/static/app/src/router/router.js (route)
- src/static/app/src/views/settings.vue (tab)
- src/static/locales/*.json (25 new translation keys)
pull/1129/head
V-Bantserov 2026-02-07 14:04:22 +02:00
parent 78a7211680
commit 476f80c37e
9 changed files with 1288 additions and 6 deletions

View File

@ -40,6 +40,7 @@ from modules.DashboardClients import DashboardClients
from modules.DashboardPlugins import DashboardPlugins
from modules.DashboardWebHooks import DashboardWebHooks
from modules.NewConfigurationTemplates import NewConfigurationTemplates
from modules.PeerHealthMonitor import PeerHealthMonitor
class CustomJsonEncoder(DefaultJSONProvider):
def __init__(self, app):
@ -174,6 +175,10 @@ def startThreads():
scheduleJobThread = threading.Thread(target=peerJobScheduleBackgroundThread, daemon=True)
scheduleJobThread.start()
# Health Monitor - start if enabled
if DashboardConfig.GetConfig("Health", "enabled")[1] == "true":
PeerHealthMonitorInstance.start()
dictConfig({
'version': 1,
'formatters': {'default': {
@ -203,6 +208,7 @@ with app.app_context():
NewConfigurationTemplates: NewConfigurationTemplates = NewConfigurationTemplates()
InitWireguardConfigurationsList(startup=True)
DashboardClients: DashboardClients = DashboardClients(WireguardConfigurations)
PeerHealthMonitorInstance: PeerHealthMonitor = PeerHealthMonitor(DashboardConfig, WireguardConfigurations, app.logger)
app.register_blueprint(createClientBlueprint(WireguardConfigurations, DashboardConfig, DashboardClients))
_, APP_PREFIX = DashboardConfig.GetConfig("Server", "app_prefix")
@ -1220,6 +1226,80 @@ def API_download():
else:
return ResponseObject(False, "File does not exist")
# =====================================================
# Health Monitor API Endpoints
# =====================================================
@app.get(f'{APP_PREFIX}/api/health/status')
def API_Health_Status():
"""Get health monitoring status"""
return ResponseObject(data=PeerHealthMonitorInstance.get_all_health())
@app.get(f'{APP_PREFIX}/api/health/peer/<public_key>')
def API_Health_Peer(public_key: str):
"""Get health info for specific peer"""
health = PeerHealthMonitorInstance.get_peer_health(public_key)
if health:
return ResponseObject(data=health)
return ResponseObject(False, "Peer not found in health data", status_code=404)
@app.post(f'{APP_PREFIX}/api/health/peer/<public_key>/ping')
def API_Health_PingPeer(public_key: str):
"""Ping specific peer immediately"""
result = PeerHealthMonitorInstance.ping_peer_now(public_key)
if result:
return ResponseObject(data=result)
return ResponseObject(False, "Peer not found", status_code=404)
@app.post(f'{APP_PREFIX}/api/health/cycle')
def API_Health_ForceCycle():
"""Force a health check cycle"""
result = PeerHealthMonitorInstance.force_cycle()
return ResponseObject(data=result)
@app.get(f'{APP_PREFIX}/api/health/stats')
def API_Health_Stats():
"""Get health monitoring statistics"""
return ResponseObject(data=PeerHealthMonitorInstance.get_stats())
@app.post(f'{APP_PREFIX}/api/health/start')
def API_Health_Start():
"""Start health monitoring"""
if PeerHealthMonitorInstance.start():
return ResponseObject(True, "Health monitoring started")
return ResponseObject(False, "Health monitoring already running")
@app.post(f'{APP_PREFIX}/api/health/stop')
def API_Health_Stop():
"""Stop health monitoring"""
if PeerHealthMonitorInstance.stop():
return ResponseObject(True, "Health monitoring stopped")
return ResponseObject(False, "Health monitoring not running")
@app.get(f'{APP_PREFIX}/api/health/config/<interface>')
def API_Health_GetInterfaceConfig(interface: str):
"""Get health monitoring config for interface"""
if interface not in WireguardConfigurations:
return ResponseObject(False, "Interface not found", status_code=404)
cfg = PeerHealthMonitorInstance.get_interface_config(interface)
return ResponseObject(data={
"interface": interface,
"enabled": cfg.enabled,
"ping_interval": cfg.ping_interval,
"set_keepalive": cfg.set_keepalive,
"keepalive_value": cfg.keepalive_value
})
@app.post(f'{APP_PREFIX}/api/health/config/<interface>')
def API_Health_SetInterfaceConfig(interface: str):
"""Set health monitoring config for interface"""
if interface not in WireguardConfigurations:
return ResponseObject(False, "Interface not found", status_code=404)
data = request.get_json()
if PeerHealthMonitorInstance.set_interface_config(interface, data):
return ResponseObject(True, "Configuration updated")
return ResponseObject(False, "Failed to update configuration")
'''
Tools

View File

@ -86,6 +86,13 @@ class DashboardConfig:
},
"WireGuardConfiguration": {
"autostart": ""
},
"Health": {
"enabled": "true",
"ping_interval": "30",
"ping_timeout": "2",
"auto_keepalive": "true",
"keepalive_value": "25"
}
}

View File

@ -0,0 +1,647 @@
"""
Peer Health Monitor for WGDashboard
===================================
Periodically pings VPN IP addresses of peers to:
1. Trigger endpoint update on roaming (IP change)
2. Collect availability statistics for peers
Even if the peer doesn't respond to ICMP (firewall), the ping
causes the server to send a packet, and the client will respond
with a WireGuard keepalive from its new IP -> endpoint update.
Status logic:
- ONLINE: handshake < 3 min AND ping success
- UNPINGABLE: handshake < 3 min AND ping failed (connected but firewall blocks ICMP)
- RECENT: handshake 3-15 min (still ping to trigger endpoint update)
- OFFLINE: handshake > 15 min (DO NOT ping - waste of resources)
- UNKNOWN: no handshake data ever
"""
import threading
import time
import ipaddress
import subprocess
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from enum import Enum
from icmplib import ping as icmp_ping
import logging
class PeerStatus(Enum):
"""Peer status based on handshake time and ping result"""
ONLINE = "online" # handshake < 3 min AND pingable
UNPINGABLE = "unpingable" # handshake < 3 min BUT ping failed (firewall)
RECENT = "recent" # handshake 3-15 min
OFFLINE = "offline" # handshake > 15 min (don't ping these)
UNKNOWN = "unknown" # no handshake data
# Timeouts for status determination
HANDSHAKE_ONLINE_TIMEOUT = timedelta(minutes=3)
HANDSHAKE_RECENT_TIMEOUT = timedelta(minutes=15)
@dataclass
class PeerHealthInfo:
"""Health information for a single peer"""
public_key: str
vpn_ip: str
interface: str
name: str = "" # Peer name from configuration
# Ping statistics
is_pingable: bool = False
last_ping_time: Optional[datetime] = None
last_ping_success: bool = False
ping_rtt_ms: float = 0.0
ping_success_count: int = 0
ping_fail_count: int = 0
# Handshake status (from WireGuard)
last_handshake: Optional[datetime] = None
status: PeerStatus = PeerStatus.UNKNOWN
# Endpoint tracking
last_endpoint: str = ""
endpoint_changed: bool = False
def to_dict(self) -> dict:
return {
"public_key": self.public_key,
"vpn_ip": self.vpn_ip,
"interface": self.interface,
"name": self.name,
"is_pingable": self.is_pingable,
"last_ping_time": self.last_ping_time.isoformat() if self.last_ping_time else None,
"last_ping_success": self.last_ping_success,
"ping_rtt_ms": round(self.ping_rtt_ms, 2),
"ping_success_rate": self._ping_success_rate(),
"status": self.status.value,
"last_handshake": self.last_handshake.isoformat() if self.last_handshake else None,
"last_endpoint": self.last_endpoint,
"endpoint_changed": self.endpoint_changed
}
def _ping_success_rate(self) -> float:
total = self.ping_success_count + self.ping_fail_count
if total == 0:
return 0.0
return round((self.ping_success_count / total) * 100, 1)
@dataclass
class InterfaceHealthConfig:
"""Configuration for health monitoring of an interface"""
enabled: bool = True
ping_interval: int = 30 # seconds
set_keepalive: bool = True # automatically set PersistentKeepalive
keepalive_value: int = 25
class PeerHealthMonitor:
"""
Background service for monitoring WireGuard peers.
Functionality:
- Periodic ping to VPN IP of all peers (except offline ones)
- Automatic PersistentKeepalive setting on server
- Collection of availability statistics
- Detection of endpoint changes
- Per-interface configuration with persistence
Status determination:
- First check handshake age (from WireGuard)
- Then ping only if peer might be reachable (handshake < 15 min)
- ONLINE = recent handshake + ping success
- UNPINGABLE = recent handshake + ping failed (firewall blocking ICMP)
- OFFLINE = old handshake (don't waste resources pinging)
"""
def __init__(self, dashboard_config, wireguard_configurations: dict, logger=None):
self.dashboard_config = dashboard_config
self.wg_configs = wireguard_configurations
self.logger = logger or logging.getLogger(__name__)
# Health data for all peers (key = public_key)
self._peer_health: Dict[str, PeerHealthInfo] = {}
self._health_lock = threading.Lock()
# Per-interface configuration
self._interface_config: Dict[str, InterfaceHealthConfig] = {}
# Thread control
self._running = False
self._thread: Optional[threading.Thread] = None
# Statistics
self._stats = {
"total_pings": 0,
"successful_pings": 0,
"failed_pings": 0,
"skipped_offline": 0,
"endpoint_updates": 0,
"last_cycle_time": None,
"last_cycle_duration_ms": 0
}
# Load saved configuration from INI file
self._load_config_from_ini()
def _load_config_from_ini(self):
"""Load per-interface configuration from wg-dashboard.ini"""
try:
# Get all sections that start with "Health:"
config = self.dashboard_config._DashboardConfig__config
for section in config.sections():
if section.startswith("Health:"):
interface_name = section[7:] # Remove "Health:" prefix
cfg = InterfaceHealthConfig()
if config.has_option(section, "enabled"):
cfg.enabled = config.get(section, "enabled").lower() == "true"
if config.has_option(section, "ping_interval"):
cfg.ping_interval = max(10, min(300, int(config.get(section, "ping_interval"))))
if config.has_option(section, "set_keepalive"):
cfg.set_keepalive = config.get(section, "set_keepalive").lower() == "true"
if config.has_option(section, "keepalive_value"):
cfg.keepalive_value = max(10, min(120, int(config.get(section, "keepalive_value"))))
self._interface_config[interface_name] = cfg
self.logger.info(f"Loaded health config for {interface_name}: enabled={cfg.enabled}, interval={cfg.ping_interval}")
except Exception as e:
self.logger.error(f"Error loading health config from INI: {e}")
def _save_interface_config_to_ini(self, interface: str):
"""Save interface configuration to wg-dashboard.ini"""
try:
if interface not in self._interface_config:
return False
cfg = self._interface_config[interface]
section = f"Health:{interface}"
# Ensure section exists
config = self.dashboard_config._DashboardConfig__config
if not config.has_section(section):
config.add_section(section)
# Set values
config.set(section, "enabled", "true" if cfg.enabled else "false")
config.set(section, "ping_interval", str(cfg.ping_interval))
config.set(section, "set_keepalive", "true" if cfg.set_keepalive else "false")
config.set(section, "keepalive_value", str(cfg.keepalive_value))
# Save to file
return self.dashboard_config.SaveConfig()
except Exception as e:
self.logger.error(f"Error saving health config for {interface}: {e}")
return False
def start(self) -> bool:
"""Start health monitoring thread"""
if self._running:
self.logger.warning("PeerHealthMonitor already running")
return False
self._running = True
self._thread = threading.Thread(
target=self._monitor_loop,
daemon=True,
name="PeerHealthMonitor"
)
self._thread.start()
self.logger.info(f"PeerHealthMonitor started (PID: {threading.get_native_id()})")
return True
def stop(self) -> bool:
"""Stop health monitoring"""
if not self._running:
return False
self._running = False
if self._thread:
self._thread.join(timeout=5)
self.logger.info("PeerHealthMonitor stopped")
return True
def is_running(self) -> bool:
return self._running
def get_interface_config(self, interface: str) -> InterfaceHealthConfig:
"""Return configuration for interface"""
if interface not in self._interface_config:
self._interface_config[interface] = InterfaceHealthConfig()
# Save default config to INI
self._save_interface_config_to_ini(interface)
return self._interface_config[interface]
def set_interface_config(self, interface: str, config: dict) -> bool:
"""Set configuration for interface and persist to INI"""
try:
if interface not in self._interface_config:
self._interface_config[interface] = InterfaceHealthConfig()
cfg = self._interface_config[interface]
if 'enabled' in config:
cfg.enabled = bool(config['enabled'])
if 'ping_interval' in config:
cfg.ping_interval = max(10, min(300, int(config['ping_interval'])))
if 'set_keepalive' in config:
cfg.set_keepalive = bool(config['set_keepalive'])
if 'keepalive_value' in config:
cfg.keepalive_value = max(10, min(120, int(config['keepalive_value'])))
# Persist to INI file
self._save_interface_config_to_ini(interface)
self.logger.info(f"Updated health config for {interface}: enabled={cfg.enabled}, interval={cfg.ping_interval}")
return True
except Exception as e:
self.logger.error(f"Error setting interface config: {e}")
return False
def get_peer_health(self, public_key: str) -> Optional[dict]:
"""Return health information for peer"""
with self._health_lock:
if public_key in self._peer_health:
return self._peer_health[public_key].to_dict()
return None
def get_all_health(self) -> dict:
"""Return health information for all peers, filtering disabled/inactive interfaces"""
result = {
"peers": {},
"interfaces": {},
"stats": self._stats.copy(),
"running": self._running
}
# Only include peers from active interfaces
with self._health_lock:
for pk, health in self._peer_health.items():
# Check if interface is active (running)
iface_name = health.interface
if iface_name in self.wg_configs:
wg_config = self.wg_configs[iface_name]
if wg_config.getStatus(): # Only include if interface is UP
result["peers"][pk] = health.to_dict()
# Only include active interfaces in the config list
for iface_name, wg_config in self.wg_configs.items():
is_active = wg_config.getStatus()
if is_active: # Only show active interfaces in Health UI
cfg = self.get_interface_config(iface_name)
result["interfaces"][iface_name] = {
"enabled": cfg.enabled,
"ping_interval": cfg.ping_interval,
"set_keepalive": cfg.set_keepalive,
"keepalive_value": cfg.keepalive_value,
"interface_active": True
}
return result
def get_stats(self) -> dict:
"""Return general statistics"""
return self._stats.copy()
def ping_peer_now(self, public_key: str) -> Optional[dict]:
"""Execute ping to specific peer immediately"""
with self._health_lock:
if public_key not in self._peer_health:
return None
health = self._peer_health[public_key]
result = self._do_ping(health.vpn_ip)
self._update_peer_after_ping(health, result)
return health.to_dict()
def force_cycle(self) -> dict:
"""Force one check cycle immediately"""
return self._run_health_cycle()
def _monitor_loop(self):
"""Main monitoring thread loop"""
# Wait a bit on startup
time.sleep(15)
while self._running:
try:
self._run_health_cycle()
except Exception as e:
self.logger.error(f"Error in health monitor cycle: {e}")
# Wait until next cycle (shortest interval from all interfaces)
min_interval = 30
for cfg in self._interface_config.values():
if cfg.enabled and cfg.ping_interval < min_interval:
min_interval = cfg.ping_interval
# Sleep in parts for faster shutdown
for _ in range(min_interval):
if not self._running:
break
time.sleep(1)
def _run_health_cycle(self) -> dict:
"""Execute one health check cycle"""
start_time = time.time()
cycle_results = {
"checked": 0,
"online": 0,
"unpingable": 0,
"recent": 0,
"offline": 0,
"skipped": 0,
"pingable": 0,
"endpoint_changes": 0
}
# Collect all peers from all interfaces
peers_to_check = []
for iface_name, wg_config in self.wg_configs.items():
# Skip interfaces that are not running (DOWN)
if not wg_config.getStatus():
continue
cfg = self.get_interface_config(iface_name)
# Skip interfaces where health monitoring is disabled
if not cfg.enabled:
continue
# Set PersistentKeepalive if enabled
if cfg.set_keepalive:
self._ensure_keepalive(iface_name, wg_config, cfg.keepalive_value)
# Collect peers
for peer in wg_config.Peers:
vpn_ip = self._extract_vpn_ip(peer.allowed_ip)
if vpn_ip:
peers_to_check.append({
"interface": iface_name,
"public_key": peer.id,
"name": getattr(peer, 'name', '') or '',
"vpn_ip": vpn_ip,
"endpoint": getattr(peer, 'endpoint', ''),
"latest_handshake": getattr(peer, 'latest_handshake', None)
})
# Check all peers
for peer_info in peers_to_check:
try:
self._check_peer(peer_info, cycle_results)
except Exception as e:
self.logger.error(f"Error checking peer {peer_info['public_key']}: {e}")
# Update statistics
duration_ms = (time.time() - start_time) * 1000
self._stats["last_cycle_time"] = datetime.now().isoformat()
self._stats["last_cycle_duration_ms"] = round(duration_ms, 2)
return cycle_results
def _check_peer(self, peer_info: dict, results: dict):
"""Check one peer - FIRST handshake, THEN ping only if needed"""
public_key = peer_info["public_key"]
vpn_ip = peer_info["vpn_ip"]
interface = peer_info["interface"]
name = peer_info.get("name", "")
# Get or create health record
with self._health_lock:
if public_key not in self._peer_health:
self._peer_health[public_key] = PeerHealthInfo(
public_key=public_key,
vpn_ip=vpn_ip,
interface=interface,
name=name
)
health = self._peer_health[public_key]
# Update VPN IP and name if changed
health.vpn_ip = vpn_ip
health.interface = interface
health.name = name
# Check for endpoint change
current_endpoint = peer_info.get("endpoint", "")
if health.last_endpoint and health.last_endpoint != current_endpoint and current_endpoint != "(none)":
health.endpoint_changed = True
results["endpoint_changes"] += 1
self._stats["endpoint_updates"] += 1
self.logger.info(f"Endpoint changed for {name or public_key[:8]}...: {health.last_endpoint} -> {current_endpoint}")
else:
health.endpoint_changed = False
health.last_endpoint = current_endpoint
# STEP 1: Parse handshake time and determine base status
latest_handshake = peer_info.get("latest_handshake")
handshake_age = self._parse_handshake_age(health, latest_handshake)
results["checked"] += 1
# STEP 2: Determine if we should ping based on handshake
if handshake_age is None:
# Never connected - unknown status, don't ping
health.status = PeerStatus.UNKNOWN
results["skipped"] += 1
self._stats["skipped_offline"] += 1
return
if handshake_age > HANDSHAKE_RECENT_TIMEOUT:
# Offline - don't waste resources pinging
health.status = PeerStatus.OFFLINE
health.is_pingable = False
results["offline"] += 1
results["skipped"] += 1
self._stats["skipped_offline"] += 1
return
# STEP 3: Peer has recent handshake - ping to trigger endpoint update
ping_result = self._do_ping(vpn_ip)
self._update_peer_after_ping(health, ping_result)
# STEP 4: Determine final status based on handshake + ping
if handshake_age < HANDSHAKE_ONLINE_TIMEOUT:
# Recent handshake (< 3 min)
if ping_result["success"]:
health.status = PeerStatus.ONLINE
health.is_pingable = True
results["online"] += 1
results["pingable"] += 1
else:
# Connected but firewall blocks ICMP
health.status = PeerStatus.UNPINGABLE
health.is_pingable = False
results["unpingable"] += 1
else:
# Handshake 3-15 min ago - "recent" status
health.status = PeerStatus.RECENT
results["recent"] += 1
if ping_result["success"]:
health.is_pingable = True
results["pingable"] += 1
def _parse_handshake_age(self, health: PeerHealthInfo, latest_handshake) -> Optional[timedelta]:
"""Parse handshake time and return age. Returns None if no valid handshake.
Handles formats:
- "0:00:54" (H:MM:SS)
- "1 day, 20:38:48" (X day(s), H:MM:SS)
- "No Handshake" / "N/A" -> None
- Unix timestamp (int/float)
- ISO format datetime string
"""
if not latest_handshake:
return None
# Handle string formats
if isinstance(latest_handshake, str):
# Skip invalid values
if latest_handshake in ("No Handshake", "N/A", "", "0"):
return None
try:
# Try to parse timedelta string format from WGDashboard
# Format: "H:MM:SS" or "X day(s), H:MM:SS"
days = 0
time_part = latest_handshake
if "day" in latest_handshake:
# "1 day, 20:38:48" or "2 days, 1:23:45"
parts = latest_handshake.split(", ")
day_part = parts[0]
days = int(day_part.split()[0])
time_part = parts[1] if len(parts) > 1 else "0:0:0"
# Parse time part "H:MM:SS" or "HH:MM:SS"
time_parts = time_part.split(":")
if len(time_parts) == 3:
hours = int(time_parts[0])
minutes = int(time_parts[1])
seconds = int(time_parts[2])
age = timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
health.last_handshake = datetime.now() - age
return age
except (ValueError, IndexError) as e:
self.logger.debug(f"Could not parse timedelta string '{latest_handshake}': {e}")
# Try ISO format
try:
health.last_handshake = datetime.fromisoformat(latest_handshake)
return datetime.now() - health.last_handshake
except ValueError:
pass
# Try as Unix timestamp string
try:
ts = float(latest_handshake)
if ts > 0:
health.last_handshake = datetime.fromtimestamp(ts)
return datetime.now() - health.last_handshake
except ValueError:
pass
# Handle numeric timestamps
elif isinstance(latest_handshake, (int, float)):
if latest_handshake > 0:
health.last_handshake = datetime.fromtimestamp(latest_handshake)
return datetime.now() - health.last_handshake
# Handle datetime objects
elif isinstance(latest_handshake, datetime):
health.last_handshake = latest_handshake
return datetime.now() - health.last_handshake
return None
def _do_ping(self, ip: str, count: int = 1, timeout: int = 2) -> dict:
"""Execute ICMP ping"""
try:
result = icmp_ping(ip, count=count, timeout=timeout, privileged=True)
self._stats["total_pings"] += 1
if result.is_alive:
self._stats["successful_pings"] += 1
return {
"success": True,
"rtt_ms": result.avg_rtt,
"packets_sent": result.packets_sent,
"packets_received": result.packets_received
}
else:
self._stats["failed_pings"] += 1
return {
"success": False,
"rtt_ms": 0,
"packets_sent": result.packets_sent,
"packets_received": 0
}
except Exception as e:
self._stats["failed_pings"] += 1
self.logger.debug(f"Ping failed for {ip}: {e}")
return {
"success": False,
"rtt_ms": 0,
"packets_sent": 1,
"packets_received": 0,
"error": str(e)
}
def _update_peer_after_ping(self, health: PeerHealthInfo, ping_result: dict):
"""Update health information after ping"""
health.last_ping_time = datetime.now()
health.last_ping_success = ping_result["success"]
health.ping_rtt_ms = ping_result.get("rtt_ms", 0)
if ping_result["success"]:
health.ping_success_count += 1
else:
health.ping_fail_count += 1
def _extract_vpn_ip(self, allowed_ips: str) -> Optional[str]:
"""Extract VPN IP from AllowedIPs"""
if not allowed_ips:
return None
for ip_str in allowed_ips.replace(" ", "").split(","):
try:
network = ipaddress.ip_network(ip_str, strict=False)
hosts = list(network.hosts())
if len(hosts) == 1:
return str(hosts[0])
elif network.prefixlen == 32:
return str(network.network_address)
except ValueError:
continue
return None
def _ensure_keepalive(self, interface: str, wg_config, keepalive: int):
"""Set PersistentKeepalive on server for all peers"""
try:
# Determine if AWG or WG
wg_cmd = "awg" if hasattr(wg_config, 'Protocol') and wg_config.Protocol == 'awg' else "wg"
for peer in wg_config.Peers:
# Check current keepalive
current_keepalive = getattr(peer, 'persistent_keepalive', 0)
if current_keepalive != keepalive:
cmd = [
wg_cmd, "set", interface,
"peer", peer.id,
"persistent-keepalive", str(keepalive)
]
subprocess.run(cmd, capture_output=True, timeout=5)
self.logger.debug(f"Set keepalive={keepalive} for {peer.id[:8]}... on {interface}")
except Exception as e:
self.logger.error(f"Error setting keepalive on {interface}: {e}")
def to_json(self) -> dict:
"""For serialization in JSON response"""
return self.get_all_health()

View File

@ -0,0 +1,461 @@
<template>
<div class="peerHealthMonitor">
<!-- Header -->
<div class="d-flex align-items-center mb-4">
<h5 class="mb-0">
<i class="bi bi-heart-pulse me-2"></i>
<LocaleText t="Peer Health Monitor"></LocaleText>
</h5>
<span
class="badge ms-2"
:class="healthData.running ? 'bg-success' : 'bg-secondary'"
>
<LocaleText :t="healthData.running ? 'Running' : 'Stopped'"></LocaleText>
</span>
<div class="ms-auto">
<button
class="btn btn-sm me-2"
:class="healthData.running ? 'btn-outline-danger' : 'btn-outline-success'"
@click="toggleMonitor"
:disabled="loading"
>
<i class="bi" :class="healthData.running ? 'bi-stop-fill' : 'bi-play-fill'"></i>
<LocaleText :t="healthData.running ? 'Stop' : 'Start'"></LocaleText>
</button>
<button
class="btn btn-sm btn-outline-primary"
@click="forceCycle"
:disabled="loading || !healthData.running"
>
<i class="bi bi-arrow-clockwise"></i>
<LocaleText t="Force Check"></LocaleText>
</button>
</div>
</div>
<!-- Stats Overview -->
<div class="row mb-4" v-if="healthData.stats">
<div class="col-md-3">
<div class="card bg-dark">
<div class="card-body text-center">
<h3 class="mb-0">{{ healthData.stats.total_pings || 0 }}</h3>
<small class="text-muted"><LocaleText t="Total Pings"></LocaleText></small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark">
<div class="card-body text-center">
<h3 class="mb-0 text-success">{{ healthData.stats.successful_pings || 0 }}</h3>
<small class="text-muted"><LocaleText t="Successful"></LocaleText></small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark">
<div class="card-body text-center">
<h3 class="mb-0 text-danger">{{ healthData.stats.failed_pings || 0 }}</h3>
<small class="text-muted"><LocaleText t="Failed"></LocaleText></small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark">
<div class="card-body text-center">
<h3 class="mb-0 text-info">{{ healthData.stats.endpoint_updates || 0 }}</h3>
<small class="text-muted"><LocaleText t="Endpoint Updates"></LocaleText></small>
</div>
</div>
</div>
</div>
<!-- Interface Configuration -->
<div class="card bg-dark mb-4">
<div class="card-header">
<i class="bi bi-gear me-2"></i>
<LocaleText t="Interface Configuration"></LocaleText>
</div>
<div class="card-body">
<div class="row" v-if="Object.keys(healthData.interfaces || {}).length > 0">
<div
class="col-md-6 mb-3"
v-for="(config, iface) in healthData.interfaces"
:key="iface"
>
<div class="card bg-secondary bg-opacity-25">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<h6 class="mb-0">{{ iface }}</h6>
<div class="form-check form-switch ms-auto">
<input
class="form-check-input"
type="checkbox"
:checked="config.enabled"
@change="toggleInterface(iface, $event.target.checked)"
>
<label class="form-check-label">
<LocaleText :t="config.enabled ? 'Enabled' : 'Disabled'"></LocaleText>
</label>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small"><LocaleText t="Ping Interval (sec)"></LocaleText></label>
<input
type="number"
class="form-control form-control-sm"
:value="config.ping_interval"
@change="updateInterfaceConfig(iface, 'ping_interval', $event.target.value)"
min="10" max="300"
>
</div>
<div class="col-6">
<label class="form-label small"><LocaleText t="Keepalive (sec)"></LocaleText></label>
<input
type="number"
class="form-control form-control-sm"
:value="config.keepalive_value"
@change="updateInterfaceConfig(iface, 'keepalive_value', $event.target.value)"
min="10" max="120"
>
</div>
</div>
<div class="form-check mt-2">
<input
class="form-check-input"
type="checkbox"
:checked="config.set_keepalive"
@change="updateInterfaceConfig(iface, 'set_keepalive', $event.target.checked)"
>
<label class="form-check-label small">
<LocaleText t="Auto-set PersistentKeepalive on server"></LocaleText>
</label>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-muted text-center py-3">
<LocaleText t="No interfaces configured. Start monitoring to detect interfaces."></LocaleText>
</div>
</div>
</div>
<!-- Peers Health Table -->
<div class="card bg-dark">
<div class="card-header d-flex align-items-center">
<i class="bi bi-people me-2"></i>
<LocaleText t="Peers Health Status"></LocaleText>
<span class="badge bg-success ms-2">
{{ activePeersCount }} active
</span>
<span
class="badge bg-secondary ms-2"
v-if="offlinePeersCount > 0"
style="cursor: pointer;"
@click="showOfflinePeers = !showOfflinePeers"
:title="showOfflinePeers ? 'Click to hide offline peers' : 'Click to show offline peers'"
>
<i class="bi" :class="showOfflinePeers ? 'bi-eye-slash' : 'bi-eye'"></i>
{{ offlinePeersCount }} offline
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th><LocaleText t="Status"></LocaleText></th>
<th><LocaleText t="Interface"></LocaleText></th>
<th><LocaleText t="Name"></LocaleText></th>
<th><LocaleText t="VPN IP"></LocaleText></th>
<th><LocaleText t="Pingable"></LocaleText></th>
<th>RTT</th>
<th><LocaleText t="Success Rate"></LocaleText></th>
<th><LocaleText t="Last Ping"></LocaleText></th>
<th><LocaleText t="Endpoint"></LocaleText></th>
<th><LocaleText t="Actions"></LocaleText></th>
</tr>
</thead>
<tbody>
<tr v-for="(peer, pk) in sortedPeers" :key="pk">
<td>
<span class="badge" :class="getStatusBadgeClass(peer.status)">
{{ getStatusLabel(peer.status) }}
</span>
<i
v-if="peer.endpoint_changed"
class="bi bi-arrow-repeat text-warning ms-1"
title="Endpoint recently changed"
></i>
</td>
<td>
<code class="small">{{ peer.interface }}</code>
</td>
<td>
<span v-if="peer.name" class="text-info">{{ peer.name }}</span>
<span v-else class="text-muted small">{{ pk.substring(0, 8) }}...</span>
</td>
<td>
<code>{{ peer.vpn_ip }}</code>
</td>
<td>
<i
class="bi"
:class="getPingableIcon(peer)"
></i>
</td>
<td>
<span v-if="peer.last_ping_success">
{{ peer.ping_rtt_ms }} ms
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<div class="progress" style="height: 20px; width: 80px;" v-if="peer.status !== 'offline' && peer.status !== 'unknown'">
<div
class="progress-bar"
:class="getSuccessRateClass(peer.ping_success_rate)"
:style="{ width: peer.ping_success_rate + '%' }"
>
{{ peer.ping_success_rate }}%
</div>
</div>
<span v-else class="text-muted">-</span>
</td>
<td>
<small class="text-muted">
{{ formatTime(peer.last_ping_time) }}
</small>
</td>
<td>
<code class="small text-truncate d-inline-block" style="max-width: 150px;">
{{ peer.last_endpoint || '-' }}
</code>
</td>
<td>
<button
class="btn btn-sm btn-outline-primary"
@click="pingPeer(pk)"
:disabled="loading || peer.status === 'offline'"
title="Ping now"
>
<i class="bi bi-broadcast"></i>
</button>
</td>
</tr>
<tr v-if="Object.keys(healthData.peers || {}).length === 0">
<td colspan="10" class="text-center text-muted py-4">
<LocaleText :t="healthData.running ? 'Waiting for first check cycle...' : 'No peer health data available. Start monitoring to collect data.'"></LocaleText>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Status Legend -->
<div class="mt-3 small text-muted">
<span class="badge bg-success me-2">online</span> Connected, ping OK
<span class="badge bg-info text-dark me-2 ms-3">unpingable</span> Connected, firewall blocks ping
<span class="badge bg-warning text-dark me-2 ms-3">recent</span> Handshake 3-15 min ago
<span v-if="showOfflinePeers" class="badge bg-danger me-2 ms-3">offline</span>
<span v-if="showOfflinePeers"> Not connected (not pinged)</span>
</div>
<!-- Last Cycle Info -->
<div class="text-muted small mt-3 text-end" v-if="healthData.stats?.last_cycle_time">
<LocaleText t="Last check"></LocaleText>: {{ formatDateTime(healthData.stats.last_cycle_time) }}
({{ healthData.stats.last_cycle_duration_ms }} ms)
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { fetchGet, fetchPost } from '@/utilities/fetch'
import LocaleText from '@/components/text/localeText.vue'
const healthData = ref({
running: false,
peers: {},
interfaces: {},
stats: {}
})
const loading = ref(false)
const showOfflinePeers = ref(false)
let refreshInterval = null
// Filter and sort peers: hide offline/unknown by default, online first
const sortedPeers = computed(() => {
const statusOrder = {
'online': 0,
'unpingable': 1,
'recent': 2,
'offline': 3,
'unknown': 4
}
let entries = Object.entries(healthData.value.peers || {})
// Filter out offline and unknown peers unless showOfflinePeers is true
if (!showOfflinePeers.value) {
entries = entries.filter(([_, peer]) =>
peer.status !== 'offline' && peer.status !== 'unknown'
)
}
entries.sort((a, b) => {
const orderA = statusOrder[a[1].status] ?? 5
const orderB = statusOrder[b[1].status] ?? 5
return orderA - orderB
})
return Object.fromEntries(entries)
})
// Count of hidden offline peers
const offlinePeersCount = computed(() => {
const entries = Object.entries(healthData.value.peers || {})
return entries.filter(([_, peer]) =>
peer.status === 'offline' || peer.status === 'unknown'
).length
})
// Count of active (shown) peers
const activePeersCount = computed(() => {
return Object.keys(sortedPeers.value).length
})
// Fetch health status
function fetchHealthStatus() {
fetchGet('/api/health/status', {}, (response) => {
if (response && response.status) {
healthData.value = response.data
}
})
}
// Toggle monitor on/off
function toggleMonitor() {
loading.value = true
const endpoint = healthData.value.running ? '/api/health/stop' : '/api/health/start'
fetchPost(endpoint, {}, () => {
fetchHealthStatus()
loading.value = false
})
}
// Force a check cycle
function forceCycle() {
loading.value = true
fetchPost('/api/health/cycle', {}, () => {
fetchHealthStatus()
loading.value = false
})
}
// Ping specific peer
function pingPeer(publicKey) {
loading.value = true
fetchPost(`/api/health/peer/${encodeURIComponent(publicKey)}/ping`, {}, () => {
fetchHealthStatus()
loading.value = false
})
}
// Toggle interface monitoring
function toggleInterface(iface, enabled) {
updateInterfaceConfig(iface, 'enabled', enabled)
}
// Update interface configuration
function updateInterfaceConfig(iface, key, value) {
fetchPost(`/api/health/config/${iface}`, { [key]: value }, () => {
fetchHealthStatus()
})
}
// Helper functions
function getStatusBadgeClass(status) {
const classes = {
'online': 'bg-success',
'unpingable': 'bg-info text-dark',
'recent': 'bg-warning text-dark',
'offline': 'bg-danger',
'unknown': 'bg-secondary'
}
return classes[status] || 'bg-secondary'
}
function getStatusLabel(status) {
const labels = {
'online': 'online',
'unpingable': 'unpingable',
'recent': 'recent',
'offline': 'offline',
'unknown': 'unknown'
}
return labels[status] || status
}
function getPingableIcon(peer) {
if (peer.status === 'offline' || peer.status === 'unknown') {
return 'bi-dash text-muted'
}
if (peer.status === 'unpingable') {
return 'bi-shield-x text-info'
}
return peer.is_pingable ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'
}
function getSuccessRateClass(rate) {
if (rate >= 80) return 'bg-success'
if (rate >= 50) return 'bg-warning'
return 'bg-danger'
}
function formatTime(isoTime) {
if (!isoTime) return '-'
const date = new Date(isoTime)
return date.toLocaleTimeString()
}
function formatDateTime(isoTime) {
if (!isoTime) return '-'
const date = new Date(isoTime)
return date.toLocaleString()
}
// Lifecycle
onMounted(() => {
fetchHealthStatus()
// Refresh every 10 seconds
refreshInterval = setInterval(fetchHealthStatus, 10000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style scoped>
.peerHealthMonitor {
padding: 1rem;
}
.table code {
background: rgba(0,0,0,0.3);
padding: 2px 6px;
border-radius: 4px;
}
.progress {
background-color: rgba(255,255,255,0.1);
}
</style>

View File

@ -65,6 +65,14 @@ const router = createRouter({
meta: {
title: "WireGuard Configuration Settings"
}
},
{
name: "Peer Health Monitor",
path: "health_monitor",
component: () => import("@/components/settingsComponent/peerHealthMonitor.vue"),
meta: {
title: "Peer Health Monitor"
}
}
],
meta: {

View File

@ -50,6 +50,10 @@ export default {
{
id: "wireguard_settings",
title: "WireGuard Configuration Settings"
},
{
id: "health_monitor",
title: "Peer Health Monitor"
}
]
}

View File

@ -438,5 +438,30 @@
"or": "или",
"or click the button below to download the ": "или щракнете бутона по-долу, за да изтеглите ",
"then": "тогава",
"to add your server": "за да добавите сървъра си"
}
"to add your server": "за да добавите сървъра си",
"Peer Health Monitor": "Мониторинг на пиъри",
"Peers Health Status": "Здравен статус на пиъри",
"Interface Configuration": "Конфигурация на интерфейси",
"Force Check": "Принудителна проверка",
"Total Pings": "Общо пингове",
"Successful": "Успешни",
"Failed": "Неуспешни",
"Endpoint Updates": "Обновени крайни точки",
"Ping Interval (sec)": "Интервал на пинг (сек)",
"Keepalive (sec)": "Keepalive (сек)",
"Auto-set PersistentKeepalive on server": "Автоматично задаване на PersistentKeepalive на сървъра",
"No interfaces configured. Start monitoring to detect interfaces.": "Няма конфигурирани интерфейси. Стартирайте мониторинга за откриване.",
"Waiting for first check cycle...": "Изчакване на първата проверка...",
"No peer health data available. Start monitoring to collect data.": "Няма данни за здравето на пиърите. Стартирайте мониторинга.",
"Last check": "Последна проверка",
"Last Ping": "Последен пинг",
"VPN IP": "VPN IP",
"Pingable": "Достъпен",
"Success Rate": "Успеваемост",
"Endpoint": "Крайна точка",
"Interface": "Интерфейс",
"Start": "Старт",
"Stop": "Стоп",
"Running": "Работи",
"Stopped": "Спрян"
}

View File

@ -438,5 +438,30 @@
"Assign successfully!": "",
"Sessions": "",
"Data": "",
"Back": ""
}
"Back": "",
"Peer Health Monitor": "",
"Peers Health Status": "",
"Interface Configuration": "",
"Force Check": "",
"Total Pings": "",
"Successful": "",
"Failed": "",
"Endpoint Updates": "",
"Ping Interval (sec)": "",
"Keepalive (sec)": "",
"Auto-set PersistentKeepalive on server": "",
"No interfaces configured. Start monitoring to detect interfaces.": "",
"Waiting for first check cycle...": "",
"No peer health data available. Start monitoring to collect data.": "",
"Last check": "",
"Last Ping": "",
"VPN IP": "",
"Pingable": "",
"Success Rate": "",
"Endpoint": "",
"Interface": "",
"Start": "",
"Stop": "",
"Running": "",
"Stopped": ""
}

View File

@ -365,5 +365,30 @@
"or": "или",
"or click the button below to download the ": "Так же, вы можете скачать ",
"then": "тогда",
"to add your server": "чтобы добавить свой сервер"
}
"to add your server": "чтобы добавить свой сервер",
"Peer Health Monitor": "Мониторинг пиров",
"Peers Health Status": "Состояние здоровья пиров",
"Interface Configuration": "Конфигурация интерфейсов",
"Force Check": "Принудительная проверка",
"Total Pings": "Всего пингов",
"Successful": "Успешных",
"Failed": "Неудачных",
"Endpoint Updates": "Обновления конечных точек",
"Ping Interval (sec)": "Интервал пинга (сек)",
"Keepalive (sec)": "Keepalive (сек)",
"Auto-set PersistentKeepalive on server": "Автоматическая установка PersistentKeepalive на сервере",
"No interfaces configured. Start monitoring to detect interfaces.": "Интерфейсы не настроены. Запустите мониторинг для обнаружения.",
"Waiting for first check cycle...": "Ожидание первой проверки...",
"No peer health data available. Start monitoring to collect data.": "Нет данных о состоянии пиров. Запустите мониторинг.",
"Last check": "Последняя проверка",
"Last Ping": "Последний пинг",
"VPN IP": "VPN IP",
"Pingable": "Доступен",
"Success Rate": "Успешность",
"Endpoint": "Крайна точка",
"Interface": "Интерфейс",
"Start": "Старт",
"Stop": "Стоп",
"Running": "Работает",
"Stopped": "Остановлен"
}