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,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,
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user