API Landmines¶
The API-surface audit walked every OPNsense controller looking for collector candidates. Some endpoints look like harmless status reads and are not: they start services, flush databases, regenerate config, or shell out to an external upload on every call. Others are technically safe to call but return misleading or lossy data if polled the way a Prometheus scraper polls. This page is the permanent record of both groups, plus the modules the audit confirmed have nothing left to collect.
This list is not a TODO. Do not re-audit any endpoint or module named here without first reading the reasoning below: each entry was checked against OPNsense source, not guessed at. If a future OPNsense release changes one of these controllers, update the entry in place rather than re-running the survey from scratch.
Last re-verified 2026-07-30 against a live OPNsense 26.7.1_1 box (source read directly off the
appliance via SSH; the four plugin-gated entries not installed there — Redis, HAProxy, hw-probe,
nginx — were checked against opnsense/plugins@master instead, since the box under test doesn't
run every plugin). Every row below carries the file (and, where stable, the line) the verdict rests
on. Result: every entry still holds. Nothing here was dropped, chased, or absorbed this round.
Never-scrape endpoints¶
These are verified in OPNsense source (os-* plugin repos and core). Every one of them looks like
a status read from its name or its GET method, and none of them are: calling one on a scrape
interval starts a service, mutates config, or ships data off-box.
| Endpoint | Method | Why it must never be scraped | Still true because |
|---|---|---|---|
api/iperf/instance/query |
GET | If the iperf-manager socket is absent, send_command() falls through to configd iperf restart/start (a GET request that starts a service and rewrites pf anchor rules). It also only ever returns manually-launched one-shot test results, purged after an hour, so there is no steady-state series to collect anyway. |
OPNsense\iperf\Api\InstanceController::queryAction() still calls send_command('query', $backend), and send_command() still falls back to configdRun('iperf restart') on a failed stream_socket_client() connect. Read on-box 2026-07-30 (os-iperf-1.0_2, .../controllers/OPNsense/iperf/Api/InstanceController.php). Unchanged. |
api/redis/service/resetdb |
GET | Flushes the Redis database. | ServiceController::resetdbAction() still runs configdRun("redis resetdb") unconditionally. Redis plugin not installed on the box audited 2026-07-30; checked instead against opnsense/plugins@master, databases/redis/.../Api/ServiceController.php. Unchanged. |
api/haproxy/maintenance/* |
POST | Every action in this controller looks like a status read but responds only to POST and fires configdRun('template reload OPNsense/HAProxy') on every single call: full config regeneration per scheduled poll if used by a collector. The server-state data it exposes is redundant with show stat anyway. |
MaintenanceController's getData()/saveData()/syncCerts() helpers still gate on $this->request->isPost() (returning status: unavailable otherwise) and every read action (searchServerAction, searchCertificateDiffAction, etc.) still runs configdRun('template reload OPNsense/HAProxy') before returning data. HAProxy plugin not installed on the box audited 2026-07-30; checked against opnsense/plugins@master, net/haproxy/.../Api/MaintenanceController.php. Unchanged. |
api/hwprobe/service/report |
GET | Runs hw-probe -all -upload, which uploads the box's hardware profile to linux-hardware.org. An exporter must never cause outbound data exfiltration as a side effect of a scrape. |
ServiceController::reportAction() still calls configdRun("hwprobe report"), and the configd action [report] in actions_hwprobe.conf still maps that to /usr/local/bin/hw-probe -all -upload. hw-probe plugin not installed on the box audited 2026-07-30; checked against opnsense/plugins@master, sysutils/hw-probe/src/opnsense/service/conf/actions.d/actions_hwprobe.conf. Unchanged. |
api/tailscale/status/net |
GET | Runs tailscale netcheck, an active multi-second network probe, and returns plain text rather than structured JSON. |
StatusController::netAction() still runs configdRun('tailscale tailscale-netcheck') and returns ['result' => $response] — the raw trimmed backend string, never json_decoded. The configd action still shells to tailscale netcheck; exit 0. Read on-box 2026-07-30 (os-tailscale-1.4). Unchanged. |
api/diagnostics/carp_status |
POST | A setter despite the read-sounding name: it enables/disables CARP or flips maintenance mode. | InterfaceController::carpStatusAction($status) still gates on $this->request->isPost() and still calls configdpRun('interface carp_set_status', [$status]). Read on-box 2026-07-30, .../Diagnostics/Api/InterfaceController.php. Unchanged. |
api/unbound/diagnostics/dumpcache |
GET | Dumps the entire unbound message cache. The payload is unbounded and scales with resolver traffic, not with anything worth graphing. | dumpcacheAction() still calls configdRun("unbound dumpcache") with no size cap or pagination. Read on-box 2026-07-30, .../Unbound/Api/DiagnosticsController.php. Unchanged. |
api/diagnostics/traffic/_top |
GET | An iftop-style sampling capture that runs for up to 10 seconds per call, with unbounded per-host cardinality. Not something a scrape interval should be triggering. | TrafficController::TopAction() still calls configdpRun('interface show top', ...), which runs /usr/local/opnsense/scripts/interfaces/traffic_top.py; that script still shells to iftop -nNb -i <if> -s 2 -t per interface under a 10-second subprocess.run(..., timeout=10), and the output is one line per observed host pair — no cap. Read on-box 2026-07-30. Unchanged (the 2-second iftop sample plus 10-second subprocess ceiling is the same shape the original entry described). |
api/nginx/bans/get |
GET | Inherited getAction() behaviour returns the entire nginx model just to expose two fields. Use searchban instead if ban data is ever wanted. |
BansController still declares no getModelNodes() override, so the inherited ApiMutableModelControllerBase::getAction() still returns $this->getModel()->getNodes() — the whole nginx config model — for a controller that only exposes ban.ip/ban.time. Confirmed the base method on-box 2026-07-30 (.../Base/ApiMutableModelControllerBase.php:203); nginx plugin itself not installed there, so BansController.php was checked against opnsense/plugins@master, www/nginx/.../Api/BansController.php. Unchanged. |
Lossy or sampling transports¶
These endpoints are safe to call (nothing mutates and nothing gets uploaded), but their transport or caching behaviour silently drops or samples data under a naive poll-on-interval pattern. They are not "do not call," they are "do not use as an ingestion source the way you'd expect."
On SSE specifically: the two entries below are rejected for a data-correctness defect in the endpoint itself — a lossy throttle, and a follow mechanism that stalls on rotation — not because the transport is SSE. The exporter does consume outbound SSE elsewhere: see Outbound SSE: the pilot below. An earlier version of this page implied SSE was unsuitable as a class; that implication is what this rewrite removes.
| Endpoint | Why | Still true because |
|---|---|---|
api/diagnostics/firewall/stream_log (SSE) |
Lossy by design: the streamer throttles to 10 lines per 100ms window and silently drops the rest, including the line straddling the window boundary. The paged ?digest= endpoint is the lossless path; never wire this SSE stream up as an ingestion transport. |
/usr/local/opnsense/scripts/filter/read_log.py still throttles at line_threshold = 10 over throttle_interval = 100 (ms): a matched line is emitted only when elapsed < throttle_interval and line_count <= line_threshold; once elapsed >= throttle_interval the counters reset without emitting the line that tripped the reset, so the boundary line is dropped, not delayed. Read on-box 2026-07-30, lines ~184-215 (line numbers drift release to release; if this citation goes stale, search line_threshold in the same file). Unchanged. Since #248 the exporter no longer needs this path at all — syslog is now pushed to us directly (internal/logship), which is strictly better than tailing OPNsense's own HTTP log stream. |
api/diagnostics/log/<module>/<scope>/live (SSE) |
Tails with tail -f, not -F: it silently stalls after the underlying log rotates, while keepalive frames keep flowing so nothing looks broken. Poll with validFrom instead of holding this stream open. |
The configd action [diag.log_live] in actions_system.conf still maps to /usr/local/opnsense/scripts/syslog/streamLog.py, which still calls LogMatcher.live_match_records(); that method still opens the tail process as ['tail', '-f', '-n 0', latest] (/usr/local/opnsense/scripts/syslog/log_matcher.py:65, read on-box 2026-07-30) — -f, not -F, so a log rotation orphans the held file descriptor and the stream goes silently stale while streamLog.py's own event: keepalive frames keep flowing. Unchanged. Same #248 note as above: the exporter receives syslog pushed directly rather than needing to hold this open. |
api/qfeeds/settings/search_events |
A filtered, 300s-configd-cached, port-only re-parse of the firewall log restricted to qfeeds tables. It is strictly a stale subset of the firewall log endpoint, where qfeeds blocks already appear natively with their rule label. | SettingsController::searchEventsAction() still calls configdRun('qfeeds logs'); the configd action [logs] in actions_qfeeds.conf still carries cache_ttl: 300, and the backing QFeedsActions.logs() (scripts/qfeeds/lib/__init__.py) still filters the firewall log through a PFLogCrawler scoped to the __qfeeds_<feed_type> alias tables only. Read on-box 2026-07-30 (os-q-feeds-connector-1.6_1). Unchanged. |
api/unbound/overview/search_queries without a client filter |
Only ever returns the latest 1000 rows: the client + timeStart + timeEnd form is the only mode that gets a real time range. Naive polling silently samples on a busy resolver. See #233 for the accepted-loss design this drove. |
OverviewController::searchQueriesAction() still branches: with client + timeStart + timeEnd all set it calls configdpRun('unbound qstats query', [$client, $time_start, $time_end]); otherwise it still falls back to configdpRun('unbound qstats details', [1000]) — the literal 1000 row cap, unfiltered by time. Read on-box 2026-07-30. Unchanged. |
Outbound SSE: the pilot¶
Decision locked 2026-07-30 (issue #553):
the exporter may consume outbound SSE — scoped to api/diagnostics/cpu_usage/stream only, as a
pilot. Every other stream endpoint on this page stays rejected until the pilot has run in
production for a while; this is not a general green light for SSE ingestion.
| Endpoint | Verdict | Why it's different from the rejected log streams |
|---|---|---|
api/diagnostics/cpu_usage/stream (SSE) |
Safe to consume, as a stream, under the pilot. Implemented in internal/cpustream/ (stream.go, accumulator.go) and wired into the cpu collector (internal/collector/cpu.go). |
It is iostat -w 1 cpu behind text/event-stream: /usr/local/opnsense/scripts/system/cpu.py, dispatched by the configd action [cpu.stream] in actions_system.conf, served by CpuUsageController::streamAction() (all confirmed on-box 2026-07-30). Frames are ~70 bytes/sec, continuous 1-second aggregate-CPU samples — no per-core breakdown, no thread counts, so it does not replace the activity collector's get_activity poll (which still owns thread-level data). The failure mode the two rejected streams have — silent stall while keepalives keep flowing — is real here too (this box's lighttpd_webgui/lighttpd.conf still sets server.max-write-idle = 999), so the pilot's consumer treats it as a defect to defend against rather than a reason to reject the endpoint: a data-freshness watchdog tears down and re-dials the connection when no frame has arrived for StallAfter (10s default), independent of the socket looking alive. Two implementation hazards specific to this endpoint: iostat's first report after each connect is a since-boot average, not an interval delta, and is unconditionally discarded (accumulator.beginConnection()/observe()); and the metric shape is cumulative counters (cpu_seconds_total{mode}), not gauges, because a 1-second stream only survives a 60-second export interval if it's accumulated rather than last-value-sampled. |
Won't-build¶
Recorded so the same collector idea does not get proposed twice.
- A "watched plugin services" collector fanning out to per-plugin
service/statusendpoints. Every relevant plugin already registers inapi/core/service/search, which the exporter exports asopnsense_services_status{name}. The one plugin that does not register there (os-beats, which ships noplugins.inc.dfile) is too thin on its own to justify a dedicated collector. hostdiscovery/service/searchsilently falls back to ARP+NDP (source: "arp-ndp") when the discovery daemon is off. Any future consumer of this data must stay source-aware rather than assuming active discovery is always what produced a row.
These two are architectural notes, not endpoint claims tied to a specific source line, and neither plugin (os-beats, hostdiscovery) was installed on the box audited 2026-07-30 — left as recorded, not re-verified this round.
Confirmed-empty modules¶
The audit checked every module below for telemetry beyond config CRUD and service running-state (already covered by the services collector) and found nothing further to collect. Do not re-run this survey against them:
caddy, bind, dnscrypt-proxy, freeradius, radsecproxy, turnserver, sslh,
shadowsocks, stunnel, tinc, openconnect, zerotier (runtime data exists only in a UI
controller, not the API), postfix (no queue stats in the API), squid/proxy (cachemgr not
bridged), cicap, ftpproxy, rspamd (own controller not bridged into the OPNsense API; it can
self-export Prometheus directly), maltrail, wazuhagent, telegraf, netdata, collectd,
zabbix agent/proxy, nodeexporter, muninnode, nrpe, netsnmp, ntopng, cloudflared,
qemuguestagent, puppetagent, tftp, wol, dhcrelay, radvd, tayga, ndpproxy,
ndproxy, mdnsrepeater, udpbroadcastrelay, dnsmasq (fully covered already), dyndns (fully
covered already), trafficshaper (fully covered already), routes/routing (gateway status is
redundant with the already-covered searchGateway), trust CRL (no expiry fields; per-CA OCSP text
parsing was judged not worth it), and Kea HA plus Kea statistics (not exposed by the OPNsense API
at all).
2026-07-30 spot check (light, per standing rule — not a re-survey): none of these plugins were
installed on the box audited, which is itself consistent with most of them being niche/legacy
add-ons rather than something that shipped a new API surface since the original audit. radvd and
dnsmasq do have their own controllers on-box, as expected — they're listed here as "fully
covered already" by existing collectors, not as absent. Nothing found that warrants pulling any
entry off this list.
See also¶
- API contract canary - the automated checks that catch renamed or removed endpoints; this page is the manual counterpart for endpoints that are safe to reach but wrong to poll.
- Adding a Collector - before wiring up a new endpoint, check it is not listed above.