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 0000000..097d04e Binary files /dev/null and b/ruh-vpn/thumbnail.webp differ 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)