From 0733efd18617a2b4a0860f76356fc176658d52f5 Mon Sep 17 00:00:00 2001 From: Umed Date: Mon, 10 Aug 2026 04:03:02 +0300 Subject: [PATCH] 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> --- ruh-vpn/README.md | 94 ++ ruh-vpn/backend/__init__.py | 0 ruh-vpn/backend/app.py | 202 ++++ ruh-vpn/backend/config/__init__.py | 0 ruh-vpn/backend/config/settings.py | 37 + ruh-vpn/backend/core/__init__.py | 0 ruh-vpn/backend/core/state.py | 55 ++ ruh-vpn/backend/geoip.py | 82 ++ ruh-vpn/backend/http/__init__.py | 0 ruh-vpn/backend/http/control.py | 224 +++++ ruh-vpn/backend/identity.py | 6 + ruh-vpn/backend/models/__init__.py | 0 ruh-vpn/backend/models/server.py | 192 ++++ ruh-vpn/backend/monitoring/__init__.py | 0 ruh-vpn/backend/monitoring/health.py | 331 +++++++ ruh-vpn/backend/monitoring/log_streamer.py | 145 +++ ruh-vpn/backend/monitoring/traffic.py | 95 ++ ruh-vpn/backend/paths.py | 25 + ruh-vpn/backend/routing/__init__.py | 0 ruh-vpn/backend/routing/rules.py | 227 +++++ ruh-vpn/backend/service/__init__.py | 0 ruh-vpn/backend/service/kill_switch.py | 151 +++ ruh-vpn/backend/service/tun_binary.py | 48 + ruh-vpn/backend/service/vpn_service.py | 1016 ++++++++++++++++++++ ruh-vpn/backend/singbox/__init__.py | 0 ruh-vpn/backend/singbox/config_builder.py | 329 +++++++ ruh-vpn/backend/singbox/process_manager.py | 309 ++++++ ruh-vpn/backend/singbox/transport.py | 147 +++ ruh-vpn/backend/storage/__init__.py | 0 ruh-vpn/backend/storage/persistence.py | 74 ++ ruh-vpn/backend/storage/subscriptions.py | 37 + ruh-vpn/backend/subscription/__init__.py | 0 ruh-vpn/backend/subscription/manager.py | 157 +++ ruh-vpn/backend/subscription/parsers.py | 311 ++++++ ruh-vpn/panel.luau | 913 ++++++++++++++++++ ruh-vpn/plugin.toml | 78 ++ ruh-vpn/pyproject.toml | 21 + ruh-vpn/service.luau | 310 ++++++ ruh-vpn/shortcut.luau | 33 + ruh-vpn/tests/conftest.py | 17 + ruh-vpn/tests/test_auth.py | 45 + ruh-vpn/tests/test_config_builder.py | 44 + ruh-vpn/tests/test_kill_switch.py | 64 ++ ruh-vpn/tests/test_models.py | 39 + ruh-vpn/tests/test_parsers.py | 41 + ruh-vpn/tests/test_persistence.py | 27 + ruh-vpn/tests/test_rules.py | 45 + ruh-vpn/thumbnail.webp | Bin 0 -> 43146 bytes ruh-vpn/translations/en.json | 107 +++ ruh-vpn/widget.luau | 47 + 50 files changed, 6125 insertions(+) create mode 100644 ruh-vpn/README.md create mode 100644 ruh-vpn/backend/__init__.py create mode 100644 ruh-vpn/backend/app.py create mode 100644 ruh-vpn/backend/config/__init__.py create mode 100644 ruh-vpn/backend/config/settings.py create mode 100644 ruh-vpn/backend/core/__init__.py create mode 100644 ruh-vpn/backend/core/state.py create mode 100644 ruh-vpn/backend/geoip.py create mode 100644 ruh-vpn/backend/http/__init__.py create mode 100644 ruh-vpn/backend/http/control.py create mode 100644 ruh-vpn/backend/identity.py create mode 100644 ruh-vpn/backend/models/__init__.py create mode 100644 ruh-vpn/backend/models/server.py create mode 100644 ruh-vpn/backend/monitoring/__init__.py create mode 100644 ruh-vpn/backend/monitoring/health.py create mode 100644 ruh-vpn/backend/monitoring/log_streamer.py create mode 100644 ruh-vpn/backend/monitoring/traffic.py create mode 100644 ruh-vpn/backend/paths.py create mode 100644 ruh-vpn/backend/routing/__init__.py create mode 100644 ruh-vpn/backend/routing/rules.py create mode 100644 ruh-vpn/backend/service/__init__.py create mode 100644 ruh-vpn/backend/service/kill_switch.py create mode 100644 ruh-vpn/backend/service/tun_binary.py create mode 100644 ruh-vpn/backend/service/vpn_service.py create mode 100644 ruh-vpn/backend/singbox/__init__.py create mode 100644 ruh-vpn/backend/singbox/config_builder.py create mode 100644 ruh-vpn/backend/singbox/process_manager.py create mode 100644 ruh-vpn/backend/singbox/transport.py create mode 100644 ruh-vpn/backend/storage/__init__.py create mode 100644 ruh-vpn/backend/storage/persistence.py create mode 100644 ruh-vpn/backend/storage/subscriptions.py create mode 100644 ruh-vpn/backend/subscription/__init__.py create mode 100644 ruh-vpn/backend/subscription/manager.py create mode 100644 ruh-vpn/backend/subscription/parsers.py create mode 100644 ruh-vpn/panel.luau create mode 100644 ruh-vpn/plugin.toml create mode 100644 ruh-vpn/pyproject.toml create mode 100644 ruh-vpn/service.luau create mode 100644 ruh-vpn/shortcut.luau create mode 100644 ruh-vpn/tests/conftest.py create mode 100644 ruh-vpn/tests/test_auth.py create mode 100644 ruh-vpn/tests/test_config_builder.py create mode 100644 ruh-vpn/tests/test_kill_switch.py create mode 100644 ruh-vpn/tests/test_models.py create mode 100644 ruh-vpn/tests/test_parsers.py create mode 100644 ruh-vpn/tests/test_persistence.py create mode 100644 ruh-vpn/tests/test_rules.py create mode 100644 ruh-vpn/thumbnail.webp create mode 100644 ruh-vpn/translations/en.json create mode 100644 ruh-vpn/widget.luau diff --git a/ruh-vpn/README.md b/ruh-vpn/README.md new file mode 100644 index 0000000..53e8e46 --- /dev/null +++ b/ruh-vpn/README.md @@ -0,0 +1,94 @@ +# Ruh VPN + +Ruh VPN is a VPN and proxy manager for `sing-box`. It manages SSH, VLESS, +VMess, Shadowsocks and SOCKS5 connections from a Noctalia bar widget, panel +and control-center shortcut. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `umedbazarov/ruh-vpn` | +| Entries | Bar widget: `vpn_widget`; panel: `vpn_panel`; service: `vpn_service`; shortcut: `vpn_toggle` | + +## Requirements + +The backend requires `sing-box`, `python3` and `pkill`, plus the Python +packages declared in `pyproject.toml`: `pydantic`, `aiofiles`, `aiohttp` and +`aiohttp-socks`. The plugin never installs packages itself: it checks the +configured interpreter at startup and, if something is missing, reports the +exact package names in the panel and does not start the backend. + +Install the packages either from your distribution (e.g. `python-pydantic`, +`python-aiofiles`, `python-aiohttp` on Arch), or into a dedicated virtual +environment: + +```sh +python3 -m venv ~/.local/share/ruh-vpn-venv +~/.local/share/ruh-vpn-venv/bin/pip install pydantic aiofiles aiohttp aiohttp-socks +``` + +Then set the `backend_python` setting to that environment's interpreter, e.g. +`~/.local/share/ruh-vpn-venv/bin/python3`. The default `backend_python` value +is `python3`, which works when the packages are installed system-wide. + +SSH connections require `ssh`; password-based SSH connections additionally +require `sshpass`. System proxy mode requires `gsettings`. TUN mode and the kill +switch use `pkexec`, `setcap`, `getcap` and `nft` for privileged operations. + +## Usage + +Add the **Ruh VPN** widget under Settings → Bar, or add the `vpn_toggle` +shortcut to the control center. Click the widget to open the panel. Select or +add a server, choose the routing mode (`rules` or `global`) and connection mode +(`system` or `tun`), then enable the main switch. + +Open or close the panel with: + +```sh +noctalia msg panel-toggle umedbazarov/ruh-vpn:vpn_panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `backend_python` | `file` | `python3` | Python executable with the backend packages installed. | +| `auto_start` | `bool` | `false` | Connect the active server when the plugin service starts. | +| `geoip_country` | `bool` | `true` | Resolve server countries through `api.country.is`. | +| `control_port` | `int` | `11090` | Loopback HTTP port used by the Luau entries and Python backend. | +| `show_ping` | `bool` | `true` | Show active-server latency in the bar. | +| `show_traffic` | `bool` | `false` | Show live upload and download rates in the bar. | + +## Notes + +- The service starts the Python backend, which starts `sing-box` and, for SSH + connections, `ssh` or `sshpass`. `sing-box` is resolved from `PATH`. +- SSH host keys are recorded on first connect into a `known_hosts` file inside + the plugin data directory and verified on every later connect; a changed host + key makes the connection fail instead of being ignored. +- Server passwords and UUIDs never leave the backend: the panel lists servers + without secrets, and an empty secret field when editing keeps the stored + value. +- Persistent settings, servers, subscriptions and generated `sing-box` + configuration are written under the directory returned by + `noctalia.pluginDataDir()`. Runtime state and logs are stored in its + `runtime/` subdirectory. +- The backend listens on the configured loopback control port. It does not bind + the control API to an external interface, and every RPC call requires a + per-launch bearer token stored in a user-only (mode 0600) file under the + runtime directory, so other local users cannot control the VPN or read + server credentials. +- Network access includes configured VPN endpoints and subscription URLs, + `api.country.is` when country detection is enabled, Cloudflare's speed-test + endpoint, and remote rule sets enabled by routing presets. +- DNS in the generated configurations: Google DNS (`8.8.8.8`) over plain UDP + through the proxy tunnel; AliDNS (`223.5.5.5`) over plain UDP directly, as + the resolver for direct-routed and unmatched domains in rules mode; Google + DNS-over-HTTPS (`8.8.8.8`, through the tunnel) in TUN mode. +- TUN mode grants `CAP_NET_ADMIN`, after a PolicyKit prompt, to a private copy + of `sing-box` kept in a user-only (mode 0700) directory under the plugin + data directory — never to the shared system binary. The copy is recreated + whenever the system `sing-box` changes, which also clears the previously + granted capability. The kill switch installs a dedicated nftables table and + removes it when disabled. diff --git a/ruh-vpn/backend/__init__.py b/ruh-vpn/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/app.py b/ruh-vpn/backend/app.py new file mode 100644 index 0000000..4313a66 --- /dev/null +++ b/ruh-vpn/backend/app.py @@ -0,0 +1,202 @@ +"""Entry point: start asyncio loop, bootstrap service, expose HTTP control API. + +The Luau [[service]] entry (service.luau) launches this module with +noctalia.runStream() and consumes the newline-JSON events we print to stdout. +Commands come back in over the loopback HTTP control port. + +Port selection: RUH_VPN_CONTROL_PORT env var, else 11090. + +Coexistence safety: if the control port is already bound, another backend is +already running (e.g. the user's active proxy). We emit a "port-in-use" error +and exit WITHOUT running the destructive shutdown path, so we never tear down a +proxy this instance does not own. The Luau service probes /healthz first and +only spawns us when no backend is present, so this is a belt-and-suspenders +guard. +""" + +from __future__ import annotations + +import asyncio +import os +import secrets +import signal +import sys +from pathlib import Path + +from backend.http.control import DEFAULT_PORT, HOST, emit, serve +from backend.identity import PREFIX +from backend.paths import RUNTIME_DIR, ensure_private_dir, protect_file +from backend.service.vpn_service import VpnService + +PIDFILE = RUNTIME_DIR / f"{PREFIX}-backend.pid" +TOKENFILE = RUNTIME_DIR / f"{PREFIX}-control.token" +DETACHED_LOG = RUNTIME_DIR / f"{PREFIX}-backend-detached.log" + + +class _StdoutGuard: + """A stdout that outlives its reader. + + service.luau spawns us through noctalia.runStream(), so stdout is a pipe + owned by that shell. When the shell exits we are meant to keep running (the + next shell re-attaches over /healthz and the proxy survives), but the read + end of the pipe closes with it, and from then on every write raises + BrokenPipeError. Because _now_log() prints, that exception surfaced inside + whichever RPC logged first: StartProxy died on its very first log line and + the proxy silently never started. A vanished reader must not be fatal, so + fall back to a file and carry on. + """ + + def __init__(self, stream: object, fallback: Path) -> None: + self._stream = stream + self._fallback = fallback + self._demoted = False + + def _demote(self) -> None: + # Close explicitly: the dead pipe still holds whatever we buffered, and + # letting the finalizer discover that prints "Exception ignored". + old, self._stream = self._stream, None + if old is not None: + try: + old.close() + except Exception: + pass + if self._demoted: + return + self._demoted = True + try: + self._stream = open(self._fallback, "a", buffering=1) + protect_file(self._fallback) + except OSError: + self._stream = None + + def write(self, data: str) -> int: + # At most two tries: pipe -> fallback file -> give up silently. + for _ in range(2): + stream = self._stream + if stream is None: + break + try: + written = stream.write(data) + # Flush here, not in flush(): the stream is buffered, so a write + # to a dead pipe succeeds and only the flush raises. By then the + # line is stranded in a buffer we are about to drop. Flushing + # while `data` is still in hand lets the retry re-send it. + stream.flush() + return written + except (BrokenPipeError, OSError, ValueError): + self._demote() + return len(data) + + def flush(self) -> None: + # write() already flushed; this only has to stay well-behaved. + stream = self._stream + if stream is None: + return + try: + stream.flush() + except (BrokenPipeError, OSError, ValueError): + self._demote() + + def isatty(self) -> bool: + return False + + +def _install_stdio_guards() -> None: + sys.stdout = _StdoutGuard(sys.stdout, DETACHED_LOG) + sys.stderr = _StdoutGuard(sys.stderr, DETACHED_LOG) + + +def _resolve_port() -> int: + raw = os.environ.get("RUH_VPN_CONTROL_PORT", "") + try: + return int(raw) if raw else DEFAULT_PORT + except ValueError: + return DEFAULT_PORT + + +def _write_pidfile(port: int) -> None: + try: + PIDFILE.write_text(f"{os.getpid()} {port}\n") + protect_file(PIDFILE) + except Exception: + pass + + +def _remove_pidfile() -> None: + try: + # Only remove if it still points at us + content = PIDFILE.read_text().split() + if content and content[0] == str(os.getpid()): + PIDFILE.unlink() + except Exception: + pass + + +def _remove_tokenfile(token: str) -> None: + try: + # Only remove if it still holds our token + if TOKENFILE.read_text().strip() == token: + TOKENFILE.unlink() + except Exception: + pass + + +async def main() -> int: + ensure_private_dir(RUNTIME_DIR) + _install_stdio_guards() + port = _resolve_port() + + svc = VpnService() + await svc.bootstrap() + + # Per-launch RPC token. serve() writes it to TOKENFILE (0600) only after + # the port is bound; the Luau service reads the file and sends it as + # "Authorization: Bearer " on every /rpc call. + token = secrets.token_urlsafe(32) + try: + control = await serve(svc, port, token=token, token_file=TOKENFILE) + except OSError as exc: + # Port already in use: another backend owns the proxy. Do NOT shut down. + emit({"event": "error", "data": {"message": f"control port {port} in use: {exc}"}}) + print(f"[error] control port {port} already in use; exiting without teardown", flush=True) + return 3 + + _write_pidfile(port) + print(f"[info] Ruh VPN backend ready on http://{HOST}:{port} (pid {os.getpid()})", flush=True) + + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + + def _shutdown(*_: object) -> None: + if not stop_event.is_set(): + print("[info] shutdown signal received", flush=True) + stop_event.set() + + for sig in (signal.SIGTERM, signal.SIGINT): + try: + loop.add_signal_handler(sig, _shutdown) + except NotImplementedError: + pass + + await stop_event.wait() + + print("[info] shutting down VPN service", flush=True) + try: + await control.stop() + except Exception as exc: + print(f"[error] control stop error: {exc}", flush=True) + try: + await svc.shutdown() + except Exception as exc: + print(f"[error] shutdown error: {exc}", flush=True) + _remove_pidfile() + _remove_tokenfile(token) + emit({"event": "exit", "data": {"code": 0}}) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(asyncio.run(main())) + except KeyboardInterrupt: + sys.exit(0) diff --git a/ruh-vpn/backend/config/__init__.py b/ruh-vpn/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/config/settings.py b/ruh-vpn/backend/config/settings.py new file mode 100644 index 0000000..fd550fc --- /dev/null +++ b/ruh-vpn/backend/config/settings.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +import os + +import aiofiles + +from backend.models.server import Settings +from backend.paths import DATA_DIR, ensure_private_dir, protect_file + +SETTINGS_FILE = DATA_DIR / "settings.json" + + +def ensure_dirs() -> None: + ensure_private_dir(DATA_DIR) + + +async def load_settings() -> Settings: + ensure_dirs() + if not SETTINGS_FILE.exists(): + return Settings() + try: + async with aiofiles.open(SETTINGS_FILE, "r") as f: + raw = await f.read() + data = json.loads(raw or "{}") + return Settings.model_validate(data) + except (json.JSONDecodeError, ValueError): + return Settings() + + +async def save_settings(settings: Settings) -> None: + ensure_dirs() + tmp = SETTINGS_FILE.with_suffix(".json.tmp") + async with aiofiles.open(tmp, "w") as f: + await f.write(json.dumps(settings.model_dump(exclude_none=True), indent=2)) + protect_file(tmp) + os.replace(tmp, SETTINGS_FILE) diff --git a/ruh-vpn/backend/core/__init__.py b/ruh-vpn/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/core/state.py b/ruh-vpn/backend/core/state.py new file mode 100644 index 0000000..676e4b8 --- /dev/null +++ b/ruh-vpn/backend/core/state.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import asyncio +from collections import deque +from dataclasses import dataclass, field +from typing import Callable, Optional + +from backend.models.server import RoutingRule, Server, Settings, StatusInfo + + +LogEntry = tuple[float, str, str] # (timestamp, level, message) + + +@dataclass +class AppState: + servers: list[Server] = field(default_factory=list) + rules: list[RoutingRule] = field(default_factory=list) + settings: Settings = field(default_factory=Settings) + status: StatusInfo = field(default_factory=StatusInfo) + pids: dict[str, int] = field(default_factory=dict) + logs: deque[LogEntry] = field(default_factory=lambda: deque(maxlen=500)) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + status_listeners: list[Callable[[StatusInfo], None]] = field(default_factory=list) + server_list_listeners: list[Callable[[], None]] = field(default_factory=list) + log_listeners: list[Callable[[str, str], None]] = field(default_factory=list) + + def get_server(self, server_id: str) -> Optional[Server]: + for s in self.servers: + if s.id == server_id: + return s + return None + + def emit_status(self) -> None: + for cb in list(self.status_listeners): + try: + cb(self.status) + except Exception: + pass + + def emit_server_list(self) -> None: + for cb in list(self.server_list_listeners): + try: + cb() + except Exception: + pass + + def emit_log(self, level: str, message: str) -> None: + import time + self.logs.append((time.time(), level, message)) + for cb in list(self.log_listeners): + try: + cb(level, message) + except Exception: + pass diff --git a/ruh-vpn/backend/geoip.py b/ruh-vpn/backend/geoip.py new file mode 100644 index 0000000..875080a --- /dev/null +++ b/ruh-vpn/backend/geoip.py @@ -0,0 +1,82 @@ +"""Resolve a server's country code, so the UI can show a flag. + +Nothing else in the backend knows a server's country: the models simply accept +the extra key. The lookup is best-effort and always optional — a server with no +country just shows no flag, exactly as before. + +Privacy: this asks a third party (api.country.is) "which country is this IP in", +which discloses the address of the user's own VPN server to that service. Hence +the `geoip_country` plugin setting, which service.luau forwards as +RUH_VPN_GEOIP so it can be turned off. The endpoint is HTTPS and returns +only {"ip": ..., "country": ...}; an offline answer isn't possible here — no +GeoIP database is installed (no *.mmdb) and sing-box's .srs rulesets only cover +specific countries (cn/ir). +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import os +import re + +# Forwarded by service.luau from the geoip_country setting. +ENABLED = os.environ.get("RUH_VPN_GEOIP", "1").lower() not in ("0", "false", "no") + +try: + import aiohttp +except ImportError: # pragma: no cover - matches monitoring/health.py's guard + aiohttp = None # type: ignore + +LOOKUP_URL = "https://api.country.is/{ip}" +TIMEOUT_SEC = 6 + +_CC_RE = re.compile(r"^[A-Za-z]{2}$") + + +async def _resolve_ip(host: str) -> str | None: + """Return `host` if it is already an IP, else its first A/AAAA record.""" + try: + ipaddress.ip_address(host) + return host + except ValueError: + pass + try: + loop = asyncio.get_running_loop() + infos = await asyncio.wait_for( + loop.getaddrinfo(host, None), timeout=TIMEOUT_SEC + ) + except (OSError, asyncio.TimeoutError): + return None + return infos[0][4][0] if infos else None + + +async def lookup_country(host: str) -> str | None: + """Best-effort ISO-3166 alpha-2 (lowercase) for `host`. None on any failure. + + Never raises: a missing flag must not be able to fail an AddServer. + """ + if not host or aiohttp is None: + return None + ip = await _resolve_ip(host.strip()) + if not ip: + return None + # A private address has no country, and asking would leak nothing useful. + try: + if not ipaddress.ip_address(ip).is_global: + return None + except ValueError: + return None + try: + timeout = aiohttp.ClientTimeout(total=TIMEOUT_SEC) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(LOOKUP_URL.format(ip=ip)) as resp: + if resp.status != 200: + return None + data = await resp.json(content_type=None) + except Exception: + return None + cc = (data or {}).get("country") if isinstance(data, dict) else None + if isinstance(cc, str) and _CC_RE.match(cc): + return cc.lower() + return None diff --git a/ruh-vpn/backend/http/__init__.py b/ruh-vpn/backend/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/http/control.py b/ruh-vpn/backend/http/control.py new file mode 100644 index 0000000..7fc8d6d --- /dev/null +++ b/ruh-vpn/backend/http/control.py @@ -0,0 +1,224 @@ +"""Localhost HTTP control interface for the VPN backend. + +Replaces the old DBus interface (backend/dbus/dbus_server.py) for the Luau +plugin. The Luau [[service]] entry talks to us two ways: + + * commands → POST http://127.0.0.1:/rpc with body + {"method": "StartProxy", "args": [...]} + reply {"result": ...} or {"error": "..."} + + * events → we print newline-delimited JSON to *stdout*, which the Luau + service consumes via noctalia.runStream(): + {"event": "StatusChanged", "data": {...}} + {"event": "ServerListChanged"} + {"event": "LogMessage", "data": {"level","message"}} + {"event": "TrafficUpdate", "data": {...}} + {"event": "ready", "data": {"port": }} + +Only 127.0.0.1 is bound, and /rpc additionally requires a per-launch bearer +token: loopback alone would let any local user (not just the owner) drive the +VPN and read server credentials. The token is generated at startup and written +to a 0600 file inside the private runtime dir, so only the owning user's +processes — the Luau service among them — can read it. /healthz stays open; it +carries nothing but liveness and the port, and the Luau service probes it +before it knows the token. + +The method map mirrors the DBus contract 1:1. Over JSON, dict arguments arrive +as plain Python dicts, so none of the DBus Variant coercion is needed. +""" + +from __future__ import annotations + +import hmac +import inspect +import json +import os +import sys +from pathlib import Path +from typing import Any, Callable + +from aiohttp import web + +from backend.service.vpn_service import VpnService + +HOST = "127.0.0.1" +DEFAULT_PORT = 11090 + + +def emit(obj: dict) -> None: + """Write one JSON event line to stdout for the Luau service to stream.""" + try: + sys.stdout.write(json.dumps(obj, ensure_ascii=False, default=str) + "\n") + sys.stdout.flush() + except Exception: + pass + + +# ------------------------------------------------------------------ dispatch + +# Each handler receives (svc, args) and returns either a value or an awaitable. +# Names and argument order match backend/dbus/dbus_server.py exactly. +def _build_handlers() -> dict[str, Callable[[VpnService, list], Any]]: + return { + # ---- lifecycle / status ---- + "StartProxy": lambda s, a: s.start_proxy(a[0], a[1], a[2]), + "StopProxy": lambda s, a: s.stop_proxy(), + "GetStatus": lambda s, a: s.get_status(), + "GetHealth": lambda s, a: s.get_health(), + "GetTrafficStats": lambda s, a: s.get_traffic_stats(), + "CheckDnsLeak": lambda s, a: s.check_dns_leak(), + "RunSpeedTest": lambda s, a: s.run_speed_test(), + # ---- servers ---- + "GetServers": lambda s, a: s.list_servers(), + "AddServer": lambda s, a: s.add_server(a[0]), + "UpdateServer": lambda s, a: s.update_server(a[0]), + "RemoveServer": lambda s, a: s.remove_server(a[0]), + "SwitchServer": lambda s, a: s.switch_server(a[0]), + "PingServer": lambda s, a: s.ping(a[0]), + "ParseShareLink": lambda s, a: s.add_from_link(a[0]), + # ---- modes ---- + "SetMode": lambda s, a: s.set_mode(a[0]), + "SetProxyMode": lambda s, a: s.set_proxy_mode(a[0]), + # ---- routing rules ---- + "GetRoutingRules": lambda s, a: s.list_rules(), + "AddRoutingRule": lambda s, a: s.add_rule(a[0]), + "RemoveRoutingRule": lambda s, a: s.remove_rule(a[0]), + "GetPresets": lambda s, a: s.list_presets(), + "TogglePreset": lambda s, a: s.toggle_preset(a[0], a[1]), + # ---- kill switch ---- + "SetKillSwitch": lambda s, a: s.set_kill_switch(a[0]), + "GetKillSwitchStatus": lambda s, a: s.get_kill_switch_status(), + # ---- subscriptions ---- + "AddSubscription": lambda s, a: s.add_subscription(a[0], a[1] if len(a) > 1 else ""), + "RemoveSubscription": lambda s, a: s.remove_subscription(a[0]), + "UpdateSubscription": lambda s, a: s.update_subscription(a[0]), + "GetSubscriptions": lambda s, a: s.list_subscriptions(), + # ---- logs / settings ---- + "GetLogs": lambda s, a: s.get_logs(), + "GetSettings": lambda s, a: s.get_settings(), + "UpdateSettings": lambda s, a: s.update_settings(a[0]), + } + + +class ControlServer: + def __init__( + self, + service: VpnService, + port: int = DEFAULT_PORT, + token: str = "", + token_file: Path | None = None, + ) -> None: + self._svc = service + self._port = port + self._token = token + self._token_file = token_file + self._handlers = _build_handlers() + self._runner: web.AppRunner | None = None + self._wire_events() + + # ------------------------------------------------------------- events + def _wire_events(self) -> None: + svc = self._svc + svc.state.status_listeners.append(self._on_status) + svc.state.server_list_listeners.append(self._on_server_list) + svc.state.log_listeners.append(self._on_log) + svc.add_traffic_listener(self._on_traffic) + + def _on_status(self, status_obj) -> None: + try: + data = status_obj.model_dump(exclude_none=True) + except Exception: + return + emit({"event": "StatusChanged", "data": data}) + + def _on_server_list(self) -> None: + emit({"event": "ServerListChanged"}) + + def _on_log(self, level: str, message: str) -> None: + msg = message if len(message) <= 1024 else message[:1024] + "..." + emit({"event": "LogMessage", "data": {"level": level, "message": msg}}) + + def _on_traffic(self, stats: dict) -> None: + emit({"event": "TrafficUpdate", "data": stats}) + + # ------------------------------------------------------------- http + def _authorized(self, request: web.Request) -> bool: + if not self._token: + return False + header = request.headers.get("Authorization", "") + scheme, _, presented = header.partition(" ") + if scheme.lower() != "bearer": + return False + return hmac.compare_digest(presented.strip(), self._token) + + async def _handle_rpc(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return web.json_response({"error": "unauthorized"}, status=401) + try: + req = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + + method = req.get("method", "") + args = req.get("args") or [] + handler = self._handlers.get(method) + if handler is None: + return web.json_response({"error": f"unknown method: {method}"}, status=404) + + try: + result = handler(self._svc, args) + if inspect.isawaitable(result): + result = await result + except ValueError as exc: + return web.json_response({"error": str(exc) or "invalid argument"}, status=400) + except IndexError: + return web.json_response({"error": f"missing arguments for {method}"}, status=400) + except Exception as exc: # noqa: BLE001 + return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500) + + return web.json_response({"result": result}, dumps=lambda o: json.dumps(o, default=str)) + + async def _handle_health(self, request: web.Request) -> web.Response: + return web.json_response({"ok": True, "port": self._port}) + + def _publish_token(self) -> None: + """Write the token file, readable by the owning user only. + + Must run only after the port is bound: a second backend losing the + EADDRINUSE race exits without ever binding, and writing earlier would + let that loser clobber the live backend's token on its way out.""" + if self._token_file is None: + return + fd = os.open(self._token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(self._token + "\n") + + async def start(self) -> None: + """Bind the control socket. Raises OSError if the port is already taken + (another backend is running) — the caller must NOT fall through to the + destructive shutdown path in that case.""" + app = web.Application() + app.router.add_post("/rpc", self._handle_rpc) + app.router.add_get("/healthz", self._handle_health) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, HOST, self._port) + await site.start() # OSError (EADDRINUSE) propagates to caller + self._publish_token() + emit({"event": "ready", "data": {"port": self._port}}) + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + +async def serve( + service: VpnService, + port: int = DEFAULT_PORT, + token: str = "", + token_file: Path | None = None, +) -> ControlServer: + server = ControlServer(service, port, token=token, token_file=token_file) + await server.start() + return server diff --git a/ruh-vpn/backend/identity.py b/ruh-vpn/backend/identity.py new file mode 100644 index 0000000..49ef19f --- /dev/null +++ b/ruh-vpn/backend/identity.py @@ -0,0 +1,6 @@ +"""Names used to identify processes and files owned by Ruh VPN.""" + +from __future__ import annotations + +PREFIX = "ruh-vpn" +TAG = "RUH_VPN_TAG" diff --git a/ruh-vpn/backend/models/__init__.py b/ruh-vpn/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/models/server.py b/ruh-vpn/backend/models/server.py new file mode 100644 index 0000000..b6a0d3a --- /dev/null +++ b/ruh-vpn/backend/models/server.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import uuid +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class _Base(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12]) + name: str + protocol: str + + +class SSHServer(_Base): + protocol: Literal["ssh"] = "ssh" + host: str + port: int = 22 + user: str + password: Optional[str] = None + keyFile: Optional[str] = None + localPort: int = 11080 + + +class VlessServer(_Base): + protocol: Literal["vless"] = "vless" + address: str + port: int + uuid: str + transport: str = "tcp" + tls: bool = False + sni: Optional[str] = None + security: Optional[Literal["tls", "reality", "none"]] = None + flow: Optional[str] = None + fp: Optional[str] = None + pbk: Optional[str] = None + sid: Optional[str] = None + # for ws/grpc/http transports + path: Optional[str] = None + host: Optional[str] = None + serviceName: Optional[str] = None + + +class VmessServer(_Base): + protocol: Literal["vmess"] = "vmess" + address: str + port: int + uuid: str + alterId: int = 0 + security: str = "auto" + transport: str = "tcp" + tls: bool = False + sni: Optional[str] = None + path: Optional[str] = None + host: Optional[str] = None + + +class ShadowsocksServer(_Base): + protocol: Literal["shadowsocks"] = "shadowsocks" + address: str + port: int + method: str + password: str + + +class Socks5Server(_Base): + protocol: Literal["socks5"] = "socks5" + host: str + port: int + username: Optional[str] = None + password: Optional[str] = None + + +Server = Annotated[ + Union[SSHServer, VlessServer, VmessServer, ShadowsocksServer, Socks5Server], + Field(discriminator="protocol"), +] + + +def parse_server(data: dict) -> Server: + """Parse a dict into one of the typed server models based on the protocol field.""" + protocol = (data.get("protocol") or "").lower() + mapping = { + "ssh": SSHServer, + "vless": VlessServer, + "vmess": VmessServer, + "shadowsocks": ShadowsocksServer, + "ss": ShadowsocksServer, + "socks": Socks5Server, + "socks5": Socks5Server, + } + cls = mapping.get(protocol) + if cls is None: + raise ValueError(f"Unsupported protocol: {protocol!r}") + data = dict(data) + if protocol == "ss": + data["protocol"] = "shadowsocks" + if protocol == "socks": + data["protocol"] = "socks5" + return cls.model_validate(data) + + +def server_to_dict(server: BaseModel) -> dict: + return server.model_dump(exclude_none=True) + + +# Connection secrets never leave the backend: list RPCs strip them, and +# UpdateServer treats an empty value as "keep the stored one". +SENSITIVE_FIELDS = ("password", "uuid") + + +def server_to_public_dict(server: BaseModel) -> dict: + data = server.model_dump(exclude_none=True) + for key in SENSITIVE_FIELDS: + data.pop(key, None) + return data + + +class RoutingRule(BaseModel): + """User-defined routing rule. + + type: force-proxy → match → proxy outbound + direct → match → direct outbound + block → match → block outbound + pattern: one of + - "example.com" exact domain + - "*.example.com" domain suffix (matches example.com + subdomains) + - "10.0.0.0/8" CIDR (v4 or v6) + """ + + model_config = ConfigDict(extra="allow") + + id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12]) + name: Optional[str] = None + enabled: bool = True + type: Literal["force-proxy", "direct", "block"] = "force-proxy" + pattern: str + + def to_singbox_rule(self) -> Optional[dict]: + if not self.enabled or not self.pattern: + return None + rule: dict = {} + if self.type == "block": + rule["action"] = "reject" + else: + rule["outbound"] = "proxy" if self.type == "force-proxy" else "direct" + pat = self.pattern.strip() + if "/" in pat and not pat.startswith("*"): + rule["ip_cidr"] = [pat] + elif pat.startswith("*."): + rule["domain_suffix"] = [pat[2:]] + elif pat.startswith("*"): + rule["domain_keyword"] = [pat.lstrip("*")] + else: + rule["domain"] = [pat] + return rule + + +class Settings(BaseModel): + model_config = ConfigDict(extra="allow") + + activeServerId: Optional[str] = None + mode: Literal["rules", "global"] = "rules" + proxyMode: Literal["system", "tun"] = "system" + autoStart: bool = False + rulesPort: int = 11081 + globalPort: int = 11082 + transportPort: int = 11080 + refilterEnabled: bool = True + healthCheckIntervalSec: int = 30 + killSwitchEnabled: bool = False + clashApiPort: int = 11089 + showPingInBar: bool = True + showTrafficInBar: bool = False + activePresets: list[str] = Field(default_factory=lambda: ["ru"]) + + +class StatusInfo(BaseModel): + model_config = ConfigDict(extra="allow") + + running: bool = False + activeServerId: Optional[str] = None + mode: str = "rules" + proxyMode: str = "system" + transportPort: int = 11080 + muxPort: Optional[int] = None + pids: dict[str, int] = Field(default_factory=dict) + message: Optional[str] = None + status: str = "ok" # ok | degraded | failed | error + reason: Optional[str] = None diff --git a/ruh-vpn/backend/monitoring/__init__.py b/ruh-vpn/backend/monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/monitoring/health.py b/ruh-vpn/backend/monitoring/health.py new file mode 100644 index 0000000..045504b --- /dev/null +++ b/ruh-vpn/backend/monitoring/health.py @@ -0,0 +1,331 @@ +"""Health monitoring: periodic TCP ping + traffic stats helpers.""" + +from __future__ import annotations + +import asyncio +import socket +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Awaitable, Callable, Optional + +try: + import aiohttp +except ImportError: # pragma: no cover + aiohttp = None # type: ignore[assignment] + +try: + from aiohttp_socks import ProxyConnector +except ImportError: # pragma: no cover + ProxyConnector = None # type: ignore[assignment] + +SPEED_DOWN_URL = "https://speed.cloudflare.com/__down?bytes=10000000" +SPEED_UP_URL = "https://speed.cloudflare.com/__up" +SPEED_UP_BYTES = 4_000_000 +SPEED_TIMEOUT = 20.0 + + +def _connector_for(proxy_url: Optional[str]): + """Return an aiohttp connector. SOCKS5 proxy if given, else default.""" + if not proxy_url: + return None + if ProxyConnector is None: + return None + try: + return ProxyConnector.from_url(proxy_url) + except Exception: + return None + + +async def tcp_ping(host: str, port: int, timeout: float = 5.0) -> Optional[int]: + """Open a TCP connection and return latency in ms, or None on failure.""" + loop = asyncio.get_running_loop() + start = loop.time() + try: + fut = asyncio.open_connection(host, port) + _, writer = await asyncio.wait_for(fut, timeout=timeout) + latency_ms = int((loop.time() - start) * 1000) + writer.close() + try: + await writer.wait_closed() + except (ConnectionError, OSError): + pass + return latency_ms + except (OSError, asyncio.TimeoutError): + return None + + +async def tcp_ping_samples( + host: str, + port: int, + count: int = 4, + timeout: float = 3.0, + gap: float = 0.15, +) -> list[int]: + samples: list[int] = [] + for i in range(count): + ms = await tcp_ping(host, port, timeout=timeout) + if ms is not None: + samples.append(ms) + if i < count - 1: + await asyncio.sleep(gap) + return samples + + +def compute_jitter(samples: list[int]) -> int: + if len(samples) < 2: + return 0 + diffs = [abs(samples[i] - samples[i - 1]) for i in range(1, len(samples))] + return int(round(sum(diffs) / len(diffs))) + + +async def measure_download_mbps( + url: str = SPEED_DOWN_URL, + timeout: float = SPEED_TIMEOUT, + proxy_url: Optional[str] = None, +) -> Optional[float]: + """Fetch URL and return throughput in Mbps (megabits/second). + + If `proxy_url` is given (e.g. "socks5://127.0.0.1:11081"), traffic is + routed through that SOCKS proxy so the measurement reflects the tunnel. + """ + if aiohttp is None: + return None + timeout_cfg = aiohttp.ClientTimeout(total=timeout, sock_connect=5.0) + loop = asyncio.get_running_loop() + connector = _connector_for(proxy_url) + try: + async with aiohttp.ClientSession( + timeout=timeout_cfg, connector=connector + ) as session: + async with session.get(url) as resp: + if resp.status != 200: + return None + start = loop.time() + total = 0 + async for chunk in resp.content.iter_chunked(65536): + total += len(chunk) + elapsed = max(loop.time() - start, 1e-6) + if total <= 0: + return None + return round((total * 8.0) / elapsed / 1_000_000.0, 1) + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return None + + +async def measure_upload_mbps( + url: str = SPEED_UP_URL, + size_bytes: int = SPEED_UP_BYTES, + timeout: float = SPEED_TIMEOUT, + proxy_url: Optional[str] = None, +) -> Optional[float]: + if aiohttp is None: + return None + payload = b"\0" * size_bytes + timeout_cfg = aiohttp.ClientTimeout(total=timeout, sock_connect=5.0) + loop = asyncio.get_running_loop() + connector = _connector_for(proxy_url) + try: + async with aiohttp.ClientSession( + timeout=timeout_cfg, connector=connector + ) as session: + start = loop.time() + async with session.post(url, data=payload) as resp: + # Read body to ensure full round-trip + await resp.read() + if resp.status >= 400: + return None + elapsed = max(loop.time() - start, 1e-6) + return round((size_bytes * 8.0) / elapsed / 1_000_000.0, 1) + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return None + + +async def resolve_host(host: str) -> Optional[str]: + loop = asyncio.get_running_loop() + try: + info = await loop.getaddrinfo(host, None, type=socket.SOCK_STREAM) + for _, _, _, _, sockaddr in info: + return sockaddr[0] + except (socket.gaierror, OSError): + return None + return None + + +def read_resolv_conf_nameservers(path: str = "/etc/resolv.conf") -> list[str]: + out: list[str] = [] + try: + with open(path, "r") as f: + for line in f: + line = line.strip() + if line.startswith("nameserver"): + parts = line.split() + if len(parts) >= 2: + out.append(parts[1]) + except OSError: + pass + return out + + +def check_dns_leak(running: bool, proxy_mode: str, mode: str) -> dict: + """Best-effort DNS leak check. + + 'leaking' is True when the proxy is up but system DNS is going to a + nameserver that won't be routed through the proxy. + + Heuristic: + - TUN mode → all UDP/53 hits sing-box → NOT leaking (regardless of resolv.conf). + - System+global → only HTTP/SOCKS goes through proxy; DNS to /etc/resolv.conf + servers goes direct over the system → leaking. + - System+rules → same as global from a DNS-leak standpoint → leaking. + - Proxy not running → not running, "leaking" reported as N/A (false). + """ + nameservers = read_resolv_conf_nameservers() + if not running: + return {"leaking": False, "dns_servers": nameservers, "reason": "proxy not running"} + if proxy_mode == "tun": + return {"leaking": False, "dns_servers": nameservers, "reason": "TUN intercepts all DNS"} + leaking = any(not (ns.startswith("127.") or ns == "::1") for ns in nameservers) + return { + "leaking": bool(leaking), + "dns_servers": nameservers, + "reason": ( + "system DNS bypasses the SOCKS proxy in system-proxy mode" + if leaking + else "all configured nameservers are local" + ), + } + + +@dataclass +class HealthState: + latency_ms: int = -1 + jitter_ms: int = -1 + down_mbps: float = -1.0 + up_mbps: float = -1.0 + speed_taken_at: float = 0.0 # epoch seconds; 0 = never + last_check: float = 0.0 # epoch seconds; 0 = never + consecutive_failures: int = 0 + status: str = "ok" # ok | degraded | failed + + def to_dict(self) -> dict: + if self.last_check: + last_iso = datetime.fromtimestamp(self.last_check, tz=timezone.utc).isoformat() + else: + last_iso = "" + if self.speed_taken_at: + speed_iso = datetime.fromtimestamp( + self.speed_taken_at, tz=timezone.utc + ).isoformat() + else: + speed_iso = "" + return { + "latency_ms": int(self.latency_ms), + "jitter_ms": int(self.jitter_ms), + "down_mbps": float(self.down_mbps), + "up_mbps": float(self.up_mbps), + "speed_taken_at": speed_iso, + "last_check": last_iso, + "consecutive_failures": int(self.consecutive_failures), + "status": self.status, + } + + +class HealthMonitor: + """Periodic TCP ping to the transport server. + + - One ping every `interval` seconds (default 30). + - 1 failure → degraded; 3 consecutive → failed + on_failed callback fires once. + - First successful ping after failure resets status to ok. + """ + + FAIL_THRESHOLD = 3 + + def __init__( + self, + host: str, + port: int, + interval: float = 30.0, + timeout: float = 5.0, + on_failed: Optional[Callable[[], Awaitable[None]]] = None, + ) -> None: + self.host = host + self.port = port + self.interval = interval + self.timeout = timeout + self._on_failed = on_failed + self._task: Optional[asyncio.Task] = None + self.state = HealthState() + self._failed_emitted = False + self._speed_task: Optional[asyncio.Task] = None + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._failed_emitted = False + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def check_now(self) -> int: + samples = await tcp_ping_samples( + self.host, self.port, count=4, timeout=self.timeout, gap=0.1 + ) + self.state.last_check = time.time() + if not samples: + self.state.latency_ms = -1 + self.state.jitter_ms = -1 + self.state.consecutive_failures += 1 + if self.state.consecutive_failures >= self.FAIL_THRESHOLD: + self.state.status = "failed" + if not self._failed_emitted and self._on_failed: + self._failed_emitted = True + try: + await self._on_failed() + except Exception: + pass + else: + self.state.status = "degraded" + return -1 + latency = int(round(sum(samples) / len(samples))) + self.state.latency_ms = latency + self.state.jitter_ms = compute_jitter(samples) + self.state.consecutive_failures = 0 + self.state.status = "ok" + self._failed_emitted = False + return latency + + async def run_speed_test(self, proxy_url: Optional[str] = None) -> dict: + """Measure download + upload throughput. Updates state in-place. + + When `proxy_url` is provided, traffic is routed through that proxy. + """ + down = await measure_download_mbps(proxy_url=proxy_url) + up = await measure_upload_mbps(proxy_url=proxy_url) + self.state.down_mbps = down if down is not None else -1.0 + self.state.up_mbps = up if up is not None else -1.0 + self.state.speed_taken_at = time.time() + return { + "down_mbps": self.state.down_mbps, + "up_mbps": self.state.up_mbps, + "ping_ms": int(self.state.latency_ms), + "jitter_ms": int(self.state.jitter_ms), + } + + async def _loop(self) -> None: + try: + # First check immediately so GetHealth has real data quickly. + await self.check_now() + while True: + await asyncio.sleep(self.interval) + await self.check_now() + except asyncio.CancelledError: + return diff --git a/ruh-vpn/backend/monitoring/log_streamer.py b/ruh-vpn/backend/monitoring/log_streamer.py new file mode 100644 index 0000000..9cbfba5 --- /dev/null +++ b/ruh-vpn/backend/monitoring/log_streamer.py @@ -0,0 +1,145 @@ +"""Tail sing-box log files and forward each new line to a callback. + +Each source is polled at a small interval; new bytes are split into complete +lines (partial trailing data is buffered). ANSI escape codes are stripped and +the line's log level is extracted when present (INFO/WARN/WARNING/ERROR/FATAL/ +DEBUG/TRACE), defaulting to "info". +""" + +from __future__ import annotations + +import asyncio +import os +import re +from pathlib import Path +from typing import Awaitable, Callable, Optional + +ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]") +LEVEL_RE = re.compile( + r"\b(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\b", + re.IGNORECASE, +) + +LineCallback = Callable[[str, str, str], Awaitable[None]] +# (source_tag, level, message) + + +class _Source: + def __init__(self, tag: str, path: Path) -> None: + self.tag = tag + self.path = path + self.fd: Optional[int] = None + self.buf = b"" + + def open(self) -> None: + if self.fd is not None: + return + try: + fd = os.open(str(self.path), os.O_RDONLY | os.O_NONBLOCK) + except FileNotFoundError: + return + # seek to end so we don't emit historical content + try: + os.lseek(fd, 0, os.SEEK_END) + except OSError: + pass + self.fd = fd + + def close(self) -> None: + if self.fd is not None: + try: + os.close(self.fd) + except OSError: + pass + self.fd = None + self.buf = b"" + + def read_lines(self) -> list[bytes]: + if self.fd is None: + self.open() + if self.fd is None: + return [] + try: + chunk = os.read(self.fd, 65536) + except BlockingIOError: + return [] + except OSError: + return [] + if not chunk: + return [] + self.buf += chunk + out: list[bytes] = [] + while True: + nl = self.buf.find(b"\n") + if nl < 0: + break + out.append(self.buf[:nl]) + self.buf = self.buf[nl + 1:] + return out + + +def parse_level(line: str) -> str: + m = LEVEL_RE.search(line) + if not m: + return "info" + lvl = m.group(1).lower() + if lvl == "warning": + return "warn" + return lvl + + +class LogStreamer: + def __init__(self, callback: LineCallback, poll_interval: float = 0.5) -> None: + self._cb = callback + self._interval = poll_interval + self._sources: dict[str, _Source] = {} + self._task: Optional[asyncio.Task] = None + + def add_source(self, tag: str, path: Path | str) -> None: + p = Path(path) + if tag in self._sources: + return + self._sources[tag] = _Source(tag, p) + + def remove_source(self, tag: str) -> None: + src = self._sources.pop(tag, None) + if src: + src.close() + + def clear(self) -> None: + for src in list(self._sources.values()): + src.close() + self._sources.clear() + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self.clear() + + async def _loop(self) -> None: + try: + while True: + await asyncio.sleep(self._interval) + for src in list(self._sources.values()): + for raw in src.read_lines(): + clean = ANSI_RE.sub(b"", raw).decode("utf-8", errors="replace").rstrip() + if not clean: + continue + lvl = parse_level(clean) + try: + await self._cb(src.tag, lvl, clean) + except Exception: + pass + except asyncio.CancelledError: + return diff --git a/ruh-vpn/backend/monitoring/traffic.py b/ruh-vpn/backend/monitoring/traffic.py new file mode 100644 index 0000000..6c5d4e8 --- /dev/null +++ b/ruh-vpn/backend/monitoring/traffic.py @@ -0,0 +1,95 @@ +"""Poll sing-box clash_api /connections endpoint for traffic stats.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Optional + +import aiohttp + + +@dataclass +class TrafficStats: + bytes_sent: int = 0 + bytes_received: int = 0 + connection_count: int = 0 + started_at: float = field(default_factory=time.time) + + def uptime(self) -> int: + return max(0, int(time.time() - self.started_at)) + + def to_dict(self) -> dict: + return { + "bytes_sent": int(self.bytes_sent), + "bytes_received": int(self.bytes_received), + "uptime_seconds": self.uptime(), + "connection_count": int(self.connection_count), + } + + +class TrafficMonitor: + """Polls /connections every `interval` seconds and tracks running totals. + + Notes on the underlying API: + sing-box clash_api /connections returns: + {"downloadTotal": int, "uploadTotal": int, "connections": [...]} + downloadTotal and uploadTotal are *since-mux-started* counters, so we + can return them directly as bytes_received / bytes_sent. + """ + + def __init__( + self, + api_url: str, + interval: float = 5.0, + on_update: Optional[Callable[[dict], Awaitable[None]]] = None, + ) -> None: + self.api_url = api_url.rstrip("/") + self.interval = interval + self._on_update = on_update + self.stats = TrafficStats() + self._task: Optional[asyncio.Task] = None + + def start(self) -> None: + self.stats = TrafficStats() + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _poll_once(self) -> None: + timeout = aiohttp.ClientTimeout(total=2.0) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(f"{self.api_url}/connections") as resp: + if resp.status != 200: + return + data = await resp.json(content_type=None) + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return + self.stats.bytes_sent = int(data.get("uploadTotal") or 0) + self.stats.bytes_received = int(data.get("downloadTotal") or 0) + self.stats.connection_count = len(data.get("connections") or []) + + async def _loop(self) -> None: + try: + while True: + await self._poll_once() + if self._on_update: + try: + await self._on_update(self.stats.to_dict()) + except Exception: + pass + await asyncio.sleep(self.interval) + except asyncio.CancelledError: + return diff --git a/ruh-vpn/backend/paths.py b/ruh-vpn/backend/paths.py new file mode 100644 index 0000000..11b6907 --- /dev/null +++ b/ruh-vpn/backend/paths.py @@ -0,0 +1,25 @@ +"""Filesystem locations supplied by the Noctalia service entry.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def _env_path(name: str, fallback: str) -> Path: + return Path(os.path.expanduser(os.environ.get(name, fallback))) + + +DATA_DIR = _env_path("RUH_VPN_DATA_DIR", "~/.local/share/ruh-vpn") +RUNTIME_DIR = _env_path("RUH_VPN_RUNTIME_DIR", str(DATA_DIR / "runtime")) +SINGBOX_DIR = DATA_DIR / "sing-box" + + +def ensure_private_dir(path: Path) -> None: + path.mkdir(mode=0o700, parents=True, exist_ok=True) + path.chmod(0o700) + + +def protect_file(path: Path) -> None: + path.chmod(0o600) + diff --git a/ruh-vpn/backend/routing/__init__.py b/ruh-vpn/backend/routing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/routing/rules.py b/ruh-vpn/backend/routing/rules.py new file mode 100644 index 0000000..f01e8eb --- /dev/null +++ b/ruh-vpn/backend/routing/rules.py @@ -0,0 +1,227 @@ +"""Helpers for translating user routing rules into sing-box rule entries.""" + +from __future__ import annotations + +import ipaddress +import re +from typing import Any + +from backend.models.server import RoutingRule + + +# Country / region routing presets. Each one adds a pair of rule_set entries +# (domains + IPs) and a single route rule that sends matches to the proxy. +# Tags must be unique across active presets so sing-box doesn't reject the +# config — the keys below were picked to avoid collisions. +PRESETS: dict[str, dict[str, Any]] = { + "ru": { + "key": "ru", + "name": "Russia", + "flag": "🇷🇺", + "description": "Re-filter list — sites and IPs blocked in Russia", + "rule_sets": [ + { + "tag": "refilter_domains", + "type": "remote", + "format": "binary", + "url": "https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-domain-refilter_domains.srs", + "download_detour": "direct", + }, + { + "tag": "refilter_ipsum", + "type": "remote", + "format": "binary", + "url": "https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-ip-refilter_ipsum.srs", + "download_detour": "direct", + }, + ], + }, + # There is no "blocked in China" list: the GFW blocks foreign services, so + # the standard bypass is the inverse — proxy everything geolocated OUTSIDE + # China and let domestic traffic go direct. sing-geosite publishes its .srs + # files on the `rule-set` branch, not as release assets. + "cn": { + "key": "cn", + "name": "China", + "flag": "🇨🇳", + "description": "GFW bypass — foreign (non-Chinese) sites via VPN", + "rule_sets": [ + { + "tag": "geosite_noncn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs", + "download_detour": "direct", + }, + ], + }, + # geosite-sanctioned covers sites unavailable from Iran (state blocks and + # foreign sanctions). geosite-ir would be the opposite — Iranian domestic + # sites, which need no proxy. Same .srs-on-branch layout as sing-geosite. + "ir": { + "key": "ir", + "name": "Iran", + "flag": "🇮🇷", + "description": "Sites unavailable from Iran (blocks and sanctions) via VPN", + "rule_sets": [ + { + "tag": "geosite_sanctioned", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-sanctioned.srs", + "download_detour": "direct", + }, + ], + }, +} + + +def preset_rule_sets(active: list[str]) -> list[dict]: + """Return rule_set entries for the given active preset keys, deduped by tag.""" + seen: set[str] = set() + out: list[dict] = [] + for key in active: + preset = PRESETS.get(key) + if not preset: + continue + for rs in preset["rule_sets"]: + if rs["tag"] in seen: + continue + seen.add(rs["tag"]) + out.append(dict(rs)) + return out + + +def preset_route_rules(active: list[str]) -> list[dict]: + """One route.rules entry per active preset routing its tags to 'proxy'.""" + out: list[dict] = [] + for key in active: + preset = PRESETS.get(key) + if not preset: + continue + tags = [rs["tag"] for rs in preset["rule_sets"]] + if tags: + out.append({"rule_set": tags, "outbound": "proxy"}) + return out + + +def preset_domain_tags(active: list[str]) -> list[str]: + """Tags of rule_sets that match domains (used for proxy-DNS rule). + + Heuristic: any tag containing 'domain' or 'site' is treated as domain-only. + IPs don't help the DNS layer, so we skip them here. + """ + tags: list[str] = [] + for key in active: + preset = PRESETS.get(key) + if not preset: + continue + for rs in preset["rule_sets"]: + t = rs["tag"] + tl = t.lower() + if "domain" in tl or "site" in tl: + tags.append(t) + return tags + +# A hostname label: 1–63 chars, alphanumerics + hyphens (not at edges). +# `*` is allowed as the leftmost label so wildcards like *.example.com work. +_LABEL_RE = re.compile(r"^(?:\*|[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)$") + + +def normalize_pattern(pattern: str) -> str: + """Clean a user-supplied pattern. + + - Strips http:// and https:// prefixes (extracts hostname). + - Strips any path / query / fragment from a URL-like input. + - Strips trailing slashes and surrounding whitespace. + Domain and CIDR forms pass through unchanged (case-folded for domains). + """ + p = (pattern or "").strip() + if not p: + return "" + low = p.lower() + if low.startswith("http://"): + p = p[len("http://"):] + elif low.startswith("https://"): + p = p[len("https://"):] + # Cut anything after the host: path, query, fragment. + for sep in ("/", "?", "#"): + # Don't cut the slash in CIDRs (digits on the right of '/'). + if sep == "/" and "/" in p: + host, _, tail = p.partition("/") + if tail and tail[0].isdigit() and host and (host[0].isdigit() or ":" in host): + # Looks like a CIDR — keep as-is. + continue + p = host + elif sep in p: + p = p.split(sep, 1)[0] + # Strip credentials and port (e.g. user:pass@host:443). + if "@" in p: + p = p.split("@", 1)[1] + # Port: only strip when it's not part of an IPv6 literal. + if p.count(":") == 1 and not p.startswith("["): + p = p.split(":", 1)[0] + return p.rstrip(".").lower() + + +def validate_pattern(pattern: str) -> str: + """Validate and return the normalized pattern. + + Raises ValueError when the pattern is not a recognized + domain / wildcard domain / CIDR form. + """ + p = normalize_pattern(pattern) + if not p: + raise ValueError("pattern is empty") + + # CIDR + if "/" in p and not p.startswith("*"): + try: + ipaddress.ip_network(p, strict=False) + except ValueError as exc: + raise ValueError(f"invalid CIDR: {p!r} ({exc})") from exc + return p + + # Bare IP without prefix is not allowed here (CIDR only) + try: + ipaddress.ip_address(p) + raise ValueError(f"{p!r} is a bare IP; use CIDR (e.g. {p}/32)") + except ValueError: + pass + + # Wildcard or domain + labels = p.split(".") + if not labels or any(not _LABEL_RE.match(label) for label in labels): + raise ValueError(f"invalid domain pattern: {p!r}") + # `*` may only appear as the leftmost label. + if any(label == "*" for label in labels[1:]): + raise ValueError(f"wildcard '*' only allowed as leftmost label: {p!r}") + return p + + +def classify_pattern(pattern: str) -> str: + """Return one of: 'cidr', 'wildcard', 'keyword', 'domain'.""" + p = pattern.strip() + if not p: + return "domain" + if "/" in p and not p.startswith("*"): + return "cidr" + if p.startswith("*."): + return "wildcard" + if p.startswith("*") or p.endswith("*"): + return "keyword" + return "domain" + + +def rules_to_singbox(rules: list[RoutingRule]) -> list[dict]: + """Convert a list of user rules into sing-box route.rules entries. + + Skips disabled rules and rules with no pattern. + Result preserves input order (first match wins in sing-box). + """ + out: list[dict] = [] + for r in rules: + sr = r.to_singbox_rule() + if sr is not None: + out.append(sr) + return out diff --git a/ruh-vpn/backend/service/__init__.py b/ruh-vpn/backend/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/service/kill_switch.py b/ruh-vpn/backend/service/kill_switch.py new file mode 100644 index 0000000..6f684da --- /dev/null +++ b/ruh-vpn/backend/service/kill_switch.py @@ -0,0 +1,151 @@ +"""nftables-backed kill switch. + +Generates a self-contained inet table that drops all traffic except: + - loopback + - established / related connections + - the noctalia-tun0 device (when present) + - the proxy mux/transport ports on 127.0.0.1 (already covered by loopback) + - explicit allowances for the active VPN server's host:port (so sing-box + can dial out to it after the rules are installed) + +The table is named "noctalia_killswitch" so removal/replacement is cheap and +does not touch anyone else's nftables config. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import shutil +import subprocess +from typing import Optional + +TABLE_NAME = "noctalia_killswitch" +NFT_BIN = "/usr/sbin/nft" + + +def _nft_path() -> str: + return shutil.which("nft") or NFT_BIN + + +def build_ruleset( + server_ips: Optional[list[str]], + server_port: Optional[int], + tun_iface: str = "noctalia-tun0", + extra_allow_tcp: Optional[list[int]] = None, +) -> str: + """Build the nft ruleset text. + + server_ips must be literal IP addresses (the caller resolves domain names + beforehand). Every value is re-parsed through the ipaddress module and + re-emitted in canonical form; anything that does not parse is dropped, so + an untrusted server entry can never inject nft syntax into the ruleset, + which runs with root privileges. + """ + port = int(server_port) if server_port else None + tcp_ports = [int(p) for p in (extra_allow_tcp or [])] + server_lines = "" + for raw in server_ips or []: + try: + ip = ipaddress.ip_address(str(raw).strip()) + except ValueError: + continue + keyword = "ip6" if ip.version == 6 else "ip" + if port: + server_lines += f" {keyword} daddr {ip} tcp dport {port} accept\n" + else: + server_lines += f" {keyword} daddr {ip} accept\n" + + tcp_port_line = "" + if tcp_ports: + ports = "{ " + ", ".join(str(p) for p in tcp_ports) + " }" + tcp_port_line = f" tcp dport {ports} accept\n" + + return ( + f"table inet {TABLE_NAME} {{\n" + f" chain output {{\n" + f" type filter hook output priority filter; policy drop;\n" + f" oif \"lo\" accept\n" + f" ct state established,related accept\n" + f" oifname \"{tun_iface}\" accept\n" + f" udp dport 53 accept\n" + f" ip daddr 192.168.0.0/16 accept\n" + f" ip daddr 10.0.0.0/8 accept\n" + f" ip daddr 172.16.0.0/12 accept\n" + f"{server_lines}{tcp_port_line}" + f" }}\n" + f" chain input {{\n" + f" type filter hook input priority filter; policy drop;\n" + f" iif \"lo\" accept\n" + f" ct state established,related accept\n" + f" iifname \"{tun_iface}\" accept\n" + f" }}\n" + f"}}\n" + ) + + +async def _run_nft(args: list[str], input_text: Optional[str] = None) -> tuple[int, str]: + nft = _nft_path() + proc = await asyncio.create_subprocess_exec( + nft, + *args, + stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + out, _ = await proc.communicate(input_text.encode() if input_text else None) + return proc.returncode or 0, out.decode("utf-8", errors="replace") + + +async def _run_nft_via_pkexec(args: list[str], input_text: Optional[str] = None) -> tuple[int, str]: + if shutil.which("pkexec") is None: + return 1, "pkexec not available" + nft = _nft_path() + cmd = ["pkexec", nft, *args] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + out, _ = await proc.communicate(input_text.encode() if input_text else None) + return proc.returncode or 0, out.decode("utf-8", errors="replace") + + +async def apply(ruleset: str) -> tuple[bool, str]: + """Install (or replace) the kill switch ruleset. Returns (ok, message).""" + # remove any prior version atomically before re-adding (idempotent) + purge_cmd = f"delete table inet {TABLE_NAME}\n" + ruleset + rc, out = await _run_nft(["-f", "-"], input_text=purge_cmd) + if rc == 0: + return True, "applied" + # try just the add (no prior table) + rc2, out2 = await _run_nft(["-f", "-"], input_text=ruleset) + if rc2 == 0: + return True, "applied" + # fall back to pkexec + rc3, out3 = await _run_nft_via_pkexec(["-f", "-"], input_text=purge_cmd) + if rc3 == 0: + return True, "applied via pkexec" + rc4, out4 = await _run_nft_via_pkexec(["-f", "-"], input_text=ruleset) + if rc4 == 0: + return True, "applied via pkexec" + return False, f"nft failed: {out2 or out}; pkexec: {out4 or out3}" + + +async def remove() -> tuple[bool, str]: + rc, out = await _run_nft(["delete", "table", "inet", TABLE_NAME]) + if rc == 0: + return True, "removed" + rc2, out2 = await _run_nft_via_pkexec(["delete", "table", "inet", TABLE_NAME]) + if rc2 == 0: + return True, "removed via pkexec" + # if the table doesn't exist, treat as success + if "No such file or directory" in (out + out2) or "does not exist" in (out + out2): + return True, "no table to remove" + return False, f"nft failed: {out}; pkexec: {out2}" + + +async def is_active() -> bool: + rc, out = await _run_nft(["list", "table", "inet", TABLE_NAME]) + return rc == 0 diff --git a/ruh-vpn/backend/service/tun_binary.py b/ruh-vpn/backend/service/tun_binary.py new file mode 100644 index 0000000..5045394 --- /dev/null +++ b/ruh-vpn/backend/service/tun_binary.py @@ -0,0 +1,48 @@ +"""Private copy of the sing-box binary used only for TUN mode. + +CAP_NET_ADMIN is granted to a plugin-private copy under DATA_DIR/bin (a 0700 +directory) instead of the shared system binary, so the privilege never +extends to other users or to sing-box invocations outside this plugin. + +When the system binary changes, the copy is rewritten from scratch; a fresh +file starts with no capabilities, so a stale copy never keeps the grant +across sing-box upgrades. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from backend.paths import DATA_DIR, ensure_private_dir + +BIN_DIR = DATA_DIR / "bin" +TUN_BIN = BIN_DIR / "sing-box-tun" + + +def source_binary(singbox_bin: str) -> str: + # setcap/getcap act on the real file, not a symlink (NixOS wraps binaries + # in store symlinks, and setcap on the link fails). + return os.path.realpath(singbox_bin) + + +def ensure_copy(singbox_bin: str) -> tuple[str, bool]: + """Make sure the private copy exists and matches the system binary. + + Returns (path to the copy, True if the copy was (re)created). Callers must + treat a recreated copy as having no capabilities. + """ + src = Path(source_binary(singbox_bin)) + ensure_private_dir(BIN_DIR) + st_src = src.stat() + if TUN_BIN.exists(): + st_dst = TUN_BIN.stat() + if st_dst.st_size == st_src.st_size and st_dst.st_mtime == st_src.st_mtime: + return str(TUN_BIN), False + # copy2 preserves mtime, which the staleness check above relies on + tmp = TUN_BIN.with_name(TUN_BIN.name + ".tmp") + shutil.copy2(src, tmp) + tmp.chmod(0o700) + os.replace(tmp, TUN_BIN) + return str(TUN_BIN), True diff --git a/ruh-vpn/backend/service/vpn_service.py b/ruh-vpn/backend/service/vpn_service.py new file mode 100644 index 0000000..7710a58 --- /dev/null +++ b/ruh-vpn/backend/service/vpn_service.py @@ -0,0 +1,1016 @@ +"""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") diff --git a/ruh-vpn/backend/singbox/__init__.py b/ruh-vpn/backend/singbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/singbox/config_builder.py b/ruh-vpn/backend/singbox/config_builder.py new file mode 100644 index 0000000..10342ed --- /dev/null +++ b/ruh-vpn/backend/singbox/config_builder.py @@ -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 -{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, + }, + } diff --git a/ruh-vpn/backend/singbox/process_manager.py b/ruh-vpn/backend/singbox/process_manager.py new file mode 100644 index 0000000..5661932 --- /dev/null +++ b/ruh-vpn/backend/singbox/process_manager.py @@ -0,0 +1,309 @@ +"""Async start/stop/monitor of sing-box and ssh transport processes. + +All managed processes are tagged via either: +- ssh: =1 environment variable +- sing-box: filename pattern -*.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 diff --git a/ruh-vpn/backend/singbox/transport.py b/ruh-vpn/backend/singbox/transport.py new file mode 100644 index 0000000..fb4ea64 --- /dev/null +++ b/ruh-vpn/backend/singbox/transport.py @@ -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 diff --git a/ruh-vpn/backend/storage/__init__.py b/ruh-vpn/backend/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/storage/persistence.py b/ruh-vpn/backend/storage/persistence.py new file mode 100644 index 0000000..b00cd56 --- /dev/null +++ b/ruh-vpn/backend/storage/persistence.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +import os + +import aiofiles + +from backend.models.server import RoutingRule, Server, parse_server, server_to_dict +from backend.paths import DATA_DIR, ensure_private_dir, protect_file + +SERVERS_FILE = DATA_DIR / "servers.json" +RULES_FILE = DATA_DIR / "rules.json" + + +def ensure_dirs() -> None: + ensure_private_dir(DATA_DIR) + + +async def load_servers() -> list[Server]: + ensure_dirs() + if not SERVERS_FILE.exists(): + return [] + try: + async with aiofiles.open(SERVERS_FILE, "r") as f: + raw = await f.read() + data = json.loads(raw or "[]") + except (json.JSONDecodeError, ValueError): + return [] + servers: list[Server] = [] + for entry in data: + try: + servers.append(parse_server(entry)) + except (ValueError, KeyError): + continue + return servers + + +async def save_servers(servers: list) -> None: + ensure_dirs() + data = [server_to_dict(s) for s in servers] + tmp = SERVERS_FILE.with_suffix(".json.tmp") + async with aiofiles.open(tmp, "w") as f: + await f.write(json.dumps(data, indent=2)) + protect_file(tmp) + os.replace(tmp, SERVERS_FILE) + + +async def load_rules() -> list[RoutingRule]: + ensure_dirs() + if not RULES_FILE.exists(): + return [] + try: + async with aiofiles.open(RULES_FILE, "r") as f: + raw = await f.read() + data = json.loads(raw or "[]") + except (json.JSONDecodeError, ValueError): + return [] + rules: list[RoutingRule] = [] + for entry in data: + try: + rules.append(RoutingRule.model_validate(entry)) + except ValueError: + continue + return rules + + +async def save_rules(rules: list[RoutingRule]) -> None: + ensure_dirs() + data = [r.model_dump(exclude_none=True) for r in rules] + tmp = RULES_FILE.with_suffix(".json.tmp") + async with aiofiles.open(tmp, "w") as f: + await f.write(json.dumps(data, indent=2)) + protect_file(tmp) + os.replace(tmp, RULES_FILE) diff --git a/ruh-vpn/backend/storage/subscriptions.py b/ruh-vpn/backend/storage/subscriptions.py new file mode 100644 index 0000000..22d0f14 --- /dev/null +++ b/ruh-vpn/backend/storage/subscriptions.py @@ -0,0 +1,37 @@ +"""Persistence for subscription metadata.""" + +from __future__ import annotations + +import json +import os + +import aiofiles + +from backend.paths import DATA_DIR, ensure_private_dir, protect_file + +SUBS_FILE = DATA_DIR / "subscriptions.json" + + +def ensure_dirs() -> None: + ensure_private_dir(DATA_DIR) + + +async def load_subscriptions() -> list[dict]: + ensure_dirs() + if not SUBS_FILE.exists(): + return [] + try: + async with aiofiles.open(SUBS_FILE, "r") as f: + raw = await f.read() + return json.loads(raw or "[]") + except (json.JSONDecodeError, ValueError): + return [] + + +async def save_subscriptions(subs: list[dict]) -> None: + ensure_dirs() + tmp = SUBS_FILE.with_suffix(".json.tmp") + async with aiofiles.open(tmp, "w") as f: + await f.write(json.dumps(subs, indent=2)) + protect_file(tmp) + os.replace(tmp, SUBS_FILE) diff --git a/ruh-vpn/backend/subscription/__init__.py b/ruh-vpn/backend/subscription/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruh-vpn/backend/subscription/manager.py b/ruh-vpn/backend/subscription/manager.py new file mode 100644 index 0000000..dd60158 --- /dev/null +++ b/ruh-vpn/backend/subscription/manager.py @@ -0,0 +1,157 @@ +"""Fetch subscription URLs, parse, import into VpnService.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Optional + +import aiohttp + +from backend.models.server import parse_server, server_to_dict +from backend.storage.subscriptions import load_subscriptions, save_subscriptions +from backend.subscription.parsers import parse_share_link, parse_subscription_body + +if TYPE_CHECKING: + from backend.service.vpn_service import VpnService + +AUTO_UPDATE_INTERVAL_SEC = 24 * 3600 +FETCH_TIMEOUT_SEC = 30 +USER_AGENT = "ruh-vpn/0.1 (subscription-fetcher)" + + +class SubscriptionManager: + def __init__(self, service: "VpnService") -> None: + self._svc = service + self._task: Optional[asyncio.Task] = None + self._subs: list[dict] = [] + + async def bootstrap(self) -> None: + self._subs = await load_subscriptions() + + def start_auto_update(self) -> None: + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._auto_loop()) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def list_subs(self) -> list[dict]: + return [dict(s) for s in self._subs] + + async def add(self, url: str, name: str = "") -> bool: + url = url.strip() + if not url: + return False + if any(s["url"] == url for s in self._subs): + return False + entry = { + "url": url, + "name": name or url, + "last_updated": 0, + "server_count": 0, + } + self._subs.append(entry) + await save_subscriptions(self._subs) + return True + + async def remove(self, url: str) -> bool: + before = len(self._subs) + self._subs = [s for s in self._subs if s["url"] != url] + if len(self._subs) == before: + return False + await save_subscriptions(self._subs) + return True + + async def update(self, url: str) -> int: + """Fetch a single subscription URL and import its servers. Returns count.""" + for s in self._subs: + if s["url"] == url: + return await self._fetch_and_import(s) + return 0 + + async def update_all(self) -> int: + total = 0 + for s in list(self._subs): + total += await self._fetch_and_import(s) + return total + + async def _fetch_and_import(self, sub: dict) -> int: + try: + body = await self._fetch(sub["url"]) + except Exception as exc: + self._svc._log("error", f"subscription fetch failed for {sub['url']}: {exc}") + return 0 + links = parse_subscription_body(body) + imported = 0 + existing_keys = {self._server_key(s) for s in self._svc.state.servers} + for link in links: + entry = parse_share_link(link) + if not entry: + continue + try: + server = parse_server(entry) + except Exception: + continue + key = self._server_key(server) + if key in existing_keys: + # update existing entry's fields by replacing with new id + existing = next( + (s for s in self._svc.state.servers if self._server_key(s) == key), None + ) + if existing: + entry["id"] = existing.id + await self._svc.update_server(entry) + continue + await self._svc.add_server(entry) + existing_keys.add(key) + imported += 1 + sub["last_updated"] = int(time.time()) + sub["server_count"] = len(links) + await save_subscriptions(self._subs) + return imported + + @staticmethod + def _server_key(server) -> tuple: + if isinstance(server, dict): + proto = server.get("protocol", "") + addr = server.get("address") or server.get("host") or "" + port = server.get("port") + secret = server.get("uuid") or server.get("password") or "" + return (proto, addr, port, secret) + proto = getattr(server, "protocol", "") + addr = getattr(server, "address", None) or getattr(server, "host", None) or "" + port = getattr(server, "port", None) + secret = ( + getattr(server, "uuid", None) + or getattr(server, "password", None) + or "" + ) + return (proto, addr, port, secret) + + async def _fetch(self, url: str) -> str: + timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT_SEC) + headers = {"User-Agent": USER_AGENT} + async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session: + async with session.get(url) as resp: + resp.raise_for_status() + return await resp.text(errors="replace") + + async def _auto_loop(self) -> None: + try: + while True: + await asyncio.sleep(AUTO_UPDATE_INTERVAL_SEC) + try: + await self.update_all() + except Exception as exc: + self._svc._log("error", f"auto-update failed: {exc}") + except asyncio.CancelledError: + return diff --git a/ruh-vpn/backend/subscription/parsers.py b/ruh-vpn/backend/subscription/parsers.py new file mode 100644 index 0000000..7b3408a --- /dev/null +++ b/ruh-vpn/backend/subscription/parsers.py @@ -0,0 +1,311 @@ +"""Parse share links (vless / vmess / ss / socks5 / sn) into server dicts. + +The output dict shape matches `backend.models.server.parse_server` so it can be +fed straight into VpnService.add_server. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import re +import struct +import urllib.parse as urlparse +import uuid +import zlib + + +def _b64_decode_padded(data: str) -> bytes: + data = data.strip().replace("\n", "").replace("\r", "") + pad = "=" * (-len(data) % 4) + try: + return base64.urlsafe_b64decode(data + pad) + except (binascii.Error, ValueError): + try: + return base64.b64decode(data + pad) + except (binascii.Error, ValueError): + return b"" + + +def parse_subscription_body(body: str) -> list[str]: + """Return a list of share-link strings from a raw subscription body. + + Body may be: + - Base64 of newline-separated share links (most common). + - Plain text with newline-separated share links. + """ + body = body.strip() + if not body: + return [] + if "://" not in body: + decoded = _b64_decode_padded(body) + try: + body = decoded.decode("utf-8", errors="replace") + except UnicodeDecodeError: + return [] + out: list[str] = [] + for line in body.splitlines(): + line = line.strip() + if "://" in line: + out.append(line) + return out + + +def parse_share_link(link: str) -> dict | None: + link = link.strip() + if link.startswith("vless://"): + return _parse_vless(link) + if link.startswith("vmess://"): + return _parse_vmess(link) + if link.startswith("ss://"): + return _parse_ss(link) + if link.startswith("socks5://") or link.startswith("socks://"): + return _parse_socks5(link) + if link.startswith("sn://"): + return _parse_sn(link) + return None + + +# ---------------------------------------------------------------- sn:// links +# +# sn://?. The payload is a binary record, +# not JSON: strings are stored raw with the high bit set on their LAST byte +# (so "192.0.2." + 0xb1 reads as "192.0.2.1"), and numbers are 32-bit LE. +# +# This layout was derived by inspection of a working ssh link — no public spec +# was found for it, and it is NOT nekoray's (its repositories contain no "sn://" +# and it has no ssh profile type). The reading was confirmed field by field +# against a real link: port came out as exactly 22 and the user as "root", and +# the owner verified the decoded password character for character. +# +# Because the format is inferred rather than specified, everything here is +# strict: only the "ssh" type is accepted, every field must survive validation, +# and anything unexpected returns None so the caller reports an unsupported link +# instead of silently creating a wrong server. Two trailing fields (an int and +# what looks like a UTF-8 remark) do not fit the scheme and are ignored — the +# name is taken from the host instead. + + +def _sn_read_string(buf: bytes, i: int) -> tuple[str, int] | None: + """Read one high-bit-terminated string starting at `i`.""" + out = bytearray() + while i < len(buf): + c = buf[i] + i += 1 + if c & 0x80: + out.append(c & 0x7F) + try: + return out.decode("utf-8"), i + except UnicodeDecodeError: + return None + out.append(c) + return None # ran off the end without a terminator + + +def _sn_read_u32(buf: bytes, i: int) -> tuple[int, int] | None: + if i + 4 > len(buf): + return None + return struct.unpack_from(" dict | None: + try: + kind, payload = link[len("sn://"):].split("?", 1) + except ValueError: + return None + if kind != "ssh": + return None # only type verified against a real link + + raw = _b64_decode_padded(payload) + if not raw: + return None + try: + buf = zlib.decompress(raw) + except zlib.error: + return None + + i = 4 # leading u32, always 0 in the sample; purpose unknown + host_r = _sn_read_string(buf, i) + if not host_r: + return None + host, i = host_r + port_r = _sn_read_u32(buf, i) + if not port_r: + return None + port, i = port_r + user_r = _sn_read_string(buf, i) + if not user_r: + return None + user, i = user_r + # Unknown u32 between user and password (1 in the sample; possibly an auth + # mode). Not trusted for anything. + skip = _sn_read_u32(buf, i) + if not skip: + return None + i = skip[1] + pw_r = _sn_read_string(buf, i) + if not pw_r: + return None + password, _ = pw_r + + if not (0 < port < 65536): + return None + for value in (host, user, password): + if not value or not _SN_PRINTABLE.match(value): + return None + + return { + "protocol": "ssh", + "name": host, + "host": host, + "port": port, + "user": user, + "password": password, + } + + +def _decode_name(fragment: str) -> str: + return urlparse.unquote(fragment or "").strip() or "imported" + + +def _parse_vless(link: str) -> dict | None: + parsed = urlparse.urlparse(link) + if not parsed.username or not parsed.hostname or not parsed.port: + return None + q = urlparse.parse_qs(parsed.query) + + def _q(k: str, default: str = "") -> str: + return (q.get(k, [default]) or [default])[0] + + out: dict = { + "name": _decode_name(parsed.fragment), + "protocol": "vless", + "address": parsed.hostname, + "port": int(parsed.port), + "uuid": parsed.username, + "transport": _q("type", "tcp") or "tcp", + } + sec = _q("security", "") + out["security"] = sec if sec in ("tls", "reality", "none") else None + out["tls"] = bool(sec in ("tls", "reality")) + sni = _q("sni") or _q("host") + if sni: + out["sni"] = sni + for src, dst in [ + ("flow", "flow"), + ("fp", "fp"), + ("pbk", "pbk"), + ("sid", "sid"), + ("path", "path"), + ("serviceName", "serviceName"), + ]: + v = _q(src) + if v: + out[dst] = v + out["id"] = _gen_id("vless", out["address"], out["port"], out["uuid"]) + return {k: v for k, v in out.items() if v is not None} + + +def _parse_vmess(link: str) -> dict | None: + payload = link[len("vmess://"):] + decoded = _b64_decode_padded(payload) + if not decoded: + return None + try: + obj = json.loads(decoded.decode("utf-8", errors="replace")) + except (json.JSONDecodeError, ValueError): + return None + addr = obj.get("add") + port = obj.get("port") + uuid_ = obj.get("id") + if not addr or not port or not uuid_: + return None + out = { + "name": obj.get("ps") or "imported", + "protocol": "vmess", + "address": addr, + "port": int(port), + "uuid": uuid_, + "alterId": int(obj.get("aid") or 0), + "security": obj.get("scy") or "auto", + "transport": obj.get("net") or "tcp", + "tls": (obj.get("tls") == "tls"), + } + if obj.get("sni") or obj.get("host"): + out["sni"] = obj.get("sni") or obj.get("host") + if obj.get("path"): + out["path"] = obj["path"] + if obj.get("host"): + out["host"] = obj["host"] + out["id"] = _gen_id("vmess", out["address"], out["port"], out["uuid"]) + return out + + +def _parse_ss(link: str) -> dict | None: + # Two common forms: + # ss://base64(method:password)@host:port#name + # ss://base64(method:password@host:port)#name + rest = link[len("ss://"):] + frag = "" + if "#" in rest: + rest, frag = rest.split("#", 1) + name = _decode_name(frag) + + method: str | None = None + password: str | None = None + host: str | None = None + port: int | None = None + + if "@" in rest: + creds_b64, host_part = rest.rsplit("@", 1) + creds = _b64_decode_padded(creds_b64).decode("utf-8", errors="replace") + if ":" in creds: + method, password = creds.split(":", 1) + if ":" in host_part: + h, p = host_part.rsplit(":", 1) + host = h + try: + port = int(p) + except ValueError: + pass + else: + whole = _b64_decode_padded(rest).decode("utf-8", errors="replace") + m = re.match(r"^([^:]+):([^@]+)@([^:]+):(\d+)$", whole) + if m: + method, password, host, port = m.group(1), m.group(2), m.group(3), int(m.group(4)) + + if not method or not password or not host or not port: + return None + return { + "id": _gen_id("ss", host, port, password), + "name": name, + "protocol": "shadowsocks", + "address": host, + "port": port, + "method": method, + "password": password, + } + + +def _parse_socks5(link: str) -> dict | None: + parsed = urlparse.urlparse(link) + if not parsed.hostname or not parsed.port: + return None + return { + "id": _gen_id("socks5", parsed.hostname, parsed.port, parsed.username or ""), + "name": _decode_name(parsed.fragment), + "protocol": "socks5", + "host": parsed.hostname, + "port": int(parsed.port), + "username": parsed.username, + "password": parsed.password, + } + + +def _gen_id(proto: str, host: str, port: int, secret: str) -> str: + h = f"{proto}|{host}|{port}|{secret}".encode("utf-8") + return uuid.uuid5(uuid.NAMESPACE_URL, h.decode("utf-8")).hex[:12] diff --git a/ruh-vpn/panel.luau b/ruh-vpn/panel.luau new file mode 100644 index 0000000..b5ba0a8 --- /dev/null +++ b/ruh-vpn/panel.luau @@ -0,0 +1,913 @@ +--!nonstrict +-- panel.luau — main VPN UI. Multi-view single panel: +-- view = "main" | "editor" | "rules" | "logs" +-- Value-driven: every render reads current state; controls report changes +-- through named global callbacks. ui.input is uncontrolled (value seeds once, +-- edits flow through onChange into the module-level `form` table). + +local MAX_ROWS = 20 -- clickable server rows +local MAX_PRE = 8 -- preset toggles +local MAX_RULE = 20 -- custom rule rows +local MAX_SUB = 12 -- subscription rows +local nonce = 0 + +local view = "main" +local slotIds = {} -- server row slot -> id +local presetKeys = {} -- preset slot -> key +local ruleIds = {} -- rule slot -> id + +-- editor form +local form = {} +local formProto = "ssh" +local editingId = nil +local formRev = 0 -- bumps to reset ui.input identity on open + +-- add-rule form +local ruleForm = { pattern = "", type = "force-proxy" } +local ruleRev = 0 + +-- subscriptions +local subUrls = {} -- sub slot -> url +local subForm = { url = "", name = "" } +local subRev = 0 + +local resetArmed = false -- "Reset all servers" waits for a second click + +local PROTOS = { "ssh", "vless", "vmess", "shadowsocks", "socks5" } +local TRANSPORTS = { "tcp", "ws", "grpc", "http" } +local SECURITIES = { "none", "tls", "reality" } +local RULE_TYPES = { "force-proxy", "direct", "block" } + +-- ── design tokens ─────────────────────────────────────────────── +-- The fixed olive/chartreuse palette gives the plugin its own visual identity. +local T = { + bg = "#1b1c17", + card = "#26271f", + cardHi = "#2e2f25", + cardActive = "#34362a", + border = "#33342a", + borderSoft = "#2a2b22", + accent = "#cfe04e", + accentText = "#1b1c17", + text = "#e8e7df", + textDim = "#a3a497", + muted = "#7d7e71", + success = "#9bd17a", + danger = "#d68a7a", + pingGood = "#9bd17a", + pingMid = "#e0c84e", + pingBad = "#d68a7a", +} + +-- Colour props take a role token or a plain hex. Flatten alpha against the +-- backdrop because alpha suffixes are only legal on theme roles. +local function mix(fg, bg, a) + local function ch(h, i) return tonumber(h:sub(i, i + 1), 16) end + local r = ch(bg, 2) + (ch(fg, 2) - ch(bg, 2)) * a + local g = ch(bg, 4) + (ch(fg, 4) - ch(bg, 4)) * a + local b = ch(bg, 6) + (ch(fg, 6) - ch(bg, 6)) * a + return string.format("#%02x%02x%02x", math.floor(r + 0.5), math.floor(g + 0.5), math.floor(b + 0.5)) +end + +-- Protocol pill colours, alpha pre-flattened. +local PROTO_TAG = { + SSH = { fg = "#9ab7d1", a = 0.12 }, + VLESS = { fg = "#cfe04e", a = 0.14 }, + VMess = { fg = "#c18cd9", a = 0.14 }, + SS = { fg = "#d99967", a = 0.14 }, + SOCKS5 = { fg = "#d99967", a = 0.14 }, +} + +local PROTO_LABEL = { + ssh = "SSH", vless = "VLESS", vmess = "VMess", + shadowsocks = "SS", socks5 = "SOCKS5", +} + +-- ── command channel ───────────────────────────────────────────── +local function send(method, args) + nonce = nonce + 1 + noctalia.state.set("cmd", { + method = method, args = args or {}, + nonce = tostring(nonce) .. ":" .. tostring(os.time()), + }) +end + +-- ── helpers ───────────────────────────────────────────────────── +local function indexOf(list, val, dflt) + for i, v in ipairs(list) do if v == val then return i - 1 end end + return dflt or 0 +end + +local function pingColor(ms) + ms = tonumber(ms) + if not ms or ms < 0 then return T.muted end + if ms < 60 then return T.pingGood end + if ms < 150 then return T.pingMid end + return T.pingBad +end + +-- Uppercase protocol pill. +local function protoTag(proto) + local label = PROTO_LABEL[(proto or ""):lower()] + if not label then return nil end + local tag = PROTO_TAG[label] + return ui.row({ fill = mix(tag.fg, T.card, tag.a), radius = 4, paddingH = 6, align = "center" }, { + ui.label({ text = label:upper(), color = tag.fg, fontSize = 10, fontWeight = "bold" }), + }) +end + +-- Coloured latency dot and milliseconds. +local function pingBadge(ms) + return ui.row({ gap = 5, align = "center" }, { + ui.box({ width = 6, height = 6, radius = 3, fill = pingColor(ms) }), + ui.label({ text = tostring(ms) .. "ms", color = T.textDim, fontSize = 11 }), + }) +end + +-- ISO-3166 alpha-2 to regional indicator pair. +local function flagFor(country) + if type(country) ~= "string" or #country ~= 2 then return nil end + local cc, out = country:upper(), "" + for i = 1, 2 do + local b = cc:byte(i) + if b < 65 or b > 90 then return nil end + out = out .. utf8.char(0x1F1E6 + b - 65) + end + return out +end + +local function activeName(servers, id) + for _, s in ipairs(servers) do if s.id == id then return s.name or s.id end end + return nil +end + +local function num(v, dflt) return tonumber(v) or dflt end + +-- Leaving the view disarms the reset confirm; it must never survive a round trip. +local function navigate(v) view = v; resetArmed = false; render() end -- render is assigned below + +-- ── declared forward ──────────────────────────────────────────── +render = nil + +-- ================================================================ +-- MAIN VIEW +-- ================================================================ +-- Server row: status, flag, name, protocol, endpoint, latency and actions. +-- Row and column nodes do not accept onClick, so the name button and status +-- dot are the click targets. +local function serverRows(servers, status, running, health) + local rows = {} + slotIds = {} + for i, s in ipairs(servers) do + local slot = i - 1 + if slot >= MAX_ROWS then break end + slotIds[slot] = s.id + local isSelected = s.id == status.activeServerId + local isActive = isSelected and running + + local dot = (isActive and T.success) or (isSelected and mix(T.accent, T.bg, 0.7)) or T.border + -- Keep idle rows transparent instead of painting over the panel. + local fill = (isActive and T.cardActive) or (isSelected and mix(T.accent, T.bg, 0.05)) or nil + local edge = (isActive and T.border) or (isSelected and mix(T.accent, T.bg, 0.4)) or nil + + local pick = "onServer" .. tostring(slot) + local title = { ui.button({ text = s.name or s.id or noctalia.tr("panel.fallback-server-name"), + variant = "ghost", contentAlign = "start", onClick = pick }) } + local tag = protoTag(s.protocol) + if tag then title[#title + 1] = tag end + + local cells = { ui.box({ width = 7, height = 7, radius = 4, fill = dot, onClick = pick }) } + local flag = flagFor(s.country) + if flag then cells[#cells + 1] = ui.label({ text = flag, fontSize = 16 }) end + cells[#cells + 1] = ui.column({ gap = 2, flexGrow = 1 }, { + ui.row({ gap = 6, align = "center" }, title), + -- Indent to clear the name button's own inner padding so the endpoint + -- lines up under the name rather than under the button's edge. + ui.row({ paddingH = 15 }, { + ui.label({ text = s.host or s.address or "", color = T.muted, fontSize = 11, maxLines = 1 }), + }), + }) + if isActive and health.latency_ms and health.latency_ms > 0 then + cells[#cells + 1] = pingBadge(health.latency_ms) + end + cells[#cells + 1] = ui.button({ glyph = "pencil", glyphSize = 13, variant = "ghost", + onClick = "onEdit" .. tostring(slot) }) + cells[#cells + 1] = ui.button({ glyph = "trash", glyphSize = 13, variant = "ghost", + onClick = "onDel" .. tostring(slot) }) + + rows[#rows + 1] = ui.row({ gap = 10, align = "center", fill = fill, radius = 10, + border = edge, borderWidth = edge and 1 or nil, + paddingH = 12, paddingV = 10 }, cells) + end + if #rows == 0 then + rows[1] = ui.row({ paddingH = 12, paddingV = 10 }, { + ui.label({ text = noctalia.tr("panel.no-servers"), color = T.muted }), + }) + end + return rows +end + +local function hero(status, backend, running) + if not backend.ready then return "shield-off", T.muted end + local level = status.statusLevel + if running and (level == "error" or level == "failed") then return "alert-triangle", T.danger end + if running and level == "degraded" then return "alert-circle", T.pingMid end + if running then return "shield-check", T.success end + return "shield-off", T.muted +end + +local function mainView() + local status = noctalia.state.get("status") or {} + local servers = noctalia.state.get("servers") or {} + local health = noctalia.state.get("health") or {} + local backend = noctalia.state.get("backend") or {} + local sp = noctalia.state.get("speedtest") or {} + local running = status.running == true + + local down = (sp.down_mbps and sp.down_mbps > 0) and sp.down_mbps or health.down_mbps + local up = (sp.up_mbps and sp.up_mbps > 0) and sp.up_mbps or health.up_mbps + -- A finished test reports its own latency; prefer it over the background probe. + local ping = (sp.ping_ms and sp.ping_ms > 0) and sp.ping_ms or health.latency_ms + + local heroGlyph, heroColor = hero(status, backend, running) + + local heroTitle + if not backend.ready then + heroTitle = noctalia.tr("panel.starting") + elseif running then + heroTitle = noctalia.tr("panel.connected") + else + heroTitle = noctalia.tr("panel.disconnected") + end + + -- Active server subtitle. + local heroSub = running and activeName(servers, status.activeServerId) or nil + + -- Telemetry line. + local line2 + if not backend.ready then + line2 = backend.error and ("backend: " .. backend.error) or "" + elseif running and ping and ping > 0 then + line2 = tostring(ping) .. " ms" + if down and down > 0 then + line2 = line2 .. " ↓" .. string.format("%.1f", down) + .. " ↑" .. string.format("%.1f", up or 0) .. " Mbps" + end + else + line2 = "" + end + + local heroHead = { + ui.box({ width = 7, height = 7, radius = 4, fill = heroColor }), + ui.label({ text = heroTitle, color = T.text, fontSize = 15, fontWeight = "semibold" }), + } + if heroSub then + heroHead[#heroHead + 1] = ui.label({ text = "· " .. heroSub, color = T.muted, + fontSize = 14, maxLines = 1, flexGrow = 1 }) + end + + local heroBody = { ui.row({ gap = 8, align = "center" }, heroHead) } + if line2 ~= "" then + heroBody[#heroBody + 1] = ui.label({ text = line2, color = T.textDim, fontSize = 12, maxLines = 1 }) + end + + -- No width here: the column fills the panel declared in plugin.toml, so the + -- content uses the whole window instead of sitting in a 380px strip. + return ui.column({ gap = 0, flexGrow = 1 }, { + -- ── header ────────────────────────────────────────────────── + ui.row({ gap = 8, align = "center", paddingH = 16, paddingV = 14 }, { + ui.label({ text = noctalia.tr("title"), color = T.text, fontSize = 16, + fontWeight = "semibold", flexGrow = 1 }), + ui.toggle({ checked = running, onChange = "onMaster" }), + ui.button({ glyph = "cloud-download", glyphSize = 15, variant = "ghost", onClick = "onOpenSubs" }), + ui.button({ glyph = "file-text", glyphSize = 15, variant = "ghost", onClick = "onOpenLogs" }), + ui.button({ glyph = "settings", glyphSize = 15, variant = "ghost", onClick = "onOpenRules" }), + ui.button({ glyph = "x", glyphSize = 15, variant = "ghost", onClick = "onClosePanel" }), + }), + ui.separator({}), + + ui.column({ gap = 14, paddingH = 16, paddingV = 14, flexGrow = 1 }, { + -- ── status hero card ────────────────────────────────────── + ui.row({ gap = 14, align = "center", fill = T.card, radius = 14, + border = T.borderSoft, borderWidth = 1, paddingH = 16, paddingV = 14 }, { + ui.row({ fill = mix(heroColor, T.card, 0.14), radius = 12, + border = mix(heroColor, T.card, 0.28), borderWidth = 1, + paddingH = 10, paddingV = 10, align = "center" }, { + ui.glyph({ name = heroGlyph, size = 22, color = heroColor }), + }), + ui.column({ gap = 3, flexGrow = 1 }, heroBody), + -- Single test button, in the hero's right corner: it drives both + -- numbers shown to its left (latency and throughput). + ui.button({ text = noctalia.tr("action.run-test"), glyph = "gauge", glyphSize = 14, + variant = "outline", onClick = "onRunTest", enabled = backend.ready == true }), + }), + + -- ── mode chips ──────────────────────────────────────────── + ui.row({ gap = 10 }, { + ui.column({ gap = 4, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("panel.routing"), fontSize = 11, color = T.muted }), + ui.select({ selectedIndex = (status.mode == "global") and 1 or 0, + options = { "rules", "global" }, onChange = "onMode", flexGrow = 1 }), + }), + ui.column({ gap = 4, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("panel.via"), fontSize = 11, color = T.muted }), + ui.select({ selectedIndex = (status.proxyMode == "tun") and 1 or 0, + options = { "system", "tun" }, onChange = "onVia", flexGrow = 1 }), + }), + }), + + -- ── servers ─────────────────────────────────────────────── + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = noctalia.tr("panel.servers") .. " (" .. tostring(#servers) .. ")", + fontSize = 12, color = T.muted, flexGrow = 1 }), + ui.button({ glyph = "clipboard", glyphSize = 14, variant = "ghost", onClick = "onImportClip" }), + ui.button({ text = noctalia.tr("action.add"), glyph = "plus", glyphSize = 14, + variant = "primary", onClick = "onAddServer" }), + }), + -- flexGrow, not a fixed height: the list takes whatever vertical space + -- is left so the panel has no dead area at the bottom. + ui.scroll({ flexGrow = 1, gap = 4 }, { ui.column({ gap = 4 }, serverRows(servers, status, running, health)) }), + }), + }) +end + +-- ================================================================ +-- EDITOR VIEW +-- ================================================================ +local function field(key, labelKey, placeholder) + return ui.column({ gap = 2 }, { + ui.label({ text = noctalia.tr(labelKey), fontSize = 11, color = T.muted }), + ui.input({ key = "fld-" .. key .. "-" .. formRev, value = tostring(form[key] or ""), + placeholder = placeholder or "", onChange = "onFld_" .. key, flexGrow = 1 }), + }) +end + +local function protoFields() + local p = formProto + -- GetServers strips secrets, so an edited server's password/uuid arrive + -- empty; the backend keeps the stored value when they stay empty on save. + local keep = editingId and noctalia.tr("editor.keep") or nil + local f = {} + if p == "ssh" then + f = { field("host", "editor.host", "1.2.3.4"), field("port", "editor.port", "22"), + field("user", "editor.user", "root"), field("password", "editor.password", keep), + field("keyFile", "editor.keyfile", "~/.ssh/id_ed25519") } + elseif p == "vless" then + f = { field("address", "editor.address"), field("port", "editor.port"), + field("uuid", "editor.uuid", keep), + ui.column({ gap = 2 }, { ui.label({ text = noctalia.tr("editor.transport"), fontSize = 11, color = T.muted }), + ui.select({ selectedIndex = indexOf(TRANSPORTS, form.transport, 0), options = TRANSPORTS, onChange = "onFldTransport", flexGrow = 1 }) }), + ui.column({ gap = 2 }, { ui.label({ text = noctalia.tr("editor.security"), fontSize = 11, color = T.muted }), + ui.select({ selectedIndex = indexOf(SECURITIES, form.security, 0), options = SECURITIES, onChange = "onFldSecurity", flexGrow = 1 }) }), + field("sni", "editor.sni"), field("flow", "editor.flow"), + field("pbk", "editor.pbk"), field("sid", "editor.sid"), field("fp", "editor.fp"), + field("path", "editor.path"), field("host", "editor.wshost") } + elseif p == "vmess" then + f = { field("address", "editor.address"), field("port", "editor.port"), + field("uuid", "editor.uuid", keep), field("alterId", "editor.alterid", "0"), + ui.column({ gap = 2 }, { ui.label({ text = noctalia.tr("editor.transport"), fontSize = 11, color = T.muted }), + ui.select({ selectedIndex = indexOf(TRANSPORTS, form.transport, 0), options = TRANSPORTS, onChange = "onFldTransport", flexGrow = 1 }) }), + field("sni", "editor.sni"), field("path", "editor.path"), field("host", "editor.wshost") } + elseif p == "shadowsocks" then + f = { field("address", "editor.address"), field("port", "editor.port"), + field("method", "editor.method", "aes-256-gcm"), field("password", "editor.password", keep) } + elseif p == "socks5" then + f = { field("host", "editor.host"), field("port", "editor.port"), + field("username", "editor.user"), field("password", "editor.password", keep) } + end + return f +end + +local function editorView() + local children = { + ui.column({ gap = 2 }, { + ui.label({ text = noctalia.tr("editor.protocol"), fontSize = 11, color = T.muted }), + ui.select({ selectedIndex = indexOf(PROTOS, formProto, 0), options = PROTOS, + onChange = "onFldProto", flexGrow = 1, enabled = editingId == nil }), + }), + field("name", "editor.name", "my server"), + -- The flag in the server row comes from here. Nothing in the backend ever + -- derives a country (the models have no such field; they just accept extra + -- keys), so without this input flagFor() had no data and the flag could + -- never render. + field("country", "editor.country", "fi"), + } + for _, f in ipairs(protoFields()) do children[#children + 1] = f end + children[#children + 1] = ui.separator({}) + children[#children + 1] = ui.row({ gap = 8 }, { + ui.button({ text = noctalia.tr("action.save"), glyph = "check", variant = "primary", onClick = "onSave" }), + ui.spacer({}), + editingId and ui.button({ text = noctalia.tr("action.delete"), glyph = "trash", glyphSize = 13, + variant = "outline", onClick = "onDelete" }) or ui.spacer({}), + }) + -- Header outside the scroll, scroll on flexGrow: a fixed scroll height + -- overflowed the panel instead of clipping, leaving Save unreachable. + return ui.column({ gap = 10, padding = 16, flexGrow = 1 }, { + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }), + ui.label({ text = editingId and noctalia.tr("editor.edit") or noctalia.tr("editor.new"), + fontWeight = "bold", fontSize = 15, flexGrow = 1 }), + }), + ui.scroll({ flexGrow = 1 }, { ui.column({ gap = 10 }, children) }), + }) +end + +-- ================================================================ +-- RULES VIEW +-- ================================================================ +local function rulesView() + local presets = noctalia.state.get("presets") or {} + local rules = noctalia.state.get("rules") or {} + local kill = noctalia.state.get("killswitch") or {} + local dns = noctalia.state.get("dnsleak") + + local dnsText, dnsColor + if dns == nil then + dnsText, dnsColor = noctalia.tr("rules.dns-hint"), T.muted + elseif dns.leaking then + dnsText, dnsColor = noctalia.tr("rules.dns-leak") .. " — " .. (dns.reason or ""), T.danger + else + dnsText, dnsColor = noctalia.tr("rules.dns-ok") .. " — " .. (dns.reason or ""), T.success + end + + local presetRows = {} + presetKeys = {} + for i, p in ipairs(presets) do + local slot = i - 1 + if slot >= MAX_PRE then break end + presetKeys[slot] = p.key + presetRows[#presetRows + 1] = ui.row({ gap = 8, align = "center" }, { + ui.label({ text = (p.flag or "") .. " " .. (p.name or p.key), flexGrow = 1 }), + ui.toggle({ checked = p.enabled == true, onChange = "onPreset" .. tostring(slot) }), + }) + end + + local ruleRows = {} + ruleIds = {} + for i, r in ipairs(rules) do + local slot = i - 1 + if slot >= MAX_RULE then break end + ruleIds[slot] = r.id + ruleRows[#ruleRows + 1] = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = r.type == "block" and "ban" or (r.type == "direct" and "arrow-right" or "shield"), + color = r.type == "block" and T.danger or T.muted }), + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = r.pattern or "", fontSize = 13 }), + ui.label({ text = r.type or "", fontSize = 10, color = T.muted }), + }), + ui.button({ glyph = "x", variant = "ghost", onClick = "onRuleDel" .. tostring(slot) }), + }) + end + if #ruleRows == 0 then + ruleRows[1] = ui.row({ paddingH = 6, paddingV = 6 }, { + ui.label({ text = noctalia.tr("rules.none"), color = T.muted }), + }) + end + + -- Header outside the scroll, scroll on flexGrow (same fix as editorView): + -- a fixed scroll height overflowed the panel instead of clipping. + return ui.column({ gap = 12, padding = 16, flexGrow = 1 }, { + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }), + ui.label({ text = noctalia.tr("rules.title"), fontWeight = "bold", fontSize = 15, flexGrow = 1 }), + }), + + ui.scroll({ flexGrow = 1 }, { ui.column({ gap = 12 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "shield-x", color = kill.enabled and T.danger or T.muted }), + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("rules.killswitch") }), + ui.label({ text = noctalia.tr("rules.killswitch-desc"), fontSize = 10, color = T.muted }), + }), + ui.toggle({ checked = kill.enabled == true, onChange = "onKill" }), + }), + + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "world-search", color = dnsColor }), + ui.label({ text = dnsText, fontSize = 11, color = dnsColor, flexGrow = 1 }), + ui.button({ text = noctalia.tr("rules.dns-check"), variant = "outline", onClick = "onDnsCheck" }), + }), + + ui.separator({}), + ui.label({ text = noctalia.tr("rules.presets"), fontSize = 12, color = T.muted }), + ui.column({ gap = 6 }, presetRows), + + ui.separator({}), + ui.label({ text = noctalia.tr("rules.custom"), fontSize = 12, color = T.muted }), + ui.row({ gap = 6, align = "center" }, { + ui.input({ key = "rule-pat-" .. ruleRev, value = ruleForm.pattern, placeholder = "*.example.com | 10.0.0.0/8", + onChange = "onRulePattern", flexGrow = 1 }), + ui.select({ selectedIndex = indexOf(RULE_TYPES, ruleForm.type, 0), options = RULE_TYPES, onChange = "onRuleType" }), + ui.button({ glyph = "plus", variant = "primary", onClick = "onRuleAdd" }), + }), + ui.column({ gap = 6 }, ruleRows), + + -- Two-step reset because there is no undo or backend bulk-clear command. + ui.separator({}), + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "trash", size = 15, color = resetArmed and T.danger or T.muted }), + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("rules.reset-servers"), color = resetArmed and T.danger or T.text }), + ui.label({ text = noctalia.tr("rules.reset-servers-desc"), fontSize = 10, color = T.muted }), + }), + ui.button({ text = resetArmed and noctalia.tr("action.confirm") or noctalia.tr("action.reset"), + variant = "outline", onClick = "onResetServers" }), + }), + }) }), + }) +end + +-- ================================================================ +-- LOGS VIEW +-- ================================================================ +local function logsView() + local logs = noctalia.state.get("logs") or {} + local rows = {} + local startI = math.max(1, #logs - 150) + for i = startI, #logs do + local e = logs[i] + local lvl = (e and e.level) or "info" + rows[#rows + 1] = ui.label({ + text = (e and e.message) or "", + fontSize = 11, + color = (lvl == "error" and T.danger) or (lvl == "warn" and T.pingMid) or T.muted, + }) + end + if #rows == 0 then rows[1] = ui.label({ text = noctalia.tr("logs.empty"), color = T.muted }) end + return ui.column({ gap = 10, padding = 16, flexGrow = 1 }, { + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }), + ui.label({ text = noctalia.tr("logs.title"), fontWeight = "bold", fontSize = 15, flexGrow = 1 }), + }), + ui.scroll({ flexGrow = 1, stickToBottom = true }, { ui.column({ gap = 3 }, rows) }), + }) +end + +-- ================================================================ +-- SUBSCRIPTIONS VIEW +-- ================================================================ +local function subsView() + local subs = noctalia.state.get("subscriptions") or {} + local rows = {} + subUrls = {} + for i, sub in ipairs(subs) do + local slot = i - 1 + if slot >= MAX_SUB then break end + subUrls[slot] = sub.url + rows[#rows + 1] = ui.row({ gap = 6, align = "center" }, { + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = (sub.name and sub.name ~= "") and sub.name or (sub.url or ""), fontSize = 13 }), + ui.label({ text = sub.url or "", fontSize = 10, color = T.muted }), + }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onSubUpd" .. tostring(slot) }), + ui.button({ glyph = "x", variant = "ghost", onClick = "onSubDel" .. tostring(slot) }), + }) + end + if #rows == 0 then + rows[1] = ui.row({ paddingH = 6, paddingV = 6 }, { + ui.label({ text = noctalia.tr("subs.none"), color = T.muted }), + }) + end + + return ui.column({ gap = 12, padding = 16, flexGrow = 1 }, { + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }), + ui.label({ text = noctalia.tr("subs.title"), fontWeight = "bold", fontSize = 15, flexGrow = 1 }), + }), + ui.column({ gap = 6 }, { + ui.input({ key = "sub-url-" .. subRev, value = subForm.url, placeholder = "https://…/sub", + onChange = "onSubUrl", flexGrow = 1 }), + ui.row({ gap = 6, align = "center" }, { + ui.input({ key = "sub-name-" .. subRev, value = subForm.name, placeholder = noctalia.tr("subs.name"), + onChange = "onSubName", flexGrow = 1 }), + ui.button({ text = noctalia.tr("action.add"), glyph = "plus", variant = "primary", onClick = "onSubAdd" }), + }), + }), + ui.separator({}), + ui.scroll({ flexGrow = 1 }, { ui.column({ gap = 8 }, rows) }), + }) +end + +-- ── dispatch ──────────────────────────────────────────────────── +render = function() + local tree + if view == "editor" then tree = editorView() + elseif view == "rules" then tree = rulesView() + elseif view == "logs" then tree = logsView() + elseif view == "subs" then tree = subsView() + else tree = mainView() end + panel.render(tree) +end + +-- ── lifecycle ─────────────────────────────────────────────────── +function onOpen() + for _, k in ipairs({ "status", "servers", "health", "backend", "presets", "rules", + "killswitch", "logs", "subscriptions", "speedtest", "dnsleak" }) do + noctalia.state.watch(k, function(_) render() end) + end + render() +end +function onClose() end + +-- ── main handlers ─────────────────────────────────────────────── +function onToggle() + local s = noctalia.state.get("status") or {} + if s.running then send("StopProxy", {}) + elseif s.activeServerId and s.activeServerId ~= "" then + send("StartProxy", { s.activeServerId, s.mode or "rules", s.proxyMode or "system" }) + else noctalia.notifyError("VPN", noctalia.tr("error.no-server")) end +end +-- The toggle reports the desired value; onToggle derives the action from state. +function onMaster(_) onToggle() end +function onClosePanel() panel.close() end + +function onMode(_, label) send("SetMode", { label }) end +function onVia(_, label) + if label == "tun" then noctalia.notify("VPN", noctalia.tr("notice.tun")) end + send("SetProxyMode", { label }) +end +-- RunSpeedTest reports latency and throughput in one command. +function onRunTest() + local s = noctalia.state.get("status") or {} + if not s.activeServerId or s.activeServerId == "" then + noctalia.notifyError("VPN", noctalia.tr("error.no-server")) + return + end + send("RunSpeedTest", {}) +end +function onOpenRules() navigate("rules") end +function onOpenLogs() navigate("logs") end +function onOpenSubs() navigate("subs") end +function onBack() navigate("main") end + +function onImportClip() + local txt = noctalia.clipboardText() + if txt and txt ~= "" then send("ParseShareLink", { txt }) + else noctalia.notifyError("VPN", noctalia.tr("error.clipboard")) end +end +function onDnsCheck() send("CheckDnsLeak", {}) end + +-- ── editor open/save ──────────────────────────────────────────── +local function openEditor(server) + form = {} + formRev = formRev + 1 + if server then + editingId = server.id + formProto = server.protocol or "ssh" + for k, v in pairs(server) do form[k] = v end + else + editingId = nil + formProto = "ssh" + form.port = "22" + end + navigate("editor") +end + +function onAddServer() openEditor(nil) end + +function onSave() + local p = { protocol = formProto, name = form.name or "" } + -- Persisted via the models' extra="allow"; only the UI reads it. + local cc = (form.country or ""):lower():gsub("%s", "") + if #cc == 2 then p.country = cc end + if editingId then p.id = editingId end + if formProto == "ssh" then + p.host = form.host; p.port = num(form.port, 22); p.user = form.user + if form.password and form.password ~= "" then p.password = form.password end + if form.keyFile and form.keyFile ~= "" then p.keyFile = form.keyFile end + elseif formProto == "vless" then + p.address = form.address; p.port = num(form.port, 443); p.uuid = form.uuid + p.transport = form.transport or "tcp"; p.security = form.security or "none" + for _, k in ipairs({ "sni", "flow", "pbk", "sid", "fp", "path", "host", "serviceName" }) do + if form[k] and form[k] ~= "" then p[k] = form[k] end + end + elseif formProto == "vmess" then + p.address = form.address; p.port = num(form.port, 443); p.uuid = form.uuid + p.alterId = num(form.alterId, 0); p.transport = form.transport or "tcp" + for _, k in ipairs({ "sni", "path", "host" }) do + if form[k] and form[k] ~= "" then p[k] = form[k] end + end + elseif formProto == "shadowsocks" then + p.address = form.address; p.port = num(form.port, 8388) + p.method = form.method; p.password = form.password + elseif formProto == "socks5" then + p.host = form.host; p.port = num(form.port, 1080) + if form.username and form.username ~= "" then p.username = form.username end + if form.password and form.password ~= "" then p.password = form.password end + end + send(editingId and "UpdateServer" or "AddServer", { p }) + navigate("main") +end + +function onDelete() + if editingId then send("RemoveServer", { editingId }) end + editingId = nil + navigate("main") +end + +-- editor field handlers (uncontrolled inputs → accumulate in form) +function onFld_name(v) form.name = v end +function onFld_country(v) form.country = v end +function onFld_host(v) form.host = v end +function onFld_address(v) form.address = v end +function onFld_port(v) form.port = v end +function onFld_user(v) form.user = v end +function onFld_password(v) form.password = v end +function onFld_keyFile(v) form.keyFile = v end +function onFld_uuid(v) form.uuid = v end +function onFld_method(v) form.method = v end +function onFld_sni(v) form.sni = v end +function onFld_flow(v) form.flow = v end +function onFld_fp(v) form.fp = v end +function onFld_pbk(v) form.pbk = v end +function onFld_sid(v) form.sid = v end +function onFld_path(v) form.path = v end +function onFld_serviceName(v) form.serviceName = v end +function onFld_username(v) form.username = v end +function onFld_alterId(v) form.alterId = v end +function onFldTransport(_, label) form.transport = label end +function onFldSecurity(_, label) form.security = label end +function onFldProto(_, label) formProto = label; formRev = formRev + 1; render() end + +-- ── rules handlers ────────────────────────────────────────────── +function onKill(value) send("SetKillSwitch", { value == "true" }) end + +-- Arms on the first click, fires on the second. Every state.set is delivered in +-- order (verified — the host queues them rather than collapsing to the last), +-- so fanning out one RemoveServer per id is safe. +function onResetServers() + if not resetArmed then + resetArmed = true + render() + return + end + resetArmed = false + for _, s in ipairs(noctalia.state.get("servers") or {}) do + send("RemoveServer", { s.id }) + end + render() +end +function onRulePattern(v) ruleForm.pattern = v end +function onRuleType(_, label) ruleForm.type = label end +function onRuleAdd() + if not ruleForm.pattern or ruleForm.pattern == "" then return end + send("AddRoutingRule", { { pattern = ruleForm.pattern, type = ruleForm.type, enabled = true } }) + ruleForm.pattern = "" + ruleRev = ruleRev + 1 + render() +end + +local function onPresetAt(slot, value) + local key = presetKeys[slot]; if key then send("TogglePreset", { key, value == "true" }) end +end +local function onRuleDelAt(slot) + local id = ruleIds[slot]; if id then send("RemoveRoutingRule", { id }) end +end + +-- ── subscription handlers ─────────────────────────────────────── +function onSubUrl(v) subForm.url = v end +function onSubName(v) subForm.name = v end +function onSubAdd() + if not subForm.url or subForm.url == "" then return end + send("AddSubscription", { subForm.url, subForm.name or "" }) + subForm.url = ""; subForm.name = ""; subRev = subRev + 1; render() +end +local function onSubUpdAt(slot) local u = subUrls[slot]; if u then send("UpdateSubscription", { u }) end end +local function onSubDelAt(slot) local u = subUrls[slot]; if u then send("RemoveSubscription", { u }) end end + +-- ── server row handlers ───────────────────────────────────────── +local function onServerAt(slot) + local id = slotIds[slot]; if not id then return end + local s = noctalia.state.get("status") or {} + if s.running then send("SwitchServer", { id }) + else send("StartProxy", { id, s.mode or "rules", s.proxyMode or "system" }) end +end +local function onEditAt(slot) + local id = slotIds[slot]; if not id then return end + for _, sv in ipairs(noctalia.state.get("servers") or {}) do + if sv.id == id then openEditor(sv); return end + end +end +local function onDelAt(slot) + local id = slotIds[slot]; if id then send("RemoveServer", { id }) end +end + +-- ── fixed handler pools (host resolves onClick by global name) ─── +function onServer0() onServerAt(0) end +function onServer1() onServerAt(1) end +function onServer2() onServerAt(2) end +function onServer3() onServerAt(3) end +function onServer4() onServerAt(4) end +function onServer5() onServerAt(5) end +function onServer6() onServerAt(6) end +function onServer7() onServerAt(7) end +function onServer8() onServerAt(8) end +function onServer9() onServerAt(9) end +function onServer10() onServerAt(10) end +function onServer11() onServerAt(11) end +function onServer12() onServerAt(12) end +function onServer13() onServerAt(13) end +function onServer14() onServerAt(14) end +function onServer15() onServerAt(15) end +function onServer16() onServerAt(16) end +function onServer17() onServerAt(17) end +function onServer18() onServerAt(18) end +function onServer19() onServerAt(19) end + +function onEdit0() onEditAt(0) end +function onEdit1() onEditAt(1) end +function onEdit2() onEditAt(2) end +function onEdit3() onEditAt(3) end +function onEdit4() onEditAt(4) end +function onEdit5() onEditAt(5) end +function onEdit6() onEditAt(6) end +function onEdit7() onEditAt(7) end +function onEdit8() onEditAt(8) end +function onEdit9() onEditAt(9) end +function onEdit10() onEditAt(10) end +function onEdit11() onEditAt(11) end +function onEdit12() onEditAt(12) end +function onEdit13() onEditAt(13) end +function onEdit14() onEditAt(14) end +function onEdit15() onEditAt(15) end +function onEdit16() onEditAt(16) end +function onEdit17() onEditAt(17) end +function onEdit18() onEditAt(18) end +function onEdit19() onEditAt(19) end + +function onDel0() onDelAt(0) end +function onDel1() onDelAt(1) end +function onDel2() onDelAt(2) end +function onDel3() onDelAt(3) end +function onDel4() onDelAt(4) end +function onDel5() onDelAt(5) end +function onDel6() onDelAt(6) end +function onDel7() onDelAt(7) end +function onDel8() onDelAt(8) end +function onDel9() onDelAt(9) end +function onDel10() onDelAt(10) end +function onDel11() onDelAt(11) end +function onDel12() onDelAt(12) end +function onDel13() onDelAt(13) end +function onDel14() onDelAt(14) end +function onDel15() onDelAt(15) end +function onDel16() onDelAt(16) end +function onDel17() onDelAt(17) end +function onDel18() onDelAt(18) end +function onDel19() onDelAt(19) end + +function onPreset0(v) onPresetAt(0, v) end +function onPreset1(v) onPresetAt(1, v) end +function onPreset2(v) onPresetAt(2, v) end +function onPreset3(v) onPresetAt(3, v) end +function onPreset4(v) onPresetAt(4, v) end +function onPreset5(v) onPresetAt(5, v) end +function onPreset6(v) onPresetAt(6, v) end +function onPreset7(v) onPresetAt(7, v) end + +function onRuleDel0() onRuleDelAt(0) end +function onRuleDel1() onRuleDelAt(1) end +function onRuleDel2() onRuleDelAt(2) end +function onRuleDel3() onRuleDelAt(3) end +function onRuleDel4() onRuleDelAt(4) end +function onRuleDel5() onRuleDelAt(5) end +function onRuleDel6() onRuleDelAt(6) end +function onRuleDel7() onRuleDelAt(7) end +function onRuleDel8() onRuleDelAt(8) end +function onRuleDel9() onRuleDelAt(9) end +function onRuleDel10() onRuleDelAt(10) end +function onRuleDel11() onRuleDelAt(11) end +function onRuleDel12() onRuleDelAt(12) end +function onRuleDel13() onRuleDelAt(13) end +function onRuleDel14() onRuleDelAt(14) end +function onRuleDel15() onRuleDelAt(15) end +function onRuleDel16() onRuleDelAt(16) end +function onRuleDel17() onRuleDelAt(17) end +function onRuleDel18() onRuleDelAt(18) end +function onRuleDel19() onRuleDelAt(19) end + +function onSubUpd0() onSubUpdAt(0) end +function onSubUpd1() onSubUpdAt(1) end +function onSubUpd2() onSubUpdAt(2) end +function onSubUpd3() onSubUpdAt(3) end +function onSubUpd4() onSubUpdAt(4) end +function onSubUpd5() onSubUpdAt(5) end +function onSubUpd6() onSubUpdAt(6) end +function onSubUpd7() onSubUpdAt(7) end +function onSubUpd8() onSubUpdAt(8) end +function onSubUpd9() onSubUpdAt(9) end +function onSubUpd10() onSubUpdAt(10) end +function onSubUpd11() onSubUpdAt(11) end + +function onSubDel0() onSubDelAt(0) end +function onSubDel1() onSubDelAt(1) end +function onSubDel2() onSubDelAt(2) end +function onSubDel3() onSubDelAt(3) end +function onSubDel4() onSubDelAt(4) end +function onSubDel5() onSubDelAt(5) end +function onSubDel6() onSubDelAt(6) end +function onSubDel7() onSubDelAt(7) end +function onSubDel8() onSubDelAt(8) end +function onSubDel9() onSubDelAt(9) end +function onSubDel10() onSubDelAt(10) end +function onSubDel11() onSubDelAt(11) end diff --git a/ruh-vpn/plugin.toml b/ruh-vpn/plugin.toml new file mode 100644 index 0000000..cb0c741 --- /dev/null +++ b/ruh-vpn/plugin.toml @@ -0,0 +1,78 @@ +id = "umedbazarov/ruh-vpn" +name = "Ruh VPN" +version = "0.1.0" +plugin_api = 3 +author = "Умеджон Базаров" +license = "MIT" +icon = "shield-lock" +description = "VPN/proxy manager (sing-box): SSH, VLESS, VMess, Shadowsocks, SOCKS5, routing rules, kill switch." +tags = ["bar", "panel", "service", "shortcut", "network", "privacy"] +dependencies = ["sing-box", "python3", "ssh", "sshpass", "gsettings", "pkexec", "setcap", "getcap", "nft", "pkill"] + +# Plugin settings +[[setting]] +key = "backend_python" +type = "file" +label_key = "settings.backend_python.label" +description_key = "settings.backend_python.description" +default = "python3" + +[[setting]] +key = "auto_start" +type = "bool" +label_key = "settings.auto_start.label" +description_key = "settings.auto_start.description" +default = false + +[[setting]] +key = "geoip_country" +type = "bool" +label_key = "settings.geoip_country.label" +description_key = "settings.geoip_country.description" +default = true + +[[setting]] +key = "control_port" +type = "int" +label_key = "settings.control_port.label" +description_key = "settings.control_port.description" +default = 11090 +min = 1024 +max = 65535 +advanced = true + +# Headless service: supervises the Python backend +[[service]] +id = "vpn_service" +entry = "service.luau" + +# Bar widget +[[widget]] +id = "vpn_widget" +entry = "widget.luau" + + [[widget.setting]] + key = "show_ping" + type = "bool" + label_key = "settings.show_ping.label" + default = true + + [[widget.setting]] + key = "show_traffic" + type = "bool" + label_key = "settings.show_traffic.label" + default = false + +# Main panel +[[panel]] +id = "vpn_panel" +entry = "panel.luau" +width = 460 +height = 560 +placement = "attached" +position = "auto" + +# Control-center tile +[[shortcut]] +id = "vpn_toggle" +entry = "shortcut.luau" diff --git a/ruh-vpn/pyproject.toml b/ruh-vpn/pyproject.toml new file mode 100644 index 0000000..1de6b37 --- /dev/null +++ b/ruh-vpn/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "ruh-vpn" +version = "0.1.0" +description = "VPN/proxy management backend for the Ruh VPN Noctalia plugin" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.0", + "aiofiles>=23.0", + "aiohttp>=3.9", + "aiohttp-socks>=0.8", +] + +[project.optional-dependencies] +test = ["pytest>=8"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["backend*"] diff --git a/ruh-vpn/service.luau b/ruh-vpn/service.luau new file mode 100644 index 0000000..96de2cd --- /dev/null +++ b/ruh-vpn/service.luau @@ -0,0 +1,310 @@ +--!nonstrict +-- service.luau — headless supervisor for the Python VPN backend. +-- +-- Responsibilities: +-- * Probe the backend HTTP control port. If a backend is already running +-- (e.g. the user's active proxy), ATTACH to it (poll only, never spawn a +-- duplicate). Otherwise SPAWN it via runStream and consume its stdout +-- event stream. +-- * Bridge: backend stdout events → noctalia.state.set(...) +-- UI commands (state "cmd") → HTTP POST /rpc +-- * Poll GetStatus / GetHealth / GetTrafficStats on the update() tick, plus +-- GetLogs while attached (an attached backend sends us no events). +-- +-- Cross-VM contract (all plain values through noctalia.state): +-- state "status" : { running, activeServerId, mode, proxyMode, ... } +-- state "health" : { latency_ms, jitter_ms, down_mbps, ... } +-- state "traffic" : { bytes_sent, bytes_received, uptime_seconds, ... } +-- state "servers" : [ { id, name, protocol, host, ... } ] +-- state "rules" : [ ... ] +-- state "subscriptions" : [ ... ] +-- state "presets" : [ { key, name, flag, enabled } ] +-- state "killswitch" : { enabled, active } +-- state "logs" : [ { level, message } ] (ring, last 200) +-- state "backend" : { ready, owns, error? } +-- state "cmd" : { method, args, nonce } (written by UI) +-- state "cmd_result" : { nonce, method, result, error } + +local PORT = tonumber(noctalia.getConfig("control_port")) or 11090 +local PY = noctalia.getConfig("backend_python") or "python3" +local PROJDIR = noctalia.pluginDir() or "." +local DATA_DIR = noctalia.pluginDataDir() or noctalia.expandPath("~/.local/state/ruh-vpn") +local RUNTIME_DIR = DATA_DIR .. "/runtime" +local PIDFILE = RUNTIME_DIR .. "/ruh-vpn-backend.pid" +local TOKENFILE = RUNTIME_DIR .. "/ruh-vpn-control.token" +local BASE = "http://127.0.0.1:" .. tostring(PORT) + +local AUTO = noctalia.getConfig("auto_start") == true +local GEOIP = noctalia.getConfig("geoip_country") ~= false + +local ownsBackend = false +local ready = false +local autoStarted = false +local lastNonce = nil +local failures = 0 -- consecutive failed polls; 3 in a row = backend gone +local spawning = false -- preflight or backend start in flight +local lastSpawnError = nil -- dedupes notifyError across spawn retries + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +-- ── control token ─────────────────────────────────────────────── +-- The backend generates a per-launch token and writes it to TOKENFILE (0600) +-- once its port is bound; every /rpc call must present it. /healthz is open, +-- so the attach probe still works before the token is read. +local TOKEN = nil + +local function readToken() + local contents = noctalia.readFile(TOKENFILE) + local t = contents and contents:match("%S+") or nil + if t then TOKEN = t end + return t ~= nil +end + +-- ── RPC helper ────────────────────────────────────────────────── +local function rpc(method, args, cb) + local headers = { "Content-Type: application/json" } + if TOKEN then headers[#headers + 1] = "Authorization: Bearer " .. TOKEN end + return noctalia.http({ + url = BASE .. "/rpc", + method = "POST", + headers = headers, + body = noctalia.json.encode({ method = method, args = args or {} }), + }, function(resp) + if not cb then return end + if resp and resp.ok then + local d = noctalia.json.decode(resp.body) + if d then cb(d.result, d.error) else cb(nil, "bad json") end + else + cb(nil, resp and ("http " .. tostring(resp.status)) or "no response") + end + end) +end + +-- ── list refreshers → state ───────────────────────────────────── +local function pub(key) return function(r) if r ~= nil then noctalia.state.set(key, r) end end end +local function refreshServers() rpc("GetServers", {}, pub("servers")) end +local function refreshRules() rpc("GetRoutingRules", {}, pub("rules")) end +local function refreshSubs() rpc("GetSubscriptions", {}, pub("subscriptions")) end +local function refreshPresets() rpc("GetPresets", {}, pub("presets")) end +local function refreshKill() rpc("GetKillSwitchStatus", {}, pub("killswitch")) end + +-- GetLogs returns preformatted lines (" [level] message"), while state +-- "logs" holds { level, message }. Split them so the panel can colour by level. +local function parseLogLine(line) + local lvl, msg = tostring(line):match("^%S+%s+%[(%w+)%]%s+(.*)$") + if not lvl then return { level = "info", message = tostring(line) } end + return { level = lvl, message = msg } +end + +-- Only the SPAWN path sees LogMessage events on stdout. When we attach to a +-- backend that outlived a previous shell there is no event stream at all, so +-- the logs view stayed empty; poll the backend's own ring buffer instead. +local function refreshLogs() + rpc("GetLogs", {}, function(list) + if type(list) ~= "table" then return end + local logs = {} + for _, line in ipairs(list) do logs[#logs + 1] = parseLogLine(line) end + noctalia.state.set("logs", logs) + end) +end + +local function refreshLists() + refreshServers(); refreshRules(); refreshSubs(); refreshPresets(); refreshKill(); refreshLogs() +end + +-- auto-connect the active server once, if enabled and currently idle +local function maybeAutoStart() + if not AUTO or autoStarted then return end + autoStarted = true + rpc("GetStatus", {}, function(st) + if st and not st.running and st.activeServerId and st.activeServerId ~= "" then + rpc("StartProxy", { st.activeServerId, st.mode or "rules", st.proxyMode or "system" }) + end + end) +end + +-- ── backend stdout event consumer ─────────────────────────────── +local function onEvent(line) + local ev = noctalia.json.decode(line) + if type(ev) ~= "table" or ev.event == nil then return end + local e = ev.event + if e == "StatusChanged" then + noctalia.state.set("status", ev.data or {}) + elseif e == "TrafficUpdate" then + noctalia.state.set("traffic", ev.data or {}) + elseif e == "ServerListChanged" then + refreshServers() + elseif e == "LogMessage" then + local logs = noctalia.state.get("logs") or {} + table.insert(logs, ev.data or {}) + while #logs > 200 do table.remove(logs, 1) end + noctalia.state.set("logs", logs) + elseif e == "ready" then + ready = true + spawning = false + failures = 0 + readToken() -- written before "ready" is emitted, so it is there by now + noctalia.state.set("backend", { ready = true, owns = ownsBackend }) + refreshLists() + maybeAutoStart() + elseif e == "error" then + spawning = false + noctalia.state.set("backend", { ready = false, error = (ev.data and ev.data.message) or "error" }) + elseif e == "exit" then + ready = false + spawning = false + noctalia.state.set("backend", { ready = false, owns = ownsBackend }) + end +end + +-- ── spawn vs attach ───────────────────────────────────────────── +-- The backend needs third-party Python packages the plugin may not install +-- itself (community rules forbid fetching and running code automatically). +-- Probe the configured interpreter first, so a missing package surfaces as a +-- readable panel message instead of a stack trace in the event stream. +local function publishSpawnError(message) + spawning = false + noctalia.state.set("backend", { ready = false, error = message }) + if message ~= lastSpawnError then + lastSpawnError = message + noctalia.notifyError("Ruh VPN", message) + end +end + +local function doSpawn(py) + ownsBackend = true + local cmd = "cd " .. shellQuote(PROJDIR) + .. " && RUH_VPN_CONTROL_PORT=" .. tostring(PORT) + .. " RUH_VPN_GEOIP=" .. (GEOIP and "1" or "0") + .. " RUH_VPN_DATA_DIR=" .. shellQuote(DATA_DIR) + .. " RUH_VPN_RUNTIME_DIR=" .. shellQuote(RUNTIME_DIR) + .. " exec " .. shellQuote(py) .. " -m backend.app" + noctalia.runStream(cmd, onEvent) +end + +local function spawnBackend() + spawning = true + local py = PY + if py:sub(1, 1) == "~" then py = noctalia.expandPath(py) end + local probe = "import importlib.util,sys;" + .. "missing=[m for m in ('pydantic','aiofiles','aiohttp','aiohttp_socks') if importlib.util.find_spec(m) is None];" + .. "print(','.join(missing));" + .. "sys.exit(1 if missing else 0)" + noctalia.runAsync(shellQuote(py) .. " -c " .. shellQuote(probe), function(res) + if res.exitCode == 0 then + lastSpawnError = nil + doSpawn(py) + elseif res.exitCode == 1 and res.stdout:match("%S") then + local missing = res.stdout:match("%S+"):gsub(",", ", ") + publishSpawnError("Python packages missing for " .. py .. ": " .. missing + .. ". Install them and set backend_python (see README).") + else + publishSpawnError("Cannot run " .. py + .. " (exit " .. tostring(res.exitCode) .. "). Check the backend_python setting.") + end + end, 30000) +end + +local function attachOrSpawn() + if spawning then return end + noctalia.http({ url = BASE .. "/healthz" }, function(resp) + if resp and resp.ok then + -- Attach: a backend outlived a previous shell, and its proxy with it. + ownsBackend = false + ready = true + failures = 0 + readToken() -- the running backend published its token at startup + noctalia.state.set("backend", { ready = true, owns = false }) + refreshLists() + maybeAutoStart() + else + spawnBackend() + end + end) +end + +attachOrSpawn() + +-- ── periodic polling ──────────────────────────────────────────── +function update() + if not ready then return end + -- Watchdog. The probe above can attach to a backend that is already on its + -- way out: reloading this file makes the old instance's onExit SIGTERM the + -- backend while the new instance is probing, so /healthz answers once and + -- then the process is gone. Without this the service stayed bound to a + -- corpse forever, since attach-or-spawn only ran at startup. + rpc("GetStatus", {}, function(st, err) + if err then + failures = failures + 1 + if failures >= 3 then + failures = 0 + ready = false + noctalia.state.set("backend", { ready = false, owns = ownsBackend }) + attachOrSpawn() + end + return + end + failures = 0 + if st ~= nil then noctalia.state.set("status", st) end + end) + rpc("GetHealth", {}, pub("health")) + local st = noctalia.state.get("status") + if st and st.running then + rpc("GetTrafficStats", {}, pub("traffic")) + end + -- Attached: no stdout stream to feed logs, so keep pulling the ring buffer. + -- When we own the backend, LogMessage events already deliver them live. + if not ownsBackend then refreshLogs() end +end + +-- ── UI command channel ────────────────────────────────────────── +noctalia.state.watch("cmd", function(c) + if type(c) ~= "table" or c.nonce == nil or c.nonce == lastNonce then return end + lastNonce = c.nonce + local method = c.method + rpc(method, c.args or {}, function(result, err) + noctalia.state.set("cmd_result", { nonce = c.nonce, method = method, result = result, error = err }) + if err then noctalia.notifyError("VPN", tostring(err)) end + if method == "RunSpeedTest" and result then noctalia.state.set("speedtest", result) end + if method == "CheckDnsLeak" and result then noctalia.state.set("dnsleak", result) end + if method == "ParseShareLink" and not err then refreshServers() end + -- refresh affected lists after mutations + if method == "AddServer" or method == "UpdateServer" or method == "RemoveServer" or method == "SwitchServer" then + refreshServers() + elseif method == "AddRoutingRule" or method == "RemoveRoutingRule" then + refreshRules() + elseif method == "TogglePreset" then + refreshPresets() + elseif method == "SetKillSwitch" then + refreshKill() + elseif method == "AddSubscription" or method == "RemoveSubscription" or method == "UpdateSubscription" then + refreshSubs(); refreshServers() + end + end) +end) + +-- ── settings changes ──────────────────────────────────────────── +-- The interesting case: the user just pointed backend_python at an +-- interpreter that has the packages. Retry the spawn without a reload. +function onConfigChanged() + PY = noctalia.getConfig("backend_python") or "python3" + AUTO = noctalia.getConfig("auto_start") == true + GEOIP = noctalia.getConfig("geoip_country") ~= false + if not ready then attachOrSpawn() end +end + +-- ── teardown: only kill the backend WE spawned ────────────────── +function onExit(sig) + if ownsBackend then + -- SIGTERM lets the backend tear down its proxy processes cleanly. + -- The pidfile holds " ". Read and validate the first field + -- before signalling it so the port can never be mistaken for a PID. + local cmd = "if read pid rest < " .. shellQuote(PIDFILE) + .. "; then case \"$pid\" in ''|*[!0-9]*) ;; *) kill \"$pid\" 2>/dev/null ;; esac; fi" + noctalia.runAsync(cmd) + end +end + +noctalia.setUpdateInterval(2000) diff --git a/ruh-vpn/shortcut.luau b/ruh-vpn/shortcut.luau new file mode 100644 index 0000000..84565c0 --- /dev/null +++ b/ruh-vpn/shortcut.luau @@ -0,0 +1,33 @@ +--!nonstrict +-- shortcut.luau — control-center tile: toggle the proxy on/off. + +local nonce = 0 +local function send(method, args) + nonce = nonce + 1 + noctalia.state.set("cmd", { method = method, args = args or {}, nonce = tostring(nonce) .. ":" .. tostring(os.time()) }) +end + +local function refresh() + local s = noctalia.state.get("status") or {} + local running = s.running == true + shortcut.setIcon(running and "lock" or "lock-open") + shortcut.setLabel(running and noctalia.tr("shortcut.on") or noctalia.tr("shortcut.off")) + shortcut.setActive(running) +end + +function update() refresh() end + +function onClick() + local s = noctalia.state.get("status") or {} + if s.running then + send("StopProxy", {}) + else + if s.activeServerId and s.activeServerId ~= "" then + send("StartProxy", { s.activeServerId, s.mode or "rules", s.proxyMode or "system" }) + else + noctalia.notifyError("VPN", noctalia.tr("error.no-server")) + end + end +end + +noctalia.setUpdateInterval(2000) diff --git a/ruh-vpn/tests/conftest.py b/ruh-vpn/tests/conftest.py new file mode 100644 index 0000000..c97eede --- /dev/null +++ b/ruh-vpn/tests/conftest.py @@ -0,0 +1,17 @@ +"""Point the backend at a throwaway data dir BEFORE any backend import. + +backend.paths reads RUH_VPN_* at import time, so this must run first — +pytest imports conftest before collecting test modules, which guarantees it. +""" + +import os +import sys +import tempfile +from pathlib import Path + +_tmp = tempfile.mkdtemp(prefix="ruh-vpn-test-") +os.environ["RUH_VPN_DATA_DIR"] = _tmp +os.environ["RUH_VPN_RUNTIME_DIR"] = os.path.join(_tmp, "runtime") +os.environ["RUH_VPN_GEOIP"] = "0" + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/ruh-vpn/tests/test_auth.py b/ruh-vpn/tests/test_auth.py new file mode 100644 index 0000000..6e51950 --- /dev/null +++ b/ruh-vpn/tests/test_auth.py @@ -0,0 +1,45 @@ +import os +from types import SimpleNamespace + +from backend.http.control import ControlServer + + +def _control(token: str = "right-token", token_file=None) -> ControlServer: + state = SimpleNamespace( + status_listeners=[], server_list_listeners=[], log_listeners=[] + ) + service = SimpleNamespace(state=state, add_traffic_listener=lambda cb: None) + return ControlServer(service, token=token, token_file=token_file) + + +def _request(header: str | None): + headers = {} if header is None else {"Authorization": header} + return SimpleNamespace(headers=headers) + + +def test_missing_header_rejected(): + assert not _control()._authorized(_request(None)) + + +def test_wrong_token_rejected(): + assert not _control()._authorized(_request("Bearer wrong")) + + +def test_wrong_scheme_rejected(): + assert not _control()._authorized(_request("Basic right-token")) + + +def test_correct_token_accepted(): + assert _control()._authorized(_request("Bearer right-token")) + + +def test_empty_configured_token_rejects_everything(): + # A backend that somehow starts without a token must fail closed. + assert not _control(token="")._authorized(_request("Bearer ")) + + +def test_token_file_written_0600(tmp_path): + path = tmp_path / "control.token" + _control(token="secret", token_file=path)._publish_token() + assert path.read_text().strip() == "secret" + assert (os.stat(path).st_mode & 0o777) == 0o600 diff --git a/ruh-vpn/tests/test_config_builder.py b/ruh-vpn/tests/test_config_builder.py new file mode 100644 index 0000000..6fa16a7 --- /dev/null +++ b/ruh-vpn/tests/test_config_builder.py @@ -0,0 +1,44 @@ +import json +import shutil +import subprocess + +import pytest + +from backend.routing.rules import PRESETS +from backend.singbox import config_builder + +ALL = list(PRESETS.keys()) + + +def test_rules_config_includes_presets(): + cfg = config_builder.build_rules_config(active_presets=ALL) + tags = {rs["tag"] for rs in cfg["route"]["rule_set"]} + expected = {rs["tag"] for p in PRESETS.values() for rs in p["rule_sets"]} + assert expected <= tags + proxy_rules = [r for r in cfg["route"]["rules"] if r.get("outbound") == "proxy" and "rule_set" in r] + assert len(proxy_rules) == len(ALL) + + +def test_rules_config_dns_covers_domain_rule_sets(): + cfg = config_builder.build_rules_config(active_presets=ALL) + dns_rule_sets = [r["rule_set"] for r in cfg["dns"]["rules"] if "rule_set" in r] + flattened = {t for group in dns_rule_sets for t in group} + assert "refilter_domains" in flattened + assert "geosite_noncn" in flattened + assert "geosite_sanctioned" in flattened + + +@pytest.mark.skipif(shutil.which("sing-box") is None, reason="sing-box not installed") +@pytest.mark.parametrize("presets", [[], ALL]) +def test_sing_box_accepts_generated_configs(tmp_path, presets): + for name, cfg in { + "rules": config_builder.build_rules_config(active_presets=presets), + "global": config_builder.build_global_config(), + }.items(): + path = tmp_path / f"{name}.json" + path.write_text(json.dumps(cfg)) + proc = subprocess.run( + ["sing-box", "check", "-c", str(path)], + capture_output=True, text=True, timeout=30, + ) + assert proc.returncode == 0, f"{name}: {proc.stderr}" diff --git a/ruh-vpn/tests/test_kill_switch.py b/ruh-vpn/tests/test_kill_switch.py new file mode 100644 index 0000000..90b7a61 --- /dev/null +++ b/ruh-vpn/tests/test_kill_switch.py @@ -0,0 +1,64 @@ +"""build_ruleset must never let untrusted text into the nft program. + +The ruleset text is executed by nft with root privileges, and server +addresses can come from untrusted subscriptions. +""" + +from backend.service.kill_switch import TABLE_NAME, build_ruleset + + +def test_ipv4_with_port(): + rs = build_ruleset(["203.0.113.7"], 443) + assert " ip daddr 203.0.113.7 tcp dport 443 accept\n" in rs + assert f"table inet {TABLE_NAME}" in rs + + +def test_ipv6_goes_to_ip6_rule(): + rs = build_ruleset(["2001:db8::1"], 8443) + assert " ip6 daddr 2001:db8::1 tcp dport 8443 accept\n" in rs + assert "ip daddr 2001:db8::1" not in rs + + +def test_ip_without_port(): + rs = build_ruleset(["203.0.113.7"], None) + assert " ip daddr 203.0.113.7 accept\n" in rs + + +def test_multiple_ips(): + rs = build_ruleset(["203.0.113.7", "2001:db8::1"], 443) + assert "ip daddr 203.0.113.7 tcp dport 443 accept" in rs + assert "ip6 daddr 2001:db8::1 tcp dport 443 accept" in rs + + +def test_domain_is_dropped(): + rs = build_ruleset(["evil.example.com"], 443) + assert "evil.example.com" not in rs + + +def test_newline_injection_is_dropped(): + payload = "1.2.3.4\ndelete table inet filter\n" + rs = build_ruleset([payload], 443) + assert "delete table inet filter" not in rs + # and the payload as a whole must not appear either + assert payload not in rs + + +def test_non_canonical_ip_is_reemitted_canonically(): + rs = build_ruleset(["2001:0DB8:0000:0000:0000:0000:0000:0001"], None) + assert "ip6 daddr 2001:db8::1 accept" in rs + + +def test_ports_are_coerced_to_int(): + rs = build_ruleset(["1.2.3.4"], "443") + assert "tcp dport 443 accept" in rs + rs2 = build_ruleset(None, None, extra_allow_tcp=["11080", 11081]) + assert "tcp dport { 11080, 11081 } accept" in rs2 + + +def test_no_server_lines_without_ips(): + rs = build_ruleset(None, None) + assert "daddr" not in rs.split("chain input")[0].replace( + "ip daddr 192.168.0.0/16 accept", "" + ).replace("ip daddr 10.0.0.0/8 accept", "").replace( + "ip daddr 172.16.0.0/12 accept", "" + ) diff --git a/ruh-vpn/tests/test_models.py b/ruh-vpn/tests/test_models.py new file mode 100644 index 0000000..f1a50d1 --- /dev/null +++ b/ruh-vpn/tests/test_models.py @@ -0,0 +1,39 @@ +from backend.models.server import ( + SENSITIVE_FIELDS, + parse_server, + server_to_dict, + server_to_public_dict, +) + + +def test_public_dict_strips_secrets(): + server = parse_server({ + "id": "s1", "name": "n", "protocol": "vless", + "address": "example.com", "port": 443, + "uuid": "11111111-2222-3333-4444-555555555555", + }) + full = server_to_dict(server) + public = server_to_public_dict(server) + assert full["uuid"] + for key in SENSITIVE_FIELDS: + assert key not in public + assert public["address"] == "example.com" + assert public["port"] == 443 + + +def test_public_dict_ssh_password(): + server = parse_server({ + "id": "s2", "name": "n", "protocol": "ssh", + "host": "example.com", "port": 22, "user": "root", "password": "pw", + }) + public = server_to_public_dict(server) + assert "password" not in public + assert public["user"] == "root" + + +def test_socks_alias(): + server = parse_server({ + "id": "s3", "name": "n", "protocol": "socks", + "host": "example.com", "port": 1080, + }) + assert server.protocol == "socks5" diff --git a/ruh-vpn/tests/test_parsers.py b/ruh-vpn/tests/test_parsers.py new file mode 100644 index 0000000..41d857e --- /dev/null +++ b/ruh-vpn/tests/test_parsers.py @@ -0,0 +1,41 @@ +import base64 + +from backend.subscription.parsers import parse_share_link + + +def test_vless_link(): + link = ( + "vless://11111111-2222-3333-4444-555555555555@example.com:443" + "?type=ws&security=tls&sni=cdn.example.com&path=%2Fws#My%20VLESS" + ) + data = parse_share_link(link) + assert data is not None + assert data["protocol"] == "vless" + assert data["address"] == "example.com" + assert data["port"] == 443 + assert data["uuid"] == "11111111-2222-3333-4444-555555555555" + assert data["transport"] == "ws" + assert data["security"] == "tls" + + +def test_ss_link(): + userinfo = base64.urlsafe_b64encode(b"aes-256-gcm:secretpw").decode().rstrip("=") + data = parse_share_link(f"ss://{userinfo}@example.com:8388#SS") + assert data is not None + assert data["protocol"] == "shadowsocks" + assert data["method"] == "aes-256-gcm" + assert data["password"] == "secretpw" + assert data["port"] == 8388 + + +def test_socks5_link(): + data = parse_share_link("socks5://user:pw@example.com:1080#S5") + assert data is not None + assert data["protocol"] == "socks5" + assert data["port"] == 1080 + + +def test_unsupported_link(): + assert parse_share_link("trojan://whatever@example.com:443") is None + assert parse_share_link("not a link") is None + assert parse_share_link("") is None diff --git a/ruh-vpn/tests/test_persistence.py b/ruh-vpn/tests/test_persistence.py new file mode 100644 index 0000000..fc9d75f --- /dev/null +++ b/ruh-vpn/tests/test_persistence.py @@ -0,0 +1,27 @@ +import asyncio +import os + +from backend.models.server import parse_server +from backend.storage.persistence import load_servers, save_servers +from backend.paths import DATA_DIR + + +def test_servers_round_trip_with_private_permissions(): + server = parse_server({ + "id": "p1", "name": "n", "protocol": "ssh", + "host": "example.com", "port": 22, "user": "root", "password": "pw", + }) + + async def run(): + await save_servers([server]) + return await load_servers() + + loaded = asyncio.run(run()) + assert len(loaded) == 1 + assert loaded[0].id == "p1" + assert loaded[0].password == "pw" # secrets persist on disk, 0600 + + server_files = [p for p in DATA_DIR.iterdir() if p.is_file()] + assert server_files, "expected persisted files in the data dir" + for path in server_files: + assert (os.stat(path).st_mode & 0o777) == 0o600, path diff --git a/ruh-vpn/tests/test_rules.py b/ruh-vpn/tests/test_rules.py new file mode 100644 index 0000000..61885c5 --- /dev/null +++ b/ruh-vpn/tests/test_rules.py @@ -0,0 +1,45 @@ +from backend.routing.rules import ( + PRESETS, + preset_domain_tags, + preset_route_rules, + preset_rule_sets, +) + +ALL = list(PRESETS.keys()) + + +def test_presets_shape(): + for key, preset in PRESETS.items(): + assert preset["key"] == key + assert preset["name"] and preset["flag"] and preset["description"] + assert preset["rule_sets"], key + for rs in preset["rule_sets"]: + assert rs["type"] == "remote" + assert rs["format"] == "binary" + assert rs["url"].startswith("https://") + assert rs["download_detour"] == "direct" + + +def test_rule_set_tags_unique_across_presets(): + tags = [rs["tag"] for p in PRESETS.values() for rs in p["rule_sets"]] + assert len(tags) == len(set(tags)) + + +def test_route_rules_target_proxy(): + rules = preset_route_rules(ALL) + assert len(rules) == len(ALL) + for rule in rules: + assert rule["outbound"] == "proxy" + assert rule["rule_set"] + + +def test_every_preset_has_a_domain_rule_set(): + # The DNS layer resolves proxied domains through the tunnel; a preset + # whose tags all look IP-only would silently skip that protection. + for key in ALL: + assert preset_domain_tags([key]), key + + +def test_unknown_preset_ignored(): + assert preset_rule_sets(["nope"]) == [] + assert preset_route_rules(["nope"]) == [] diff --git a/ruh-vpn/thumbnail.webp b/ruh-vpn/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..097d04eb3ed8752e762bab6d5e2dc79bceeb96c8 GIT binary patch literal 43146 zcmWIYbaQK3!N3si>J$(bV4+a9f`Or6BGX((tpm(5Om>`1jSh`^3=9I(${wtW-Z+2e ztE%UA-mULW?>ufGH+LJG@~qgq3tcMXf877}Uv-a7s(s7<_)n*QpFh~H{NwnS{Yn3? z{rg-%s1fB8T5-~0dl z7xsTy{~`YUetr8oy94$ws-ylh{9pfy@n!w9_}}#_|0&fS{~P(U{aXH!{qN&n?mzR# z{m1od_5pvq{(by+{m=FX@>Ao|?8@I~U)cX7KFY57AM@Aj-|m0%KmEILzwQ5)`rCDp zUoC&!{JHp>;E&}W*FT?s0j@^kKaૃgWxq9l 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 IvPFOa06lHB<^TWy literal 0 HcmV?d00001 diff --git a/ruh-vpn/translations/en.json b/ruh-vpn/translations/en.json new file mode 100644 index 0000000..02b0b7f --- /dev/null +++ b/ruh-vpn/translations/en.json @@ -0,0 +1,107 @@ +{ + "title": "VPN", + "action": { + "start": "Start", + "stop": "Stop", + "add": "Add", + "save": "Save", + "delete": "Delete", + "run-test": "Run test", + "reset": "Reset", + "confirm": "Confirm" + }, + "panel": { + "routing": "Routing", + "via": "Via", + "servers": "Servers", + "no-servers": "No servers configured", + "starting": "Starting backend…", + "connected": "Connected", + "disconnected": "Disconnected", + "fallback-server-name": "Server" + }, + "editor": { + "new": "New server", + "edit": "Edit server", + "protocol": "Protocol", + "name": "Name", + "host": "Host", + "port": "Port", + "user": "User", + "password": "Password", + "keyfile": "Key file", + "address": "Address", + "uuid": "UUID", + "transport": "Transport", + "security": "Security", + "sni": "SNI", + "flow": "Flow", + "pbk": "Public key (pbk)", + "sid": "Short ID (sid)", + "fp": "Fingerprint", + "path": "Path", + "wshost": "WS/HTTP Host", + "alterid": "alterId", + "method": "Method", + "country": "Country code (for the flag)", + "keep": "leave empty to keep current" + }, + "rules": { + "title": "Routing & rules", + "killswitch": "Kill switch", + "killswitch-desc": "Block all traffic when the proxy is down", + "presets": "Country presets", + "custom": "Custom rules", + "none": "No custom rules", + "dns-check": "Check DNS", + "dns-hint": "DNS leak not checked yet", + "dns-leak": "DNS leak", + "dns-ok": "No DNS leak", + "reset-servers": "Reset all servers", + "reset-servers-desc": "Remove every server. Rules and settings stay." + }, + "subs": { + "title": "Subscriptions", + "name": "Name (optional)", + "none": "No subscriptions" + }, + "logs": { + "title": "Logs", + "empty": "No logs yet" + }, + "shortcut": { + "on": "VPN On", + "off": "VPN Off" + }, + "notice": { + "tun": "TUN mode may prompt for administrator rights" + }, + "error": { + "no-server": "No server selected", + "clipboard": "Clipboard is empty" + }, + "settings": { + "backend_python": { + "label": "Backend Python", + "description": "Python 3 executable with the backend dependencies installed" + }, + "control_port": { + "label": "Control port", + "description": "Localhost port for the backend HTTP control API" + }, + "show_ping": { + "label": "Show ping in bar" + }, + "show_traffic": { + "label": "Show traffic in bar" + }, + "auto_start": { + "label": "Auto-connect on start", + "description": "Connect the active server automatically when the plugin loads" + }, + "geoip_country": { + "label": "Detect server country", + "description": "Look up each server's country by IP to show its flag. Sends the server address to api.country.is." + } + } +} \ No newline at end of file diff --git a/ruh-vpn/widget.luau b/ruh-vpn/widget.luau new file mode 100644 index 0000000..7f48b7e --- /dev/null +++ b/ruh-vpn/widget.luau @@ -0,0 +1,47 @@ +--!nonstrict +-- widget.luau — bar tile. Reads shared state published by service.luau. + +local PANEL = "umedbazarov/ruh-vpn:vpn_panel" + +local function activeName(status) + if not status.running then return "VPN" end + for _, sv in ipairs(noctalia.state.get("servers") or {}) do + if sv.id == status.activeServerId then return sv.name or "VPN" end + end + return "VPN" +end + +local function fmtBytes(n) + n = tonumber(n) or 0 + if n < 1024 then return string.format("%dB", n) end + local u = { "K", "M", "G", "T" } + local i = 0 + repeat n = n / 1024; i = i + 1 until n < 1024 or i >= #u + return string.format(n < 10 and "%.1f%s" or "%.0f%s", n, u[i]) +end + +function update() + local s = noctalia.state.get("status") or {} + local h = noctalia.state.get("health") or {} + local running = s.running == true + + barWidget.setGlyph(running and "lock" or "lock-open") + barWidget.setColor(running and "primary" or "on_surface_variant") + + local label = activeName(s) + if noctalia.getConfig("show_ping") and running and h.latency_ms and h.latency_ms > 0 then + label = label .. " · " .. tostring(h.latency_ms) .. "ms" + end + if noctalia.getConfig("show_traffic") and running then + local t = noctalia.state.get("traffic") or {} + label = label .. " ↓" .. fmtBytes(t.bytes_received) .. " ↑" .. fmtBytes(t.bytes_sent) + end + barWidget.setText(label) + barWidget.setTooltip(running and ("Connected — " .. activeName(s)) or "VPN off") +end + +function onClick() + noctalia.togglePanel(PANEL) +end + +noctalia.setUpdateInterval(2000)