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