Files
community-plugins/ruh-vpn/service.luau
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

311 lines
13 KiB
Luau

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