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

310 lines
11 KiB
Python

"""Async start/stop/monitor of sing-box and ssh transport processes.
All managed processes are tagged via either:
- ssh: <TAG>=1 environment variable
- sing-box: filename pattern <PREFIX>-*.json passed as -c argument
This is intentionally narrow so pkill_zombies can use very specific patterns
and never affect unrelated proxy processes.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
import aiofiles
from backend.identity import PREFIX, TAG
from backend.paths import DATA_DIR, RUNTIME_DIR, SINGBOX_DIR, ensure_private_dir, protect_file
# PATH first so NixOS and other non-FHS layouts work; /usr/bin only as a
# last-resort guess when the backend env has a stripped PATH.
SINGBOX_BIN = shutil.which("sing-box") or "/usr/bin/sing-box"
SSHPASS_BIN = shutil.which("sshpass") or "/usr/bin/sshpass"
SSH_BIN = shutil.which("ssh") or "/usr/bin/ssh"
SINGBOX_CONFIG_DIR = SINGBOX_DIR
LOG_DIR = RUNTIME_DIR
STATE_FILE = LOG_DIR / f"{PREFIX}.state.json"
# Plugin-owned known-hosts: accept-new records a server's key on first connect
# and every later connect verifies it, so a changed key fails loudly instead of
# being silently ignored (the old UserKnownHostsFile=/dev/null behavior).
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
CONFIG_NAMES = {
"transport": f"{PREFIX}-transport.json",
"rules": f"{PREFIX}-rules.json",
"global": f"{PREFIX}-global.json",
"tun": f"{PREFIX}-tun.json",
}
LOG_NAMES = {
"transport": f"{PREFIX}-transport.log",
"rules": f"{PREFIX}-rules.log",
"global": f"{PREFIX}-global.log",
"tun": f"{PREFIX}-tun.log",
"ssh": f"{PREFIX}-ssh.log",
}
# Must only ever match processes started with this plugin's identity.
PKILL_PATTERNS = [
f"ssh.*{TAG}=1",
f"sing-box.*{PREFIX}-",
]
@dataclass
class ManagedProc:
name: str # one of: transport, rules, global, tun, ssh
proc: asyncio.subprocess.Process
cmd: list[str]
log_path: Path
started_at: float = field(default_factory=time.time)
@property
def pid(self) -> int:
return self.proc.pid
def is_running(self) -> bool:
return self.proc.returncode is None
class ProcessManager:
def __init__(self, logger: Optional[Callable[[str, str], None]] = None) -> None:
self._procs: dict[str, ManagedProc] = {}
self._monitor_task: Optional[asyncio.Task] = None
self._monitor_cb: Optional[Callable[[str], Awaitable[None]]] = None
self._log = logger or (lambda level, msg: None)
ensure_private_dir(SINGBOX_CONFIG_DIR)
ensure_private_dir(LOG_DIR)
# ----------------------------------------------------------------- config IO
async def write_config(self, name: str, config: dict[str, Any]) -> Path:
if name not in CONFIG_NAMES:
raise ValueError(f"Unknown sing-box config name: {name}")
path = SINGBOX_CONFIG_DIR / CONFIG_NAMES[name]
async with aiofiles.open(path, "w") as f:
await f.write(json.dumps(config, indent=2))
protect_file(path)
return path
def config_path(self, name: str) -> Path:
return SINGBOX_CONFIG_DIR / CONFIG_NAMES[name]
# ----------------------------------------------------------------- launch
async def start_singbox(self, name: str, binary: Optional[str] = None) -> ManagedProc:
if name not in CONFIG_NAMES:
raise ValueError(f"Unknown sing-box config name: {name}")
if name in self._procs and self._procs[name].is_running():
raise RuntimeError(f"sing-box '{name}' already running")
config_path = self.config_path(name)
if not config_path.exists():
raise FileNotFoundError(f"Missing config file: {config_path}")
log_path = LOG_DIR / LOG_NAMES[name]
log_fh = open(log_path, "ab") # binary, append; sing-box writes structured text
protect_file(log_path)
cmd = [binary or SINGBOX_BIN, "run", "-c", str(config_path), "-D", str(SINGBOX_CONFIG_DIR)]
self._log("info", f"start sing-box ({name}): {' '.join(cmd)}")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=log_fh,
stderr=log_fh,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
log_fh.close()
managed = ManagedProc(name=name, proc=proc, cmd=cmd, log_path=log_path)
self._procs[name] = managed
return managed
async def start_ssh(
self,
host: str,
port: int,
user: str,
local_port: int,
password: Optional[str] = None,
key_file: Optional[str] = None,
) -> ManagedProc:
if "ssh" in self._procs and self._procs["ssh"].is_running():
raise RuntimeError("ssh transport already running")
log_path = LOG_DIR / LOG_NAMES["ssh"]
log_fh = open(log_path, "ab")
protect_file(log_path)
KNOWN_HOSTS_FILE.touch(mode=0o600, exist_ok=True)
protect_file(KNOWN_HOSTS_FILE)
env = dict(os.environ)
env[TAG] = "1"
common_ssh_opts = [
"-N",
"-D",
f"127.0.0.1:{local_port}",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"ServerAliveInterval=30",
"-o",
"ServerAliveCountMax=3",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
f"UserKnownHostsFile={KNOWN_HOSTS_FILE}",
"-o",
f"SetEnv={TAG}=1",
"-o",
f"SendEnv={TAG}",
"-p",
str(port),
]
if password:
cmd = [SSHPASS_BIN, "-e", SSH_BIN, *common_ssh_opts, f"{user}@{host}"]
env["SSHPASS"] = password
elif key_file:
cmd = [SSH_BIN, *common_ssh_opts, "-i", key_file, f"{user}@{host}"]
else:
cmd = [SSH_BIN, *common_ssh_opts, f"{user}@{host}"]
self._log("info", f"start ssh transport to {user}@{host}:{port} -D {local_port}")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=log_fh,
stderr=log_fh,
stdin=subprocess.DEVNULL,
env=env,
start_new_session=True,
)
log_fh.close()
managed = ManagedProc(name="ssh", proc=proc, cmd=cmd, log_path=log_path)
self._procs["ssh"] = managed
return managed
# ----------------------------------------------------------------- stop / monitor
async def stop(self, name: str, timeout: float = 3.0) -> None:
managed = self._procs.get(name)
if managed is None:
return
if managed.is_running():
try:
managed.proc.terminate()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(managed.proc.wait(), timeout=timeout)
except asyncio.TimeoutError:
try:
managed.proc.kill()
await managed.proc.wait()
except ProcessLookupError:
pass
self._procs.pop(name, None)
async def stop_all(self) -> None:
await asyncio.gather(*(self.stop(n) for n in list(self._procs.keys())))
await self.pkill_zombies()
async def pkill_zombies(self) -> None:
"""Kill any leftover processes matching our narrow patterns."""
for pattern in PKILL_PATTERNS:
try:
proc = await asyncio.create_subprocess_exec(
"pkill", "-f", pattern,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
await proc.wait()
except FileNotFoundError:
return
# ----------------------------------------------------------------- introspection
def running_pids(self) -> dict[str, int]:
return {n: m.pid for n, m in self._procs.items() if m.is_running()}
def running_names(self) -> list[str]:
return [n for n, m in self._procs.items() if m.is_running()]
def is_running(self, name: str) -> bool:
m = self._procs.get(name)
return bool(m and m.is_running())
async def read_log_tail(self, name: str, max_bytes: int = 8192) -> str:
log_path = LOG_DIR / LOG_NAMES.get(name, "")
if not log_path.exists():
return ""
size = log_path.stat().st_size
offset = max(0, size - max_bytes)
async with aiofiles.open(log_path, "rb") as f:
await f.seek(offset)
data = await f.read()
try:
return data.decode("utf-8", errors="replace")
except UnicodeDecodeError:
return data.decode("latin-1", errors="replace")
# ----------------------------------------------------------------- monitor loop
def start_monitor(self, on_unexpected_exit: Callable[[str], Awaitable[None]]) -> None:
self._monitor_cb = on_unexpected_exit
if self._monitor_task and not self._monitor_task.done():
return
self._monitor_task = asyncio.create_task(self._monitor_loop())
async def stop_monitor(self) -> None:
if self._monitor_task and not self._monitor_task.done():
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
self._monitor_task = None
async def _monitor_loop(self) -> None:
try:
while True:
await asyncio.sleep(1.0)
for name, m in list(self._procs.items()):
if not m.is_running():
rc = m.proc.returncode
self._log("error", f"managed process '{name}' exited rc={rc}")
self._procs.pop(name, None)
if self._monitor_cb:
try:
await self._monitor_cb(name)
except Exception as exc:
self._log("error", f"monitor callback failed: {exc}")
except asyncio.CancelledError:
return
# ----------------------------------------------------------------- state file
async def write_state(self, state: dict[str, Any]) -> None:
tmp = STATE_FILE.with_suffix(".json.tmp")
async with aiofiles.open(tmp, "w") as f:
await f.write(json.dumps(state, indent=2))
protect_file(tmp)
os.replace(tmp, STATE_FILE)
async def clear_state(self) -> None:
try:
STATE_FILE.unlink()
except FileNotFoundError:
pass