Files
community-plugins/ruh-vpn/backend/monitoring/log_streamer.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

146 lines
4.0 KiB
Python

"""Tail sing-box log files and forward each new line to a callback.
Each source is polled at a small interval; new bytes are split into complete
lines (partial trailing data is buffered). ANSI escape codes are stripped and
the line's log level is extracted when present (INFO/WARN/WARNING/ERROR/FATAL/
DEBUG/TRACE), defaulting to "info".
"""
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
from typing import Awaitable, Callable, Optional
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]")
LEVEL_RE = re.compile(
r"\b(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\b",
re.IGNORECASE,
)
LineCallback = Callable[[str, str, str], Awaitable[None]]
# (source_tag, level, message)
class _Source:
def __init__(self, tag: str, path: Path) -> None:
self.tag = tag
self.path = path
self.fd: Optional[int] = None
self.buf = b""
def open(self) -> None:
if self.fd is not None:
return
try:
fd = os.open(str(self.path), os.O_RDONLY | os.O_NONBLOCK)
except FileNotFoundError:
return
# seek to end so we don't emit historical content
try:
os.lseek(fd, 0, os.SEEK_END)
except OSError:
pass
self.fd = fd
def close(self) -> None:
if self.fd is not None:
try:
os.close(self.fd)
except OSError:
pass
self.fd = None
self.buf = b""
def read_lines(self) -> list[bytes]:
if self.fd is None:
self.open()
if self.fd is None:
return []
try:
chunk = os.read(self.fd, 65536)
except BlockingIOError:
return []
except OSError:
return []
if not chunk:
return []
self.buf += chunk
out: list[bytes] = []
while True:
nl = self.buf.find(b"\n")
if nl < 0:
break
out.append(self.buf[:nl])
self.buf = self.buf[nl + 1:]
return out
def parse_level(line: str) -> str:
m = LEVEL_RE.search(line)
if not m:
return "info"
lvl = m.group(1).lower()
if lvl == "warning":
return "warn"
return lvl
class LogStreamer:
def __init__(self, callback: LineCallback, poll_interval: float = 0.5) -> None:
self._cb = callback
self._interval = poll_interval
self._sources: dict[str, _Source] = {}
self._task: Optional[asyncio.Task] = None
def add_source(self, tag: str, path: Path | str) -> None:
p = Path(path)
if tag in self._sources:
return
self._sources[tag] = _Source(tag, p)
def remove_source(self, tag: str) -> None:
src = self._sources.pop(tag, None)
if src:
src.close()
def clear(self) -> None:
for src in list(self._sources.values()):
src.close()
self._sources.clear()
def start(self) -> None:
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
self.clear()
async def _loop(self) -> None:
try:
while True:
await asyncio.sleep(self._interval)
for src in list(self._sources.values()):
for raw in src.read_lines():
clean = ANSI_RE.sub(b"", raw).decode("utf-8", errors="replace").rstrip()
if not clean:
continue
lvl = parse_level(clean)
try:
await self._cb(src.tag, lvl, clean)
except Exception:
pass
except asyncio.CancelledError:
return