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,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)
|
||||
Reference in New Issue
Block a user