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

914 lines
40 KiB
Luau

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