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 0000000..58a6a0a Binary files /dev/null and b/portctl/thumbnail.webp differ 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