Files
community-plugins/ruh-vpn/backend/app.py
T
0733efd186 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>
2026-08-09 21:03:02 -04:00

203 lines
6.6 KiB
Python

"""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)