* 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>
1017 lines
40 KiB
Python
1017 lines
40 KiB
Python
"""Main orchestrator: ties together state, sing-box, ssh, system-proxy and TUN.
|
|
|
|
Lifecycle:
|
|
StartProxy(server_id, mode, proxy_mode):
|
|
1. stop_all + pkill_zombies + sleep 1s
|
|
2. start transport layer (sing-box or ssh) → port 11080
|
|
3. wait until 11080 is listening
|
|
4. start mux layer (rules → 11081 or global → 11082)
|
|
5. wait until mux port is listening
|
|
6. apply user-facing entry: gsettings (system) or TUN (sing-box)
|
|
7. start monitor task — any process dying tears down the whole stack
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from backend.config.settings import load_settings, save_settings
|
|
from backend.core.state import AppState
|
|
from backend.geoip import ENABLED as GEOIP_ENABLED
|
|
from backend.geoip import lookup_country
|
|
from backend.models.server import (
|
|
SENSITIVE_FIELDS,
|
|
RoutingRule,
|
|
Server,
|
|
Settings,
|
|
SSHServer,
|
|
StatusInfo,
|
|
parse_server,
|
|
server_to_dict,
|
|
server_to_public_dict,
|
|
)
|
|
from backend.monitoring.health import HealthMonitor, tcp_ping
|
|
from backend.service import tun_binary
|
|
from backend.monitoring.log_streamer import LogStreamer
|
|
from backend.monitoring.traffic import TrafficMonitor
|
|
from backend.singbox.process_manager import LOG_DIR, LOG_NAMES, SINGBOX_BIN
|
|
from backend.singbox import config_builder
|
|
from backend.singbox.process_manager import ProcessManager
|
|
from backend.storage.persistence import (
|
|
load_rules,
|
|
load_servers,
|
|
save_rules,
|
|
save_servers,
|
|
)
|
|
|
|
|
|
def _now_log(level: str, message: str) -> None:
|
|
print(f"[{level}] {message}", flush=True)
|
|
|
|
|
|
class VpnService:
|
|
"""Owns AppState + ProcessManager and exposes high-level operations.
|
|
|
|
All operations are guarded by state.lock so they serialize cleanly.
|
|
"""
|
|
|
|
def __init__(self, state: Optional[AppState] = None) -> None:
|
|
self.state = state or AppState()
|
|
self._pm = ProcessManager(logger=self._log)
|
|
self._teardown_in_progress = False
|
|
self._health: Optional[HealthMonitor] = None
|
|
self._log_streamer = LogStreamer(self._on_singbox_log_line)
|
|
self._log_streamer.start()
|
|
self._traffic: Optional[TrafficMonitor] = None
|
|
self._traffic_listeners: list = []
|
|
# Latched once getcap confirms CAP_NET_ADMIN on the plugin-private
|
|
# sing-box copy, so subsequent TUN starts never re-prompt via pkexec.
|
|
# Reset whenever the copy is rewritten (a fresh file has no caps).
|
|
self._tun_caps_granted = False
|
|
# Serializes pkexec invocations so a duplicate caller can't open a
|
|
# second polkit dialog while the first one is still on screen.
|
|
self._tun_caps_lock = asyncio.Lock()
|
|
# Subscription manager (initialized in bootstrap)
|
|
from backend.subscription.manager import SubscriptionManager
|
|
self._subs = SubscriptionManager(self)
|
|
|
|
# ----------------------------------------------------------------- bootstrap
|
|
|
|
async def bootstrap(self) -> None:
|
|
"""Load persisted servers/rules/settings."""
|
|
self.state.servers = await load_servers()
|
|
self.state.rules = await load_rules()
|
|
self.state.settings = await load_settings()
|
|
await self._subs.bootstrap()
|
|
self._subs.start_auto_update()
|
|
self._update_status_basics()
|
|
self._log(
|
|
"info",
|
|
f"loaded {len(self.state.servers)} servers, {len(self.state.rules)} rules, "
|
|
f"{len(await self._subs.list_subs())} subscriptions",
|
|
)
|
|
# Servers stored before geoip existed have no country. Do it off the
|
|
# bootstrap path so a slow or dead lookup service cannot delay startup;
|
|
# each server is only ever looked up once, since the result is saved.
|
|
asyncio.create_task(self.backfill_countries())
|
|
|
|
async def shutdown(self) -> None:
|
|
if self._health:
|
|
await self._health.stop()
|
|
self._health = None
|
|
if self._traffic:
|
|
await self._traffic.stop()
|
|
self._traffic = None
|
|
await self._subs.stop()
|
|
await self._log_streamer.stop()
|
|
await self._pm.stop_monitor()
|
|
await self._pm.stop_all()
|
|
await self._unset_system_proxy(silent=True)
|
|
await self._pm.clear_state()
|
|
|
|
async def _on_singbox_log_line(self, source: str, level: str, message: str) -> None:
|
|
# forward verbatim into the in-memory ring + LogMessage signal
|
|
self.state.emit_log(level, f"[{source}] {message}")
|
|
|
|
# ----------------------------------------------------------------- public API
|
|
|
|
async def start_proxy(self, server_id: str, mode: str, proxy_mode: str) -> bool:
|
|
async with self.state.lock:
|
|
return await self._start_locked(server_id, mode, proxy_mode)
|
|
|
|
async def stop_proxy(self) -> bool:
|
|
async with self.state.lock:
|
|
return await self._stop_locked(reason="user request")
|
|
|
|
async def switch_server(self, server_id: str) -> bool:
|
|
async with self.state.lock:
|
|
was_running = self.state.status.running
|
|
mode = self.state.settings.mode
|
|
proxy_mode = self.state.settings.proxyMode
|
|
if was_running:
|
|
await self._stop_locked(reason="switch server", clear_active=False)
|
|
self.state.settings.activeServerId = server_id
|
|
await save_settings(self.state.settings)
|
|
self._update_status_basics()
|
|
if was_running:
|
|
return await self._start_locked(server_id, mode, proxy_mode)
|
|
return True
|
|
|
|
async def set_mode(self, mode: str) -> bool:
|
|
if mode not in ("rules", "global"):
|
|
return False
|
|
async with self.state.lock:
|
|
was_running = self.state.status.running
|
|
current_server = self.state.settings.activeServerId
|
|
proxy_mode = self.state.settings.proxyMode
|
|
self.state.settings.mode = mode # type: ignore[assignment]
|
|
await save_settings(self.state.settings)
|
|
self._update_status_basics()
|
|
if was_running and current_server:
|
|
await self._stop_locked(reason="set_mode", clear_active=False)
|
|
return await self._start_locked(current_server, mode, proxy_mode)
|
|
return True
|
|
|
|
async def set_proxy_mode(self, proxy_mode: str) -> bool:
|
|
if proxy_mode not in ("system", "tun"):
|
|
return False
|
|
async with self.state.lock:
|
|
was_running = self.state.status.running
|
|
current_server = self.state.settings.activeServerId
|
|
mode = self.state.settings.mode
|
|
self.state.settings.proxyMode = proxy_mode # type: ignore[assignment]
|
|
await save_settings(self.state.settings)
|
|
self._update_status_basics()
|
|
if was_running and current_server:
|
|
await self._stop_locked(reason="set_proxy_mode", clear_active=False)
|
|
return await self._start_locked(current_server, mode, proxy_mode)
|
|
return True
|
|
|
|
# --- server CRUD
|
|
|
|
async def add_server(self, server_dict: dict) -> str:
|
|
server = parse_server(server_dict)
|
|
await self._fill_country(server)
|
|
async with self.state.lock:
|
|
self.state.servers = [s for s in self.state.servers if s.id != server.id]
|
|
self.state.servers.append(server)
|
|
await save_servers(self.state.servers)
|
|
self.state.emit_server_list()
|
|
return server.id
|
|
|
|
async def _fill_country(self, server) -> bool:
|
|
"""Look up `server.country` when absent. Best effort; never raises.
|
|
|
|
Only the UI reads this (it draws the flag); the models keep it via their
|
|
extra="allow". Off unless the user enables geoip_country — the lookup
|
|
discloses their server's address to a third party.
|
|
"""
|
|
if not GEOIP_ENABLED or getattr(server, "country", None):
|
|
return False
|
|
host = getattr(server, "host", None) or getattr(server, "address", None)
|
|
if not host:
|
|
return False
|
|
try:
|
|
cc = await lookup_country(str(host))
|
|
except Exception:
|
|
return False
|
|
if not cc:
|
|
return False
|
|
try:
|
|
setattr(server, "country", cc)
|
|
except (AttributeError, ValueError):
|
|
return False
|
|
return True
|
|
|
|
async def backfill_countries(self) -> int:
|
|
"""Fill in country for servers added before geoip was available."""
|
|
if not GEOIP_ENABLED:
|
|
return 0
|
|
changed = 0
|
|
for server in list(self.state.servers):
|
|
if await self._fill_country(server):
|
|
changed += 1
|
|
if changed:
|
|
async with self.state.lock:
|
|
await save_servers(self.state.servers)
|
|
self.state.emit_server_list()
|
|
self._log("info", f"geoip: filled in country for {changed} server(s)")
|
|
return changed
|
|
|
|
async def add_from_link(self, link: str) -> str:
|
|
"""Parse one vless://, vmess://, ss://, socks5:// or sn:// share link."""
|
|
from backend.subscription.parsers import parse_share_link
|
|
|
|
data = parse_share_link((link or "").strip())
|
|
if not data:
|
|
raise ValueError("Unsupported or invalid share link")
|
|
return await self.add_server(data)
|
|
|
|
async def remove_server(self, server_id: str) -> bool:
|
|
async with self.state.lock:
|
|
before = len(self.state.servers)
|
|
self.state.servers = [s for s in self.state.servers if s.id != server_id]
|
|
if len(self.state.servers) == before:
|
|
return False
|
|
await save_servers(self.state.servers)
|
|
if self.state.settings.activeServerId == server_id:
|
|
if self.state.status.running:
|
|
await self._stop_locked(reason="active server removed")
|
|
self.state.settings.activeServerId = None
|
|
await save_settings(self.state.settings)
|
|
self._update_status_basics()
|
|
self.state.emit_server_list()
|
|
return True
|
|
|
|
async def update_server(self, server_dict: dict) -> bool:
|
|
if "id" not in server_dict:
|
|
return False
|
|
async with self.state.lock:
|
|
idx = next(
|
|
(i for i, s in enumerate(self.state.servers) if s.id == server_dict["id"]),
|
|
None,
|
|
)
|
|
if idx is None:
|
|
return False
|
|
# The editor never sees secrets (list_servers strips them), so an
|
|
# empty or missing secret in an update means "keep the stored one".
|
|
existing = server_to_dict(self.state.servers[idx])
|
|
if existing.get("protocol") == server_dict.get("protocol"):
|
|
for key in SENSITIVE_FIELDS:
|
|
if not server_dict.get(key) and existing.get(key):
|
|
server_dict[key] = existing[key]
|
|
self.state.servers[idx] = parse_server(server_dict)
|
|
await save_servers(self.state.servers)
|
|
self.state.emit_server_list()
|
|
return True
|
|
|
|
async def list_servers(self) -> list[dict]:
|
|
return [server_to_public_dict(s) for s in self.state.servers]
|
|
|
|
async def ping(self, server_id: str) -> int:
|
|
server = self.state.get_server(server_id)
|
|
if server is None:
|
|
return -1
|
|
host, port = self._server_endpoint(server)
|
|
if not host:
|
|
return -1
|
|
latency = await tcp_ping(host, port, timeout=5.0)
|
|
return latency if latency is not None else -1
|
|
|
|
# ---------------- routing rules CRUD + hot-reload
|
|
|
|
async def list_rules(self) -> list[dict]:
|
|
return [r.model_dump(exclude_none=True) for r in self.state.rules]
|
|
|
|
async def add_rule(self, rule_dict: dict) -> str:
|
|
from backend.routing.rules import validate_pattern
|
|
try:
|
|
rule = RoutingRule.model_validate(rule_dict)
|
|
except Exception as exc:
|
|
raise ValueError(f"Invalid routing rule: {exc}") from exc
|
|
try:
|
|
rule.pattern = validate_pattern(rule.pattern)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid routing rule pattern: {exc}") from exc
|
|
async with self.state.lock:
|
|
self.state.rules = [r for r in self.state.rules if r.id != rule.id]
|
|
self.state.rules.append(rule)
|
|
await save_rules(self.state.rules)
|
|
await self._reload_rules_mux()
|
|
return rule.id
|
|
|
|
async def remove_rule(self, rule_id: str) -> bool:
|
|
async with self.state.lock:
|
|
before = len(self.state.rules)
|
|
self.state.rules = [r for r in self.state.rules if r.id != rule_id]
|
|
if len(self.state.rules) == before:
|
|
return False
|
|
await save_rules(self.state.rules)
|
|
await self._reload_rules_mux()
|
|
return True
|
|
|
|
async def _reload_rules_mux(self) -> None:
|
|
"""If the proxy is running in rules mode, regenerate and restart the rules mux."""
|
|
async with self.state.lock:
|
|
if not self.state.status.running:
|
|
return
|
|
if self.state.settings.mode != "rules":
|
|
return
|
|
self._log("info", "hot-reloading rules mux after rule change")
|
|
self._log_streamer.remove_source("rules")
|
|
await self._pm.stop("rules")
|
|
cfg = config_builder.build_rules_config(
|
|
transport_port=self.state.settings.transportPort,
|
|
listen_port=self.state.settings.rulesPort,
|
|
custom_rules=self.state.rules,
|
|
active_presets=list(self.state.settings.activePresets or []),
|
|
)
|
|
await self._pm.write_config("rules", cfg)
|
|
try:
|
|
await self._pm.start_singbox("rules")
|
|
self._log_streamer.add_source("rules", LOG_DIR / LOG_NAMES["rules"])
|
|
except Exception as exc:
|
|
self._log("error", f"failed to restart rules mux: {exc}")
|
|
return
|
|
if not await self._wait_port("127.0.0.1", self.state.settings.rulesPort, 5.0):
|
|
self._log("error", "rules mux did not reopen its port after reload")
|
|
|
|
async def get_logs(self) -> list[str]:
|
|
out: list[str] = []
|
|
for ts, lvl, msg in list(self.state.logs):
|
|
iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
out.append(f"{iso} [{lvl}] {msg}")
|
|
return out[-100:]
|
|
|
|
def get_status(self) -> dict:
|
|
return self.state.status.model_dump(exclude_none=True)
|
|
|
|
# ----------------------------------------------------------------- internals
|
|
|
|
def _log(self, level: str, message: str) -> None:
|
|
_now_log(level, message)
|
|
self.state.emit_log(level, message)
|
|
|
|
def _server_endpoint(self, server: Server) -> tuple[Optional[str], int]:
|
|
if isinstance(server, SSHServer):
|
|
return server.host, server.port
|
|
host = getattr(server, "address", None) or getattr(server, "host", None)
|
|
return host, getattr(server, "port", 0)
|
|
|
|
def _update_status_basics(self) -> None:
|
|
s = self.state.status
|
|
s.activeServerId = self.state.settings.activeServerId
|
|
s.mode = self.state.settings.mode
|
|
s.proxyMode = self.state.settings.proxyMode
|
|
s.transportPort = self.state.settings.transportPort
|
|
s.muxPort = (
|
|
self.state.settings.rulesPort
|
|
if self.state.settings.mode == "rules"
|
|
else self.state.settings.globalPort
|
|
)
|
|
s.pids = self._pm.running_pids()
|
|
s.running = bool(s.pids) and any(
|
|
n in s.pids for n in ("transport", "ssh")
|
|
)
|
|
self.state.emit_status()
|
|
|
|
# --- start / stop body (assumes lock is held)
|
|
|
|
async def _start_locked(self, server_id: str, mode: str, proxy_mode: str) -> bool:
|
|
if mode not in ("rules", "global"):
|
|
self._log("error", f"invalid mode: {mode!r}")
|
|
return False
|
|
if proxy_mode not in ("system", "tun"):
|
|
self._log("error", f"invalid proxy_mode: {proxy_mode!r}")
|
|
return False
|
|
server = self.state.get_server(server_id)
|
|
if server is None:
|
|
self._log("error", f"unknown server id: {server_id!r}")
|
|
self.state.status.message = f"Unknown server: {server_id}"
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
# full clean slate
|
|
await self._pm.stop_monitor()
|
|
await self._pm.stop_all()
|
|
await self._unset_system_proxy(silent=True)
|
|
await asyncio.sleep(1.0)
|
|
|
|
transport_port = self.state.settings.transportPort
|
|
mux_port = (
|
|
self.state.settings.rulesPort
|
|
if mode == "rules"
|
|
else self.state.settings.globalPort
|
|
)
|
|
|
|
# 1. transport layer
|
|
try:
|
|
if isinstance(server, SSHServer):
|
|
await self._pm.start_ssh(
|
|
host=server.host,
|
|
port=server.port,
|
|
user=server.user,
|
|
local_port=transport_port,
|
|
password=server.password,
|
|
key_file=server.keyFile,
|
|
)
|
|
self._log_streamer.add_source("ssh", LOG_DIR / LOG_NAMES["ssh"])
|
|
else:
|
|
cfg = config_builder.build_transport_config(server, listen_port=transport_port)
|
|
await self._pm.write_config("transport", cfg)
|
|
await self._pm.start_singbox("transport")
|
|
self._log_streamer.add_source("transport", LOG_DIR / LOG_NAMES["transport"])
|
|
except Exception as exc:
|
|
self._log("error", f"failed to start transport: {exc}")
|
|
await self._safe_teardown()
|
|
self.state.status.message = f"Transport failed: {exc}"
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
if not await self._wait_port("127.0.0.1", transport_port, 12.0):
|
|
tail = await self._pm.read_log_tail(
|
|
"ssh" if isinstance(server, SSHServer) else "transport"
|
|
)
|
|
self._log("error", f"transport port {transport_port} did not open. log tail:\n{tail[-1500:]}")
|
|
await self._safe_teardown()
|
|
self.state.status.message = "Transport port did not open"
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
# 2. mux layer
|
|
# Global mux (11082) always starts. In rules mode the rules mux
|
|
# (11081) starts alongside it; the user-facing entry points to 11081.
|
|
# Clash API binds only to the rules mux (or global when alone) to
|
|
# avoid a port conflict when both are running.
|
|
try:
|
|
global_port = self.state.settings.globalPort
|
|
global_cfg = config_builder.build_global_config(
|
|
transport_port=transport_port,
|
|
listen_port=global_port,
|
|
clash_api_port=self.state.settings.clashApiPort if mode == "global" else None,
|
|
)
|
|
await self._pm.write_config("global", global_cfg)
|
|
await self._pm.start_singbox("global")
|
|
self._log_streamer.add_source("global", LOG_DIR / LOG_NAMES["global"])
|
|
|
|
if mode == "rules":
|
|
rules_cfg = config_builder.build_rules_config(
|
|
transport_port=transport_port,
|
|
listen_port=mux_port,
|
|
custom_rules=self.state.rules,
|
|
active_presets=list(self.state.settings.activePresets or []),
|
|
clash_api_port=self.state.settings.clashApiPort,
|
|
)
|
|
await self._pm.write_config("rules", rules_cfg)
|
|
await self._pm.start_singbox("rules")
|
|
self._log_streamer.add_source("rules", LOG_DIR / LOG_NAMES["rules"])
|
|
except Exception as exc:
|
|
self._log("error", f"failed to start mux ({mode}): {exc}")
|
|
await self._safe_teardown()
|
|
self.state.status.message = f"Mux failed: {exc}"
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
if not await self._wait_port("127.0.0.1", mux_port, 8.0):
|
|
tail = await self._pm.read_log_tail("rules" if mode == "rules" else "global")
|
|
self._log("error", f"mux port {mux_port} did not open. log tail:\n{tail[-1500:]}")
|
|
await self._safe_teardown()
|
|
self.state.status.message = f"Mux port {mux_port} did not open"
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
# 3. user-facing entry
|
|
if proxy_mode == "system":
|
|
ok = await self._set_system_proxy(mux_port)
|
|
if not ok:
|
|
self._log("error", "failed to set system proxy via gsettings")
|
|
await self._safe_teardown()
|
|
self.state.status.message = "Failed to set system proxy"
|
|
self.state.emit_status()
|
|
return False
|
|
else: # tun
|
|
ok = await self._start_tun(mux_port, server)
|
|
if not ok:
|
|
await self._safe_teardown()
|
|
return False
|
|
|
|
# 4. persist settings and arm monitor
|
|
self.state.settings.activeServerId = server_id
|
|
self.state.settings.mode = mode # type: ignore[assignment]
|
|
self.state.settings.proxyMode = proxy_mode # type: ignore[assignment]
|
|
await save_settings(self.state.settings)
|
|
self._update_status_basics()
|
|
self.state.status.message = None
|
|
self.state.emit_status()
|
|
|
|
await self._pm.write_state({
|
|
"running": True,
|
|
"activeServerId": server_id,
|
|
"mode": mode,
|
|
"proxyMode": proxy_mode,
|
|
"pids": self._pm.running_pids(),
|
|
"startedAt": time.time(),
|
|
})
|
|
|
|
self._pm.start_monitor(self._on_unexpected_exit)
|
|
|
|
# arm health monitor
|
|
if self._health:
|
|
await self._health.stop()
|
|
host, port = self._server_endpoint(server)
|
|
if host:
|
|
self._health = HealthMonitor(
|
|
host=host,
|
|
port=port,
|
|
interval=float(self.state.settings.healthCheckIntervalSec),
|
|
on_failed=self._on_health_failed,
|
|
)
|
|
self._health.start()
|
|
|
|
# arm traffic monitor
|
|
if self._traffic:
|
|
await self._traffic.stop()
|
|
self._traffic = TrafficMonitor(
|
|
api_url=f"http://127.0.0.1:{self.state.settings.clashApiPort}",
|
|
interval=5.0,
|
|
on_update=self._on_traffic_update,
|
|
)
|
|
self._traffic.start()
|
|
|
|
self._log("info", f"proxy started: server={server.name} mode={mode} proxy_mode={proxy_mode}")
|
|
return True
|
|
|
|
async def _stop_locked(self, *, reason: str, clear_active: bool = True) -> bool:
|
|
self._log("info", f"stopping proxy ({reason})")
|
|
if self._health:
|
|
await self._health.stop()
|
|
self._health = None
|
|
if self._traffic:
|
|
await self._traffic.stop()
|
|
self._traffic = None
|
|
self._log_streamer.clear()
|
|
await self._pm.stop_monitor()
|
|
if self.state.settings.proxyMode == "system":
|
|
await self._unset_system_proxy(silent=True)
|
|
await self._pm.stop_all()
|
|
await self._pm.clear_state()
|
|
if clear_active:
|
|
self.state.status.message = None
|
|
self.state.status.reason = None
|
|
self.state.status.status = "ok"
|
|
self._update_status_basics()
|
|
self.state.status.running = False
|
|
self.state.emit_status()
|
|
return True
|
|
|
|
async def _safe_teardown(self) -> None:
|
|
if self._teardown_in_progress:
|
|
return
|
|
self._teardown_in_progress = True
|
|
try:
|
|
if self._health:
|
|
await self._health.stop()
|
|
self._health = None
|
|
if self._traffic:
|
|
await self._traffic.stop()
|
|
self._traffic = None
|
|
self._log_streamer.clear()
|
|
await self._pm.stop_monitor()
|
|
await self._unset_system_proxy(silent=True)
|
|
await self._pm.stop_all()
|
|
await self._pm.clear_state()
|
|
self._update_status_basics()
|
|
self.state.status.running = False
|
|
self.state.emit_status()
|
|
finally:
|
|
self._teardown_in_progress = False
|
|
|
|
async def _on_traffic_update(self, stats: dict) -> None:
|
|
for cb in list(self._traffic_listeners):
|
|
try:
|
|
cb(stats)
|
|
except Exception:
|
|
pass
|
|
|
|
def add_traffic_listener(self, cb) -> None:
|
|
self._traffic_listeners.append(cb)
|
|
|
|
def get_traffic_stats(self) -> dict:
|
|
if self._traffic is None:
|
|
return {
|
|
"bytes_sent": 0,
|
|
"bytes_received": 0,
|
|
"uptime_seconds": 0,
|
|
"connection_count": 0,
|
|
}
|
|
return self._traffic.stats.to_dict()
|
|
|
|
async def _on_health_failed(self) -> None:
|
|
self._log("error", "health check failed 3 times in a row")
|
|
self.state.status.status = "error"
|
|
self.state.status.reason = "health_check_failed"
|
|
self.state.emit_status()
|
|
|
|
# ---------------- subscriptions
|
|
|
|
async def add_subscription(self, url: str, name: str) -> bool:
|
|
return await self._subs.add(url, name)
|
|
|
|
async def remove_subscription(self, url: str) -> bool:
|
|
return await self._subs.remove(url)
|
|
|
|
async def update_subscription(self, url: str) -> int:
|
|
return await self._subs.update(url)
|
|
|
|
async def list_subscriptions(self) -> list[dict]:
|
|
return await self._subs.list_subs()
|
|
|
|
# ---------------- settings
|
|
|
|
async def get_settings(self) -> dict:
|
|
return self.state.settings.model_dump(exclude_none=True)
|
|
|
|
async def update_settings(self, patch: dict) -> dict:
|
|
"""Apply a partial update to user-visible settings.
|
|
|
|
Only fields explicitly handled here may be mutated; ports and other
|
|
connection-critical fields are deliberately ignored so the bar widget
|
|
toggles can't accidentally clobber them.
|
|
"""
|
|
async with self.state.lock:
|
|
if "showPingInBar" in patch:
|
|
self.state.settings.showPingInBar = bool(patch["showPingInBar"])
|
|
if "showTrafficInBar" in patch:
|
|
self.state.settings.showTrafficInBar = bool(patch["showTrafficInBar"])
|
|
await save_settings(self.state.settings)
|
|
return self.state.settings.model_dump(exclude_none=True)
|
|
|
|
# ---------------- routing presets
|
|
|
|
async def list_presets(self) -> list[dict]:
|
|
from backend.routing.rules import PRESETS
|
|
active = set(self.state.settings.activePresets or [])
|
|
out: list[dict] = []
|
|
for key, p in PRESETS.items():
|
|
out.append({
|
|
"key": p["key"],
|
|
"name": p["name"],
|
|
"flag": p.get("flag", ""),
|
|
"description": p.get("description", ""),
|
|
"enabled": key in active,
|
|
})
|
|
return out
|
|
|
|
async def toggle_preset(self, key: str, enabled: bool) -> bool:
|
|
from backend.routing.rules import PRESETS
|
|
if key not in PRESETS:
|
|
return False
|
|
async with self.state.lock:
|
|
current = list(self.state.settings.activePresets or [])
|
|
has = key in current
|
|
if enabled and not has:
|
|
current.append(key)
|
|
elif (not enabled) and has:
|
|
current = [k for k in current if k != key]
|
|
else:
|
|
return True # no-op
|
|
self.state.settings.activePresets = current
|
|
await save_settings(self.state.settings)
|
|
await self._reload_rules_mux()
|
|
return True
|
|
|
|
# ---------------- kill switch
|
|
|
|
async def set_kill_switch(self, enabled: bool) -> bool:
|
|
from backend.service import kill_switch as ks
|
|
async with self.state.lock:
|
|
self.state.settings.killSwitchEnabled = bool(enabled)
|
|
await save_settings(self.state.settings)
|
|
if enabled:
|
|
host, port = self._active_server_endpoint()
|
|
# Only literal, pre-resolved IPs may enter the ruleset: its
|
|
# text is executed by nft with root privileges, and the host
|
|
# can come from an untrusted subscription.
|
|
server_ips = await self._resolve_host_ips(host, port) if host else []
|
|
if host and not server_ips:
|
|
self._log(
|
|
"warn",
|
|
f"kill switch: could not resolve {host!r}; "
|
|
"applying without a server allowance",
|
|
)
|
|
ruleset = ks.build_ruleset(
|
|
server_ips=server_ips,
|
|
server_port=port,
|
|
extra_allow_tcp=[
|
|
self.state.settings.transportPort,
|
|
self.state.settings.rulesPort,
|
|
self.state.settings.globalPort,
|
|
self.state.settings.clashApiPort,
|
|
],
|
|
)
|
|
ok, msg = await ks.apply(ruleset)
|
|
self._log("info" if ok else "error", f"kill switch apply: {msg}")
|
|
return ok
|
|
else:
|
|
ok, msg = await ks.remove()
|
|
self._log("info" if ok else "warn", f"kill switch remove: {msg}")
|
|
return ok
|
|
|
|
async def get_kill_switch_status(self) -> dict:
|
|
from backend.service import kill_switch as ks
|
|
active = await ks.is_active()
|
|
return {
|
|
"enabled": bool(self.state.settings.killSwitchEnabled),
|
|
"active": bool(active),
|
|
}
|
|
|
|
def _active_server_endpoint(self) -> tuple[Optional[str], Optional[int]]:
|
|
sid = self.state.settings.activeServerId
|
|
if not sid:
|
|
return None, None
|
|
server = self.state.get_server(sid)
|
|
if server is None:
|
|
return None, None
|
|
return self._server_endpoint(server)
|
|
|
|
def check_dns_leak(self) -> dict:
|
|
from backend.monitoring.health import check_dns_leak as _check
|
|
return _check(
|
|
running=self.state.status.running,
|
|
proxy_mode=self.state.settings.proxyMode,
|
|
mode=self.state.settings.mode,
|
|
)
|
|
|
|
def get_health(self) -> dict:
|
|
if self._health is None:
|
|
return {
|
|
"latency_ms": -1,
|
|
"jitter_ms": -1,
|
|
"down_mbps": -1.0,
|
|
"up_mbps": -1.0,
|
|
"speed_taken_at": "",
|
|
"last_check": "",
|
|
"consecutive_failures": 0,
|
|
"status": "ok" if not self.state.status.running else "degraded",
|
|
}
|
|
return self._health.state.to_dict()
|
|
|
|
async def run_speed_test(self) -> dict:
|
|
empty = {
|
|
"down_mbps": -1.0,
|
|
"up_mbps": -1.0,
|
|
"ping_ms": -1,
|
|
"jitter_ms": -1,
|
|
}
|
|
if self._health is None:
|
|
return empty
|
|
# Always measure through the transport upstream (11080). The mux port
|
|
# would re-enter the routing engine and send speed-test domains DIRECT
|
|
# in rules mode, which defeats the test and can hang on slow paths.
|
|
transport_port = (
|
|
self.state.status.transportPort
|
|
or self.state.settings.transportPort
|
|
or 11080
|
|
)
|
|
# If the transport isn't listening, fail fast instead of hanging.
|
|
from backend.monitoring.health import tcp_ping
|
|
if not self.state.status.running or await tcp_ping(
|
|
"127.0.0.1", transport_port, timeout=1.0
|
|
) is None:
|
|
return {**empty, "error": "proxy transport not listening on 127.0.0.1:%d" % transport_port}
|
|
proxy_url = f"socks5://127.0.0.1:{transport_port}"
|
|
try:
|
|
return await asyncio.wait_for(
|
|
self._health.run_speed_test(proxy_url=proxy_url),
|
|
timeout=45.0,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
st = self._health.state
|
|
return {
|
|
"down_mbps": float(st.down_mbps),
|
|
"up_mbps": float(st.up_mbps),
|
|
"ping_ms": int(st.latency_ms),
|
|
"jitter_ms": int(st.jitter_ms),
|
|
"error": "speed test timed out after 45s",
|
|
}
|
|
|
|
async def _on_unexpected_exit(self, name: str) -> None:
|
|
self._log("error", f"unexpected exit of '{name}'; tearing down")
|
|
async with self.state.lock:
|
|
await self._safe_teardown()
|
|
self.state.status.message = f"Process '{name}' exited unexpectedly"
|
|
self.state.emit_status()
|
|
|
|
# ----------------------------------------------------------------- system proxy / TUN
|
|
|
|
async def _set_system_proxy(self, port: int) -> bool:
|
|
if shutil.which("gsettings") is None:
|
|
self._log("warn", "gsettings not found — cannot set system proxy")
|
|
return False
|
|
cmds = [
|
|
["gsettings", "set", "org.gnome.system.proxy", "mode", "manual"],
|
|
["gsettings", "set", "org.gnome.system.proxy.socks", "host", "127.0.0.1"],
|
|
["gsettings", "set", "org.gnome.system.proxy.socks", "port", str(port)],
|
|
["gsettings", "set", "org.gnome.system.proxy.http", "host", "127.0.0.1"],
|
|
["gsettings", "set", "org.gnome.system.proxy.http", "port", str(port)],
|
|
["gsettings", "set", "org.gnome.system.proxy.https", "host", "127.0.0.1"],
|
|
["gsettings", "set", "org.gnome.system.proxy.https", "port", str(port)],
|
|
[
|
|
"gsettings",
|
|
"set",
|
|
"org.gnome.system.proxy",
|
|
"use-same-proxy",
|
|
"true",
|
|
],
|
|
]
|
|
for cmd in cmds:
|
|
rc = await self._run(cmd)
|
|
if rc != 0:
|
|
self._log("error", f"gsettings failed: {' '.join(cmd)} rc={rc}")
|
|
return False
|
|
return True
|
|
|
|
async def _unset_system_proxy(self, silent: bool = False) -> None:
|
|
if shutil.which("gsettings") is None:
|
|
return
|
|
await self._run(["gsettings", "set", "org.gnome.system.proxy", "mode", "none"])
|
|
if not silent:
|
|
self._log("info", "system proxy disabled")
|
|
|
|
async def _start_tun(self, upstream_port: int, server: Server) -> bool:
|
|
try:
|
|
tun_bin, refreshed = await asyncio.to_thread(
|
|
tun_binary.ensure_copy, SINGBOX_BIN
|
|
)
|
|
except OSError as exc:
|
|
self._log("error", f"failed to prepare private sing-box copy for TUN: {exc}")
|
|
self.state.status.message = "Could not prepare sing-box copy for TUN"
|
|
self.state.emit_status()
|
|
return False
|
|
if refreshed:
|
|
# a rewritten copy starts with no file capabilities
|
|
self._tun_caps_granted = False
|
|
|
|
cap_ok = await self._check_tun_caps(tun_bin)
|
|
if not cap_ok:
|
|
self._log(
|
|
"warn",
|
|
"TUN sing-box copy missing CAP_NET_ADMIN; attempting pkexec setcap fallback",
|
|
)
|
|
ok = await self._grant_tun_caps(tun_bin)
|
|
if not ok:
|
|
self.state.status.message = (
|
|
f"TUN requires CAP_NET_ADMIN on {tun_bin}. "
|
|
f"Run: sudo setcap cap_net_admin+ep {tun_bin}"
|
|
)
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
route_exclusions = await self._resolve_transport_endpoints(server)
|
|
if not route_exclusions:
|
|
host, _ = self._server_endpoint(server)
|
|
self._log("error", f"failed to resolve transport endpoint for TUN: {host}")
|
|
self.state.status.message = "Could not resolve VPN server for TUN routing"
|
|
self.state.emit_status()
|
|
return False
|
|
|
|
cfg = config_builder.build_tun_config(
|
|
upstream_socks_port=upstream_port,
|
|
route_exclude_addresses=route_exclusions,
|
|
)
|
|
try:
|
|
await self._pm.write_config("tun", cfg)
|
|
await self._pm.start_singbox("tun", binary=tun_bin)
|
|
self._log_streamer.add_source("tun", LOG_DIR / LOG_NAMES["tun"])
|
|
except Exception as exc:
|
|
self._log("error", f"failed to start tun: {exc}")
|
|
return False
|
|
|
|
await asyncio.sleep(0.8)
|
|
if not self._pm.is_running("tun"):
|
|
tail = await self._pm.read_log_tail("tun")
|
|
self._log("error", f"tun process died. log tail:\n{tail[-1500:]}")
|
|
self.state.status.message = "TUN failed to start"
|
|
self.state.emit_status()
|
|
return False
|
|
return True
|
|
|
|
async def _resolve_host_ips(self, host: str, port: Optional[int]) -> list[str]:
|
|
"""Resolve a host to canonical literal IPs; a literal IP passes through."""
|
|
try:
|
|
return [str(ipaddress.ip_address(host))]
|
|
except ValueError:
|
|
pass
|
|
|
|
loop = asyncio.get_running_loop()
|
|
try:
|
|
info = await loop.getaddrinfo(host, port or 443, type=socket.SOCK_STREAM)
|
|
except (socket.gaierror, OSError):
|
|
return []
|
|
|
|
ips: set[str] = set()
|
|
for _, _, _, _, sockaddr in info:
|
|
try:
|
|
ips.add(str(ipaddress.ip_address(sockaddr[0])))
|
|
except ValueError:
|
|
continue
|
|
return sorted(ips)
|
|
|
|
async def _resolve_transport_endpoints(self, server: Server) -> list[str]:
|
|
"""Resolve the transport host to host-prefixes excluded from TUN.
|
|
|
|
The transport is started before TUN, so resolving here uses the normal
|
|
system path and cannot recurse through the new interface.
|
|
"""
|
|
host, port = self._server_endpoint(server)
|
|
if not host:
|
|
return []
|
|
prefixes = []
|
|
for ip_str in await self._resolve_host_ips(host, port):
|
|
ip = ipaddress.ip_address(ip_str)
|
|
prefixes.append(f"{ip}/{ip.max_prefixlen}")
|
|
return sorted(prefixes)
|
|
|
|
async def _check_tun_caps(self, binary: str) -> bool:
|
|
if self._tun_caps_granted:
|
|
return True
|
|
rc, stdout = await self._run_capture(["getcap", binary])
|
|
if rc != 0:
|
|
return False
|
|
if "cap_net_admin" in stdout.lower():
|
|
self._tun_caps_granted = True
|
|
return True
|
|
return False
|
|
|
|
async def _grant_tun_caps(self, binary: str) -> bool:
|
|
# pkexec/setcap is meaningful only for TUN mode — refuse to prompt
|
|
# the user during a system-proxy switch.
|
|
if self.state.settings.proxyMode != "tun":
|
|
return False
|
|
if shutil.which("pkexec") is None:
|
|
return False
|
|
async with self._tun_caps_lock:
|
|
# A concurrent caller may have already granted caps while we were
|
|
# waiting for the lock; re-check before firing pkexec again.
|
|
if await self._check_tun_caps(binary):
|
|
return True
|
|
# One prompt does both: grant the cap to the private copy and drop
|
|
# the grant older plugin versions left on the shared system binary.
|
|
# Paths travel as positional arguments, never spliced into the
|
|
# script text.
|
|
script = 'setcap cap_net_admin+ep "$1" && { setcap -r "$2" 2>/dev/null; true; }'
|
|
rc = await self._run(
|
|
[
|
|
"pkexec", "sh", "-c", script, "sh",
|
|
binary,
|
|
tun_binary.source_binary(SINGBOX_BIN),
|
|
]
|
|
)
|
|
if rc != 0:
|
|
return False
|
|
return await self._check_tun_caps(binary)
|
|
|
|
# ----------------------------------------------------------------- low-level
|
|
|
|
@staticmethod
|
|
async def _wait_port(host: str, port: int, timeout: float) -> bool:
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + timeout
|
|
while loop.time() < deadline:
|
|
try:
|
|
_, writer = await asyncio.wait_for(
|
|
asyncio.open_connection(host, port), timeout=0.5
|
|
)
|
|
writer.close()
|
|
try:
|
|
await writer.wait_closed()
|
|
except (ConnectionError, OSError):
|
|
pass
|
|
return True
|
|
except (OSError, asyncio.TimeoutError):
|
|
await asyncio.sleep(0.25)
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _run(cmd: list[str]) -> int:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
return await proc.wait()
|
|
|
|
@staticmethod
|
|
async def _run_capture(cmd: list[str]) -> tuple[int, str]:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
)
|
|
out, _ = await proc.communicate()
|
|
return proc.returncode or 0, out.decode("utf-8", errors="replace")
|