From 38d6255b980cf4bc381ba716c819b46ad482a5fc Mon Sep 17 00:00:00 2001 From: Dave Hammer Date: Sun, 9 Aug 2026 10:24:22 -0400 Subject: [PATCH] Add davemhammer/opnsense (#315) OPNsense health, interfaces, gateways, rules, logs, and service control. --- opnsense/README.md | 64 ++ opnsense/assets/opnsense-full.svg | 31 + opnsense/assets/opnsense-green.png | Bin 0 -> 431 bytes opnsense/assets/opnsense-green.svg | 1 + opnsense/assets/opnsense-grey.png | Bin 0 -> 426 bytes opnsense/assets/opnsense-grey.svg | 1 + opnsense/assets/opnsense-orange.png | Bin 0 -> 438 bytes opnsense/assets/opnsense-orange.svg | 1 + opnsense/assets/opnsense-red.png | Bin 0 -> 433 bytes opnsense/assets/opnsense-red.svg | 1 + opnsense/assets/opnsense.svg | 1 + opnsense/launcher.luau | 292 +++++++ opnsense/panel.luau | 732 ++++++++++++++++++ opnsense/plugin.toml | 120 +++ opnsense/service.luau | 1113 +++++++++++++++++++++++++++ opnsense/thumbnail.webp | Bin 0 -> 50202 bytes opnsense/translations/en.json | 158 ++++ opnsense/widget.luau | 127 +++ 18 files changed, 2642 insertions(+) create mode 100644 opnsense/README.md create mode 100644 opnsense/assets/opnsense-full.svg create mode 100644 opnsense/assets/opnsense-green.png create mode 100644 opnsense/assets/opnsense-green.svg create mode 100644 opnsense/assets/opnsense-grey.png create mode 100644 opnsense/assets/opnsense-grey.svg create mode 100644 opnsense/assets/opnsense-orange.png create mode 100644 opnsense/assets/opnsense-orange.svg create mode 100644 opnsense/assets/opnsense-red.png create mode 100644 opnsense/assets/opnsense-red.svg create mode 100644 opnsense/assets/opnsense.svg create mode 100644 opnsense/launcher.luau create mode 100644 opnsense/panel.luau create mode 100644 opnsense/plugin.toml create mode 100644 opnsense/service.luau create mode 100644 opnsense/thumbnail.webp create mode 100644 opnsense/translations/en.json create mode 100644 opnsense/widget.luau diff --git a/opnsense/README.md b/opnsense/README.md new file mode 100644 index 0000000..4f0c054 --- /dev/null +++ b/opnsense/README.md @@ -0,0 +1,64 @@ +# OPNsense + +Monitor OPNsense system health, interfaces, gateways, services, firewall rules, and recent firewall logs from Noctalia. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `davemhammer/opnsense` | +| Entries | Bar widget: `status`; panel: `manager`; service: `service`; launcher: `opn` | +| Launcher Prefix | `/opn` | + +## Requirements + +- Network access to your OPNsense REST API +- An API key + secret with permission to read status (and control services if you use restart/start/stop) +- On `PATH` (declared in `plugin.toml` `dependencies`): + - `curl` — on-demand firewall log fetch + - `jq` — slim log JSON for the panel + - `xdg-open` — open the OPNsense web UI + +## Usage + +Configure **Base URL**, **API key**, and **API secret** under plugin settings (key/secret are sensitive string fields). + +Add the **status** bar widget (`davemhammer/opnsense:status`). Click to open the manager panel. + +Panel tabs: **Status**, **Interfaces**, **Gateways**, **Services**, **Rules**, **Logs**. Logs load only when you open the Logs tab (last 100 events). + +Launcher: `/opn` for categories and quick actions. + +```sh +noctalia msg panel-toggle davemhammer/opnsense:manager +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `base_url` | `string` | `https://192.168.1.1` | OPNsense base URL (no trailing `/api`). | +| `api_key` | `string` | _(empty)_ | API key (basic auth username). | +| `api_secret` | `string` | _(empty)_ | API secret (basic auth password). | +| `allow_insecure_tls` | `bool` | `true` | Skip TLS certificate verification (default **on** for common LAN self-signed certs; set **false** when you have a trusted cert). | +| `refresh_interval` | `int` | `20` | Core status poll interval in seconds. | +| `notify_on_issue` | `bool` | `true` | Notify when a new subsystem/gateway issue appears. | +| `web_ui_url` | `string` | _(empty)_ | Override URL for “Open Web UI”; empty uses `base_url`. | +| `show_label` | `bool` (widget) | `true` | Show OK / issue label on the bar. | +| `ok_color` | `select` (widget) | `tertiary` | Bar color when status is OK. | +| `warn_color` | `select` (widget) | `error` | Bar color when issues are present. | + +## IPC + +```sh +noctalia msg panel-toggle davemhammer/opnsense:manager +noctalia msg plugin davemhammer/opnsense:service all refresh +noctalia msg plugin davemhammer/opnsense:service all logs +``` + +## Notes + +- Uses `noctalia.http` for status/rules/services (Basic Auth). Log fetch uses `curl` + `jq` with `?limit=100` so large log dumps do not stall Luau. Web UI opens via `xdg-open`. +- API credentials are stored in Noctalia settings (not in this repo). Prefer a restricted API key. +- `allow_insecure_tls` applies to both `noctalia.http` and the log `curl` request. Default is **true** (verification skipped); turn it **off** when the firewall presents a certificate you trust. +- Service control mutates the firewall only when you request start/stop/restart. diff --git a/opnsense/assets/opnsense-full.svg b/opnsense/assets/opnsense-full.svg new file mode 100644 index 0000000..65e6a58 --- /dev/null +++ b/opnsense/assets/opnsense-full.svg @@ -0,0 +1,31 @@ + + + + + diff --git a/opnsense/assets/opnsense-green.png b/opnsense/assets/opnsense-green.png new file mode 100644 index 0000000000000000000000000000000000000000..28b6dd1d976e7bb31d369e22325a451bf2e13167 GIT binary patch literal 431 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hE|@caLo{6cq1^2l|i~?8RG*B zr_T%pJO^1Bt|;$dY_MVpXV@TrP?q71a{(vAZNWQC2h3QmGi>M!$nr0J|2O1S_RaLS z)jD&P?w)VRo|fh$!SF*;QH0@+atm`qbPIc_WT>5B(z%9gE}n&q3NNHS*m5q=Tj0mE zf_ueehF46lR2p_QSm769J#+8wwi%bB?dK{h&tXtfu35_%D3~X9u!Ey~Khs44vHL%5 z`tKSv-;@{rXSFqdn%H;7z_df_r5AE=thRQ1&oO)3p?QL4^I7*Oy|Ln{bh%M0^`$MK zROVYtK&|`()`u5bJJ?$en>(#lo)OczwI`uW{f5>d_IX|TFPA53&1GOOPNsense \ No newline at end of file diff --git a/opnsense/assets/opnsense-grey.png b/opnsense/assets/opnsense-grey.png new file mode 100644 index 0000000000000000000000000000000000000000..011824c32b2a10cb1ea9584be6f381a036c67747 GIT binary patch literal 426 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEN$XETo{@r} z_sxk@f^ONImydev8^!!L++|7L?Xv4q=VcfcbSDTgTyQqvW_Y16gN@;s;2CCyWDY%s zhB_8=1_${jZH5O{P1Xz#q?)`L9@sQRGdz%K%4T>VvGB{hSJP58|8D(K{KfX0-^6$6 z2b8Og6?qsw7&~b&JdjnCVffHhkUTx{DYxP?o(dP2tPBQ)7g8T=IS=R^@MGG*yTlZ47(Yu@e3&}`u6Tx(z2-R?j8e1hm@A*QUan6Qw4iEK77*_;7$BK*FSshw4f{h zW_-B*vUc;9-TWO!!gY5!TpcX#a8|inY!&_1Q}9dYf#}0ai6@i~J-xiZyk*_v4eBlL zelzHEIEyRE3AE+6tWjPO(;4X&uv>Ub*9T6)m-ilW9e(kHfq{X+)78&qol`;+0B8@c AQvd(} literal 0 HcmV?d00001 diff --git a/opnsense/assets/opnsense-grey.svg b/opnsense/assets/opnsense-grey.svg new file mode 100644 index 0000000..128e1fa --- /dev/null +++ b/opnsense/assets/opnsense-grey.svg @@ -0,0 +1 @@ +OPNsense \ No newline at end of file diff --git a/opnsense/assets/opnsense-orange.png b/opnsense/assets/opnsense-orange.png new file mode 100644 index 0000000000000000000000000000000000000000..10925007a229474791f2e0107ca02bba5b2c4b16 GIT binary patch literal 438 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEsxV3V>eDTZl>`6xLudn~Sn6>9%)`I_Dem?T$k0)QPILp|e>ol1mq5Xmm!wm%! zUWTt6XPF&jo46S?+zXf)QaRQ!B-l4uGu&u@AjGguc?WAlF2_2?1X;zY^Iqk}Jqw9D zZI^vN`K8FqIyRZjCc=jp4!q}B$nZc$QHEiTQq0QJD?YUf6tl@p77or};Mm3X$A>*Y zJYYHV3hot`8LzNjdCBmK=~W3sDPyTs!>$G^S%+AMxqJ)s7Wg44D?0S%Zf>_^-u&mz zDJ_f&Mu)yj35Y&S5$x$G_@*tuoA`0=`P+vrmiF9~7yf6pG=G}dcgDc9Lq~f9SX!>m zT~OZQ8=ZKbWA=HbIKeX6HXEfkcDz;28diMOP8$38KQslDDn4Rd_{(iYDa&N-jnf3r fykm)W{lOx;ed_bzNfFiz3=9mOu6{1-oD!MOPNsense \ No newline at end of file diff --git a/opnsense/assets/opnsense-red.png b/opnsense/assets/opnsense-red.png new file mode 100644 index 0000000000000000000000000000000000000000..84603a62a7f5cece41b1309e9cea87d34838720e GIT binary patch literal 433 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEq5LUtz9U8-m!1%OW;-#={e)-dM3@y`-poj)J8IFy578pl+o22rP@3<-?^mJBkk z1q=-C0&^G~G@FzeG#m@q7^($jm=4IXEN9ptdeD#|$FYEqA)CYZ<;r!^@v}b#eT)1r zcY65=2IB|Qp3W5DV3;P*#CV{*rGeptE0m+B8yd7O1QU!ZDK0FRrkUDg? z+%EXPfkX1Q<1PFBUd~>+`*(we@x$xA0W2+7=PoF3@r_PA&oTQvQ=DL#Y@3bJ8#~@A zXALX9YA20-{2!VMUN{|LKJ-v};VnU*bw{$?40a2z5q-$Dt$xze%}pn#FfcGMc)I$z JtaD0e0stTeue<;N literal 0 HcmV?d00001 diff --git a/opnsense/assets/opnsense-red.svg b/opnsense/assets/opnsense-red.svg new file mode 100644 index 0000000..332cf99 --- /dev/null +++ b/opnsense/assets/opnsense-red.svg @@ -0,0 +1 @@ +OPNsense \ No newline at end of file diff --git a/opnsense/assets/opnsense.svg b/opnsense/assets/opnsense.svg new file mode 100644 index 0000000..53a38d7 --- /dev/null +++ b/opnsense/assets/opnsense.svg @@ -0,0 +1 @@ +OPNsense \ No newline at end of file diff --git a/opnsense/launcher.luau b/opnsense/launcher.luau new file mode 100644 index 0000000..a96bb49 --- /dev/null +++ b/opnsense/launcher.luau @@ -0,0 +1,292 @@ +--!nonstrict +-- /opn launcher for OPNsense. + +local STATE_KEY = "opn_snapshot" +local COMMAND_KEY = "opn_command" +local PANEL_ID = "davemhammer/opnsense:manager" +local MAX_ROWS = 40 + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + configured = false, + loading = true, + widgets = {}, + interfaces = {}, + gateways = {}, + services = {}, + issueCount = 0, + host = "", + error = "", +} + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + end +end) + +local function trim(s) + return noctalia.string.trim(tostring(s or "")) +end + +local function lower(s) + return string.lower(tostring(s or "")) +end + +local function send(action, values) + local command = { action = action, requestId = "launcher-" .. tostring(os.time()) } + if type(values) == "table" then + for k, v in pairs(values) do command[k] = v end + end + noctalia.state.set(COMMAND_KEY, command) +end + +local function scoreText(filter, ...) + if filter == "" then return 1 end + local best = nil + for i = 1, select("#", ...) do + local text = tostring(select(i, ...) or "") + if text ~= "" then + local s = noctalia.fuzzyScore(filter, text) + if s ~= nil and (best == nil or s > best) then best = s end + if best == nil and lower(text):find(lower(filter), 1, true) then best = 0.5 end + end + end + return best +end + +local function statusRow(title, subtitle, glyph) + return { id = "", title = title, subtitle = subtitle, glyph = glyph or "shield" } +end + +local function topCategories() + return { + { id = "cat:status", title = noctalia.tr("launcher.cat.status"), subtitle = noctalia.tr("launcher.cat.status-sub"), glyph = "heart-rate-monitor", score = 100 }, + { id = "cat:interfaces", title = noctalia.tr("launcher.cat.interfaces"), subtitle = noctalia.tr("launcher.cat.interfaces-sub"), glyph = "network", score = 90 }, + { id = "cat:gateways", title = noctalia.tr("launcher.cat.gateways"), subtitle = noctalia.tr("launcher.cat.gateways-sub"), glyph = "router", score = 85 }, + { id = "cat:services", title = noctalia.tr("launcher.cat.services"), subtitle = noctalia.tr("launcher.cat.services-sub"), glyph = "settings", score = 80 }, + { id = "cat:rules", title = noctalia.tr("launcher.cat.rules"), subtitle = noctalia.tr("launcher.cat.rules-sub"), glyph = "list-check", score = 75 }, + { id = "cat:logs", title = noctalia.tr("launcher.cat.logs"), subtitle = noctalia.tr("launcher.cat.logs-sub"), glyph = "file-text", score = 72 }, + { id = "act:panel", title = noctalia.tr("launcher.cat.panel"), subtitle = noctalia.tr("launcher.cat.panel-sub"), glyph = "layout-dashboard", score = 70 }, + { id = "act:ui", title = noctalia.tr("launcher.cat.ui"), subtitle = noctalia.tr("launcher.cat.ui-sub"), glyph = "external-link", score = 60 }, + { id = "act:refresh", title = noctalia.tr("launcher.cat.refresh"), subtitle = noctalia.tr("launcher.cat.refresh-sub"), glyph = "refresh", score = 50 }, + } +end + +local function listStatus(filter) + local rows = {} + for _, w in ipairs(snapshot.widgets or {}) do + local s = scoreText(filter, w.name, w.status, w.message) + if s ~= nil then + table.insert(rows, { + id = "st:" .. w.id, + title = w.name, + subtitle = w.status .. (w.message ~= "" and (" · " .. w.message) or ""), + glyph = w.ok and "circle-check" or "circle-x", + score = s, + }) + end + end + if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end + return rows +end + +local function listIfaces(filter) + local rows = {} + for _, i in ipairs(snapshot.interfaces or {}) do + local s = scoreText(filter, i.name, i.description, i.status, i.ipv4) + if s ~= nil then + table.insert(rows, { + id = "if:" .. i.id, + title = i.name, + subtitle = `{i.status} · {i.ipv4}`, + glyph = "network", + score = s, + }) + end + end + if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end + return rows +end + +local function listGateways(filter) + local rows = {} + for _, g in ipairs(snapshot.gateways or {}) do + local s = scoreText(filter, g.name, g.status, g.address) + if s ~= nil then + table.insert(rows, { + id = "gw:" .. g.id, + title = g.name, + subtitle = `{g.status} · {g.address}`, + glyph = "router", + score = s, + }) + end + end + if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end + return rows +end + +local function serviceActions(name) + return { + { id = "svcact:restart:" .. name, title = noctalia.tr("launcher.action.restart"), subtitle = name, glyph = "refresh", score = 100 }, + { id = "svcact:start:" .. name, title = noctalia.tr("launcher.action.start"), subtitle = name, glyph = "player-play", score = 90 }, + { id = "svcact:stop:" .. name, title = noctalia.tr("launcher.action.stop"), subtitle = name, glyph = "player-stop", score = 80 }, + { id = "svcact:copy:" .. name, title = noctalia.tr("launcher.action.copy"), subtitle = name, glyph = "copy", score = 70 }, + } +end + +local function listServices(filter) + local rows = {} + for _, s in ipairs(snapshot.services or {}) do + local sc = scoreText(filter, s.name, s.status, s.description) + if sc ~= nil then + table.insert(rows, { + id = "svc:" .. s.id, + title = s.name, + subtitle = s.status .. (s.description ~= "" and (" · " .. s.description) or ""), + glyph = s.running and "player-play" or "player-stop", + score = sc, + }) + end + end + table.sort(rows, function(a, b) return (a.score or 0) > (b.score or 0) end) + while #rows > MAX_ROWS do table.remove(rows) end + if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end + return rows +end + +local function findService(name) + name = trim(name) + for _, s in ipairs(snapshot.services or {}) do + if s.name == name or s.id == name then return s end + end + local hits = {} + local q = lower(name) + for _, s in ipairs(snapshot.services or {}) do + if lower(s.name):find(q, 1, true) then table.insert(hits, s) end + end + if #hits == 1 then return hits[1] end + return nil +end + +function onQuery(query) + if not snapshot.configured then + launcher.setResults(query, { + statusRow(noctalia.tr("panel.not_configured"), "", "settings"), + { id = "act:panel", title = noctalia.tr("launcher.cat.panel"), subtitle = "Configure in plugin settings", glyph = "layout-dashboard" }, + }) + return + end + + if snapshot.loading and not snapshot.available then + send("refresh") + launcher.setResults(query, { statusRow(noctalia.tr("launcher.loading"), snapshot.host, "loader") }) + return + end + + if not snapshot.available then + launcher.setResults(query, { + statusRow(noctalia.tr("launcher.unavailable"), snapshot.error or "", "cloud-off"), + { id = "act:refresh", title = noctalia.tr("launcher.cat.refresh"), subtitle = "", glyph = "refresh" }, + }) + return + end + + local text = trim(query) + if text == "" then + launcher.setResults(query, topCategories()) + return + end + + local tokens = {} + for t in text:gmatch("%S+") do table.insert(tokens, t) end + local head = lower(tokens[1] or "") + local rest = table.concat(tokens, " ", 2) + + local function is(name, aliases) + if head == name then return true end + for _, a in ipairs(aliases) do if head == a then return true end end + return false + end + + if is("status", { "st", "health" }) then + launcher.setResults(query, listStatus(rest)) + return + end + if is("interfaces", { "iface", "if", "int" }) then + launcher.setResults(query, listIfaces(rest)) + return + end + if is("gateways", { "gw", "gateway" }) then + launcher.setResults(query, listGateways(rest)) + return + end + if is("services", { "svc", "service", "s" }) then + local svc = findService(rest) + if svc and (lower(rest) == lower(svc.name) or rest:find(svc.name, 1, true)) and rest ~= "" then + if lower(rest) == lower(svc.name) then + launcher.setResults(query, serviceActions(svc.name)) + return + end + end + launcher.setResults(query, listServices(rest)) + return + end + + local rows = {} + for _, row in ipairs(topCategories()) do + local s = scoreText(text, row.title, row.id) + if s ~= nil then + row.score = s + table.insert(rows, row) + end + end + for _, r in ipairs(listServices(text)) do + if r.id ~= "" then table.insert(rows, r) end + end + if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), text, "search") end + launcher.setResults(query, rows) +end + +function onActivate(id) + if id == nil or id == "" then return end + + if id == "cat:status" then launcher.setQuery("status "); return end + if id == "cat:interfaces" then launcher.setQuery("interfaces "); return end + if id == "cat:gateways" then launcher.setQuery("gateways "); return end + if id == "cat:services" then launcher.setQuery("services "); return end + if id == "cat:rules" then noctalia.togglePanel(PANEL_ID); return end + if id == "cat:logs" then noctalia.togglePanel(PANEL_ID); return end + + if id == "act:panel" then noctalia.togglePanel(PANEL_ID); return end + if id == "act:ui" then send("open_ui"); return end + if id == "act:refresh" then + send("refresh") + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) + return + end + + local svc = id:match("^svc:(.+)$") + if svc then + launcher.setQuery("services " .. svc .. " ") + return + end + + local act, name = id:match("^svcact:([%w]+):(.+)$") + if act and name then + if act == "restart" then send("restart_service", { name = name }) + elseif act == "start" then send("start_service", { name = name }) + elseif act == "stop" then send("stop_service", { name = name }) + elseif act == "copy" then send("copy", { name = name }) + end + return + end + + local st = id:match("^st:(.+)$") + if st then send("copy", { name = st }); return end + local iface = id:match("^if:(.+)$") + if iface then send("copy", { name = iface }); return end + local gw = id:match("^gw:(.+)$") + if gw then send("copy", { name = gw }); return end +end diff --git a/opnsense/panel.luau b/opnsense/panel.luau new file mode 100644 index 0000000..bd18da6 --- /dev/null +++ b/opnsense/panel.luau @@ -0,0 +1,732 @@ +--!nonstrict +-- OPNsense manager panel. + +local STATE_KEY = "opn_snapshot" +local COMMAND_KEY = "opn_command" +local RESULT_KEY = "opn_action_result" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + configured = false, + loading = true, + busy = false, + host = "", + widgets = {}, + interfaces = {}, + gateways = {}, + services = {}, + rules = {}, + logs = {}, + info = {}, + resources = {}, + issueCount = 0, + okCount = 0, + blockLogCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local tab = "status" +local selectedId = "" +local filterText = "" +local filterKey = 0 +local requestCounter = 0 +local feedback = "" +local feedbackError = false +local dirty = true + +local render + +local function tr(key, subst) + return noctalia.tr(key, subst) +end + +local function nextRequestId() + requestCounter += 1 + return `panel-{requestCounter}` +end + +local function send(action, values) + local command = { action = action, requestId = nextRequestId() } + if type(values) == "table" then + for k, v in pairs(values) do + command[k] = v + end + end + noctalia.state.set(COMMAND_KEY, command) + return command.requestId +end + +local function lower(s) + return string.lower(tostring(s or "")) +end + +local function haystackContains(needle, ...) + if needle == "" then return true end + for i = 1, select("#", ...) do + local part = lower(select(i, ...)) + if part ~= "" and part:find(needle, 1, true) then + return true + end + end + return false +end + +local function matchesFilter(...) + local q = noctalia.string.trim(filterText) + if q == "" then return true end + for raw in q:gmatch("%S+") do + local neg = false + local term = raw + if term:sub(1, 1) == "!" then + neg = true + term = term:sub(2) + end + term = lower(term) + if term ~= "" then + local hit = haystackContains(term, ...) + if neg then + if hit then return false end + else + if not hit then return false end + end + end + end + return true +end + +local function statusColor(ok) + return ok and "tertiary" or "error" +end + +local function listButton(props) + props.contentAlign = "start" + props.controlSize = props.controlSize or "md" + return ui.button(props) +end + +local function selectedService() + if tab ~= "services" then return nil end + for _, s in ipairs(snapshot.services or {}) do + if s.id == selectedId then return s end + end + return nil +end + +local function selectedIface() + if tab ~= "interfaces" then return nil end + for _, i in ipairs(snapshot.interfaces or {}) do + if i.id == selectedId then return i end + end + return nil +end + +local function selectedGw() + if tab ~= "gateways" then return nil end + for _, g in ipairs(snapshot.gateways or {}) do + if g.id == selectedId then return g end + end + return nil +end + +local function selectedWidget() + if tab ~= "status" then return nil end + for _, w in ipairs(snapshot.widgets or {}) do + if w.id == selectedId then return w end + end + return nil +end + +local function selectedRule() + if tab ~= "rules" then return nil end + for _, r in ipairs(snapshot.rules or {}) do + if r.id == selectedId then return r end + end + return nil +end + +local function selectedLog() + if tab ~= "logs" then return nil end + for _, l in ipairs(snapshot.logs or {}) do + if l.id == selectedId then return l end + end + return nil +end + +local function emptyList(msg) + return ui.column({ + key = "empty-" .. tab, + align = "center", + justify = "center", + padding = 24, + gap = 8, + flexGrow = 1, + }, { + ui.glyph({ name = "search", size = 36, color = "on_surface_variant" }), + ui.label({ text = msg, color = "on_surface_variant", textAlign = "center" }), + }) +end + +local function itemColumn(rows) + return ui.column({ + key = "items-" .. tab, + align = "stretch", + justify = "start", + gap = 8, + flexGrow = 1, + }, rows) +end + +local function statusRows() + local rows = {} + for _, w in ipairs(snapshot.widgets or {}) do + if matchesFilter(w.name, w.status, w.message) then + local selected = w.id == selectedId + table.insert(rows, listButton({ + key = "st-" .. w.id, + text = `{w.name} · {w.status}` .. (w.message ~= "" and (` · {w.message}`) or ""), + glyph = w.ok and "circle-check" or "circle-x", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = w.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function ifaceRows() + local rows = {} + for _, i in ipairs(snapshot.interfaces or {}) do + if matchesFilter(i.name, i.description, i.status, i.ipv4) then + local selected = i.id == selectedId + local label = i.description ~= "" and (`{i.name} ({i.description})`) or i.name + table.insert(rows, listButton({ + key = "if-" .. i.id, + text = `{label} · {i.status} · {i.ipv4} · ↓{i.inBytes} ↑{i.outBytes}`, + glyph = "network", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = i.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function gwRows() + local rows = {} + for _, g in ipairs(snapshot.gateways or {}) do + if matchesFilter(g.name, g.status, g.address, g.rtt) then + local selected = g.id == selectedId + table.insert(rows, listButton({ + key = "gw-" .. g.id, + text = `{g.name} · {g.status} · {g.address} · rtt {g.rtt}`, + glyph = "router", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = g.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function serviceRows() + local rows = {} + for _, s in ipairs(snapshot.services or {}) do + if matchesFilter(s.name, s.status, s.description) then + local selected = s.id == selectedId + table.insert(rows, listButton({ + key = "svc-" .. s.id, + text = `{s.name} · {s.status}` .. (s.description ~= "" and (` · {s.description}`) or ""), + glyph = s.running and "player-play" or "player-stop", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = s.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function ruleRows() + local rows = {} + for _, r in ipairs(snapshot.rules or {}) do + if matchesFilter( + r.description, r.action, r.direction, r.source, r.destination, + r.protocol, r.interface, r.enabled and "enabled" or "disabled", + r.automatic and "automatic" or "manual" + ) then + local selected = r.id == selectedId + local en = r.enabled and "" or " [off]" + local auto = r.automatic and " auto" or "" + local text = `{r.action}{en}{auto} · {r.direction} · {r.description} · {r.source} → {r.destination}` + table.insert(rows, listButton({ + key = "rule-" .. r.id, + text = text, + glyph = lower(r.action) == "block" and "ban" or "shield-check", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = r.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function logRows() + local rows = {} + for _, l in ipairs(snapshot.logs or {}) do + if matchesFilter( + l.action, l.direction, l.interface, l.protocol, + l.src, l.dst, l.label, l.time + ) then + local selected = l.id == selectedId + local text = `{l.time} · {l.action} {l.direction} · {l.interface} · {l.protocol} · {l.src} → {l.dst}` + table.insert(rows, listButton({ + key = "log-" .. l.id, + text = text, + glyph = l.blocked and "ban" or "arrow-right", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = l.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function itemList() + local rows + if tab == "status" then rows = statusRows() + elseif tab == "interfaces" then rows = ifaceRows() + elseif tab == "gateways" then rows = gwRows() + elseif tab == "services" then rows = serviceRows() + elseif tab == "rules" then rows = ruleRows() + else rows = logRows() + end + if #rows == 0 then + return emptyList(tr("panel.empty")) + end + return itemColumn(rows) +end + +local function toolbar() + local busy = snapshot.busy == true + if tab == "services" then + local s = selectedService() + if not s then + return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }) + end + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "settings", size = 18, color = s.running and "tertiary" or "on_surface_variant" }), + ui.label({ text = s.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ text = s.status, color = s.running and "tertiary" or "on_surface_variant", fontSize = 12 }), + }), + ui.label({ + text = s.description, + color = "on_surface_variant", + fontSize = 12, + visible = s.description ~= "", + maxLines = 2, + }), + ui.row({ gap = 6 }, { + ui.button({ text = tr("actions.restart"), glyph = "refresh", variant = "primary", enabled = not busy, onClick = "onRestart" }), + ui.button({ text = tr("actions.start"), glyph = "player-play", variant = "outline", enabled = not busy and not s.running, onClick = "onStart" }), + ui.button({ text = tr("actions.stop"), glyph = "player-stop", variant = "outline", enabled = not busy and s.running, onClick = "onStop" }), + ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "ghost", onClick = "onCopyService" }), + }), + }) + end + + if tab == "rules" then + local r = selectedRule() + if not r then + return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }) + end + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ + name = lower(r.action) == "block" and "ban" or "shield-check", + size = 18, + color = lower(r.action) == "block" and "error" or "tertiary", + }), + ui.label({ text = r.description, fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ + text = `{r.action} · {r.direction}` .. (r.enabled and "" or " · off"), + color = lower(r.action) == "block" and "error" or "tertiary", + fontSize = 12, + }), + }), + ui.label({ + text = tr("rule.detail", { + src = r.source ~= "" and r.source or "any", + dst = r.destination ~= "" and r.destination or "any", + proto = r.protocol ~= "" and r.protocol or "any", + iface = r.interface ~= "" and r.interface or "—", + }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 2, + }), + ui.label({ + text = tr("rule.stats", { + packets = r.packets, + bytes = r.bytes, + evaluations = r.evaluations, + }), + color = "on_surface_variant", + fontSize = 11, + }), + ui.row({ gap = 6 }, { + ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "outline", onClick = "onCopyRule" }), + }), + }) + end + + if tab == "logs" then + local l = selectedLog() + if not l then + return ui.label({ + text = tr("logs.hint", { n = #(snapshot.logs or {}), blocks = snapshot.blockLogCount or 0 }), + color = "on_surface_variant", + }) + end + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ + name = l.blocked and "ban" or "arrow-right", + size = 18, + color = l.blocked and "error" or "tertiary", + }), + ui.label({ + text = `{l.action} {l.direction} · {l.protocol}`, + fontWeight = "bold", + flexGrow = 1, + maxLines = 1, + }), + ui.label({ text = l.time, color = "on_surface_variant", fontSize = 12 }), + }), + ui.label({ + text = tr("logs.flow", { src = l.src, dst = l.dst, iface = l.interface }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 2, + }), + ui.label({ + text = l.label, + color = "on_surface_variant", + fontSize = 11, + visible = l.label ~= "", + maxLines = 2, + }), + ui.row({ gap = 6 }, { + ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "outline", onClick = "onCopyLog" }), + }), + }) + end + + local item = selectedWidget() or selectedIface() or selectedGw() + if not item then + return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }) + end + local title = item.name or item.id + local detail = item.message or item.description or item.address or "" + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "info-circle", size = 18, color = statusColor(item.ok ~= false) }), + ui.label({ text = tostring(title), fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ + text = tostring(item.status or ""), + color = statusColor(item.ok ~= false), + fontSize = 12, + }), + }), + ui.label({ + text = tostring(detail), + color = "on_surface_variant", + fontSize = 12, + visible = detail ~= "", + maxLines = 3, + }), + ui.row({ gap = 6 }, { + ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "outline", onClick = "onCopySelected" }), + }), + }) +end + +local function tabButton(label, id, cb) + return ui.button({ + text = label, + selected = tab == id, + variant = tab == id and "primary" or "ghost", + onClick = cb, + }) +end + +render = function() + dirty = false + local notes = {} + if not snapshot.configured then + table.insert(notes, ui.label({ text = tr("panel.not_configured"), color = "error", maxLines = 3 })) + end + if snapshot.loading then + table.insert(notes, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" })) + end + if snapshot.logsLoading then + table.insert(notes, ui.label({ text = tr("panel.logs_loading"), color = "on_surface_variant" })) + end + if snapshot.busy then + table.insert(notes, ui.label({ text = tr("panel.busy"), color = "primary" })) + end + if type(snapshot.error) == "string" and snapshot.error ~= "" then + table.insert(notes, ui.label({ text = snapshot.error, color = "error", maxLines = 3 })) + end + if feedback ~= "" then + table.insert(notes, ui.label({ + text = feedback, + color = feedbackError and "error" or "tertiary", + maxLines = 2, + })) + end + + local load = "" + if type(snapshot.resources) == "table" then + load = tostring(snapshot.resources.load or "") + end + local summary = tr("panel.summary", { + ok = snapshot.okCount or 0, + issues = snapshot.issueCount or 0, + load = load ~= "" and load or "—", + }) + local version = "" + if type(snapshot.info) == "table" then + version = tostring(snapshot.info.version or "") + end + + local titleIcon = "assets/opnsense-grey.png" + if snapshot.available == true then + local issues = tonumber(snapshot.issueCount) or 0 + titleIcon = issues == 0 and "assets/opnsense-orange.png" or "assets/opnsense-red.png" + end + + panel.render(ui.column({ flexGrow = 1, gap = 10 }, { + ui.row({ align = "center", gap = 10 }, { + ui.image({ + path = titleIcon, + width = 28, + height = 28, + fit = "contain", + }), + ui.column({ flexGrow = 1, gap = 0 }, { + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }), + ui.label({ + text = tr("panel.host", { host = snapshot.host ~= "" and snapshot.host or "—" }) + .. (version ~= "" and (` · {version}`) or ""), + fontSize = 11, + color = "on_surface_variant", + }), + }), + ui.button({ text = tr("actions.open_ui"), glyph = "external-link", variant = "outline", onClick = "onOpenUi" }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }), + ui.button({ glyph = "close", onClick = "onClose" }), + }), + + ui.row({ gap = 4, align = "center" }, { + tabButton(tr("tabs.status"), "status", "onTabStatus"), + tabButton(tr("tabs.interfaces"), "interfaces", "onTabInterfaces"), + tabButton(tr("tabs.gateways"), "gateways", "onTabGateways"), + tabButton(tr("tabs.services"), "services", "onTabServices"), + tabButton(tr("tabs.rules"), "rules", "onTabRules"), + tabButton(tr("tabs.logs"), "logs", "onTabLogs"), + }), + + ui.label({ + text = summary .. ( + tab == "logs" and (` · {tr("logs.summary", { n = #(snapshot.logs or {}), blocks = snapshot.blockLogCount or 0 })}`) + or (tab == "rules" and (` · {tr("rules.summary", { n = #(snapshot.rules or {}) })}`) or "") + ), + color = "on_surface_variant", + fontSize = 11, + maxLines = 1, + }), + + ui.row({ gap = 8, align = "center" }, { + ui.input({ + key = `filter-{tab}-{filterKey}`, + value = filterText, + placeholder = tr("filter.placeholder"), + flexGrow = 1, + controlSize = "sm", + onChange = "onFilterChange", + }), + ui.button({ + glyph = "x", + variant = "ghost", + visible = filterText ~= "", + onClick = "onClearFilter", + }), + }), + + toolbar(), + ui.column({ gap = 3, align = "stretch" }, notes), + ui.scroll({ + key = "scroll-" .. tab, + flexGrow = 1, + gap = 8, + align = "stretch", + }, { itemList() }), + ui.label({ + text = (snapshot.updatedAt or 0) > 0 + and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) }) + or "", + color = "on_surface_variant", + fontSize = 11, + }), + })) +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) ~= "table" then return end + local changed = value.revision ~= snapshot.revision + or value.busy ~= snapshot.busy + or value.loading ~= snapshot.loading + or value.logsLoading ~= snapshot.logsLoading + or value.error ~= snapshot.error + or value.issueCount ~= snapshot.issueCount + or value.blockLogCount ~= snapshot.blockLogCount + or #(value.logs or {}) ~= #(snapshot.logs or {}) + or #(value.rules or {}) ~= #(snapshot.rules or {}) + snapshot = value + if selectedId ~= "" then + if not (selectedWidget() or selectedIface() or selectedGw() or selectedService() + or selectedRule() or selectedLog()) then + selectedId = "" + end + end + if changed then dirty = true end +end) + +noctalia.state.watch(RESULT_KEY, function(result) + if type(result) ~= "table" then return end + if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then return end + feedback = tostring(result.message or "") + feedbackError = result.ok ~= true + dirty = true +end) + +panel.setWantsSecondTicks(true) + +function onOpen(_context) + feedback = "" + send("refresh") + render() +end + +function update() + if dirty then render() end +end + +function onClose() panel.close() end +function onRefresh() + send("refresh") + if tab == "logs" then + send("fetch_logs") + end +end +function onOpenUi() send("open_ui") end + +local function switchTab(next) + tab = next + selectedId = "" + filterKey += 1 + render() +end + +function onTabStatus() switchTab("status") end +function onTabInterfaces() switchTab("interfaces") end +function onTabGateways() switchTab("gateways") end +function onTabServices() switchTab("services") end +function onTabRules() switchTab("rules") end +function onTabLogs() + switchTab("logs") + -- Logs are large; fetch only when viewing the Logs tab. + if not snapshot.logsLoading then + send("fetch_logs") + end +end + +function onFilterChange(value) + filterText = if type(value) == "string" then value else "" + render() +end + +function onClearFilter() + filterText = "" + filterKey += 1 + render() +end + +function onRestart() + local s = selectedService() + if s then send("restart_service", { name = s.name }) end +end +function onStart() + local s = selectedService() + if s then send("start_service", { name = s.name }) end +end +function onStop() + local s = selectedService() + if s then send("stop_service", { name = s.name }) end +end +function onCopyService() + local s = selectedService() + if s then send("copy", { name = s.name }) end +end +function onCopySelected() + local item = selectedWidget() or selectedIface() or selectedGw() + if item then send("copy", { name = item.name or item.id }) end +end +function onCopyRule() + local r = selectedRule() + if r then + send("copy", { + name = `{r.action} {r.direction} {r.description} {r.source} -> {r.destination}`, + }) + end +end +function onCopyLog() + local l = selectedLog() + if l then + send("copy", { + name = `{l.time} {l.action} {l.direction} {l.interface} {l.protocol} {l.src} -> {l.dst} {l.label}`, + }) + end +end diff --git a/opnsense/plugin.toml b/opnsense/plugin.toml new file mode 100644 index 0000000..78862ec --- /dev/null +++ b/opnsense/plugin.toml @@ -0,0 +1,120 @@ +# OPNsense firewall status and service control via REST API. + +id = "davemhammer/opnsense" +name = "OPNsense" +version = "1.1.6" +plugin_api = 10 +author = "davemhammer" +license = "MIT" +dependencies = ["curl", "jq", "xdg-open"] +tags = ["network", "utility", "bar", "panel", "service", "launcher"] +icon = "shield" +description = "Monitor OPNsense health, interfaces, gateways, firewall rules, and logs." + +[[setting]] +key = "base_url" +type = "string" +label_key = "settings.base_url.label" +description_key = "settings.base_url.description" +default = "https://192.168.1.1" + +[[setting]] +key = "api_key" +type = "string" +label_key = "settings.api_key.label" +description_key = "settings.api_key.description" +default = "" + +[[setting]] +key = "api_secret" +type = "string" +label_key = "settings.api_secret.label" +description_key = "settings.api_secret.description" +default = "" + +[[setting]] +key = "allow_insecure_tls" +type = "bool" +label_key = "settings.allow_insecure_tls.label" +description_key = "settings.allow_insecure_tls.description" +default = true + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 20 +min = 5 +max = 300 + +[[setting]] +key = "notify_on_issue" +type = "bool" +label_key = "settings.notify_on_issue.label" +description_key = "settings.notify_on_issue.description" +default = true + +[[setting]] +key = "web_ui_url" +type = "string" +label_key = "settings.web_ui_url.label" +description_key = "settings.web_ui_url.description" +default = "" +advanced = true + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "show_label" + type = "bool" + label_key = "settings.show_label.label" + description_key = "settings.show_label.description" + default = true + + [[widget.setting]] + key = "ok_color" + type = "select" + label_key = "settings.ok_color.label" + default = "tertiary" + options = [ + { value = "tertiary", label_key = "colors.tertiary" }, + { value = "primary", label_key = "colors.primary" }, + { value = "secondary", label_key = "colors.secondary" } + ] + + [[widget.setting]] + key = "warn_color" + type = "select" + label_key = "settings.warn_color.label" + default = "error" + options = [ + { value = "error", label_key = "colors.error" }, + { value = "primary", label_key = "colors.primary" }, + { value = "on_surface_variant", label_key = "colors.muted" } + ] + +[[panel]] +id = "manager" +entry = "panel.luau" +width = 760 +height = 660 +placement = "floating" +position = "center" +open_near_click = true +keyboard_focus = "exclusive" +dismiss_on_outside_click = true + +[[service]] +id = "service" +entry = "service.luau" + +[[launcher_provider]] +id = "opn" +entry = "launcher.luau" +prefix = "opn" +glyph = "shield" +include_in_global_search = false +debounce_ms = 80 diff --git a/opnsense/service.luau b/opnsense/service.luau new file mode 100644 index 0000000..fcef5f9 --- /dev/null +++ b/opnsense/service.luau @@ -0,0 +1,1113 @@ +--!nonstrict +-- OPNsense API backend: system status, interfaces, gateways, services. + +local STATE_KEY = "opn_snapshot" +local COMMAND_KEY = "opn_command" +local RESULT_KEY = "opn_action_result" + +-- Tail this many firewall log events when the Logs tab asks for them. +local LOG_LIMIT = 100 +-- If any HTTP callback aborts (CPU budget), clear loading after this. +local STUCK_REFRESH_SEC = 12 +local STUCK_LOGS_SEC = 35 + +local snapshot = { + available = false, + configured = false, + loading = true, + logsLoading = false, + busy = false, + host = "", + widgets = {}, + interfaces = {}, + gateways = {}, + services = {}, + rules = {}, + logs = {}, + info = {}, + resources = {}, + issueCount = 0, + okCount = 0, + blockLogCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local refreshGeneration = 0 +local refreshPending = false +local refreshAgain = false +local actionBusy = false +local dataSignature = "" +local prevIssues = {} + +local function trim(value) + return noctalia.string.trim(tostring(value or "")) +end + +local function lower(s) + return string.lower(tostring(s or "")) +end + +local function asString(v) + if v == nil then + return "" + end + if type(v) == "boolean" then + return v and "true" or "false" + end + return tostring(v) +end + +local function refreshIntervalMs() + local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 20 + seconds = math.max(5, math.min(300, math.floor(seconds))) + return seconds * 1000 +end + +local function updateRevision(signature) + if signature ~= dataSignature then + dataSignature = signature + snapshot.revision += 1 + end +end + +local function publishSnapshot() + snapshot.busy = actionBusy + noctalia.state.set(STATE_KEY, snapshot) +end + +local function actionResult(command, ok, message, extra) + local result = { + requestId = command and command.requestId or "", + action = command and command.action or "", + ok = ok, + message = message or "", + } + if type(extra) == "table" then + for k, v in pairs(extra) do + result[k] = v + end + end + noctalia.state.set(RESULT_KEY, result) +end + +local function notifyOk(msg) + noctalia.notify(noctalia.tr("title"), msg) +end + +local function notifyErr(msg) + noctalia.notifyError(noctalia.tr("title"), msg) +end + +local function isConfigured() + local url = trim(noctalia.getConfig("base_url")) + local key = trim(noctalia.getConfig("api_key")) + local secret = trim(noctalia.getConfig("api_secret")) + return url ~= "" and key ~= "" and secret ~= "" +end + +local function baseUrl() + local url = trim(noctalia.getConfig("base_url")) + url = url:gsub("/+$", "") + url = url:gsub("/api$", "") + return url +end + +local function webUiUrl() + local override = trim(noctalia.getConfig("web_ui_url")) + if override ~= "" then + return override + end + return baseUrl() +end + +local function hostLabel() + local url = baseUrl() + return url:match("^https?://([^/:]+)") or url +end + +local function nowSec() + if type(noctalia.nowMs) == "function" then + local ms = noctalia.nowMs() + if type(ms) == "number" and ms > 0 then + return math.floor(ms / 1000) + end + end + return os.time() +end + +local function shellQuote(v) + return "'" .. tostring(v):gsub("'", "'\\''") .. "'" +end + +local function apiRequest(method, path, body, callback) + local url = baseUrl() .. "/api/" .. path:gsub("^/+", "") + local key = trim(noctalia.getConfig("api_key")) + local secret = trim(noctalia.getConfig("api_secret")) + local insecure = noctalia.getConfig("allow_insecure_tls") ~= false + + local req = { + url = url, + method = method or "GET", + basic_username = key, + basic_password = secret, + allow_insecure_tls = insecure, + headers = { "Accept: application/json" }, + } + if body ~= nil then + local encoded = noctalia.json.encode(body) + req.body = encoded or "" + table.insert(req.headers, "Content-Type: application/json") + end + + -- If the user callback throws (or hits CPU budget as an error), still + -- invoke it via pcall so callers can put cleanup (finish()) outside work. + local function safeCb(res) + if type(callback) ~= "function" then + return + end + local okCall, errCall = pcall(callback, res) + if not okCall then + noctalia.log(`opnsense: api callback error on {path}: {tostring(errCall)}`) + end + end + + local ok = noctalia.http(req, safeCb) + if not ok then + safeCb({ ok = false, status = 0, body = "http queue full" }) + end + return ok +end + +local function decodeBody(res) + if type(res) ~= "table" then + return nil, "no response" + end + if not res.ok and (res.status == 0 or res.status == nil) then + return nil, trim(res.body) ~= "" and trim(res.body) or "network error" + end + if res.status == 401 or res.status == 403 then + return nil, "auth failed (" .. tostring(res.status) .. ") — check API key and secret" + end + if res.status and res.status >= 400 then + return nil, "HTTP " .. tostring(res.status) + end + local body = res.body + if type(body) ~= "string" or body == "" then + return {}, nil + end + local data, err = noctalia.json.decode(body) + if data == nil then + return nil, err or "invalid JSON" + end + return data, nil +end + +local function isOkStatus(status) + local s = lower(status) + if s == "" or s == "ok" or s == "online" or s == "up" or s == "none" + or s == "running" or s == "active" then + return true + end + if s:find("error", 1, true) or s:find("down", 1, true) or s:find("offline", 1, true) + or s:find("fail", 1, true) or s:find("crit", 1, true) or s:find("warn", 1, true) + then + return false + end + return true +end + +local function pushWidget(widgets, name, info) + if type(info) ~= "table" then + return + end + local status = asString(info.status) + local code = tonumber(info.statusCode or info.status) + local ok = true + if type(info.status) == "number" or info.statusCode ~= nil then + -- OPNsense dashboard: 2 = OK + ok = (code or 0) == 2 + if status == tostring(code) or status == "" then + status = ok and "OK" or "Issue" + end + else + ok = isOkStatus(status) + end + local title = asString(info.title) + if title == "" then + title = tostring(name) + end + table.insert(widgets, { + id = tostring(name), + name = title, + status = status ~= "" and status or (ok and "OK" or "Issue"), + message = asString(info.message), + statusCode = code, + ok = ok, + }) +end + +local function parseSystemStatus(data) + local widgets = {} + if type(data) ~= "table" then + return widgets + end + -- OPNsense 26+: { metadata = { system = { status, message, title }, subsystems = [...] } } + if type(data.metadata) == "table" then + local meta = data.metadata + if type(meta.system) == "table" then + pushWidget(widgets, "System", meta.system) + end + if type(meta.subsystems) == "table" then + for i, sub in ipairs(meta.subsystems) do + if type(sub) == "table" then + pushWidget(widgets, asString(sub.name or sub.title or ("sub-" .. i)), sub) + end + end + end + -- other metadata keys that look like widgets + for name, info in pairs(meta) do + if name ~= "system" and name ~= "subsystems" and name ~= "translations" and type(info) == "table" then + if info.status ~= nil or info.message ~= nil or info.statusCode ~= nil then + pushWidget(widgets, name, info) + end + end + end + else + -- older shape: top-level named widgets + for name, info in pairs(data) do + if type(info) == "table" and (info.status ~= nil or info.message ~= nil or info.statusCode ~= nil) then + pushWidget(widgets, name, info) + end + end + end + table.sort(widgets, function(a, b) + if a.ok ~= b.ok then + return not a.ok + end + return a.name < b.name + end) + return widgets +end + +local function formatBytes(n) + n = tonumber(n) or 0 + if n >= 1e12 then return string.format("%.1fT", n / 1e12) end + if n >= 1e9 then return string.format("%.1fG", n / 1e9) end + if n >= 1e6 then return string.format("%.1fM", n / 1e6) end + if n >= 1e3 then return string.format("%.1fK", n / 1e3) end + return tostring(math.floor(n)) +end + +local function parseInterfaces(statsData, namesData) + local nameMap = {} + if type(namesData) == "table" then + for k, v in pairs(namesData) do + if type(v) == "string" then + nameMap[tostring(k)] = v + elseif type(v) == "table" then + nameMap[tostring(k)] = asString(v.descr or v.description or v.name or k) + end + end + end + + local list = {} + local stats = statsData + if type(statsData) == "table" and type(statsData.statistics) == "table" then + stats = statsData.statistics + end + if type(stats) ~= "table" then + return list + end + + -- Aggregate rows that share the same interface device (OPNsense emits one row per address). + local byDev = {} + for label, row in pairs(stats) do + if type(row) == "table" then + local dev = asString(row.name) + if dev == "" then + dev = tostring(label) + end + local entry = byDev[dev] + if not entry then + local flags = asString(row.flags) + -- FreeBSD IFF_UP is 0x1 + local flagNum = tonumber(flags) or tonumber(flags:match("0x(%x+)"), 16) or 0 + local up = (flagNum % 2 == 1) or lower(flags):find("up", 1, true) ~= nil + -- Prefer friendly label from statistics key: "[WAN] (vtnet0) / …" + local descr = tostring(label):match("^%[(.-)%]") or nameMap[dev] or "" + entry = { + id = dev, + name = dev, + description = descr, + status = up and "up" or "down", + ok = up, + ipv4 = "", + ipv6 = "", + inBytesRaw = 0, + outBytesRaw = 0, + } + byDev[dev] = entry + end + local addr = asString(row.address) + if addr:match("^%d+%.%d+%.%d+%.%d+$") and entry.ipv4 == "" then + entry.ipv4 = addr + elseif addr:find(":", 1, true) and not addr:find("^%d+%.%d+") and entry.ipv6 == "" and not lower(addr):find("fe80", 1, true) then + entry.ipv6 = addr + end + -- Prefer link-level counters (largest) when present + local rin = tonumber(row["received-bytes"] or row["bytes received"] or row.bytes_received or row.inbytes) or 0 + local rout = tonumber(row["sent-bytes"] or row["bytes transmitted"] or row.bytes_transmitted or row.outbytes) or 0 + if rin > entry.inBytesRaw then entry.inBytesRaw = rin end + if rout > entry.outBytesRaw then entry.outBytesRaw = rout end + end + end + + for _, entry in pairs(byDev) do + entry.inBytes = formatBytes(entry.inBytesRaw) + entry.outBytes = formatBytes(entry.outBytesRaw) + entry.inBytesRaw = nil + entry.outBytesRaw = nil + table.insert(list, entry) + end + table.sort(list, function(a, b) + if a.ok ~= b.ok then return not a.ok end + return a.name < b.name + end) + return list +end + +local function parseGateways(data) + local list = {} + local rows = data + if type(data) == "table" and type(data.items) == "table" then + rows = data.items + elseif type(data) == "table" and type(data.gateways) == "table" then + rows = data.gateways + end + if type(rows) ~= "table" then + return list + end + + local function addGw(name, row) + if type(row) ~= "table" then return end + local status = asString(row.status_translated or row.status or "") + local ok = true + if status ~= "" then + local st = lower(status) + ok = st == "online" or st == "none" or st == "ok" + end + table.insert(list, { + id = tostring(name), + name = tostring(name), + status = status ~= "" and status or (ok and "online" or "down"), + ok = ok, + address = asString(row.address or row.gateway or ""), + monitor = asString(row.monitor or ""), + rtt = asString(row.delay or row.rtt or ""), + loss = asString(row.loss or ""), + }) + end + + if rows[1] ~= nil then + for _, row in ipairs(rows) do + addGw(asString(row.name or row.gateway or "gateway"), row) + end + else + for name, row in pairs(rows) do + addGw(name, row) + end + end + table.sort(list, function(a, b) + if a.ok ~= b.ok then return not a.ok end + return a.name < b.name + end) + return list +end + +local function parseServices(data) + local list = {} + local rows = data + if type(data) == "table" and type(data.rows) == "table" then + rows = data.rows + end + if type(rows) ~= "table" then + return list + end + for _, row in ipairs(rows) do + if type(row) == "table" then + local name = asString(row.name or row.id) + local running = row.running == true or row.running == 1 or asString(row.running) == "1" + or lower(asString(row.status)) == "running" + table.insert(list, { + id = name, + name = name, + running = running, + status = running and "running" or "stopped", + ok = true, + description = asString(row.description or row.desc or ""), + }) + end + end + table.sort(list, function(a, b) return a.name < b.name end) + return list +end + +local function parseInfo(infoData, resData, timeData) + local info = {} + if type(infoData) == "table" then + info.hostname = asString(infoData.name or infoData.hostname) + if type(infoData.versions) == "table" and infoData.versions[1] then + info.version = asString(infoData.versions[1]) + else + info.version = asString(infoData.version or infoData.product_version) + end + info.updates = asString(infoData.updates or "") + info.uptime = asString(infoData.uptime or "") + end + if type(timeData) == "table" then + if info.uptime == "" then info.uptime = asString(timeData.uptime) end + info.datetime = asString(timeData.datetime or timeData.date) + end + local resources = {} + if type(resData) == "table" then + resources.load = asString(resData.loadavg or resData.load or "") + if resources.load == "" and type(resData.cpu) == "table" then + resources.load = asString(resData.cpu.load or resData.cpu.usage) + end + if type(resData.memory) == "table" then + local used = resData.memory.used_frmt or resData.memory.used + local total = resData.memory.total_frmt or resData.memory.total + resources.memoryUsed = asString(used) + resources.memoryTotal = asString(total) + if resources.load == "" and resData.memory.used and resData.memory.total then + local u = tonumber(resData.memory.used) or 0 + local t = tonumber(resData.memory.total) or 1 + resources.load = string.format("mem %.0f%%", (u / t) * 100) + end + end + end + return info, resources +end + +local function countIssues(widgets, gateways) + local n, ok = 0, 0 + for _, w in ipairs(widgets) do + if w.ok then ok += 1 else n += 1 end + end + for _, g in ipairs(gateways) do + if not g.ok then n += 1 end + end + return n, ok +end + +local function parseRules(data) + local list = {} + local rows = data + if type(data) == "table" and type(data.rows) == "table" then + rows = data.rows + end + if type(rows) ~= "table" then + return list + end + for i, row in ipairs(rows) do + if type(row) == "table" then + local action = asString(row["%action"] or row.action) + local direction = asString(row["%direction"] or row.direction) + local enabled = asString(row.enabled) == "1" or row.enabled == true or row.enabled == 1 + local descr = asString(row.description) + local src = asString(row.source_net) + local dst = asString(row.destination_net) + local sport = asString(row.source_port) + local dport = asString(row.destination_port) + local proto = asString(row["%protocol"] or row.protocol) + local iface = asString(row.interface) + local automatic = row.is_automatic == true or row.legacy == true + local uuid = asString(row.uuid) + if uuid == "" then + uuid = "rule-" .. tostring(i) + end + local srcText = src + if sport ~= "" then srcText = srcText .. ":" .. sport end + local dstText = dst + if dport ~= "" then dstText = dstText .. ":" .. dport end + table.insert(list, { + id = uuid, + description = descr ~= "" and descr or ("Rule " .. uuid:sub(1, 8)), + action = action, + direction = direction, + enabled = enabled, + source = srcText, + destination = dstText, + protocol = proto, + interface = iface, + automatic = automatic, + packets = tonumber(row.packets) or 0, + bytes = tonumber(row.bytes) or 0, + evaluations = tonumber(row.evaluations) or 0, + ok = lower(action) ~= "block" or not enabled, -- visual only; blocks aren't "issues" + }) + end + end + return list +end + +local function parseLogs(data) + local list = {} + if type(data) ~= "table" then + return list, 0 + end + -- API may return array directly or { rows = ... } + local rows = data + if data.rows then + rows = data.rows + end + if type(rows) ~= "table" then + return list, 0 + end + + local blockCount = 0 + local start = 1 + local finish = #rows + -- Prefer newest: if timestamps look chronological ascending, reverse + if #rows >= 2 then + local t1 = asString(rows[1]["__timestamp__"] or "") + local t2 = asString(rows[#rows]["__timestamp__"] or "") + if t1 ~= "" and t2 ~= "" and t1 < t2 then + -- oldest first -> iterate reverse + local rev = {} + for i = #rows, 1, -1 do + table.insert(rev, rows[i]) + end + rows = rev + end + end + + local n = 0 + for _, row in ipairs(rows) do + if type(row) == "table" then + local action = asString(row.action) + if lower(action) == "block" then + blockCount += 1 + end + n += 1 + if n <= LOG_LIMIT then + local src = asString(row.src) + local dst = asString(row.dst) + local sport = asString(row.srcport) + local dport = asString(row.dstport) + if sport ~= "" then src = src .. ":" .. sport end + if dport ~= "" then dst = dst .. ":" .. dport end + local ts = asString(row["__timestamp__"]) + -- shorten timestamp display + local tsShort = ts:match("T(%d+:%d+:%d+)") or ts + local datePart = ts:match("^(%d+-%d+-%d+)") or "" + table.insert(list, { + id = asString(row["__digest__"] or row.id or (ts .. src .. dst)), + action = action, + direction = asString(row.dir), + interface = asString(row.interface), + protocol = asString(row.protoname), + src = src, + dst = dst, + label = asString(row.label), + timestamp = ts, + time = (datePart ~= "" and (datePart .. " " .. tsShort) or tsShort), + blocked = lower(action) == "block", + }) + end + end + end + return list, blockCount +end + +local function notifyNewIssues(widgets, gateways) + if noctalia.getConfig("notify_on_issue") == false then + return + end + local current = {} + local function consider(name, ok, status) + if not ok then + current[name] = true + if not prevIssues[name] then + notifyErr(noctalia.tr("result.issue", { name = name, status = status })) + end + end + end + for _, w in ipairs(widgets) do + consider(w.name, w.ok, w.status) + end + for _, g in ipairs(gateways) do + consider("gw:" .. g.name, g.ok, g.status) + end + prevIssues = current +end + +local refreshAll +local fetchLogs +local refreshStartedAt = 0 +local logsFetchPending = false +local logsStartedAt = 0 + +-- Firewall log JSON is huge (~800KB+). Decoding it in an http callback +-- exceeds the Luau CPU budget, aborts the callback before finish(), and +-- leaves loading stuck forever. Logs are on-demand with ?limit=N + field slim. +local function applyCoreSnapshot(bag, errors) + local widgets = {} + local interfaces = {} + local gateways = {} + local services = {} + local rules = {} + local info, resources = {}, {} + + local okParse, errParse = pcall(function() + widgets = parseSystemStatus(bag.status) + interfaces = parseInterfaces(bag.ifstats, bag.ifnames) + gateways = parseGateways(bag.gateways) + services = parseServices(bag.services) + rules = parseRules(bag.rules) + info, resources = parseInfo(bag.info, bag.resources, bag.time) + end) + if not okParse then + noctalia.log(`opnsense: parse error: {tostring(errParse)}`) + table.insert(errors, "parse: " .. tostring(errParse)) + end + + local issues, oks = countIssues(widgets, gateways) + pcall(notifyNewIssues, widgets, gateways) + + local available = #widgets > 0 or #interfaces > 0 or #services > 0 + or #gateways > 0 or #rules > 0 or #(snapshot.logs or {}) > 0 + snapshot.available = available + snapshot.loading = false + snapshot.error = available and "" or (errors[1] or "no data") + snapshot.widgets = widgets + snapshot.interfaces = interfaces + snapshot.gateways = gateways + snapshot.services = services + snapshot.rules = rules + -- keep previous logs unless fetchLogs updates them + snapshot.info = info + snapshot.resources = resources + snapshot.issueCount = issues + snapshot.okCount = oks + snapshot.updatedAt = nowSec() + refreshPending = false + refreshStartedAt = 0 + noctalia.setUpdateInterval(refreshIntervalMs()) + + updateRevision(table.concat({ + snapshot.host, + tostring(issues), + tostring(#interfaces), + tostring(#gateways), + tostring(#services), + tostring(#rules), + tostring(#(snapshot.logs or {})), + }, "|")) + publishSnapshot() +end + +local function forceUnstick(reason) + noctalia.log("opnsense: " .. reason) + refreshPending = false + refreshStartedAt = 0 + logsFetchPending = false + logsStartedAt = 0 + snapshot.loading = false + snapshot.logsLoading = false + if snapshot.error == "" then + snapshot.error = reason + end + noctalia.setUpdateInterval(refreshIntervalMs()) + publishSnapshot() +end + +refreshAll = function() + -- Recover from a stuck refresh (CPU-budget abort / hung HTTP). + if refreshPending and refreshStartedAt > 0 and (nowSec() - refreshStartedAt) >= STUCK_REFRESH_SEC then + forceUnstick("refresh timed out") + end + if logsFetchPending and logsStartedAt > 0 and (nowSec() - logsStartedAt) >= STUCK_LOGS_SEC then + noctalia.log("opnsense: log fetch timed out") + logsFetchPending = false + logsStartedAt = 0 + snapshot.logsLoading = false + publishSnapshot() + end + + if refreshPending then + refreshAgain = true + return + end + refreshPending = true + refreshAgain = false + refreshStartedAt = nowSec() + refreshGeneration += 1 + local generation = refreshGeneration + + snapshot.host = hostLabel() + snapshot.configured = isConfigured() + + if not snapshot.configured then + snapshot.available = false + snapshot.loading = false + snapshot.error = noctalia.tr("result.not_configured") + snapshot.widgets = {} + snapshot.interfaces = {} + snapshot.gateways = {} + snapshot.services = {} + snapshot.rules = {} + snapshot.logs = {} + snapshot.issueCount = 0 + snapshot.okCount = 0 + snapshot.blockLogCount = 0 + refreshPending = false + refreshStartedAt = 0 + updateRevision("not-configured") + publishSnapshot() + return + end + + -- Only show "Querying API…" on first load; background polls stay quiet. + if not snapshot.available then + snapshot.loading = true + publishSnapshot() + end + -- Poll faster while a refresh is in flight so stuck recovery is prompt. + noctalia.setUpdateInterval(1000) + + -- Lean core set — no firewall log dump (on-demand via fetchLogs). + local paths = { + { path = "core/system/status", key = "status" }, + { path = "diagnostics/interface/getInterfaceStatistics", key = "ifstats" }, + { path = "diagnostics/interface/getInterfaceNames", key = "ifnames" }, + { path = "diagnostics/system/systemInformation", key = "info" }, + { path = "diagnostics/system/systemResources", key = "resources" }, + { path = "routes/gateway/status", key = "gateways" }, + } + + -- GETs + rules POST + services POST + local pending = #paths + 2 + local bag = {} + local errors = {} + local finished = false + + local function finish() + if generation ~= refreshGeneration then + return + end + pending -= 1 + if pending > 0 then + return + end + if finished then + return + end + finished = true + + local okApply, errApply = pcall(applyCoreSnapshot, bag, errors) + if not okApply then + noctalia.log(`opnsense: apply snapshot failed: {tostring(errApply)}`) + snapshot.loading = false + if not snapshot.available then + snapshot.error = "refresh failed: " .. tostring(errApply) + end + refreshPending = false + refreshStartedAt = 0 + noctalia.setUpdateInterval(refreshIntervalMs()) + publishSnapshot() + end + + if refreshAgain then + refreshAgain = false + refreshAll() + end + end + + -- Decode + bag store inside pcall; finish() ALWAYS runs so one bad + -- response cannot leave loading stuck. + local function onGet(item, res) + if generation ~= refreshGeneration then + return + end + local okInner, errInner = pcall(function() + local data, err = decodeBody(res) + if data ~= nil then + bag[item.key] = data + else + table.insert(errors, item.path .. ": " .. tostring(err)) + end + end) + if not okInner then + table.insert(errors, item.path .. ": " .. tostring(errInner)) + end + finish() + end + + for _, item in ipairs(paths) do + local captured = item + apiRequest("GET", captured.path, nil, function(res) + onGet(captured, res) + end) + end + + apiRequest("POST", "firewall/filter/search_rule", { + current = 1, + rowCount = 100, + sort = {}, + searchPhrase = "", + show_all = 1, + }, function(res) + if generation ~= refreshGeneration then + return + end + local okInner, errInner = pcall(function() + local data, err = decodeBody(res) + if data ~= nil then + bag.rules = data + else + table.insert(errors, "rules: " .. tostring(err)) + end + end) + if not okInner then + table.insert(errors, "rules: " .. tostring(errInner)) + end + finish() + end) + + apiRequest("POST", "core/service/search", { + current = 1, + rowCount = 50, + sort = {}, + searchPhrase = "", + }, function(res) + if generation ~= refreshGeneration then + return + end + local okInner, errInner = pcall(function() + local data, err = decodeBody(res) + if data ~= nil then + bag.services = data + else + table.insert(errors, "services: " .. tostring(err)) + end + end) + if not okInner then + table.insert(errors, "services: " .. tostring(errInner)) + end + finish() + end) +end + +-- On-demand: GET ?limit=N, slim fields with jq so Luau never sees ~800KB. +fetchLogs = function(command) + if not isConfigured() then + actionResult(command, false, noctalia.tr("result.not_configured")) + return + end + if logsFetchPending then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + + logsFetchPending = true + logsStartedAt = nowSec() + snapshot.logsLoading = true + publishSnapshot() + + local key = trim(noctalia.getConfig("api_key")) + local secret = trim(noctalia.getConfig("api_secret")) + local insecure = noctalia.getConfig("allow_insecure_tls") ~= false + local url = baseUrl() .. "/api/diagnostics/firewall/log?limit=" .. tostring(LOG_LIMIT) + + local curlArgs = { "curl", "-sS", "--max-time", "20", "-H", "Accept: application/json" } + if insecure then + table.insert(curlArgs, "-k") + end + table.insert(curlArgs, "-u") + table.insert(curlArgs, key .. ":" .. secret) + table.insert(curlArgs, url) + + local parts = {} + for _, a in ipairs(curlArgs) do + table.insert(parts, shellQuote(a)) + end + -- Project only UI fields so decode stays well under the CPU budget. + local cmd = table.concat(parts, " ") + .. " | jq -c 'if type==\"array\" then [.[] | {action,dir,interface,protoname,src,dst,srcport,dstport,label,__timestamp__,__digest__}] else . end'" + + local function doneLogs() + logsFetchPending = false + logsStartedAt = 0 + snapshot.logsLoading = false + end + + local accepted = noctalia.runAsync(cmd, function(result) + local okAll, errAll = pcall(function() + if not result or result.exitCode ~= 0 then + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "log fetch failed") + if err == "" then err = "log fetch failed" end + doneLogs() + if not snapshot.available then + snapshot.error = err + end + publishSnapshot() + actionResult(command, false, noctalia.tr("result.failed", { error = err })) + return + end + + local parsed = noctalia.json.decode(result.stdout or "") + if parsed == nil then + doneLogs() + publishSnapshot() + actionResult(command, false, noctalia.tr("result.failed", { error = "log parse failed" })) + return + end + + local logs, blockLogs = parseLogs(parsed) + snapshot.logs = logs + snapshot.blockLogCount = blockLogs + if snapshot.error:find("log", 1, true) or snapshot.error:find("timed out", 1, true) then + snapshot.error = "" + end + snapshot.updatedAt = nowSec() + doneLogs() + updateRevision("logs:" .. tostring(#logs) .. ":" .. tostring(blockLogs)) + publishSnapshot() + actionResult(command, true, noctalia.tr("result.logs_loaded", { n = #logs })) + end) + if not okAll then + noctalia.log(`opnsense: log fetch failed: {tostring(errAll)}`) + doneLogs() + publishSnapshot() + actionResult(command, false, noctalia.tr("result.failed", { error = "log parse failed" })) + end + end, 30000) + + if not accepted then + doneLogs() + publishSnapshot() + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start log fetch" })) + end +end + +local function finishAction(command, ok, message) + actionBusy = false + actionResult(command, ok, message) + if ok then + notifyOk(message) + else + notifyErr(message) + end + publishSnapshot() + refreshAll() +end + +local function serviceControl(command, verb) + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + if not isConfigured() then + actionResult(command, false, noctalia.tr("result.not_configured")) + return + end + local name = trim(command.name or command.id) + if name == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing service" })) + return + end + actionBusy = true + publishSnapshot() + apiRequest("POST", "core/service/" .. verb .. "/" .. noctalia.string.urlEncode(name), {}, function(res) + local data, err = decodeBody(res) + local ok = data ~= nil and (res.status == nil or res.status < 400) + if type(data) == "table" and data.result ~= nil then + ok = asString(data.result) == "ok" or data.result == true + end + if ok then + local msgKey = verb == "restart" and "result.restarted" + or (verb == "start" and "result.started" or "result.stopped") + finishAction(command, true, noctalia.tr(msgKey, { name = name })) + else + finishAction(command, false, noctalia.tr("result.failed", { error = err or "service action failed" })) + end + end) +end + +local function openUi() + local url = webUiUrl() + if url == "" then return end + noctalia.runAsync("xdg-open " .. "'" .. url:gsub("'", "'\\''") .. "'") +end + +local function executeAction(command) + if type(command) ~= "table" or type(command.action) ~= "string" then + return + end + if command.action == "refresh" then + refreshAll() + return + end + if command.action == "fetch_logs" then + fetchLogs(command) + return + end + if command.action == "open_ui" then + openUi() + actionResult(command, true, noctalia.tr("result.success")) + return + end + if command.action == "restart_service" then + serviceControl(command, "restart") + return + end + if command.action == "start_service" then + serviceControl(command, "start") + return + end + if command.action == "stop_service" then + serviceControl(command, "stop") + return + end + if command.action == "copy" then + local text = trim(command.text or command.name) + if text ~= "" then + noctalia.copyToClipboard(text, "text/plain") + actionResult(command, true, noctalia.tr("result.copied", { name = text })) + notifyOk(noctalia.tr("result.copied", { name = text })) + end + return + end + actionResult(command, false, "Unknown action: " .. command.action) +end + +noctalia.state.watch(COMMAND_KEY, executeAction) +noctalia.setUpdateInterval(refreshIntervalMs()) +refreshAll() + +function update() + -- Stuck recovery runs at the top of refreshAll (1s cadence while in flight). + refreshAll() +end + +function onConfigChanged() + noctalia.setUpdateInterval(refreshIntervalMs()) + refreshPending = false + refreshStartedAt = 0 + logsFetchPending = false + snapshot.logsLoading = false + refreshAll() +end + +function onIpc(event, _payload) + if event == "refresh" then + refreshPending = false + refreshStartedAt = 0 + refreshAll() + elseif event == "logs" then + fetchLogs({ action = "fetch_logs", requestId = "ipc-logs" }) + end +end diff --git a/opnsense/thumbnail.webp b/opnsense/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..b3f8045faaf5120189c36385772109fe35d4b802 GIT binary patch literal 50202 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 literal 0 HcmV?d00001 diff --git a/opnsense/translations/en.json b/opnsense/translations/en.json new file mode 100644 index 0000000..8b262cf --- /dev/null +++ b/opnsense/translations/en.json @@ -0,0 +1,158 @@ +{ + "title": "OPNsense", + "settings": { + "base_url": { + "label": "Base URL", + "description": "OPNsense origin only, e.g. https://192.168.1.1 (no trailing /api)." + }, + "api_key": { + "label": "API key", + "description": "System → Access → Users → API keys (key = username)." + }, + "api_secret": { + "label": "API secret", + "description": "API secret paired with the key (password)." + }, + "allow_insecure_tls": { + "label": "Allow insecure TLS", + "description": "Skip certificate verification (self-signed / private CA)." + }, + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often to poll the API." + }, + "notify_on_issue": { + "label": "Notify on new issues", + "description": "Desktop notification when a status widget or gateway becomes unhealthy." + }, + "web_ui_url": { + "label": "Web UI URL override", + "description": "Optional alternate URL for “Open UI”. Defaults to base URL." + }, + "show_label": { + "label": "Show status text on bar", + "description": "Display OK / issues count next to the icon." + }, + "ok_color": { + "label": "Healthy color" + }, + "warn_color": { + "label": "Issue color" + } + }, + "colors": { + "tertiary": "Tertiary", + "primary": "Primary", + "secondary": "Secondary", + "error": "Error", + "muted": "Muted" + }, + "widget": { + "tooltip_ok": "{host} · healthy · {ifaces} interfaces · load {load}", + "tooltip_issues": "{host} · {issues} issue(s) · {detail}", + "tooltip_missing": "Configure base URL + API key/secret in plugin settings", + "tooltip_down": "Unreachable: {error}", + "refresh_requested": "Refreshing OPNsense…", + "label_ok": "OK", + "label_issues": "{n}" + }, + "panel": { + "subtitle": "Firewall health & services", + "loading": "Querying API…", + "logs_loading": "Loading firewall logs…", + "busy": "Working…", + "select_hint": "Select an item for actions.", + "updated": "Updated {time}", + "host": "Host: {host}", + "summary": "{ok} healthy · {issues} issue(s) · load {load}", + "empty": "No items match the filter.", + "not_configured": "Set Base URL, API key, and API secret under Settings → Plugins → OPNsense." + }, + "result": { + "logs_loaded": "Loaded {n} log events", + "success": "Done", + "failed": "Failed: {error}", + "busy": "Another operation is running.", + "not_configured": "OPNsense API is not configured.", + "restarted": "Restarted {name}", + "started": "Started {name}", + "stopped": "Stopped {name}", + "copied": "Copied {name}", + "issue": "{name}: {status}" + }, + "tabs": { + "status": "Status", + "interfaces": "Interfaces", + "gateways": "Gateways", + "services": "Services", + "rules": "Rules", + "logs": "Logs" + }, + "filter": { + "placeholder": "Filter… e.g. block or !pass" + }, + "rules": { + "summary": "{n} rules" + }, + "rule": { + "detail": "{src} → {dst} · {proto} · {iface}", + "stats": "{packets} pkts · {bytes} B · {evaluations} evals" + }, + "logs": { + "summary": "{n} events · {blocks} blocks", + "hint": "Select a log line · showing {n} recent · {blocks} blocks in buffer", + "flow": "{src} → {dst} · {iface}" + }, + "status": { + "widget": "{name}: {status}", + "message": "{message}" + }, + "iface": { + "summary": "{status} · {ipv4} · in {in} · out {out}" + }, + "gateway": { + "summary": "{status} · {address} · rtt {rtt}" + }, + "service": { + "summary": "{status} · {description}" + }, + "actions": { + "refresh": "Refresh", + "open_ui": "Open UI", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "copy": "Copy" + }, + "launcher": { + "loading": "Loading OPNsense…", + "unavailable": "OPNsense unavailable", + "no-matches": "No matches", + "cat": { + "status": "Status widgets", + "status-sub": "Crash reporter, firewall, system checks", + "interfaces": "Interfaces", + "interfaces-sub": "Link and address overview", + "gateways": "Gateways", + "gateways-sub": "Gateway monitor status", + "services": "Services", + "services-sub": "Start / stop / restart", + "rules": "Firewall rules", + "rules-sub": "Filter rules overview", + "logs": "Firewall logs", + "logs-sub": "Recent pass/block events", + "panel": "Open panel", + "panel-sub": "Full OPNsense manager", + "ui": "Open Web UI", + "ui-sub": "Browser dashboard", + "refresh": "Refresh", + "refresh-sub": "Poll API now" + }, + "action": { + "restart": "Restart service", + "start": "Start service", + "stop": "Stop service", + "copy": "Copy name" + } + } +} diff --git a/opnsense/widget.luau b/opnsense/widget.luau new file mode 100644 index 0000000..30caf93 --- /dev/null +++ b/opnsense/widget.luau @@ -0,0 +1,127 @@ +--!nonstrict + +local PANEL_ID = "davemhammer/opnsense:manager" +local STATE_KEY = "opn_snapshot" +local COMMAND_KEY = "opn_command" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + configured = false, + issueCount = 0, + host = "", + error = "", + resources = {}, + interfaces = {}, +} + +local requestId = 0 + +local function configString(key, fallback) + local value = noctalia.getConfig(key) + return type(value) == "string" and value or fallback +end + +local function brandIcon(configured, available, healthy) + -- Real OPNsense mark (Simple Icons); tinted for state. + if not configured or not available then + return "assets/opnsense-grey.png" + end + if healthy then + return "assets/opnsense-orange.png" -- brand orange when healthy + end + return "assets/opnsense-red.png" -- issues +end + +local function render() + local available = snapshot.available == true + local configured = snapshot.configured == true + local issues = tonumber(snapshot.issueCount) or 0 + local healthy = available and issues == 0 + local showLabel = noctalia.getConfig("show_label") ~= false + local okColor = configString("ok_color", "tertiary") + local warnColor = configString("warn_color", "error") + local color = (not configured or not available) and "on_surface_variant" + or (healthy and okColor or warnColor) + + local children = { + ui.image({ + path = brandIcon(configured, available, healthy), + width = 16, + height = 16, + fit = "contain", + }), + } + + if showLabel then + if not configured then + table.insert(children, ui.label({ text = "…", color = "on_surface_variant" })) + elseif available then + table.insert(children, ui.label({ + text = healthy and noctalia.tr("widget.label_ok") + or noctalia.tr("widget.label_issues", { n = issues }), + fontWeight = "bold", + color = color, + })) + end + end + + if configured and available then + table.insert(children, ui.box({ + width = 7, + height = 7, + radius = 4, + fill = healthy and okColor or warnColor, + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) + + if not configured then + barWidget.setTooltip(noctalia.tr("widget.tooltip_missing")) + elseif not available then + barWidget.setTooltip(noctalia.tr("widget.tooltip_down", { + error = snapshot.error ~= "" and snapshot.error or "unknown", + })) + elseif healthy then + local load = "" + if type(snapshot.resources) == "table" then + load = tostring(snapshot.resources.load or "") + end + barWidget.setTooltip(noctalia.tr("widget.tooltip_ok", { + host = snapshot.host ~= "" and snapshot.host or "opnsense", + ifaces = #(snapshot.interfaces or {}), + load = load ~= "" and load or "—", + })) + else + barWidget.setTooltip(noctalia.tr("widget.tooltip_issues", { + host = snapshot.host ~= "" and snapshot.host or "opnsense", + issues = issues, + detail = snapshot.error ~= "" and snapshot.error or "see panel", + })) + end +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +noctalia.setUpdateInterval(8000) +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + requestId += 1 + noctalia.state.set(COMMAND_KEY, { action = "refresh", requestId = `widget-{requestId}` }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) +end