241 lines
8.3 KiB
Luau
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
|