From 022817b14c70c92778cfe308056c36360b1892cd Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Thu, 16 Jul 2026 07:04:08 -0500 Subject: [PATCH] feat: add portctl plugin (#24) --- portctl/README.md | 59 +++++ portctl/panel.luau | 404 +++++++++++++++++++++++++++++++++++ portctl/plugin.toml | 71 ++++++ portctl/service.luau | 240 +++++++++++++++++++++ portctl/thumbnail.webp | Bin 0 -> 43576 bytes portctl/translations/en.json | 54 +++++ portctl/widget.luau | 40 ++++ 7 files changed, 868 insertions(+) create mode 100644 portctl/README.md create mode 100644 portctl/panel.luau create mode 100644 portctl/plugin.toml create mode 100644 portctl/service.luau create mode 100644 portctl/thumbnail.webp create mode 100644 portctl/translations/en.json create mode 100644 portctl/widget.luau diff --git a/portctl/README.md b/portctl/README.md new file mode 100644 index 0000000..0f08a1e --- /dev/null +++ b/portctl/README.md @@ -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()`. diff --git a/portctl/panel.luau b/portctl/panel.luau new file mode 100644 index 0000000..3aad2ab --- /dev/null +++ b/portctl/panel.luau @@ -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() diff --git a/portctl/plugin.toml b/portctl/plugin.toml new file mode 100644 index 0000000..2bb517a --- /dev/null +++ b/portctl/plugin.toml @@ -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" diff --git a/portctl/service.luau b/portctl/service.luau new file mode 100644 index 0000000..443c3f2 --- /dev/null +++ b/portctl/service.luau @@ -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 diff --git a/portctl/thumbnail.webp b/portctl/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..58a6a0a013b463ecf05adfe20c475b75924d3fad GIT binary patch literal 43576 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
y!>O|? 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 JAL+xu008OykO%+( literal 0 HcmV?d00001 diff --git a/portctl/translations/en.json b/portctl/translations/en.json new file mode 100644 index 0000000..9c3084f --- /dev/null +++ b/portctl/translations/en.json @@ -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." + } + } +} diff --git a/portctl/widget.luau b/portctl/widget.luau new file mode 100644 index 0000000..9d5773c --- /dev/null +++ b/portctl/widget.luau @@ -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