Files
community-plugins/ruh-vpn/backend/monitoring/traffic.py
T
0733efd186 Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box (#304)
* Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box

New community plugin: bar widget, panel, service and control-center
shortcut for managing SSH, VLESS, VMess, Shadowsocks and SOCKS5
connections through sing-box, with routing presets, custom rules,
system-proxy/TUN modes and a kill switch. The bundled Python backend
serves a loopback control API protected by a per-launch bearer token.

* Address review: sanitize kill-switch ruleset, scope TUN capability, fix mux error path, disclose DNS

- kill switch: only pre-resolved, canonicalized literal IPs enter the nft
  ruleset; domains are resolved first and anything unparseable is dropped,
  so subscription-supplied addresses can no longer inject nft syntax
- TUN: CAP_NET_ADMIN is granted to a plugin-private copy of sing-box in a
  0700 directory instead of the shared system binary; the copy is refreshed
  (clearing the cap) when the system binary changes, and the legacy grant
  on the shared binary is removed in the same polkit prompt
- fix NameError in the mux startup failure path (undefined mux_name) that
  hid the log tail and skipped teardown
- README: disclose plain-UDP DNS endpoints (8.8.8.8 via tunnel, 223.5.5.5
  direct in rules mode) alongside the TUN DoH endpoint

---------

Co-authored-by: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com>
2026-08-09 21:03:02 -04:00

96 lines
3.0 KiB
Python

"""Poll sing-box clash_api /connections endpoint for traffic stats."""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional
import aiohttp
@dataclass
class TrafficStats:
bytes_sent: int = 0
bytes_received: int = 0
connection_count: int = 0
started_at: float = field(default_factory=time.time)
def uptime(self) -> int:
return max(0, int(time.time() - self.started_at))
def to_dict(self) -> dict:
return {
"bytes_sent": int(self.bytes_sent),
"bytes_received": int(self.bytes_received),
"uptime_seconds": self.uptime(),
"connection_count": int(self.connection_count),
}
class TrafficMonitor:
"""Polls /connections every `interval` seconds and tracks running totals.
Notes on the underlying API:
sing-box clash_api /connections returns:
{"downloadTotal": int, "uploadTotal": int, "connections": [...]}
downloadTotal and uploadTotal are *since-mux-started* counters, so we
can return them directly as bytes_received / bytes_sent.
"""
def __init__(
self,
api_url: str,
interval: float = 5.0,
on_update: Optional[Callable[[dict], Awaitable[None]]] = None,
) -> None:
self.api_url = api_url.rstrip("/")
self.interval = interval
self._on_update = on_update
self.stats = TrafficStats()
self._task: Optional[asyncio.Task] = None
def start(self) -> None:
self.stats = TrafficStats()
if self._task and not self._task.done():
return
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _poll_once(self) -> None:
timeout = aiohttp.ClientTimeout(total=2.0)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(f"{self.api_url}/connections") as resp:
if resp.status != 200:
return
data = await resp.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
return
self.stats.bytes_sent = int(data.get("uploadTotal") or 0)
self.stats.bytes_received = int(data.get("downloadTotal") or 0)
self.stats.connection_count = len(data.get("connections") or [])
async def _loop(self) -> None:
try:
while True:
await self._poll_once()
if self._on_update:
try:
await self._on_update(self.stats.to_dict())
except Exception:
pass
await asyncio.sleep(self.interval)
except asyncio.CancelledError:
return