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 0000000..28b6dd1
Binary files /dev/null and b/opnsense/assets/opnsense-green.png differ
diff --git a/opnsense/assets/opnsense-green.svg b/opnsense/assets/opnsense-green.svg
new file mode 100644
index 0000000..bcf7d55
--- /dev/null
+++ b/opnsense/assets/opnsense-green.svg
@@ -0,0 +1 @@
+
\ 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 0000000..011824c
Binary files /dev/null and b/opnsense/assets/opnsense-grey.png differ
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 @@
+
\ 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 0000000..1092500
Binary files /dev/null and b/opnsense/assets/opnsense-orange.png differ
diff --git a/opnsense/assets/opnsense-orange.svg b/opnsense/assets/opnsense-orange.svg
new file mode 100644
index 0000000..3329aa2
--- /dev/null
+++ b/opnsense/assets/opnsense-orange.svg
@@ -0,0 +1 @@
+
\ 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 0000000..84603a6
Binary files /dev/null and b/opnsense/assets/opnsense-red.png differ
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 @@
+
\ 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 @@
+
\ 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 0000000..b3f8045
Binary files /dev/null and b/opnsense/thumbnail.webp differ
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