feat: add portctl plugin (#24)
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# Portctl
|
||||||
|
|
||||||
|
A simple and minimal plugin to inspect and terminate listening TCP/UDP ports from the bar.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `rxtsel/portctl` |
|
||||||
|
| Entries | Bar widget: `indicator`; panel: `panel`; service: `scanner` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Install `ss` from `iproute2` on `PATH`. Available on all major Linux distributions; install the `iproute2` package if missing.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Add the `indicator` widget to a bar. It shows a plug icon with the count of active listening ports. The widget is hidden when no ports are detected. Click it to open the port panel.
|
||||||
|
|
||||||
|
Open the panel directly with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg panel-toggle rxtsel/portctl:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
The panel lists listening ports grouped by category (Development, Databases, Containers, Servers, Cloud, Other). From there you can:
|
||||||
|
|
||||||
|
- Search by port number, process name, or PID.
|
||||||
|
- Toggle TCP and UDP visibility independently with the header toggles.
|
||||||
|
- Click any PID label to copy the PID to the clipboard.
|
||||||
|
- Kill a process: click `×` to stage the kill, then confirm with `Kill` in the inline confirmation row. The row transforms in place — no dialog opens.
|
||||||
|
|
||||||
|
To customize the bar icon, right-click the widget → settings → **Glyph**.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `refresh_interval` | `int` | `5` | Seconds between automatic port scans (1–60). |
|
||||||
|
| `ignore_list` | `string` | *(empty)* | Comma-separated process name substrings to hide (e.g. `discord,chrome,steam`). |
|
||||||
|
| `ignore_ports` | `string` | *(empty)* | Comma-separated port numbers to hide (e.g. `37700,6463`). |
|
||||||
|
| `hide_system_ports` | `bool` | `true` | Hide ports below 1024 (privileged/root ports). |
|
||||||
|
| `hide_unknown_ports` | `bool` | `false` | Hide ports whose process info is inaccessible (root-owned processes, rootlessport, etc.). |
|
||||||
|
|
||||||
|
Widget settings (right-click widget → settings):
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `glyph` | `glyph` | `plug` | Icon shown in the bar. |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
**Root-owned ports** — ports owned by root-level processes show `—` as the PID and cannot be killed from the plugin (no privilege escalation is performed). Enable `hide_unknown_ports` to exclude them from the list.
|
||||||
|
|
||||||
|
**Container ports with pasta networking** — ports forwarded via `pasta` (the default network backend in Podman 4+) do not create a host-side socket and are not visible to `ss`. They will not appear in portctl. Ports forwarded via `rootlessport` (older Podman, or explicit `--network slirp4netns`) do appear, categorized under Containers.
|
||||||
|
|
||||||
|
**`rootlessport` entries** — expected behavior when running Podman or Docker in rootless mode with published ports (`-p`). Add `rootlessport` to `ignore_list` or the specific port number to `ignore_ports` to suppress them.
|
||||||
|
|
||||||
|
**Processes spawned** — `ss -ltnp` and `ss -lunp` on every scan. No network calls. No filesystem writes outside `noctalia.pluginDataDir()`.
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- portctl — panel entry.
|
||||||
|
|
||||||
|
-- ── Module state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local data = noctalia.state.get("portctl_data") or { ports = {}, error = nil }
|
||||||
|
local killingPid = nil -- pid currently being killed (orange bullet)
|
||||||
|
local pendingKill = nil -- slot index staged for kill (first click — shows checkmark)
|
||||||
|
local searchQuery = ""
|
||||||
|
local showTCP = true
|
||||||
|
local showUDP = false
|
||||||
|
|
||||||
|
-- ── Pre-declared row slot handlers ───────────────────────────────────────
|
||||||
|
-- Explicit top-level declarations so Noctalia captures them at load time.
|
||||||
|
-- Dynamic _ENV[name] = fn is skipped — Noctalia likely whitelists globals
|
||||||
|
-- via static analysis at script load, silently dropping dynamic assignments.
|
||||||
|
--
|
||||||
|
-- Kill flow (no overlay): first click stages slot (pendingKill=i, icon→check),
|
||||||
|
-- second click on same slot confirms kill.
|
||||||
|
|
||||||
|
local MAX_ROWS = 50
|
||||||
|
local rowSlots = {}
|
||||||
|
local rowCursor = 0
|
||||||
|
|
||||||
|
local function _doKill(i)
|
||||||
|
local t = rowSlots[i]
|
||||||
|
if not t or t.pid == 0 then return end
|
||||||
|
if pendingKill == i then
|
||||||
|
killingPid = t.pid
|
||||||
|
pendingKill = nil
|
||||||
|
noctalia.state.set("portctl_command", { action = "kill", pid = t.pid })
|
||||||
|
render()
|
||||||
|
else
|
||||||
|
pendingKill = i
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _doCopy(i)
|
||||||
|
local t = rowSlots[i]
|
||||||
|
if not t or t.pid == 0 then return end
|
||||||
|
local ok = noctalia.copyToClipboard(tostring(t.pid), "text/plain")
|
||||||
|
if ok then noctalia.notify(noctalia.tr("title"), noctalia.tr("panel.pid_copied", { pid = tostring(t.pid) })) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _pkill1() _doKill(1) end function _pcopy1() _doCopy(1) end
|
||||||
|
function _pkill2() _doKill(2) end function _pcopy2() _doCopy(2) end
|
||||||
|
function _pkill3() _doKill(3) end function _pcopy3() _doCopy(3) end
|
||||||
|
function _pkill4() _doKill(4) end function _pcopy4() _doCopy(4) end
|
||||||
|
function _pkill5() _doKill(5) end function _pcopy5() _doCopy(5) end
|
||||||
|
function _pkill6() _doKill(6) end function _pcopy6() _doCopy(6) end
|
||||||
|
function _pkill7() _doKill(7) end function _pcopy7() _doCopy(7) end
|
||||||
|
function _pkill8() _doKill(8) end function _pcopy8() _doCopy(8) end
|
||||||
|
function _pkill9() _doKill(9) end function _pcopy9() _doCopy(9) end
|
||||||
|
function _pkill10() _doKill(10) end function _pcopy10() _doCopy(10) end
|
||||||
|
function _pkill11() _doKill(11) end function _pcopy11() _doCopy(11) end
|
||||||
|
function _pkill12() _doKill(12) end function _pcopy12() _doCopy(12) end
|
||||||
|
function _pkill13() _doKill(13) end function _pcopy13() _doCopy(13) end
|
||||||
|
function _pkill14() _doKill(14) end function _pcopy14() _doCopy(14) end
|
||||||
|
function _pkill15() _doKill(15) end function _pcopy15() _doCopy(15) end
|
||||||
|
function _pkill16() _doKill(16) end function _pcopy16() _doCopy(16) end
|
||||||
|
function _pkill17() _doKill(17) end function _pcopy17() _doCopy(17) end
|
||||||
|
function _pkill18() _doKill(18) end function _pcopy18() _doCopy(18) end
|
||||||
|
function _pkill19() _doKill(19) end function _pcopy19() _doCopy(19) end
|
||||||
|
function _pkill20() _doKill(20) end function _pcopy20() _doCopy(20) end
|
||||||
|
function _pkill21() _doKill(21) end function _pcopy21() _doCopy(21) end
|
||||||
|
function _pkill22() _doKill(22) end function _pcopy22() _doCopy(22) end
|
||||||
|
function _pkill23() _doKill(23) end function _pcopy23() _doCopy(23) end
|
||||||
|
function _pkill24() _doKill(24) end function _pcopy24() _doCopy(24) end
|
||||||
|
function _pkill25() _doKill(25) end function _pcopy25() _doCopy(25) end
|
||||||
|
function _pkill26() _doKill(26) end function _pcopy26() _doCopy(26) end
|
||||||
|
function _pkill27() _doKill(27) end function _pcopy27() _doCopy(27) end
|
||||||
|
function _pkill28() _doKill(28) end function _pcopy28() _doCopy(28) end
|
||||||
|
function _pkill29() _doKill(29) end function _pcopy29() _doCopy(29) end
|
||||||
|
function _pkill30() _doKill(30) end function _pcopy30() _doCopy(30) end
|
||||||
|
function _pkill31() _doKill(31) end function _pcopy31() _doCopy(31) end
|
||||||
|
function _pkill32() _doKill(32) end function _pcopy32() _doCopy(32) end
|
||||||
|
function _pkill33() _doKill(33) end function _pcopy33() _doCopy(33) end
|
||||||
|
function _pkill34() _doKill(34) end function _pcopy34() _doCopy(34) end
|
||||||
|
function _pkill35() _doKill(35) end function _pcopy35() _doCopy(35) end
|
||||||
|
function _pkill36() _doKill(36) end function _pcopy36() _doCopy(36) end
|
||||||
|
function _pkill37() _doKill(37) end function _pcopy37() _doCopy(37) end
|
||||||
|
function _pkill38() _doKill(38) end function _pcopy38() _doCopy(38) end
|
||||||
|
function _pkill39() _doKill(39) end function _pcopy39() _doCopy(39) end
|
||||||
|
function _pkill40() _doKill(40) end function _pcopy40() _doCopy(40) end
|
||||||
|
function _pkill41() _doKill(41) end function _pcopy41() _doCopy(41) end
|
||||||
|
function _pkill42() _doKill(42) end function _pcopy42() _doCopy(42) end
|
||||||
|
function _pkill43() _doKill(43) end function _pcopy43() _doCopy(43) end
|
||||||
|
function _pkill44() _doKill(44) end function _pcopy44() _doCopy(44) end
|
||||||
|
function _pkill45() _doKill(45) end function _pcopy45() _doCopy(45) end
|
||||||
|
function _pkill46() _doKill(46) end function _pcopy46() _doCopy(46) end
|
||||||
|
function _pkill47() _doKill(47) end function _pcopy47() _doCopy(47) end
|
||||||
|
function _pkill48() _doKill(48) end function _pcopy48() _doCopy(48) end
|
||||||
|
function _pkill49() _doKill(49) end function _pcopy49() _doCopy(49) end
|
||||||
|
function _pkill50() _doKill(50) end function _pcopy50() _doCopy(50) end
|
||||||
|
|
||||||
|
-- ── Category metadata ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local CAT_ORDER = { "Development", "Databases", "Containers", "Servers", "Cloud", "Other" }
|
||||||
|
|
||||||
|
local CAT_META = {
|
||||||
|
Development = { color = "primary", icon = "code" },
|
||||||
|
Databases = { color = "tertiary", icon = "database" },
|
||||||
|
Containers = { color = "secondary", icon = "box" },
|
||||||
|
Servers = { color = "error", icon = "server" },
|
||||||
|
Cloud = { color = "warning", icon = "cloud" },
|
||||||
|
Other = { color = "on_surface_variant", icon = "plug" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local COLOR_ACTIVE = "#22c55e"
|
||||||
|
local COLOR_KILLING = "warning"
|
||||||
|
|
||||||
|
-- ── Filtering & grouping ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function filteredPorts()
|
||||||
|
local ports = data.ports or {}
|
||||||
|
if searchQuery == "" and showTCP and showUDP then return ports end
|
||||||
|
local q = searchQuery:lower()
|
||||||
|
local out = {}
|
||||||
|
for _, p in ipairs(ports) do
|
||||||
|
if p.proto == "tcp" and not showTCP then continue end
|
||||||
|
if p.proto == "udp" and not showUDP then continue end
|
||||||
|
if searchQuery ~= "" then
|
||||||
|
if not (tostring(p.port):find(q, 1, true)
|
||||||
|
or p.name:lower():find(q, 1, true)
|
||||||
|
or tostring(p.pid):find(q, 1, true)) then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(out, p)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function groupByCategory(ports)
|
||||||
|
local groups = {}
|
||||||
|
local seen = {}
|
||||||
|
for _, p in ipairs(ports) do
|
||||||
|
local cat = p.category or "Other"
|
||||||
|
if not groups[cat] then groups[cat] = {}; table.insert(seen, cat) end
|
||||||
|
table.insert(groups[cat], p)
|
||||||
|
end
|
||||||
|
local ordered = {}
|
||||||
|
for _, cat in ipairs(CAT_ORDER) do
|
||||||
|
if groups[cat] then table.insert(ordered, cat) end
|
||||||
|
end
|
||||||
|
for _, cat in ipairs(seen) do
|
||||||
|
local inOrder = false
|
||||||
|
for _, oc in ipairs(CAT_ORDER) do if oc == cat then inOrder = true; break end end
|
||||||
|
if not inOrder then table.insert(ordered, cat) end
|
||||||
|
end
|
||||||
|
return groups, ordered
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Port row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- cancelFn names: _pcancel1.._pcancel50
|
||||||
|
local function _doCancel(i)
|
||||||
|
if pendingKill == i then pendingKill = nil; render() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _pcancel1() _doCancel(1) end function _pcancel2() _doCancel(2) end
|
||||||
|
function _pcancel3() _doCancel(3) end function _pcancel4() _doCancel(4) end
|
||||||
|
function _pcancel5() _doCancel(5) end function _pcancel6() _doCancel(6) end
|
||||||
|
function _pcancel7() _doCancel(7) end function _pcancel8() _doCancel(8) end
|
||||||
|
function _pcancel9() _doCancel(9) end function _pcancel10() _doCancel(10) end
|
||||||
|
function _pcancel11() _doCancel(11) end function _pcancel12() _doCancel(12) end
|
||||||
|
function _pcancel13() _doCancel(13) end function _pcancel14() _doCancel(14) end
|
||||||
|
function _pcancel15() _doCancel(15) end function _pcancel16() _doCancel(16) end
|
||||||
|
function _pcancel17() _doCancel(17) end function _pcancel18() _doCancel(18) end
|
||||||
|
function _pcancel19() _doCancel(19) end function _pcancel20() _doCancel(20) end
|
||||||
|
function _pcancel21() _doCancel(21) end function _pcancel22() _doCancel(22) end
|
||||||
|
function _pcancel23() _doCancel(23) end function _pcancel24() _doCancel(24) end
|
||||||
|
function _pcancel25() _doCancel(25) end function _pcancel26() _doCancel(26) end
|
||||||
|
function _pcancel27() _doCancel(27) end function _pcancel28() _doCancel(28) end
|
||||||
|
function _pcancel29() _doCancel(29) end function _pcancel30() _doCancel(30) end
|
||||||
|
function _pcancel31() _doCancel(31) end function _pcancel32() _doCancel(32) end
|
||||||
|
function _pcancel33() _doCancel(33) end function _pcancel34() _doCancel(34) end
|
||||||
|
function _pcancel35() _doCancel(35) end function _pcancel36() _doCancel(36) end
|
||||||
|
function _pcancel37() _doCancel(37) end function _pcancel38() _doCancel(38) end
|
||||||
|
function _pcancel39() _doCancel(39) end function _pcancel40() _doCancel(40) end
|
||||||
|
function _pcancel41() _doCancel(41) end function _pcancel42() _doCancel(42) end
|
||||||
|
function _pcancel43() _doCancel(43) end function _pcancel44() _doCancel(44) end
|
||||||
|
function _pcancel45() _doCancel(45) end function _pcancel46() _doCancel(46) end
|
||||||
|
function _pcancel47() _doCancel(47) end function _pcancel48() _doCancel(48) end
|
||||||
|
function _pcancel49() _doCancel(49) end function _pcancel50() _doCancel(50) end
|
||||||
|
|
||||||
|
local function portRow(entry)
|
||||||
|
rowCursor += 1
|
||||||
|
local slot = rowCursor
|
||||||
|
local hasKill = entry.pid ~= 0
|
||||||
|
local isKilling = (killingPid == entry.pid)
|
||||||
|
local isPending = (pendingKill == slot)
|
||||||
|
rowSlots[slot] = { pid = entry.pid, port = entry.port, name = entry.name }
|
||||||
|
|
||||||
|
local killFn = "_pkill" .. slot
|
||||||
|
local copyFn = "_pcopy" .. slot
|
||||||
|
local cancelFn = "_pcancel" .. slot
|
||||||
|
local dotClr = isKilling and COLOR_KILLING or COLOR_ACTIVE
|
||||||
|
|
||||||
|
-- Killing state: replace entire row content with port + spinner
|
||||||
|
if isKilling then
|
||||||
|
return ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 8,
|
||||||
|
paddingH = 16,
|
||||||
|
paddingV = 8,
|
||||||
|
}, {
|
||||||
|
ui.glyph({ name = "circle-filled", size = 8, color = COLOR_KILLING }),
|
||||||
|
ui.label({ text = ":" .. tostring(entry.port), fontWeight = "bold", minWidth = 58, fontSize = 13 }),
|
||||||
|
ui.label({ text = entry.proto:upper(), color = "on_surface_variant", fontSize = 10, minWidth = 34 }),
|
||||||
|
ui.label({ text = noctalia.tr("panel.killing", { name = entry.name }), flexGrow = 1, fontSize = 13, color = "on_surface_variant" }),
|
||||||
|
ui.button({ glyph = "loader-circle", variant = "ghost", disabled = true, opacity = 0.5, paddingH = 2, paddingV = 2 }),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pending state: row transforms to inline confirm
|
||||||
|
if isPending then
|
||||||
|
return ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 8,
|
||||||
|
paddingH = 16,
|
||||||
|
paddingV = 8,
|
||||||
|
fill = "error/0.06",
|
||||||
|
}, {
|
||||||
|
ui.glyph({ name = "circle-filled", size = 8, color = COLOR_ACTIVE }),
|
||||||
|
ui.label({ text = ":" .. tostring(entry.port), fontWeight = "bold", minWidth = 58, fontSize = 13 }),
|
||||||
|
ui.label({ text = entry.proto:upper(), color = "on_surface_variant", fontSize = 10, minWidth = 34 }),
|
||||||
|
ui.label({ text = noctalia.tr("panel.kill_confirm", { name = entry.name }), flexGrow = 1, fontSize = 13, color = "error" }),
|
||||||
|
ui.button({ text = noctalia.tr("panel.cancel"), variant = "ghost", onClick = cancelFn, paddingH = 6, paddingV = 2 }),
|
||||||
|
ui.button({ text = noctalia.tr("panel.kill"), variant = "destructive", onClick = killFn, paddingH = 6, paddingV = 2 }),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normal state
|
||||||
|
return ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 8,
|
||||||
|
paddingH = 16,
|
||||||
|
paddingV = 8,
|
||||||
|
}, {
|
||||||
|
ui.glyph({ name = "circle-filled", size = 8, color = dotClr }),
|
||||||
|
ui.label({ text = ":" .. tostring(entry.port), fontWeight = "bold", minWidth = 58, fontSize = 13 }),
|
||||||
|
ui.label({ text = entry.proto:upper(), color = "on_surface_variant", fontSize = 10, minWidth = 34 }),
|
||||||
|
ui.label({ text = entry.name, flexGrow = 1, fontSize = 13 }),
|
||||||
|
ui.button({
|
||||||
|
text = "PID " .. (hasKill and tostring(entry.pid) or "—"),
|
||||||
|
variant = "ghost",
|
||||||
|
fontSize = 11,
|
||||||
|
color = "on_surface_variant",
|
||||||
|
onClick = hasKill and copyFn or nil,
|
||||||
|
paddingH = 4,
|
||||||
|
paddingV = 2,
|
||||||
|
}),
|
||||||
|
ui.button({
|
||||||
|
glyph = "x",
|
||||||
|
variant = "ghost",
|
||||||
|
onClick = killFn,
|
||||||
|
opacity = hasKill and 1 or 0,
|
||||||
|
paddingH = 2,
|
||||||
|
paddingV = 2,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Category section ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function catSection(catName, ports)
|
||||||
|
local meta = CAT_META[catName] or CAT_META.Other
|
||||||
|
local rows = {}
|
||||||
|
|
||||||
|
table.insert(rows, ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 6,
|
||||||
|
paddingH = 16,
|
||||||
|
paddingV = 7,
|
||||||
|
fill = "surface_container/0.4",
|
||||||
|
}, {
|
||||||
|
ui.glyph({ name = meta.icon, size = 12, color = meta.color }),
|
||||||
|
ui.label({ text = noctalia.tr("categories." .. catName), fontSize = 11, fontWeight = "bold", color = meta.color }),
|
||||||
|
ui.label({ text = tostring(#ports), fontSize = 10, color = meta.color .. "/0.6" }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, p in ipairs(ports) do
|
||||||
|
table.insert(rows, portRow(p))
|
||||||
|
end
|
||||||
|
|
||||||
|
return ui.column({ gap = 0 }, rows)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Main render ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function render()
|
||||||
|
data = noctalia.state.get("portctl_data") or data
|
||||||
|
|
||||||
|
rowCursor = 0
|
||||||
|
for i = 1, MAX_ROWS do rowSlots[i] = nil end
|
||||||
|
|
||||||
|
local ports = filteredPorts()
|
||||||
|
noctalia.state.set("portctl_visible_count", #ports)
|
||||||
|
|
||||||
|
local header = ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 6,
|
||||||
|
paddingH = 10,
|
||||||
|
paddingV = 10,
|
||||||
|
}, {
|
||||||
|
ui.input({
|
||||||
|
key = "search",
|
||||||
|
placeholder = noctalia.tr("panel.search_placeholder"),
|
||||||
|
flexGrow = 1,
|
||||||
|
onChange = "onSearchChange",
|
||||||
|
}),
|
||||||
|
ui.toggle({ checked = showTCP, onChange = "onToggleTCP" }),
|
||||||
|
ui.label({ text = noctalia.tr("panel.tcp"), fontSize = 10, color = "on_surface_variant" }),
|
||||||
|
ui.toggle({ checked = showUDP, onChange = "onToggleUDP" }),
|
||||||
|
ui.label({ text = noctalia.tr("panel.udp"), fontSize = 10, color = "on_surface_variant" }),
|
||||||
|
ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }),
|
||||||
|
ui.button({ glyph = "x", variant = "ghost", onClick = "onClose" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
local body
|
||||||
|
if data.error then
|
||||||
|
body = ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, {
|
||||||
|
ui.glyph({ name = "alert-circle", size = 36, color = "error" }),
|
||||||
|
ui.label({ text = data.error, color = "on_surface_variant", textAlign = "center", fontSize = 13 }),
|
||||||
|
})
|
||||||
|
elseif #ports == 0 then
|
||||||
|
local msg = searchQuery ~= ""
|
||||||
|
and noctalia.tr("panel.no_results", { query = searchQuery })
|
||||||
|
or noctalia.tr("panel.no_ports")
|
||||||
|
body = ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, {
|
||||||
|
ui.glyph({ name = "plug-off", size = 36, color = "on_surface_variant" }),
|
||||||
|
ui.label({ text = msg, color = "on_surface_variant", fontSize = 13 }),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
local groups, order = groupByCategory(ports)
|
||||||
|
local sections = {}
|
||||||
|
for i, cat in ipairs(order) do
|
||||||
|
table.insert(sections, catSection(cat, groups[cat]))
|
||||||
|
if i < #order then
|
||||||
|
table.insert(sections, ui.separator({ thickness = 1, color = "outline_variant" }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
body = ui.scroll({ flexGrow = 1 }, {
|
||||||
|
ui.column({ gap = 0 }, sections),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
panel.render(ui.column({ flexGrow = 1, gap = 0 }, {
|
||||||
|
header,
|
||||||
|
ui.separator({ thickness = 1, color = "outline_variant" }),
|
||||||
|
body,
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Global handlers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onSearchChange(value)
|
||||||
|
searchQuery = value or ""
|
||||||
|
pendingKill = nil
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onToggleTCP(value)
|
||||||
|
showTCP = value ~= "false"
|
||||||
|
pendingKill = nil
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onToggleUDP(value)
|
||||||
|
showUDP = value ~= "false"
|
||||||
|
pendingKill = nil
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRefresh()
|
||||||
|
pendingKill = nil
|
||||||
|
noctalia.state.set("portctl_refresh", os.time())
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClose()
|
||||||
|
panel.close()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onOpen(_ctx)
|
||||||
|
killingPid = nil
|
||||||
|
pendingKill = nil
|
||||||
|
searchQuery = ""
|
||||||
|
noctalia.state.set("portctl_refresh", os.time())
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.watch("portctl_data", function(d)
|
||||||
|
if not d then return end
|
||||||
|
data = d
|
||||||
|
killingPid = nil
|
||||||
|
pendingKill = nil
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
render()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# portctl — inspect and kill listening ports from the bar.
|
||||||
|
|
||||||
|
id = "rxtsel/portctl"
|
||||||
|
name = "Portctl"
|
||||||
|
version = "0.2.0"
|
||||||
|
min_noctalia = "5.0.0"
|
||||||
|
author = "Cristhian Melo"
|
||||||
|
license = "MIT"
|
||||||
|
icon = "plug"
|
||||||
|
description = "Inspect and kill listening TCP/UDP ports."
|
||||||
|
tags = ["network", "utility", "development", "indicator", "bar", "panel", "service"]
|
||||||
|
dependencies = ["ss"]
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "refresh_interval"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.refresh_interval.label"
|
||||||
|
description_key = "settings.refresh_interval.description"
|
||||||
|
default = 5
|
||||||
|
min = 1
|
||||||
|
max = 60
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "ignore_list"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.ignore_list.label"
|
||||||
|
description_key = "settings.ignore_list.description"
|
||||||
|
default = ""
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "ignore_ports"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.ignore_ports.label"
|
||||||
|
description_key = "settings.ignore_ports.description"
|
||||||
|
default = ""
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hide_system_ports"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hide_system_ports.label"
|
||||||
|
description_key = "settings.hide_system_ports.description"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hide_unknown_ports"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hide_unknown_ports.label"
|
||||||
|
description_key = "settings.hide_unknown_ports.description"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[service]]
|
||||||
|
id = "scanner"
|
||||||
|
entry = "service.luau"
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "panel"
|
||||||
|
entry = "panel.luau"
|
||||||
|
width = 428
|
||||||
|
height = 280
|
||||||
|
open_near_click = true
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "indicator"
|
||||||
|
entry = "widget.luau"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "glyph"
|
||||||
|
type = "glyph"
|
||||||
|
label_key = "settings.glyph.label"
|
||||||
|
description_key = "settings.glyph.description"
|
||||||
|
default = "plug"
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
--!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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"title": "Portctl",
|
||||||
|
"panel": {
|
||||||
|
"search_placeholder": "Search port, process, PID…",
|
||||||
|
"tcp": "TCP",
|
||||||
|
"udp": "UDP",
|
||||||
|
"no_ports": "No listening ports",
|
||||||
|
"no_results": "No results for \"{query}\"",
|
||||||
|
"killing": "Killing {name}…",
|
||||||
|
"kill_confirm": "Kill {name}?",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"kill": "Kill",
|
||||||
|
"pid_copied": "Copied PID {pid}"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"Development": "Development",
|
||||||
|
"Databases": "Databases",
|
||||||
|
"Containers": "Containers",
|
||||||
|
"Servers": "Servers",
|
||||||
|
"Cloud": "Cloud",
|
||||||
|
"Other": "Other"
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"terminated": "Process {pid} terminated",
|
||||||
|
"kill_failed": "Failed to kill PID {pid}",
|
||||||
|
"ss_not_found": "ss (iproute2) not found — install the iproute2 package"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"refresh_interval": {
|
||||||
|
"label": "Refresh interval",
|
||||||
|
"description": "Seconds between port scans"
|
||||||
|
},
|
||||||
|
"ignore_list": {
|
||||||
|
"label": "Ignore processes",
|
||||||
|
"description": "Comma-separated process name substrings to hide (e.g. discord,chrome,steam)"
|
||||||
|
},
|
||||||
|
"ignore_ports": {
|
||||||
|
"label": "Ignore ports",
|
||||||
|
"description": "Comma-separated port numbers to hide (e.g. 37700,6463)"
|
||||||
|
},
|
||||||
|
"hide_system_ports": {
|
||||||
|
"label": "Hide system ports",
|
||||||
|
"description": "Hide ports below 1024 (privileged/root ports)"
|
||||||
|
},
|
||||||
|
"hide_unknown_ports": {
|
||||||
|
"label": "Hide unknown ports",
|
||||||
|
"description": "Hide ports whose process info is not accessible (e.g. rootlessport, root-owned processes without sudo)"
|
||||||
|
},
|
||||||
|
"glyph": {
|
||||||
|
"label": "Glyph",
|
||||||
|
"description": "Icon shown in the bar."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- portctl — bar widget.
|
||||||
|
-- Count syncs with panel filters (portctl_visible_count).
|
||||||
|
-- Falls back to service total when panel not open.
|
||||||
|
|
||||||
|
local visibleCount = nil -- set by panel on each render
|
||||||
|
|
||||||
|
local function applyCount(n)
|
||||||
|
barWidget.setVisible(n > 0)
|
||||||
|
barWidget.setGlyph(noctalia.getConfig("glyph") or "plug")
|
||||||
|
barWidget.setText(tostring(n))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Panel publishes filtered count on every render
|
||||||
|
noctalia.state.watch("portctl_visible_count", function(n)
|
||||||
|
visibleCount = n
|
||||||
|
applyCount(n or 0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Service publishes stats; use count_tcp (matches panel default: TCP only)
|
||||||
|
noctalia.state.watch("portctl_stats", function(stats)
|
||||||
|
if visibleCount ~= nil then return end -- panel count takes priority
|
||||||
|
local n = (stats and stats.count_tcp) or (stats and stats.count) or 0
|
||||||
|
applyCount(n)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Cold start: apply whatever is already in state
|
||||||
|
local v = noctalia.state.get("portctl_visible_count")
|
||||||
|
if v ~= nil then
|
||||||
|
visibleCount = v
|
||||||
|
applyCount(v)
|
||||||
|
else
|
||||||
|
local stats = noctalia.state.get("portctl_stats")
|
||||||
|
local n = (stats and stats.count_tcp) or (stats and stats.count) or 0
|
||||||
|
applyCount(n)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.togglePanel("rxtsel/portctl:panel")
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user