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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user