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 @@ +OPNsense \ 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 @@ +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 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 @@ +OPNsense \ 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 @@ +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 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