Files
community-plugins/portctl/service.luau
T

241 lines
8.3 KiB
Luau

--!nonstrict
-- portctl — headless scanner service.
-- Owns all port data. Widget and panel consume via noctalia.state.
--
-- Published state:
-- portctl_data { ports: PortEntry[], error: string?, last_updated: number }
-- portctl_stats { count: number }
--
-- Consumed state:
-- portctl_command { action: "kill", pid: number }
-- portctl_refresh any → triggers immediate scan
--
-- PortEntry = { port, proto, name, pid, category }
-- ── Category detection ────────────────────────────────────────────────────
local CATEGORIES = {
{ name = "Development", patterns = {
"node", "vite", "webpack", "next", "nuxt", "expo", "tsx", "ts%-node",
"nodemon", "bun", "deno", "storybook", "esbuild", "rollup", "gatsby",
"parcel", "turbo", "jest", "vitest", "remix", "svelte", "pm2",
}},
{ name = "Databases", patterns = {
"postgres", "postmaster", "mysqld", "mariadbd", "mongod", "redis%-server",
"memcached", "elasticsearch", "influxd", "prometheus", "clickhouse",
"cassandra", "couchdb", "rethinkdb", "valkey",
}},
{ name = "Containers", patterns = {
"dockerd", "docker%-proxy", "containerd", "podman", "kubelet",
"kube%-proxy", "buildkitd", "nerdctl", "crio",
"rootlessport", "rootlesskit", "slirp4netns",
}},
{ name = "Servers", patterns = {
"nginx", "apache2", "httpd", "caddy", "traefik", "haproxy", "sshd",
"lighttpd", "envoy", "squid", "gunicorn", "uvicorn", "puma", "unicorn",
}},
{ name = "Cloud", patterns = {
"cloudflared", "tailscaled", "openvpn", "wg", "wireguard",
"ngrok", "bore", "frpc", "frps",
}},
}
local function detectCategory(name)
local lower = name:lower()
for _, cat in ipairs(CATEGORIES) do
for _, pat in ipairs(cat.patterns) do
if lower:find(pat) then return cat.name end
end
end
return "Other"
end
-- ── Ignore list ───────────────────────────────────────────────────────────
-- Comma-separated substrings matched case-insensitively against process name.
-- Example config value: "discord,chrome,steam,spotify"
local function buildIgnorePatterns()
local raw = noctalia.getConfig("ignore_list") or ""
local patterns = {}
for entry in raw:gmatch("[^,]+") do
local pat = entry:match("^%s*(.-)%s*$"):lower()
if pat ~= "" then table.insert(patterns, pat) end
end
return patterns
end
local function buildIgnorePorts()
local raw = noctalia.getConfig("ignore_ports") or ""
local ports = {}
for entry in raw:gmatch("[^,]+") do
local n = tonumber(entry:match("^%s*(.-)%s*$"))
if n then ports[n] = true end
end
return ports
end
local function isIgnored(name, patterns)
if #patterns == 0 then return false end
local lower = name:lower()
for _, pat in ipairs(patterns) do
if lower:find(pat, 1, true) then return true end
end
return false
end
-- ── SsProvider ────────────────────────────────────────────────────────────
-- Implements PortProvider contract: scan(callback(ports, error?))
--
-- Handles both iproute2 output formats:
-- Old: State Recv-Q Send-Q LocalAddr:Port PeerAddr:Port [users:(...)]
-- New: Netid State Recv-Q Send-Q LocalAddr:Port PeerAddr:Port [users:(...)]
local function parseSsLine(line)
local parts = {}
for tok in line:gmatch("%S+") do table.insert(parts, tok) end
if #parts < 5 then return nil end
local state, addrIdx
if parts[1] == "LISTEN" or parts[1] == "UNCONN" then
state, addrIdx = parts[1], 4 -- old format
elseif parts[2] == "LISTEN" or parts[2] == "UNCONN" then
state, addrIdx = parts[2], 5 -- new format (Netid prepended)
else
return nil
end
if #parts < addrIdx then return nil end
local port = tonumber((parts[addrIdx]):match(":(%d+)$"))
if not port then return nil end
local proto = state == "UNCONN" and "udp" or "tcp"
local procIdx = addrIdx + 2 -- skip peer address column
local procField = #parts >= procIdx and table.concat(parts, " ", procIdx) or ""
local entries = {}
for name, pidStr in procField:gmatch('"([^"]+)",pid=(%d+)') do
local pid = tonumber(pidStr)
if pid then
table.insert(entries, { port = port, proto = proto, name = name, pid = pid })
end
end
if #entries == 0 then
-- Port visible but process info hidden (likely root-owned, no sudo)
table.insert(entries, { port = port, proto = proto, name = "(unknown)", pid = 0 })
end
return entries
end
local SsProvider = {}
function SsProvider.parse(stdout)
local ports = {}
for line in stdout:gmatch("[^\n]+") do
local entries = parseSsLine(line)
if entries then
for _, e in ipairs(entries) do table.insert(ports, e) end
end
end
return ports
end
function SsProvider.scan(callback)
local out = { tcp = "", udp = "" }
local remaining = 2
local function finish(key, data)
out[key] = data
remaining -= 1
if remaining > 0 then return end
callback(SsProvider.parse(out.tcp .. "\n" .. out.udp), nil)
end
if not noctalia.runAsync("ss -ltnp 2>/dev/null", function(r) finish("tcp", r.stdout or "") end) then
finish("tcp", "")
end
if not noctalia.runAsync("ss -lunp 2>/dev/null", function(r) finish("udp", r.stdout or "") end) then
finish("udp", "")
end
end
-- ── Publishing ────────────────────────────────────────────────────────────
local function publish(ports, err)
if ports and #ports > 0 then
table.sort(ports, function(a, b) return a.port < b.port end)
end
local tcpCount = 0
for _, p in ipairs(ports or {}) do
if p.proto == "tcp" then tcpCount += 1 end
end
noctalia.state.set("portctl_data", {
ports = ports or {},
error = err,
last_updated = os.time(),
})
noctalia.state.set("portctl_stats", {
count = ports and #ports or 0,
count_tcp = tcpCount,
})
end
-- ── Scan orchestrator ─────────────────────────────────────────────────────
local function runScan()
if not noctalia.commandExists("ss") then
publish(nil, noctalia.tr("service.ss_not_found"))
return
end
local ignorePatterns = buildIgnorePatterns()
local ignorePorts = buildIgnorePorts()
local hideSystem = noctalia.getConfig("hide_system_ports") == true
local hideUnknown = noctalia.getConfig("hide_unknown_ports") == true
SsProvider.scan(function(ports, _err)
local filtered = {}
for _, p in ipairs(ports) do
if hideSystem and p.port < 1024 then continue end
if hideUnknown and p.pid == 0 then continue end
if ignorePorts[p.port] then continue end
if isIgnored(p.name, ignorePatterns) then continue end
p.category = detectCategory(p.name)
table.insert(filtered, p)
end
publish(filtered, nil)
end)
end
-- ── Command handlers ──────────────────────────────────────────────────────
noctalia.state.watch("portctl_command", function(cmd)
if type(cmd) ~= "table" or cmd.action ~= "kill" or not cmd.pid then return end
local pid = tostring(cmd.pid)
noctalia.runAsync("kill -15 " .. pid, function(r)
if r.exitCode == 0 then
noctalia.notify(noctalia.tr("title"), noctalia.tr("service.terminated", { pid = pid }))
else
noctalia.notifyError(noctalia.tr("title"), noctalia.tr("service.kill_failed", { pid = pid }))
end
runScan()
end)
end)
noctalia.state.watch("portctl_refresh", function()
runScan()
end)
-- ── Lifecycle ─────────────────────────────────────────────────────────────
publish({}, nil)
runScan()
function update()
noctalia.setUpdateInterval((noctalia.getConfig("refresh_interval") or 2) * 1000)
runScan()
end
function onConfigChanged()
noctalia.setUpdateInterval((noctalia.getConfig("refresh_interval") or 2) * 1000)
end