Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box (#304)

* Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box

New community plugin: bar widget, panel, service and control-center
shortcut for managing SSH, VLESS, VMess, Shadowsocks and SOCKS5
connections through sing-box, with routing presets, custom rules,
system-proxy/TUN modes and a kill switch. The bundled Python backend
serves a loopback control API protected by a per-launch bearer token.

* Address review: sanitize kill-switch ruleset, scope TUN capability, fix mux error path, disclose DNS

- kill switch: only pre-resolved, canonicalized literal IPs enter the nft
  ruleset; domains are resolved first and anything unparseable is dropped,
  so subscription-supplied addresses can no longer inject nft syntax
- TUN: CAP_NET_ADMIN is granted to a plugin-private copy of sing-box in a
  0700 directory instead of the shared system binary; the copy is refreshed
  (clearing the cap) when the system binary changes, and the legacy grant
  on the shared binary is removed in the same polkit prompt
- fix NameError in the mux startup failure path (undefined mux_name) that
  hid the log tail and skipped teardown
- README: disclose plain-UDP DNS endpoints (8.8.8.8 via tunnel, 223.5.5.5
  direct in rules mode) alongside the TUN DoH endpoint

---------

Co-authored-by: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com>
This commit is contained in:
Umed
2026-08-09 21:03:02 -04:00
committed by GitHub
co-authored by Umedjon Bazarov
parent 443056892e
commit 0733efd186
50 changed files with 6125 additions and 0 deletions
+94
View File
@@ -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.
View File
+202
View File
@@ -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 <token>" 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)
View File
+37
View File
@@ -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)
View File
+55
View File
@@ -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
+82
View File
@@ -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
View File
+224
View File
@@ -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:<port>/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": <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
+6
View File
@@ -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"
View File
+192
View File
@@ -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
+331
View File
@@ -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
+145
View File
@@ -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
+95
View File
@@ -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
+25
View File
@@ -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)
View File
+227
View File
@@ -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
View File
+151
View File
@@ -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
+48
View File
@@ -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
File diff suppressed because it is too large Load Diff
View File
+329
View File
@@ -0,0 +1,329 @@
"""Build sing-box JSON configs for transport / rules-mux / global-mux / TUN.
Each helper returns a dict that can be JSON-dumped straight into the matching
the plugin data directory as <PREFIX>-{transport,rules,global,tun}.json.
Architecture (as proven by reference noctalia-rules.json / noctalia-global.json):
Transport layer → port 11080 (talks to remote VPN server)
Rules mux → port 11081 (refilter rules → proxy, rest → direct)
Global mux → port 11082 (everything → proxy)
TUN → tun device; outbound = socks5 → 11081 or 11082
The TUN config never talks to the remote server directly — it always hops
through one of the mux ports so we never create a routing loop.
"""
from __future__ import annotations
from typing import Any
from backend.models.server import RoutingRule, Server, SSHServer
from backend.routing.rules import (
preset_domain_tags,
preset_route_rules,
preset_rule_sets,
)
from backend.identity import PREFIX
from backend.paths import SINGBOX_DIR
from backend.singbox.transport import build_outbound
CONFIG_DIR = SINGBOX_DIR
RULESET_CACHE_DIR = CONFIG_DIR # sing-box stores ruleset cache here
RULES_DB = CONFIG_DIR / f"{PREFIX}-rules.db"
DEFAULT_LOG = {"level": "info", "timestamp": True}
PROXY_DNS_ADDR = "8.8.8.8"
DIRECT_DNS_ADDR = "223.5.5.5"
TUN_DNS_SERVER_NAME = "dns.google"
def _dns_rules_from_user(rules: list) -> list[dict]:
"""Translate user routing rules into DNS rules with matching server tags.
For each enabled rule:
- extract matcher (domain / domain_suffix / domain_keyword / ip_cidr)
- force-proxy → server: proxy-dns
- direct → server: direct-dns
- block → action: reject (no DNS lookup at all)
"""
out: list[dict] = []
for r in rules:
sr = r.to_singbox_rule() if hasattr(r, "to_singbox_rule") else None
if not sr:
continue
dns_rule: dict = {}
for k in ("domain", "domain_suffix", "domain_keyword", "ip_cidr"):
if k in sr:
dns_rule[k] = sr[k]
if not dns_rule:
continue
if sr.get("action") == "reject":
dns_rule["action"] = "reject"
elif sr.get("outbound") == "proxy":
dns_rule["server"] = "proxy-dns"
else:
dns_rule["server"] = "direct-dns"
out.append(dns_rule)
return out
def _build_dns_rules(
custom_rules: list,
active_presets: list[str],
default_proxy: bool,
) -> dict:
"""Return the dns section for a mux config.
default_proxy=True → unmatched DNS goes through proxy (global mode).
default_proxy=False → unmatched DNS goes direct (rules mode).
For each active preset, domain-style rule_sets are routed via proxy-dns
so DNS resolution for blocked sites doesn't leak to the direct resolver.
"""
servers = [
{
"type": "udp",
"tag": "proxy-dns",
"server": PROXY_DNS_ADDR,
"server_port": 53,
"detour": "proxy",
},
{
"type": "udp",
"tag": "direct-dns",
"server": DIRECT_DNS_ADDR,
"server_port": 53,
},
]
rules = _dns_rules_from_user(custom_rules)
if not default_proxy:
dom_tags = preset_domain_tags(active_presets or [])
if dom_tags:
rules.append({"rule_set": dom_tags, "server": "proxy-dns"})
return {
"servers": servers,
"rules": rules,
"final": "proxy-dns" if default_proxy else "direct-dns",
"strategy": "ipv4_only",
}
def build_transport_config(server: Server, listen_port: int = 11080) -> dict[str, Any]:
"""Build sing-box config for the transport layer.
Listens on 127.0.0.1:listen_port (SOCKS5) and forwards through the
server-specific outbound.
For SSH, this returns None — SSH is handled outside sing-box.
"""
if isinstance(server, SSHServer):
raise ValueError(
"SSH is handled directly by OpenSSH; do not build a sing-box transport config"
)
outbound = build_outbound(server, tag="proxy")
return {
"log": DEFAULT_LOG,
"inbounds": [
{
"type": "socks",
"tag": "in",
"listen": "127.0.0.1",
"listen_port": listen_port,
"users": [],
}
],
"outbounds": [
outbound,
{"type": "direct", "tag": "direct"},
],
"route": {"final": "proxy", "auto_detect_interface": True},
}
def build_rules_config(
transport_port: int = 11080,
listen_port: int = 11081,
custom_rules: list[RoutingRule] | None = None,
active_presets: list[str] | None = None,
clash_api_port: int = 11089,
) -> dict[str, Any]:
"""Build the rules-mux config.
Listens on 127.0.0.1:listen_port (mixed inbound — accepts both SOCKS5 and
HTTP), routes traffic per rules to either the upstream proxy (the transport
listening on `transport_port`) or direct.
`active_presets` is a list of preset keys (e.g. ["ru"]). Each preset
contributes its rule_set definitions and one route.rules entry that sends
matches to the 'proxy' outbound. User custom_rules are placed first so they
take precedence over preset rules (sing-box matches top-to-bottom).
"""
rules: list[dict[str, Any]] = []
custom_rules = custom_rules or []
active_presets = list(active_presets or [])
for r in custom_rules:
if not r.enabled:
continue
sr = r.to_singbox_rule()
if sr is not None:
rules.append(sr)
rules.extend(preset_route_rules(active_presets))
rule_set = preset_rule_sets(active_presets)
route: dict[str, Any] = {
"final": "direct",
"auto_detect_interface": True,
"default_domain_resolver": "direct-dns",
"rules": rules,
}
if rule_set:
route["rule_set"] = rule_set
return {
"log": DEFAULT_LOG,
"dns": _build_dns_rules(custom_rules, active_presets, default_proxy=False),
"experimental": {
"cache_file": {"enabled": True, "path": str(RULES_DB)},
"clash_api": {"external_controller": f"127.0.0.1:{clash_api_port}"},
},
"inbounds": [
{
"type": "mixed",
"tag": "in",
"listen": "127.0.0.1",
"listen_port": listen_port,
}
],
"outbounds": [
{"type": "direct", "tag": "direct"},
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": transport_port,
"version": "5",
},
],
"route": route,
}
def build_global_config(
transport_port: int = 11080,
listen_port: int = 11082,
clash_api_port: int | None = 11089,
) -> dict[str, Any]:
"""Build the global-mux config: everything → proxy."""
experimental: dict[str, Any] = {}
if clash_api_port is not None:
experimental["clash_api"] = {"external_controller": f"127.0.0.1:{clash_api_port}"}
return {
"log": DEFAULT_LOG,
"dns": _build_dns_rules([], active_presets=[], default_proxy=True),
**({"experimental": experimental} if experimental else {}),
"inbounds": [
{
"type": "mixed",
"tag": "in",
"listen": "127.0.0.1",
"listen_port": listen_port,
}
],
"outbounds": [
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": transport_port,
"version": "5",
},
{"type": "direct", "tag": "direct"},
],
"route": {
"final": "proxy",
"auto_detect_interface": True,
"default_domain_resolver": "proxy-dns",
},
}
def build_tun_config(
upstream_socks_port: int,
interface_name: str = "noctalia-tun0",
inet4_address: str = "172.19.0.1/30",
route_exclude_addresses: list[str] | None = None,
) -> dict[str, Any]:
"""Build the TUN config.
The TUN outbound is a SOCKS5 client to 127.0.0.1:upstream_socks_port
(either the rules mux on 11081 or the global mux on 11082). Private/LAN
traffic goes direct so we don't black-hole local services.
sing-box exposes the second address of the TUN subnet (172.19.0.2 by
default) to systemd-resolved. DNS must therefore be hijacked before the
private-address rule, otherwise queries are sent direct to that synthetic
address and immediately re-enter the TUN in a tight loop. DoH is used so
SSH SOCKS transports, which cannot relay UDP, work as well.
``route_exclude_addresses`` contains the resolved transport endpoint(s).
They must stay on the physical interface or an SSH/VPN transport would be
captured by the TUN and recursively sent through itself.
"""
tun_inbound: dict[str, Any] = {
"type": "tun",
"tag": "tun-in",
"interface_name": interface_name,
"address": [inet4_address],
"auto_route": True,
"strict_route": True,
"stack": "system",
}
if route_exclude_addresses:
tun_inbound["route_exclude_address"] = route_exclude_addresses
return {
"log": DEFAULT_LOG,
"dns": {
"servers": [
{
"type": "https",
"tag": "tun-dns",
"server": PROXY_DNS_ADDR,
"server_port": 443,
"path": "/dns-query",
"tls": {
"enabled": True,
"server_name": TUN_DNS_SERVER_NAME,
},
"detour": "proxy",
}
],
"final": "tun-dns",
"strategy": "ipv4_only",
},
"inbounds": [tun_inbound],
"outbounds": [
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": upstream_socks_port,
"version": "5",
},
{"type": "direct", "tag": "direct"},
],
"route": {
"rules": [
{"action": "sniff"},
{"protocol": "dns", "action": "hijack-dns"},
{"ip_is_private": True, "outbound": "direct"},
],
"final": "proxy",
"auto_detect_interface": True,
},
}
+309
View File
@@ -0,0 +1,309 @@
"""Async start/stop/monitor of sing-box and ssh transport processes.
All managed processes are tagged via either:
- ssh: <TAG>=1 environment variable
- sing-box: filename pattern <PREFIX>-*.json passed as -c argument
This is intentionally narrow so pkill_zombies can use very specific patterns
and never affect unrelated proxy processes.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
import aiofiles
from backend.identity import PREFIX, TAG
from backend.paths import DATA_DIR, RUNTIME_DIR, SINGBOX_DIR, ensure_private_dir, protect_file
# PATH first so NixOS and other non-FHS layouts work; /usr/bin only as a
# last-resort guess when the backend env has a stripped PATH.
SINGBOX_BIN = shutil.which("sing-box") or "/usr/bin/sing-box"
SSHPASS_BIN = shutil.which("sshpass") or "/usr/bin/sshpass"
SSH_BIN = shutil.which("ssh") or "/usr/bin/ssh"
SINGBOX_CONFIG_DIR = SINGBOX_DIR
LOG_DIR = RUNTIME_DIR
STATE_FILE = LOG_DIR / f"{PREFIX}.state.json"
# Plugin-owned known-hosts: accept-new records a server's key on first connect
# and every later connect verifies it, so a changed key fails loudly instead of
# being silently ignored (the old UserKnownHostsFile=/dev/null behavior).
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
CONFIG_NAMES = {
"transport": f"{PREFIX}-transport.json",
"rules": f"{PREFIX}-rules.json",
"global": f"{PREFIX}-global.json",
"tun": f"{PREFIX}-tun.json",
}
LOG_NAMES = {
"transport": f"{PREFIX}-transport.log",
"rules": f"{PREFIX}-rules.log",
"global": f"{PREFIX}-global.log",
"tun": f"{PREFIX}-tun.log",
"ssh": f"{PREFIX}-ssh.log",
}
# Must only ever match processes started with this plugin's identity.
PKILL_PATTERNS = [
f"ssh.*{TAG}=1",
f"sing-box.*{PREFIX}-",
]
@dataclass
class ManagedProc:
name: str # one of: transport, rules, global, tun, ssh
proc: asyncio.subprocess.Process
cmd: list[str]
log_path: Path
started_at: float = field(default_factory=time.time)
@property
def pid(self) -> int:
return self.proc.pid
def is_running(self) -> bool:
return self.proc.returncode is None
class ProcessManager:
def __init__(self, logger: Optional[Callable[[str, str], None]] = None) -> None:
self._procs: dict[str, ManagedProc] = {}
self._monitor_task: Optional[asyncio.Task] = None
self._monitor_cb: Optional[Callable[[str], Awaitable[None]]] = None
self._log = logger or (lambda level, msg: None)
ensure_private_dir(SINGBOX_CONFIG_DIR)
ensure_private_dir(LOG_DIR)
# ----------------------------------------------------------------- config IO
async def write_config(self, name: str, config: dict[str, Any]) -> Path:
if name not in CONFIG_NAMES:
raise ValueError(f"Unknown sing-box config name: {name}")
path = SINGBOX_CONFIG_DIR / CONFIG_NAMES[name]
async with aiofiles.open(path, "w") as f:
await f.write(json.dumps(config, indent=2))
protect_file(path)
return path
def config_path(self, name: str) -> Path:
return SINGBOX_CONFIG_DIR / CONFIG_NAMES[name]
# ----------------------------------------------------------------- launch
async def start_singbox(self, name: str, binary: Optional[str] = None) -> ManagedProc:
if name not in CONFIG_NAMES:
raise ValueError(f"Unknown sing-box config name: {name}")
if name in self._procs and self._procs[name].is_running():
raise RuntimeError(f"sing-box '{name}' already running")
config_path = self.config_path(name)
if not config_path.exists():
raise FileNotFoundError(f"Missing config file: {config_path}")
log_path = LOG_DIR / LOG_NAMES[name]
log_fh = open(log_path, "ab") # binary, append; sing-box writes structured text
protect_file(log_path)
cmd = [binary or SINGBOX_BIN, "run", "-c", str(config_path), "-D", str(SINGBOX_CONFIG_DIR)]
self._log("info", f"start sing-box ({name}): {' '.join(cmd)}")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=log_fh,
stderr=log_fh,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
log_fh.close()
managed = ManagedProc(name=name, proc=proc, cmd=cmd, log_path=log_path)
self._procs[name] = managed
return managed
async def start_ssh(
self,
host: str,
port: int,
user: str,
local_port: int,
password: Optional[str] = None,
key_file: Optional[str] = None,
) -> ManagedProc:
if "ssh" in self._procs and self._procs["ssh"].is_running():
raise RuntimeError("ssh transport already running")
log_path = LOG_DIR / LOG_NAMES["ssh"]
log_fh = open(log_path, "ab")
protect_file(log_path)
KNOWN_HOSTS_FILE.touch(mode=0o600, exist_ok=True)
protect_file(KNOWN_HOSTS_FILE)
env = dict(os.environ)
env[TAG] = "1"
common_ssh_opts = [
"-N",
"-D",
f"127.0.0.1:{local_port}",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"ServerAliveInterval=30",
"-o",
"ServerAliveCountMax=3",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
f"UserKnownHostsFile={KNOWN_HOSTS_FILE}",
"-o",
f"SetEnv={TAG}=1",
"-o",
f"SendEnv={TAG}",
"-p",
str(port),
]
if password:
cmd = [SSHPASS_BIN, "-e", SSH_BIN, *common_ssh_opts, f"{user}@{host}"]
env["SSHPASS"] = password
elif key_file:
cmd = [SSH_BIN, *common_ssh_opts, "-i", key_file, f"{user}@{host}"]
else:
cmd = [SSH_BIN, *common_ssh_opts, f"{user}@{host}"]
self._log("info", f"start ssh transport to {user}@{host}:{port} -D {local_port}")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=log_fh,
stderr=log_fh,
stdin=subprocess.DEVNULL,
env=env,
start_new_session=True,
)
log_fh.close()
managed = ManagedProc(name="ssh", proc=proc, cmd=cmd, log_path=log_path)
self._procs["ssh"] = managed
return managed
# ----------------------------------------------------------------- stop / monitor
async def stop(self, name: str, timeout: float = 3.0) -> None:
managed = self._procs.get(name)
if managed is None:
return
if managed.is_running():
try:
managed.proc.terminate()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(managed.proc.wait(), timeout=timeout)
except asyncio.TimeoutError:
try:
managed.proc.kill()
await managed.proc.wait()
except ProcessLookupError:
pass
self._procs.pop(name, None)
async def stop_all(self) -> None:
await asyncio.gather(*(self.stop(n) for n in list(self._procs.keys())))
await self.pkill_zombies()
async def pkill_zombies(self) -> None:
"""Kill any leftover processes matching our narrow patterns."""
for pattern in PKILL_PATTERNS:
try:
proc = await asyncio.create_subprocess_exec(
"pkill", "-f", pattern,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
await proc.wait()
except FileNotFoundError:
return
# ----------------------------------------------------------------- introspection
def running_pids(self) -> dict[str, int]:
return {n: m.pid for n, m in self._procs.items() if m.is_running()}
def running_names(self) -> list[str]:
return [n for n, m in self._procs.items() if m.is_running()]
def is_running(self, name: str) -> bool:
m = self._procs.get(name)
return bool(m and m.is_running())
async def read_log_tail(self, name: str, max_bytes: int = 8192) -> str:
log_path = LOG_DIR / LOG_NAMES.get(name, "")
if not log_path.exists():
return ""
size = log_path.stat().st_size
offset = max(0, size - max_bytes)
async with aiofiles.open(log_path, "rb") as f:
await f.seek(offset)
data = await f.read()
try:
return data.decode("utf-8", errors="replace")
except UnicodeDecodeError:
return data.decode("latin-1", errors="replace")
# ----------------------------------------------------------------- monitor loop
def start_monitor(self, on_unexpected_exit: Callable[[str], Awaitable[None]]) -> None:
self._monitor_cb = on_unexpected_exit
if self._monitor_task and not self._monitor_task.done():
return
self._monitor_task = asyncio.create_task(self._monitor_loop())
async def stop_monitor(self) -> None:
if self._monitor_task and not self._monitor_task.done():
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
self._monitor_task = None
async def _monitor_loop(self) -> None:
try:
while True:
await asyncio.sleep(1.0)
for name, m in list(self._procs.items()):
if not m.is_running():
rc = m.proc.returncode
self._log("error", f"managed process '{name}' exited rc={rc}")
self._procs.pop(name, None)
if self._monitor_cb:
try:
await self._monitor_cb(name)
except Exception as exc:
self._log("error", f"monitor callback failed: {exc}")
except asyncio.CancelledError:
return
# ----------------------------------------------------------------- state file
async def write_state(self, state: dict[str, Any]) -> None:
tmp = STATE_FILE.with_suffix(".json.tmp")
async with aiofiles.open(tmp, "w") as f:
await f.write(json.dumps(state, indent=2))
protect_file(tmp)
os.replace(tmp, STATE_FILE)
async def clear_state(self) -> None:
try:
STATE_FILE.unlink()
except FileNotFoundError:
pass
+147
View File
@@ -0,0 +1,147 @@
"""Protocol-specific outbound builders for sing-box.
Each builder returns the outbound dict that goes into the sing-box "outbounds"
list when configuring the transport layer (the layer that actually talks to the
remote VPN server).
SSH is handled outside sing-box (via OpenSSH itself opening a SOCKS5
listener on the local transport port), so it does NOT appear here.
"""
from __future__ import annotations
from typing import Any
from backend.models.server import (
Server,
ShadowsocksServer,
Socks5Server,
SSHServer,
VlessServer,
VmessServer,
)
def build_outbound(server: Server, tag: str = "proxy") -> dict[str, Any]:
"""Return a sing-box outbound dict for the given server.
Raises ValueError for SSH (not a sing-box outbound) and for unsupported
protocols.
"""
if isinstance(server, SSHServer):
raise ValueError("SSH transport is handled outside sing-box")
if isinstance(server, VlessServer):
return _build_vless(server, tag)
if isinstance(server, VmessServer):
return _build_vmess(server, tag)
if isinstance(server, ShadowsocksServer):
return _build_shadowsocks(server, tag)
if isinstance(server, Socks5Server):
return _build_socks5(server, tag)
raise ValueError(f"Unsupported server type: {type(server).__name__}")
def _build_tls(server: VlessServer | VmessServer) -> dict[str, Any] | None:
if not getattr(server, "tls", False) and getattr(server, "security", None) not in (
"tls",
"reality",
):
return None
tls: dict[str, Any] = {"enabled": True}
if server.sni:
tls["server_name"] = server.sni
fp = getattr(server, "fp", None)
if fp:
tls["utls"] = {"enabled": True, "fingerprint": fp}
if getattr(server, "security", None) == "reality":
pbk = getattr(server, "pbk", None) or ""
sid = getattr(server, "sid", None) or ""
tls["reality"] = {"enabled": True, "public_key": pbk, "short_id": sid}
return tls
def _build_transport(server: VlessServer | VmessServer) -> dict[str, Any] | None:
t = (getattr(server, "transport", "tcp") or "tcp").lower()
if t in ("tcp", "raw", ""):
return None
if t == "ws":
out: dict[str, Any] = {"type": "ws"}
if getattr(server, "path", None):
out["path"] = server.path
if getattr(server, "host", None):
out["headers"] = {"Host": server.host}
return out
if t == "grpc":
return {"type": "grpc", "service_name": getattr(server, "serviceName", "") or ""}
if t == "http":
out = {"type": "http"}
if getattr(server, "path", None):
out["path"] = server.path
if getattr(server, "host", None):
out["host"] = [server.host]
return out
return None
def _build_vless(s: VlessServer, tag: str) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "vless",
"tag": tag,
"server": s.address,
"server_port": s.port,
"uuid": s.uuid,
}
if s.flow:
out["flow"] = s.flow
tls = _build_tls(s)
if tls:
out["tls"] = tls
tp = _build_transport(s)
if tp:
out["transport"] = tp
return out
def _build_vmess(s: VmessServer, tag: str) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "vmess",
"tag": tag,
"server": s.address,
"server_port": s.port,
"uuid": s.uuid,
"alter_id": s.alterId,
"security": s.security or "auto",
}
tls = _build_tls(s)
if tls:
out["tls"] = tls
tp = _build_transport(s)
if tp:
out["transport"] = tp
return out
def _build_shadowsocks(s: ShadowsocksServer, tag: str) -> dict[str, Any]:
return {
"type": "shadowsocks",
"tag": tag,
"server": s.address,
"server_port": s.port,
"method": s.method,
"password": s.password,
}
def _build_socks5(s: Socks5Server, tag: str) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "socks",
"tag": tag,
"server": s.host,
"server_port": s.port,
"version": "5",
}
if s.username:
out["username"] = s.username
if s.password:
out["password"] = s.password
return out
View File
+74
View File
@@ -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)
+37
View File
@@ -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)
+157
View File
@@ -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
+311
View File
@@ -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://<type>?<urlsafe-base64 of zlib(payload)>. 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("<I", buf, i)[0], i + 4
_SN_PRINTABLE = re.compile(r"^[\x20-\x7e]+$")
def _parse_sn(link: str) -> 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]
+913
View File
@@ -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
+78
View File
@@ -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"
+21
View File
@@ -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*"]
+310
View File
@@ -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 ("<iso> [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 "<pid> <port>". 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)
+33
View File
@@ -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)
+17
View File
@@ -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))
+45
View File
@@ -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
+44
View File
@@ -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}"
+64
View File
@@ -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", ""
)
+39
View File
@@ -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"
+41
View File
@@ -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
+27
View File
@@ -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
+45
View File
@@ -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"]) == []
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+107
View File
@@ -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."
}
}
}
+47
View File
@@ -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)