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:
Umed
2026-08-09 21:03:02 -04:00
committed by GitHub
co-authored by Umedjon Bazarov
parent 443056892e
commit 0733efd186
50 changed files with 6125 additions and 0 deletions
View File
+329
View File
@@ -0,0 +1,329 @@
"""Build sing-box JSON configs for transport / rules-mux / global-mux / TUN.
Each helper returns a dict that can be JSON-dumped straight into the matching
the plugin data directory as <PREFIX>-{transport,rules,global,tun}.json.
Architecture (as proven by reference noctalia-rules.json / noctalia-global.json):
Transport layer → port 11080 (talks to remote VPN server)
Rules mux → port 11081 (refilter rules → proxy, rest → direct)
Global mux → port 11082 (everything → proxy)
TUN → tun device; outbound = socks5 → 11081 or 11082
The TUN config never talks to the remote server directly — it always hops
through one of the mux ports so we never create a routing loop.
"""
from __future__ import annotations
from typing import Any
from backend.models.server import RoutingRule, Server, SSHServer
from backend.routing.rules import (
preset_domain_tags,
preset_route_rules,
preset_rule_sets,
)
from backend.identity import PREFIX
from backend.paths import SINGBOX_DIR
from backend.singbox.transport import build_outbound
CONFIG_DIR = SINGBOX_DIR
RULESET_CACHE_DIR = CONFIG_DIR # sing-box stores ruleset cache here
RULES_DB = CONFIG_DIR / f"{PREFIX}-rules.db"
DEFAULT_LOG = {"level": "info", "timestamp": True}
PROXY_DNS_ADDR = "8.8.8.8"
DIRECT_DNS_ADDR = "223.5.5.5"
TUN_DNS_SERVER_NAME = "dns.google"
def _dns_rules_from_user(rules: list) -> list[dict]:
"""Translate user routing rules into DNS rules with matching server tags.
For each enabled rule:
- extract matcher (domain / domain_suffix / domain_keyword / ip_cidr)
- force-proxy → server: proxy-dns
- direct → server: direct-dns
- block → action: reject (no DNS lookup at all)
"""
out: list[dict] = []
for r in rules:
sr = r.to_singbox_rule() if hasattr(r, "to_singbox_rule") else None
if not sr:
continue
dns_rule: dict = {}
for k in ("domain", "domain_suffix", "domain_keyword", "ip_cidr"):
if k in sr:
dns_rule[k] = sr[k]
if not dns_rule:
continue
if sr.get("action") == "reject":
dns_rule["action"] = "reject"
elif sr.get("outbound") == "proxy":
dns_rule["server"] = "proxy-dns"
else:
dns_rule["server"] = "direct-dns"
out.append(dns_rule)
return out
def _build_dns_rules(
custom_rules: list,
active_presets: list[str],
default_proxy: bool,
) -> dict:
"""Return the dns section for a mux config.
default_proxy=True → unmatched DNS goes through proxy (global mode).
default_proxy=False → unmatched DNS goes direct (rules mode).
For each active preset, domain-style rule_sets are routed via proxy-dns
so DNS resolution for blocked sites doesn't leak to the direct resolver.
"""
servers = [
{
"type": "udp",
"tag": "proxy-dns",
"server": PROXY_DNS_ADDR,
"server_port": 53,
"detour": "proxy",
},
{
"type": "udp",
"tag": "direct-dns",
"server": DIRECT_DNS_ADDR,
"server_port": 53,
},
]
rules = _dns_rules_from_user(custom_rules)
if not default_proxy:
dom_tags = preset_domain_tags(active_presets or [])
if dom_tags:
rules.append({"rule_set": dom_tags, "server": "proxy-dns"})
return {
"servers": servers,
"rules": rules,
"final": "proxy-dns" if default_proxy else "direct-dns",
"strategy": "ipv4_only",
}
def build_transport_config(server: Server, listen_port: int = 11080) -> dict[str, Any]:
"""Build sing-box config for the transport layer.
Listens on 127.0.0.1:listen_port (SOCKS5) and forwards through the
server-specific outbound.
For SSH, this returns None — SSH is handled outside sing-box.
"""
if isinstance(server, SSHServer):
raise ValueError(
"SSH is handled directly by OpenSSH; do not build a sing-box transport config"
)
outbound = build_outbound(server, tag="proxy")
return {
"log": DEFAULT_LOG,
"inbounds": [
{
"type": "socks",
"tag": "in",
"listen": "127.0.0.1",
"listen_port": listen_port,
"users": [],
}
],
"outbounds": [
outbound,
{"type": "direct", "tag": "direct"},
],
"route": {"final": "proxy", "auto_detect_interface": True},
}
def build_rules_config(
transport_port: int = 11080,
listen_port: int = 11081,
custom_rules: list[RoutingRule] | None = None,
active_presets: list[str] | None = None,
clash_api_port: int = 11089,
) -> dict[str, Any]:
"""Build the rules-mux config.
Listens on 127.0.0.1:listen_port (mixed inbound — accepts both SOCKS5 and
HTTP), routes traffic per rules to either the upstream proxy (the transport
listening on `transport_port`) or direct.
`active_presets` is a list of preset keys (e.g. ["ru"]). Each preset
contributes its rule_set definitions and one route.rules entry that sends
matches to the 'proxy' outbound. User custom_rules are placed first so they
take precedence over preset rules (sing-box matches top-to-bottom).
"""
rules: list[dict[str, Any]] = []
custom_rules = custom_rules or []
active_presets = list(active_presets or [])
for r in custom_rules:
if not r.enabled:
continue
sr = r.to_singbox_rule()
if sr is not None:
rules.append(sr)
rules.extend(preset_route_rules(active_presets))
rule_set = preset_rule_sets(active_presets)
route: dict[str, Any] = {
"final": "direct",
"auto_detect_interface": True,
"default_domain_resolver": "direct-dns",
"rules": rules,
}
if rule_set:
route["rule_set"] = rule_set
return {
"log": DEFAULT_LOG,
"dns": _build_dns_rules(custom_rules, active_presets, default_proxy=False),
"experimental": {
"cache_file": {"enabled": True, "path": str(RULES_DB)},
"clash_api": {"external_controller": f"127.0.0.1:{clash_api_port}"},
},
"inbounds": [
{
"type": "mixed",
"tag": "in",
"listen": "127.0.0.1",
"listen_port": listen_port,
}
],
"outbounds": [
{"type": "direct", "tag": "direct"},
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": transport_port,
"version": "5",
},
],
"route": route,
}
def build_global_config(
transport_port: int = 11080,
listen_port: int = 11082,
clash_api_port: int | None = 11089,
) -> dict[str, Any]:
"""Build the global-mux config: everything → proxy."""
experimental: dict[str, Any] = {}
if clash_api_port is not None:
experimental["clash_api"] = {"external_controller": f"127.0.0.1:{clash_api_port}"}
return {
"log": DEFAULT_LOG,
"dns": _build_dns_rules([], active_presets=[], default_proxy=True),
**({"experimental": experimental} if experimental else {}),
"inbounds": [
{
"type": "mixed",
"tag": "in",
"listen": "127.0.0.1",
"listen_port": listen_port,
}
],
"outbounds": [
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": transport_port,
"version": "5",
},
{"type": "direct", "tag": "direct"},
],
"route": {
"final": "proxy",
"auto_detect_interface": True,
"default_domain_resolver": "proxy-dns",
},
}
def build_tun_config(
upstream_socks_port: int,
interface_name: str = "noctalia-tun0",
inet4_address: str = "172.19.0.1/30",
route_exclude_addresses: list[str] | None = None,
) -> dict[str, Any]:
"""Build the TUN config.
The TUN outbound is a SOCKS5 client to 127.0.0.1:upstream_socks_port
(either the rules mux on 11081 or the global mux on 11082). Private/LAN
traffic goes direct so we don't black-hole local services.
sing-box exposes the second address of the TUN subnet (172.19.0.2 by
default) to systemd-resolved. DNS must therefore be hijacked before the
private-address rule, otherwise queries are sent direct to that synthetic
address and immediately re-enter the TUN in a tight loop. DoH is used so
SSH SOCKS transports, which cannot relay UDP, work as well.
``route_exclude_addresses`` contains the resolved transport endpoint(s).
They must stay on the physical interface or an SSH/VPN transport would be
captured by the TUN and recursively sent through itself.
"""
tun_inbound: dict[str, Any] = {
"type": "tun",
"tag": "tun-in",
"interface_name": interface_name,
"address": [inet4_address],
"auto_route": True,
"strict_route": True,
"stack": "system",
}
if route_exclude_addresses:
tun_inbound["route_exclude_address"] = route_exclude_addresses
return {
"log": DEFAULT_LOG,
"dns": {
"servers": [
{
"type": "https",
"tag": "tun-dns",
"server": PROXY_DNS_ADDR,
"server_port": 443,
"path": "/dns-query",
"tls": {
"enabled": True,
"server_name": TUN_DNS_SERVER_NAME,
},
"detour": "proxy",
}
],
"final": "tun-dns",
"strategy": "ipv4_only",
},
"inbounds": [tun_inbound],
"outbounds": [
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": upstream_socks_port,
"version": "5",
},
{"type": "direct", "tag": "direct"},
],
"route": {
"rules": [
{"action": "sniff"},
{"protocol": "dns", "action": "hijack-dns"},
{"ip_is_private": True, "outbound": "direct"},
],
"final": "proxy",
"auto_detect_interface": True,
},
}
+309
View File
@@ -0,0 +1,309 @@
"""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
+147
View File
@@ -0,0 +1,147 @@
"""Protocol-specific outbound builders for sing-box.
Each builder returns the outbound dict that goes into the sing-box "outbounds"
list when configuring the transport layer (the layer that actually talks to the
remote VPN server).
SSH is handled outside sing-box (via OpenSSH itself opening a SOCKS5
listener on the local transport port), so it does NOT appear here.
"""
from __future__ import annotations
from typing import Any
from backend.models.server import (
Server,
ShadowsocksServer,
Socks5Server,
SSHServer,
VlessServer,
VmessServer,
)
def build_outbound(server: Server, tag: str = "proxy") -> dict[str, Any]:
"""Return a sing-box outbound dict for the given server.
Raises ValueError for SSH (not a sing-box outbound) and for unsupported
protocols.
"""
if isinstance(server, SSHServer):
raise ValueError("SSH transport is handled outside sing-box")
if isinstance(server, VlessServer):
return _build_vless(server, tag)
if isinstance(server, VmessServer):
return _build_vmess(server, tag)
if isinstance(server, ShadowsocksServer):
return _build_shadowsocks(server, tag)
if isinstance(server, Socks5Server):
return _build_socks5(server, tag)
raise ValueError(f"Unsupported server type: {type(server).__name__}")
def _build_tls(server: VlessServer | VmessServer) -> dict[str, Any] | None:
if not getattr(server, "tls", False) and getattr(server, "security", None) not in (
"tls",
"reality",
):
return None
tls: dict[str, Any] = {"enabled": True}
if server.sni:
tls["server_name"] = server.sni
fp = getattr(server, "fp", None)
if fp:
tls["utls"] = {"enabled": True, "fingerprint": fp}
if getattr(server, "security", None) == "reality":
pbk = getattr(server, "pbk", None) or ""
sid = getattr(server, "sid", None) or ""
tls["reality"] = {"enabled": True, "public_key": pbk, "short_id": sid}
return tls
def _build_transport(server: VlessServer | VmessServer) -> dict[str, Any] | None:
t = (getattr(server, "transport", "tcp") or "tcp").lower()
if t in ("tcp", "raw", ""):
return None
if t == "ws":
out: dict[str, Any] = {"type": "ws"}
if getattr(server, "path", None):
out["path"] = server.path
if getattr(server, "host", None):
out["headers"] = {"Host": server.host}
return out
if t == "grpc":
return {"type": "grpc", "service_name": getattr(server, "serviceName", "") or ""}
if t == "http":
out = {"type": "http"}
if getattr(server, "path", None):
out["path"] = server.path
if getattr(server, "host", None):
out["host"] = [server.host]
return out
return None
def _build_vless(s: VlessServer, tag: str) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "vless",
"tag": tag,
"server": s.address,
"server_port": s.port,
"uuid": s.uuid,
}
if s.flow:
out["flow"] = s.flow
tls = _build_tls(s)
if tls:
out["tls"] = tls
tp = _build_transport(s)
if tp:
out["transport"] = tp
return out
def _build_vmess(s: VmessServer, tag: str) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "vmess",
"tag": tag,
"server": s.address,
"server_port": s.port,
"uuid": s.uuid,
"alter_id": s.alterId,
"security": s.security or "auto",
}
tls = _build_tls(s)
if tls:
out["tls"] = tls
tp = _build_transport(s)
if tp:
out["transport"] = tp
return out
def _build_shadowsocks(s: ShadowsocksServer, tag: str) -> dict[str, Any]:
return {
"type": "shadowsocks",
"tag": tag,
"server": s.address,
"server_port": s.port,
"method": s.method,
"password": s.password,
}
def _build_socks5(s: Socks5Server, tag: str) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "socks",
"tag": tag,
"server": s.host,
"server_port": s.port,
"version": "5",
}
if s.username:
out["username"] = s.username
if s.password:
out["password"] = s.password
return out