Skip to content

Syslog receiver

The exporter can listen for syslog and have OPNsense push its logs to it, instead of polling the API for them. It parses what it receives and enriches it from the OPNsense API before shipping it on through the log pipeline.

This is off by default. It needs configuration on both sides: the receiver on the exporter, and a logging target on the firewall.

If a log line from your firewall is not parsed the way you expect, the parsers are open source: see internal/logship/ on GitHub and report the format with a sample line.

Why this exists

A generic syslog collector (Alloy, Vector, rsyslog) can already receive these logs. What it cannot do is understand them. A raw firewall log line looks like this:

16,115,,6cafbc76-9f4d-4150-949e-e3c37dd0a596,igb0,match,block,in,4,0x0,,58,0,0,none,6,tcp,40,203.0.113.9,198.51.100.4,54321,22,...

Nothing there tells you which rule that was, what igb0 is called, or who owns those addresses. The exporter already holds an authenticated OPNsense API client, so it can resolve all of it at ingest - which is the one thing a general-purpose log collector structurally cannot do.

Set up the exporter

opnsense2otel \
  --logs.enabled \
  --logs.syslog.enabled

The receiver listens on port 5514 for both UDP and TCP by default. (Not 514: that is a privileged port, and the container runs as a non-root user.)

If you run the exporter in a container, publish the port for both protocols - missing one is the most common reason nothing arrives:

ports:
  - "5514:5514/udp"
  - "5514:5514/tcp"
FlagDefaultNotes
--logs.syslog.enabledfalseEnables the receiver. Also needs --logs.enabled.
--logs.syslog.listen-udp:5514Empty disables the UDP listener.
--logs.syslog.listen-tcp:5514Empty disables the TCP listener.
--logs.syslog.listen-tls(none)TLS listen address (OPNsense tls4/tls6). Empty disables it. Needs the cert/key flags below. See TLS transport.
--logs.syslog.tls-cert-file(none)PEM server certificate for the TLS listener.
--logs.syslog.tls-key-file(none)PEM private key for the TLS listener.
--logs.syslog.tls-client-ca-file(none)PEM CA bundle to verify sender client certificates. When set, a sender must present a cert signed by this CA - the only real sender authentication syslog has.
--logs.syslog.allowed-peers(any)CIDR allowlist of permitted senders.
--logs.syslog.max-conns64Cap on concurrent TCP connections.
--logs.syslog.enrichtrueEnrich records from the OPNsense API.
--logs.syslog.exclude-programs(none)Programs to drop, e.g. radvd,cron.
--logs.syslog.include-programs(none)If set, ship ONLY these. Mutually exclusive with exclude.
--logs.syslog.min-severity(none)Drop below this severity, e.g. notice drops info and debug.
--logs.syslog.samplefalseDrop high-volume raw lines after deriving their metrics. See Derived metrics and sampling.
--logs.syslog.sampled-attributetrueWhile sampling, stamp sampled="true" on shipped lines. Only takes effect with --logs.syslog.sample.

Set up the firewall

In the OPNsense UI: System → Settings → Logging → Targets → +

FieldValue
TransportTCP(4) - see below
Applicationsleave empty
Levelsleave empty
Facilitiesleave empty
Hostnamethe exporter's address
Port5514
rfc5424ticked

Then Apply.

Three of those deserve explanation.

Leave Applications, Levels and Facilities empty. Empty means all - that is how OPNsense's target model works. Selecting nothing forwards everything the box logs, which is what you want: the receiver ships unknown programs as generic records rather than discarding them, so anything you don't explicitly model still reaches Loki.

Prefer TCP. UDP is OPNsense's default and it works, but datagram loss is silent and unrecoverable - you will never know what you didn't receive. Firewall logs are the highest-volume stream on the box and the one most worth not losing.

Tick rfc5424. OPNsense leaves this off by default, which sends the legacy BSD format. The receiver parses both, so it will work either way - but RFC5424 carries a proper timestamp with a UTC offset, where the legacy format has no year at all and must be inferred.

Filtering (optional, off by default)

The receiver ships everything unless you tell it otherwise - an unknown program is never dropped, because that is the point of a catch-all receiver and your box runs plugins we have never heard of.

If you do pay per GB of ingest, a firewall at debug level is loud. radvd logs a timer tick every two minutes and says nothing; HAProxy logs every request. So:

--logs.syslog.exclude-programs=radvd,cron     # drop the known-useless
--logs.syslog.min-severity=notice             # drop info and debug, keep notice and worse

Syslog severity is inverted (0 = emerg, 7 = debug), so --min-severity=notice keeps everything at or above notice. Anything dropped is counted in opnsense_exporter_logs_rejected_total{reason="filtered"}, so nothing is discarded silently.

You can also filter on the firewall itself (the target's Applications/Levels/Facilities selectors). Use that for coarse cuts you never want to see; use the exporter for tuning you might change your mind about, since it needs no firewall config edit.

Derived metrics and sampling

The receiver counts what it parses. Every firewall, HAProxy, sshd, DHCP (server and this firewall's own WAN DHCP/DHCPv6 client), audit, IDS, gateway, CARP, UPnP, VPN lifecycle and supported FreeRADIUS access line it recognises increments a Prometheus counter at /metrics, so you get rates and totals without querying Loki at all:

MetricLabels
opnsense_log_events_firewall_totalaction, interface, rule_id, rule_name, scope
opnsense_log_events_haproxy_totalevent, backend, server, state, status_class
opnsense_log_events_sshd_totalresult, method, scope
opnsense_log_events_dhcp_totalaction, interface, server
opnsense_log_events_dhcp_client_totalinterface, type
opnsense_log_events_dhcp6c_message_totalinterface, direction, type
opnsense_log_events_dhcp6c_event_totalinterface, event, reason
opnsense_log_events_audit_totalevent, result
opnsense_log_events_ids_totalevent_type, action, category, severity
opnsense_log_events_gateway_totalevent, gateway
opnsense_log_events_radius_totalevent, result, client_scope
opnsense_log_events_vpn_totalbackend, event, result, connection
opnsense_log_events_carp_totalevent, from, to, interface, vhid
opnsense_log_events_upnp_totalevent, result, protocol

No IP, port, SID, hostname, username, MAC, NAS/client identity, certificate subject or serial, IKE identity, SPI, reply text, credential or signature text becomes a label. Configuration-scale values such as gateway, interface, rule, backend, server and VPN connection names are protected by the per-family --logs.max-metric-keys budget. This is on by default; turn it off with --exporter.disable-log-events.

Gateway monitor (dpinger) alarms

On OPNsense 27.1.a_40, the recognised RFC5424 form is:

<PRI>1 <timestamp> <host> dpinger <pid> - [meta sequenceId="<sequence>"] MONITOR: <gateway> (Addr: <address> Alarm: none -> down RTT: <milliseconds> ms RTTd: <milliseconds> ms Loss: <percent> %)

The clear form is identical except for Alarm: down -> none. These are the only two transition directions currently counted: none -> down becomes event="alarm_started"; down -> none becomes event="alarm_cleared". opnsense_log_events_gateway_total carries only event and gateway (along with the exporter's standard opnsense_instance attribution). The address, RTT, RTTd, loss, sequence ID and message text are deliberately not metric labels.

Matching lines are enriched with gateway.event, gateway.name, gateway.address, gateway.alarm.previous, gateway.alarm.current, gateway.rtt_ms, gateway.rttd_ms and gateway.loss_percent. A dpinger line that does not match this grammar still ships as a generic record; it is not counted as an inferred gateway transition.

CARP state changes and demotion (kernel)

CARP transitions come from the FreeBSD kernel, not from OPNsense, under the RFC5424 APP-NAME kernel. They require net.inet.carp.log=1, which is the FreeBSD default. On OPNsense 27.1.a_40 (FreeBSD 15) the two recognised forms are:

<PRI>1 <timestamp> <host> kernel - - [meta sequenceId="<sequence>"] <<kpri>>[<kuptime>] carp: <vhid>@<device>: INIT -> BACKUP (initialization complete)
<PRI>1 <timestamp> <host> kernel - - [meta sequenceId="<sequence>"] <<kpri>>[<kuptime>] carp: demoted by <delta> to <total> (pfsync bulk start)

The <6>[754] prefix ahead of carp: is the kernel's own priority and monotonic counter. It is part of the message, both numbers vary freely, and the receiver tolerates it being absent.

event is closed to three values. A <FROM> -> <TO> line is state_changed, with from and to closed to master, backup or init (lowercased) and the OS device and VHID in interface and vhid. A demoted by line is demoted when the delta is positive and promoted when it is negative — FreeBSD has no separate promotion line, so the sign of the delta is the whole distinction. A demotion is global to the node and names neither an interface nor a VHID, so from, to, interface and vhid are all empty on those series.

The kernel's causeinitialization complete, master timed out, hardware interface up, pfsync bulk start, pfsync bulk fail, service disruption, and whatever a future FreeBSD adds — is deliberately not a metric label, and is not bucketed into a reason_class either: it is open-ended free text across FreeBSD versions. It ships on the record as carp.reason, alongside carp.event, carp.state.previous, carp.state.current, carp.interface, carp.vhid, carp.demotion.delta and carp.demotion.total. Those last two are the numbers that explain why a node demoted, which the opnsense_carp_demotion current-state gauge cannot retain.

Because this parser claims kernel, it sees every kernel line on the box. Anything that is not one of the two forms above — link-state changes, watchdog resets, ZFS, USB, arp — is not claimed: it ships as a generic record exactly as it did before, and is never counted as an inferred CARP transition.

OPNsense's own CARP syshook also logs transitions under APP-NAME opnsense, carrying the admin's VIP description. That source is deliberately not parsed: opnsense is the catch-all app-name for every log_msg() call on the box, so claiming it would put an unbounded variety of unrelated lines through a CARP matcher.

UPnP / NAT-PMP mapping events (miniupnpd)

The os-upnp plugin's daemon logs under the RFC5424 APP-NAME miniupnpd. Five forms are recognised, and all five are failures or expiries - see the warning below about what is not here:

<PRI>1 <timestamp> <host> miniupnpd <pid> - [meta sequenceId="<sequence>"] remove port mapping <external_port> UDP because it has expired
<PRI>1 <timestamp> <host> miniupnpd <pid> - [meta sequenceId="<sequence>"] could not find nat rule to delete iport=<internal_port> addr=<token>
<PRI>1 <timestamp> <host> miniupnpd <pid> - [meta sequenceId="<sequence>"] could not find redirect rule to delete eport=<external_port>
<PRI>1 <timestamp> <host> miniupnpd <pid> - [meta sequenceId="<sequence>"] Unauthorized to remove PCP mapping internal port <internal_port>, protocol UDP
<PRI>1 <timestamp> <host> miniupnpd <pid> - [meta sequenceId="<sequence>"] could not open lease file: <path>

They map onto opnsense_log_events_upnp_total{event,result,protocol} (plus the standard opnsense_instance label) with fully closed values:

AttributeClosed values
upnp.eventexpired, cleanup_failed, unauthorized, lease_file_error
upnp.resultok (only for expired), failure
upnp.protocoltcp, udp, or empty on the three forms that name no protocol

expired is a mapping reaching the end of its lease and being torn down - the lifecycle working. cleanup_failed means the daemon and pf disagree about which rules exist. unauthorized is a PCP client trying to remove a mapping it does not own, which the plugin's default secure_mode refuses.

There is no active-mapping count, and no successful add or delete

No gauge of currently-active mappings is exported and none can be derived from this family. The plugin's own status page lists mappings by running pfctl rather than exposing them through an API; an event stream cannot see mappings that already existed, and it cannot survive a daemon restart. expired is a decrement with no matching increment, so anything gauge-shaped built from it would drift negative without bound.

A successful add and a successful delete are also absent. miniupnpd logs those at a verbosity os-upnp does not expose a setting for, so no captured grammar proves one. The daemon's request lines (AddPortMapping:, redirecting port, DeletePortMapping:, removing redirect rule port) and its Returning UPnPError <code>: <text> responses are deliberately not parsed: a request is not an outcome, and in a live capture a real add logged AddPortMapping, then redirecting port, then Returning UPnPError 501: Action Failed, having created no rule at all. shutting down MiniUPnPd and Listening for NAT-PMP/PCP traffic on port <n> are not parsed either - daemon lifecycle, not mapping lifecycle. All of them still ship as generic records with the body verbatim.

Ports ship on the record as upnp.port.external and upnp.port.internal alongside upnp.event, upnp.result and upnp.protocol, and are deliberately not metric labels - an ephemeral client port is unbounded and would mint a series per mapping. The raw record is therefore the only place the specific mapping behind a failure can be identified. The daemon's opaque addr= token and the lease-file path are not even attributes: they stay in the message body.

Recognised on OPNsense 26.7.1_1 and 27.1.a_40 with miniupnpd 2.3.9_2,1 / os-upnp 1.9.

ppp is mpd5, the dialler that brings a PPPoE WAN up. Every shape below was taken from a real capture of a single link flap; nothing here is inferred from mpd's documentation.

<PRI>1 <timestamp> <host> ppp <pid> - [meta sequenceId="<sequence>"] [<link>] Link: UP event
<PRI>1 <timestamp> <host> ppp <pid> - [meta sequenceId="<sequence>"] [<bundle>] IPCP: state change Ack-Sent --> Opened
<PRI>1 <timestamp> <host> ppp <pid> - [meta sequenceId="<sequence>"] ppp-linkup: executing on <interface> for <af>

Thirteen ppp.event values are recognised: link_up, link_down, iface_up, iface_down, reconnecting, bundle_status, negotiation_state_change, terminate_requested, session_established, session_closed, session_failed, auth_success and address_assigned. Alongside them a matching line carries whichever of ppp.bundle, ppp.link, ppp.interface, ppp.protocol, ppp.state.previous, ppp.state.current, ppp.links_up, ppp.bandwidth_bps, ppp.retry_attempt, ppp.retry_delay_seconds, ppp.address.local, ppp.address.peer, ppp.address_family and ppp.error the line stated.

Two details of the grammar are worth knowing, because both are places a plausible-looking parser goes wrong:

LCP is always bracketed under the link (opt7_link0) while IPCP and IPV6CP are bracketed under the bundle (opt7). That is mpd's own architecture — LCP negotiates per physical link, the network-layer protocols per bundle, which may stack several links — so the scope is decided by which protocol produced the line, not by pattern-matching the bracketed name.

The [<bundle>] <x> -> <y> shape carries either an address assignment or an IPv6 interface-identifier pair (9ab7:85ff:fe21:aff2 -> 9e89:1eff:fe2e:0000). The two are told apart by whether both sides parse as IP addresses; the identifier form has four hex groups and no ::, so it fails to parse and ships as a generic record with no ppp.address.* attributes rather than as a fabricated address.

The CHAP authname, the MESG: circuit identifier and the link magic number are subscriber-identifying and are never captured into an attribute. Only the LCP: authorization successful outcome is parsed. A test walks every attribute value looking for the authname and fails if it appears.

No derived counter yet. A link-flap rate is the obvious follow-up, but the three layers (Link, LCP, IPCP) each report a state change for one physical event, so a naive counter would treble every flap.

Alias and table maintenance (firewall)

The program name is misleading and the mistake it invites is worth stating plainly: firewall is OPNsense's alias and table maintenance logger, not packet filtering. Packet filtering logs under filterlog and is parsed separately.

<PRI>1 <timestamp> <host> firewall <pid> - [meta sequenceId="<sequence>"] resolving <n> hostnames (<m> addresses) for <alias> took <seconds> seconds
<PRI>1 <timestamp> <host> firewall <pid> - [meta sequenceId="<sequence>"] fetch alias url <url> (bytes: <n>)
<PRI>1 <timestamp> <host> firewall <pid> - [meta sequenceId="<sequence>"] processing alias url <url> took <seconds>s

Five alias.event values are recognised: resolved, fetched, processed, geoip_updated and archive_detected. Matching lines carry whichever of alias.name, alias.hostnames, alias.addresses, alias.duration_seconds, alias.url, alias.bytes, alias.lines, geoip.files and geoip.lines applies.

The fetch grammar reports either a byte count or a line count depending on the payload, and only the one present is emitted — a text list reports lines, a JSON or archive body reports bytes.

Note that the two duration forms differ: resolving ... took 0.08 seconds has a space and the full word, while processing ... took 0.14s has neither. They come from different subsystems and are matched by separate grammars; a test pins them apart, because conflating them is the natural bug here. Both are emitted as the bare number of seconds exactly as it appeared on the wire, with no unit conversion.

This is where a slow or failing alias URL becomes visible. A URL is an operator-configured value and is kept in full as structured metadata; it is never a metric label.

Certificate lifecycle (acme.sh and the ACME plugin)

Two programs feed one parser. The OPNsense ACME plugin logs its own lifecycle under the catch-all program opnsense, and the acme.sh client logs its progress under acme.sh. Records from the two are told apart by cert.source, which is plugin or acme.sh.

<PRI>1 <timestamp> <host> opnsense <pid> - [meta sequenceId="<sequence>"] AcmeClient: successfully issued/renewed certificate: <domain>
<PRI>1 <timestamp> <host> acme.sh <pid> - [meta sequenceId="<sequence>"] [<client timestamp>] Cert success.

opnsense is a catch-all: every OPNsense PHP log line on the box arrives under it, not just the ACME plugin's. The parser therefore refuses anything outside a captured AcmeClient: shape, exactly as the kernel grammars do, so unrelated PHP lines still degrade to generic records. Loosening that match would relabel every PHP log line on the box as certificate activity.

Recognised cert.event values are renewal_not_required, renewal_required, issue_started, issue_succeeded, config_wiped, removal_failed, account_registered, ca_imported, ca_selected, challenge_type_selected, challenge_added, challenge_removed, validation_pending, validation_succeeded, signing_started, cert_downloaded, cert_installed, domain_skipped and shell_command. Alongside them a line may carry cert.domain, cert.ca, cert.challenge_type, cert.challenge_domain, cert.result, cert.exit_code, cert.attempt and cert.attempt_max.

There is deliberately no issue_failed: no failure has been captured from a real box, and inventing a grammar for one would pin a shape upstream may never produce. It should be added from a real capture when one occurs.

The acme.sh lines are prefixed with the client's own bracketed local timestamp. It is stripped and never emitted — the syslog envelope already carries the record's time, and a second, differently-formatted, differently-zoned time would only be a trap.

These lines carry secrets, and several are handled by refusing to match them at all: the ACME account thumbprint, the Le_OrderFinalize= and Le_LinkCert= order URLs, and every filesystem path to a private key produce no attributes whatsoever. On the TXT challenge lines the challenge domain is captured but the challenge value is matched non-capturing and discarded. A test asserts that the thumbprint and a TXT value never appear in any attribute across the whole captured corpus.

DNS blocklist and service lifecycle (unbound)

Beyond the local-zone query log and SERVFAIL lines documented above, the DNSBL subsystem and unbound's own start/stop are parsed.

<PRI>1 <timestamp> <host> unbound <pid> - [meta sequenceId="<sequence>"] [<pid>:<thread>] info: dnsbl_module: blocklist loaded. length is <n>
<PRI>1 <timestamp> <host> unbound <pid> - [meta sequenceId="<sequence>"] blocklist parsing done in <seconds> seconds (<n> records)

Seven dnsbl.event values are recognised: blocklist_updating, blocklist_loaded, blocklist_parsed, pipe_opening, pipe_opened, pipe_closed and backend_missing. The start/stop pair emits dns.event (service_started or service_stopped) with dns.version.

dnsbl.entries is emitted for both the blocklist loaded. length is <n> and the blocklist parsing done ... (<n> records) lines. They report the same quantity by two routes, and a consumer should not have to know which line it came from. dnsbl.parse_duration_seconds comes from the parsing-done line.

Note that the parsing-done line carries no [<pid>:<thread>] prefix while the dnsbl_module: lines do. That asymmetry is real and is pinned by a test. Where the prefix exists, only the thread number is kept as unbound.thread; the pid changes on every restart and carries no operational meaning.

The multi-line statistics dump — server stats for thread <n>, the recursion histogram, the percentile line and the lower(secs) upper(secs) recursions header — is deliberately not parsed. Its shape varies per thread, and half-parsing a multi-line report yields records that look complete and are not.

These metrics answer different questions and are easy to confuse. opnsense_unbound_dns_dnsbl_blocklist_size is the number of entries currently loaded in unbound's DNSBL module. opnsense_unbound_dns_blocklist_enabled reports whether any DNSBL policy is enabled in OPNsense's own Unbound configuration. A box whose blocklist is populated by a third-party plugin — Q-Feeds, for instance — will report a large size alongside blocklist_enabled=0, because the plugin loads the list into the module without going through core's blocklist configuration. That combination is not a bug in either metric; it means core Unbound has no blocklist selected and no policy, and what is loaded got there another way.

FreeRADIUS access outcomes and credential handling

OPNsense 27.1.a_40 with os-freeradius 1.10.2 emits authentication outcomes under the RFC5424 APP-NAME radiusd. The captured normal-service forms are Login OK and Login incorrect. They become:

AttributeClosed values
radius.eventaccess
radius.resultaccepted, rejected
radius.client_scopeconfigured

The corresponding counter is opnsense_log_events_radius_total{event,result,client_scope} (plus the standard opnsense_instance label). Usernames and the configured RADIUS client name are not attributes or labels.

FreeRADIUS is a stricter confidentiality boundary than the other syslog parsers. For every radiusd frame, the exporter replaces the original hostname, PID, structured data and message before parser dispatch, generic enrichment, debug capture, shape-key calculation, metric derivation, sampling or queue admission. Recognised access outcomes become fixed code-defined messages. Any other radiusd message becomes a fixed sanitized generic record and is not counted. A malformed RFC5424 or RFC3164 frame with a recognizable radiusd program token also fails closed to that safe generic record. This prevents an enabled FreeRADIUS password-log option from reaching the raw generic or debug-capture paths.

Configure the plugin and target manually on OPNsense:

  1. In the FreeRADIUS general settings, select syslog as the log destination and enable authentication-request logging.
  2. Leave both accepted-password (auth_goodpass) and rejected-password (auth_badpass) logging disabled.
  3. Add an OPNsense syslog Target for program radiusd pointing at the exporter's syslog listener.

The exporter does not create that Target or write firewall rules.

Normal Accounting Start, Interim-Update and Stop requests returned Accounting-Response in the capture but emitted no syslog records. Accounting therefore remains unsupported and is not inferred from request traffic.

IPsec and OpenVPN tunnel lifecycle events

The vpn family counts tunnel lifecycle transitions for both VPN backends, from charon (IPsec) and from the per-instance openvpn_* programs. Its vocabularies are closed and resolved in code:

LabelClosed values
backendipsec, openvpn
eventestablished, terminated, authentication_failed, liveness_failed, certificate_failed
resultsuccess (established, terminated), failure (the three failure events)

connection is the fourth label and the only one that is not code-defined. It is the configured name of the IPsec connection or OpenVPN instance, resolved from the id in the log line against the inventory the exporter already fetches for its IPsec and OpenVPN collectors (the same enrichment that adds ipsec.connection / openvpn.instance to every record). It is empty when the id could not be resolved, and it is never the raw UUID.

connection is populated for IPsec and is ALWAYS EMPTY for OpenVPN. That is by design, not a fault. Read this before reporting an empty label as a bug:

  • IPsec: populated. Every captured charon line carries the connection id, so the label resolves whenever the tunnel is still in the fetched inventory. It is empty only for a tunnel deleted since the last inventory refresh, or before the first refresh completes.
  • OpenVPN: always empty. None of the four captured OpenVPN lines contains the instance id — OpenVPN prints it only on its MANAGEMENT: Client connected from /var/etc/openvpn/instance-<uuid>.sock line, which is not one of the four shapes this family parses. There is therefore nothing on those lines to resolve, and the label will never populate. Operator workaround: attribute those events using the program attribute on the raw log record (openvpn_server40, openvpn_client2), which names the instance. Nothing is inferred from the program-name suffix: the exporter's OpenVPN instance inventory has no numeric key to match 40 against, so guessing would be a fabrication rather than a resolution.

Recognised grammar

Only the grammar captured on an isolated testbed running OPNsense 27.1.a_40 with strongSwan 6.0.7 and the OpenVPN server package 2.7.5 is counted. The shapes, with every value replaced by a placeholder:

charon:  <thread>[<tag>] <<connection-id>|<n>> generating IKE_AUTH response <n> [ N(AUTH_FAILED) ]
charon:  <thread>[<tag>] <<connection-id>|<n>> IKE_SA <id>[<n>] established between <local>[<local-id>]...<remote>[<remote-id>]
charon:  <thread>[<tag>] <<connection-id>|<n>> giving up after <n> retransmits
charon:  <thread>[<tag>] <<connection-id>|<n>> IKE_SA deleted

openvpn: <peer-context> [<common-name>] Peer Connection Initiated with [AF_INET]<address>:<port>
openvpn: <peer-context> SENT CONTROL [<common-name>]: 'AUTH_FAILED' (status=<n>)
openvpn: <peer-context> VERIFY ERROR: depth=<n>, error=<error text>
openvpn: <peer-context> SIGUSR1[soft,ping-restart] received, client-instance restarting

The strongSwan <thread> number and <tag> subsystem vary line to line and are not anchored on. Mapping: N(AUTH_FAILED) in a generated IKE_AUTH response is authentication_failed; IKE_SA … established between … is established; giving up after N retransmits is liveness_failed; IKE_SA deleted is terminated. On the OpenVPN side Peer Connection Initiated with is established, 'AUTH_FAILED' is authentication_failed, a VERIFY ERROR: in OpenVPN's depth=<n>, error=<text> form is certificate_failed, and the ping-restart SIGUSR1 is terminated.

Three points of tolerance, so the recognised set is neither narrower nor wider than it looks:

  • N(AUTH_FAILED) is counted wherever it appears in the response's payload list, not only when it is the sole notify. Only generated responses count — the box rejecting a peer. The initiator-side parsed IKE_AUTH response … N(AUTH_FAILED) (the far end rejecting our credentials) is a different event, was not captured, and is deliberately not counted.
  • certificate_failed matches OpenVPN's format string, so expired, revoked and depth-N rejections all count as the same event class. A line that merely mentions the words does not.
  • [AF_INET6] is accepted alongside the captured [AF_INET], derived from OpenVPN's own address-family tag rather than from a capture, so an IPv6 peer's establishment is not silently uncounted.

What is deliberately not counted

Anything else, including every stable-release form. A read-only search of a production OPNsense 26.7.1_1's retained logs found zero usable lifecycle or failure records, so no stable-release grammar exists to parse and none is inferred from the development formats. Unmatched and version-new lines still ship in full as generic records with the body verbatim, and still appear in the debug capture as unmodelled signal — they are simply not counted as a transition the exporter did not witness.

These lines were captured for real and are deliberately left generic:

LineWhy it is not an event
SIGUSR1[soft,tls-error] received, client-instance restartingA control-channel failure, often for a session that never established. Counting it as terminated would mint terminations with no matching established.
SIGTERM[soft,delayed-exit] received, client-instance exitingThe instance being shut down by an administrator, not the peer going away.
TLS Error: TLS handshake failed / TLS Error: TLS object -> incoming plaintext read errorCompanions to the rejection VERIFY ERROR: already counts; counting them too would report three failures for one rejected client.
Inactivity timeout (--ping-restart), restartingThe first of the two lines OpenVPN logs for one ping-restart. The SIGUSR1 line is the second, and exactly one of the pair may be counted.
IPsec CHILD_SA establishment, rekeys, DPD probes, individual retransmits, deleting IKE_SANot part of the captured lifecycle vocabulary; rekey and daemon shutdown were explicitly excluded from it.

Identity handling

The captured lines carry usernames, certificate subjects and serials, IKE identities, peer addresses and ports. The parsers extract none of them: the only attributes they add are vpn.backend, vpn.event and vpn.result. Those values stay in the record body, which ships verbatim exactly as it did before this family existed, so an investigation still has the detail while the metric stays bounded and non-identifying.

A matched line keeps everything it had while it was generic: the peer.* address resolution, the interface resolution, the tunnel-id resolution and the verbatim body are all still there, with the VPN attributes added on top. Structured parsers normally skip the address scan — a parser that reports its own source and destination would otherwise report them twice — but these two extract no address at all, so the scan still runs for them and no existing query on peer.* for charon or openvpn_* lines changes behaviour.

Because the counters already carry the totals, you can stop shipping the raw lines they count and keep only the ones worth reading. That is --logs.syslog.sample (off by default):

--logs.syslog.sample     # keep firewall block/reject and HAProxy errors, drop the rest

With sampling on, the receiver keeps firewall block/reject lines and drops the passes, keeps HAProxy state changes and errors and drops per-connection noise, and keeps every low-volume program (sshd, DHCP, audit, IDS, gateway, RADIUS and the VPN lifecycle events) in full. A line is only ever dropped after its metric has been counted, so the counters stay complete even though the log stream is not. Sampling requires the log_events collector to be on (the exporter refuses to start otherwise), because counting first is the whole point.

Every shipped line then carries a sampled="true" attribute so a consumer knows the stream is incomplete and must use the counters for totals. Turn that stamp off with --logs.syslog.sampled-attribute=false if you would rather not have it.

WireGuard, Tailscale and NetBird tunnel lifecycle attributes

wireguard, tailscaled and /usr/local/bin/netbird lines also carry vpn.backend, vpn.event and vpn.result, so the dashboard's Tunnel lifecycle annotation layer marks them the same way it marks IPsec and OpenVPN. They are not counted into opnsense_log_events_vpn_total: that counter's backend label stays ipsec or openvpn, and its connection label is resolved from an IPsec or OpenVPN id that these lines do not contain. The records are unaffected, and because they are not counted, sampling never drops them.

The granularity is different and it matters when you read a marker: these are service-level events for a tunnel instance or for the whole node, never per peer.

ProgramLinevpn.event
wireguardwireguard instance <name> (<dev>) startedestablished
wireguardwireguard instance <name> (<dev>) stoppedterminated
wireguardwireguard instance <name> (<dev>) switching to UP (CARP promotion)established
wireguardwireguard instance <name> (<dev>) switching to DOWN (CARP demotion)terminated
tailscaledSwitching ipn state <from> -> Running (WantRunning=…, nm=…)established
tailscaledSwitching ipn state Running -> <to> (WantRunning=…, nm=…)terminated
tailscaledDestroying <dev> adapter (the rc script's service-stop teardown)terminated
/usr/local/bin/netbirdNetbird engine started, the IP is: <addr>established
/usr/local/bin/netbirdstopped NetBird clientterminated

vpn.result is always success: none of these app-names states an authentication, certificate or liveness verdict.

Four things to know before expecting these to appear:

  • WireGuard's per-peer handshakes are not events, and they are not even here. They come from the FreeBSD kernel driver, so they arrive under program kernel, and they are behind the per-instance Debug Log flag, which is off by default. They also repeat roughly every two minutes for a healthy peer, and there is no "handshake completed" line at all — so the tunnel-up edge is not expressible from them, and treating them as one would paint the dashboard with markers. The exporter's WireGuard peer handshake data comes from the API poll lane instead.
  • Tailscale's own log is not routed to syslog on a stock box. The FreeBSD rc script runs tailscaled under daemon(8) and only forwards its output to syslog when tailscaled_syslog_output_enable="YES", which defaults to NO and which the OPNsense plugin does not set. Until you set it (in /etc/rc.conf.local, which survives OPNsense's config regeneration), the only line you will see is the service-stop teardown. The two never double-count one event: a service tailscaled stop produces no ipn state line, and tailscale down produces no teardown line.
  • NetBird's app-name is a PATH, not a program name, and that is not a typo above. The daemon passes an empty syslog tag, Go's log/syslog substitutes argv[0], and the rc script starts it as /usr/local/bin/netbird — so that is what arrives on the wire, confirmed against an enrolled box. If you filter your own log pipeline by program name, netbird matches only the rc script's service-start notice; the daemon's several thousand lines a day are under the path.
  • NetBird's markers are the ENGINE, and its per-peer lines are deliberately not events. client/internal/lazyconn closes and reopens an idle peer on a 15-minute inactivity threshold, and whether it does so at all is decided by a server-side management feature flag the firewall cannot see, so peer connection closed means different things on different tenants and the log cannot tell them apart. The engine pair above is immune to that: both lines come from one function, and the terminated line sits strictly downstream of the established one, so a teardown marker can never appear without a matching bring-up. Note also that starting/stopped NetBird service are the daemon process, not the overlay — an unenrolled box logs those all day and never establishes a tunnel — and stopped Netbird Engine is logged twice per teardown from two call sites upstream. Neither is an event here.

TLS transport (optional)

The receiver can take syslog over TLS in addition to (or instead of) plain UDP/TCP - OPNsense's tls4/tls6 transports. It matters when the firewall ships across an untrusted segment; on a LAN-local link it is unnecessary.

opnsense2otel \
  --logs.enabled \
  --logs.syslog.enabled \
  --logs.syslog.listen-tls=:6514 \
  --logs.syslog.tls-cert-file=/etc/exporter/syslog.pem \
  --logs.syslog.tls-key-file=/etc/exporter/syslog.key \
  --logs.syslog.tls-client-ca-file=/etc/exporter/senders-ca.pem   # optional, see below

Set --logs.syslog.tls-client-ca-file too: it's the only real sender authentication syslog has. With it, a sender must present a client certificate signed by that CA - the peer allowlist only filters by IP, it doesn't prove who's on the other end. Left empty, the listener encrypts but accepts any TLS client. On the firewall, set the target's Transport to TLS(4) and point it at the TLS port.

What you get

Structured parsers run for these programs; everything else ships as a generic record with its message body verbatim and its envelope as metadata.

ProgramParsed into
filterlogFirewall packet decisions - see below
audit, configd.py, configconfig_user, config_revision, config_uri (who changed the config), plus configd authorisation and RPC events. config adds the config-apply event itself: config.event, config.backup_path, config.version. configctl is deliberately NOT parsed - it re-emits the same line inside a second envelope, so structuring it too would double-count every config change. configd.py template-regeneration chatter stays generic.
sshd, sshd-sessionauth.result (accepted/failed/invalid-user), auth.user (also as the semconv user.name), auth.method, key fingerprint, source address. A failed login is raised to warning - sshd logs a rejected login at the same severity as a successful one, and you should not have to know that to find it.
dhcpd, dnsmasq, dnsmasq-dhcp, kea-dhcp4, kea-dhcp6, dhcrelaydhcp.action, dhcp.ip, dhcp.mac, dhcp.hostname, dhcp.lease_seconds - normalised across every backend, so you can query DHCP activity without caring which one your box runs
dhclientThis firewall's own WAN DHCP client lease lifecycle (#541) - not the LAN DHCP servers above: dhcp_client.type, dhcp_client.interface, dhcp_client.server, dhcp_client.address, dhcp_client.lease.* timestamps, dhcp_client.script.reason.
dhcp6cThis firewall's own WAN DHCPv6 client and delegated-prefix lifecycle (#546): dhcp6c.event, dhcp6c.type, dhcp6c.direction, dhcp6c.interface, dhcp6c.prefix, dhcp6c.prefix_length, dhcp6c.address, plus prefix/address lease timestamps.
haproxyServer UP/DOWN health transitions and "backend has no server available" (severity-mapped), plus per-connection frontend/mode. HTTP fields use OTel semconv names: http.request.method, http.response.status_code, url.path, network.protocol.version.
dpingerGateway monitor transitions none -> down and down -> none, with the observed address, alarm state and probe values. Plus watcher lifecycle: watcher_started (carrying the configured gateway.latency_alarm_ms / gateway.loss_alarm_percent thresholds), watcher_stopped and watcher_reloaded. Those matter because a dpinger restart clears the alarm state - without them an apparent recovery is indistinguishable from a real one. Reading the thresholds back also shows when they are 0, which means a latency alarm cannot fire on that box at all. Nonmatching dpinger lines remain generic records.
radiusdFreeRADIUS access accepted/rejected with closed non-PII attributes. Every unsupported or malformed recognizable radiusd form is sanitized before it becomes a generic record or debug capture.
unboundLocal-zone query log, only when Unbound's log-local-actions: yes is set: dns.query_name, dns.query_type, dns.query_class, dns.local_zone, dns.local_action, resolved src.*; plus SERVFAIL failures as dns.error/dns.upstream/dns.error_zone. No blocklist match, policy, resolution source or DNSSEC status - that stays poll-lane-only, see Unbound. Cache maintenance and DNSBL/plugin chatter stay generic records.
kernelFreeBSD CARP transitions: carp.event, carp.state.previous, carp.state.current, carp.interface, carp.vhid, carp.reason, carp.demotion.delta, carp.demotion.total. Plus promiscuous-mode toggles: kernel.event (promiscuous_enabled/promiscuous_disabled) and interface.device - an interface entering promiscuous mode is what packet capture looks like from the outside, and it was previously invisible. Every other kernel line stays a generic record - link state, watchdogs, ZFS, USB and the rest are not claimed.
miniupnpdUPnP/NAT-PMP mapping expiries and failures: upnp.event, upnp.result, upnp.protocol, upnp.port.external, upnp.port.internal. No successful add or delete, and no active-mapping count - see above. Request, response and daemon-lifecycle lines stay generic records.
charon, openvpn_*IPsec and OpenVPN tunnel lifecycle: vpn.backend, vpn.event, vpn.result and nothing else - no username, certificate subject, IKE identity, address or port is extracted. openvpn_* is matched by PREFIX because OPNsense names one program per configured instance. Only the captured grammar above is parsed; everything else stays a generic record.
wireguard, tailscaled, /usr/local/bin/netbirdThe same three vpn.* attributes, at service granularity rather than per peer: a WireGuard instance started/stopped/switched by CARP, this node's own Tailscale ipn state entering or leaving Running, and the NetBird engine coming up with an overlay address or its connect loop finishing teardown. NetBird's app-name is argv[0], not netbird - see below. Not counted into opnsense_log_events_vpn_total - see WireGuard, Tailscale and NetBird tunnel lifecycle attributes. No instance name, device, node key, peer or overlay address is extracted.
suricataSuricata EVE alerts only, when the syslog_eve setting is forwarding them: event_type, signature, alert_sid, alert_action, alert_category, alert_severity, resolved src/dest endpoints. See Suricata alerts below. Non-JSON suricata lines (engine text) stay generic.
cron, /usr/sbin/croncron.user, cron.action (cmd/mail), cron.command (CMD lines only, never set for MAIL).
radvdradvd.event (polling/timer), interface, interface.name, radvd.interval_seconds (polling lines only).
syslog-ngThe firewall's own log daemon, which is how the box reports that our feed dropped: syslogng.event (connection_established/connection_closed/connection_broken/eof/child_exited/read_error/statistics), syslogng.fd, dst.ip/dst.port, and from the statistics line only syslogng.dropped_total and syslogng.truncated_total. Deliberately narrow: the statistics line carries dozens of per-destination counters and none of them are fanned out. Configuration-reload lines stay generic.
DhcpLFC, plus Kea LFC lines from kea-dhcp6Kea's lease-file cleanup: kea.msg_id, kea.component, and on the read/write stats lines kea.lfc_leases, kea.lfc_attempts, kea.lfc_errors, kea.lfc_phase. This was the single largest source of unattributed syslog volume on a live box. Note DhcpLFC is CamelCase, so the kea- prefix rule does not reach it.
rule-updater.pySuricata ruleset freshness, which no metric exposes: ids.event (ruleset_downloaded, ruleset_version, ruleset_skipped, and the failure verbs), ids.ruleset_url, ids.ruleset_version, ids.http_status. The ruleset URL is operator-supplied and unbounded, so it is structured metadata only and never a label.
sudoPrivilege escalation: auth.result (allowed/failed), auth.user, auth.target_user, auth.command, auth.pwd, auth.tty. auth.command is the command line verbatim, arguments included - a password typed as an argument is logged by sudo itself and ships either way, so structuring it changes how you query it, not who can see it. Like every attribute here it is structured metadata and never a metric label.

Every record, structured or generic, also gets:

  • an opnsense.subsystem attribute (opnsense_subsystem in Loki) with a value like firewall, auth, dhcp, ipsec, vpn, upnp, proxy, routing or ups, so you can select a whole class of events without enumerating program names;
  • any IP address mentioned anywhere in the message resolved to a hostname, MAC and scope (self/local/remote);
  • any interface device resolved to its friendly name (vtnet0LAN);
  • for IPsec and OpenVPN, the tunnel UUID resolved to its name - charon logs <5e891b0c-…|8> sending DPD request, which is unreadable; the exporter turns it into ipsec.connection: "site-to-site" because it already has the API.

Firewall lines specifically

Firewall (filterlog) lines are parsed into structured fields and enriched:

AttributeResolved from
rule.descriptiondiagnostics/firewall/list_rule_ids
interface.namethe interface overview (vtnet0LAN)
src.hostname / dst.hostnameDHCPv4/DHCPv6/Kea/dnsmasq leases
src.mac / dst.macthe ARP and NDP tables
src.scope / dst.scopeself, local or remote
src.service / dst.servicea compiled-in well-known-port table
network.typethe IP-version field (ipv4/ipv6) - OTel semconv
network.transportthe protocol, for TCP/UDP only (tcp/udp) - OTel semconv

So the line above arrives looking like this:

{
  "body": "block in on WAN: 203.0.113.9:54321 -> 198.51.100.4:22 (tcp)",
  "attributes": {
    "action": "block", "direction": "in",
    "interface": "igb0", "interface.name": "WAN",
    "rule.description": "Default deny / state violation rule",
    "src.ip": "203.0.113.9", "src.scope": "remote",
    "dst.ip": "198.51.100.4", "dst.scope": "self", "dst.service": "ssh",
    "tcp.flags": "S", "tcp.window": "64240"
  }
}

Every other program OPNsense routes through syslog-ng - the bare configd daemon (as opposed to configd.py's authorisation/RPC lines, parsed above), routing daemons (bgpd, ospfd, zebra), netbird (the rc script's app-name, which carries only a service-start notice; the daemon's own lines are parsed under argv[0] as above), package installs, su, sudo, and a catch-all for everything else - ships as a generic record with its raw body and its envelope attributes.

Querying it in Loki

Attribute names lose their dots

The exporter emits OTLP attributes with dots (rule.description, src.ip) - the names used throughout this page. Loki sanitises dots to underscores. What you type in LogQL is rule_description and src_ip. A query written with the dotted name matches nothing and reports no error, which is the single easiest way to waste an afternoon here.

Two index labels select the stream; everything else is structured metadata, filtered with | after it:

{service_name="opnsense2otel", service_instance_id="opnsense"}
  | opnsense_subsystem="firewall" | action="block" | src_scope="remote"

A record, as it lands:

{
  "action": "pass", "direction": "in",
  "interface": "pppoe0", "interface_name": "WAN2",
  "rule_description": "[WAN2] Allow inbound ICMP (monitors)",
  "rule_id": "7ed3ec06-ecf8-4ca8-9a2a-bb346967850f",
  "src_ip": "203.0.113.55", "src_scope": "remote",
  "dst_ip": "198.51.100.23", "dst_scope": "self",
  "protocol": "icmp", "ip_version": "4", "network_type": "ipv4",
  "opnsense_source": "syslog", "opnsense_subsystem": "firewall"
}

Useful starting points:

{service_name="opnsense2otel"} | opnsense_subsystem="audit"                    # who changed the config
{service_name="opnsense2otel"} | opnsense_subsystem="auth" | auth_result="failed"
{service_name="opnsense2otel"} | opnsense_subsystem="firewall" | action="block" | dst_scope="self"
{service_name="opnsense2otel"} | program="filterlog" | src_hostname="WINSRV"

opnsense.source and opnsense.subsystem (namespaced so they can never collide with a Loki-reserved key) are the two attributes worth indexing if you query them often. Both ride on the OTLP resource for exactly that reason, and both can be promoted to real Loki labels with a one-off tenant config change - see the Loki label model. Nothing else should be promoted: src_ip as a label is one stream per address.

Multi-line messages

syslog-ng frames with newlines, not octet counts, so a message that itself contains newlines - a configd Python traceback, a cron command spanning lines - arrives as several frames of which only the first carries a <PRI> header. The receiver rejoins them: a line that does not begin with < cannot start a new message, so it is appended to the previous one. The assembled message is capped at 64KB like any other (the overflowing tail is dropped and counted as oversized), and a message with no successor to complete it is flushed after 250ms rather than waiting for the next line. Octet-counted frames carry their own length and are passed through untouched, as are UDP datagrams - one datagram is always exactly one message.

Resolving rule ids

A filterlog rule id is either a rule UUID (for rules you wrote) or a content hash (for the auto-generated ones: anti-lockout, default-deny, bogon blocks, DHCP-allow, IPv6 RFC4890). The rule inventory the exporter already collects only contains the first kind - on a stock box that is a small minority of the rules that actually match traffic. list_rule_ids resolves both, which is why the receiver uses it.

Lines where the rule id is 0 (NAT and floating-rule matches) carry no rule id at all by design. They get rule.ref (rule #16.115) instead of a description.

What you do not get

The receiver is not a total replacement for the API-polling sources. Three things have no usable syslog path, and their poll lanes remain:

  • Blocklist/policy/DNSSEC DNS detail (--logs.unbound.enabled) - the syslog unbound parser structures Unbound's local-zone query log (query name/type/class, matched local-zone, resolved client) and SERVFAIL failures, but blocklist match, policy, resolution source and DNSSEC status come only from OPNsense's reporting database, which only the poll lane reads. See What you get above.
  • CrowdSec (--logs.crowdsec.enabled) - CrowdSec logs to file only. Nothing it produces reaches syslog, and it ships no syslog notification plugin.
  • Suricata payloads (--logs.ids.enabled) - the syslog copy of an EVE alert never carries the packet payload. See below.

Suricata alerts: pick ONE path

The receiver does parse Suricata EVE alerts when the box forwards them (the IDS syslog_eve setting, off by default). So does the ids poll lane, from the richer file-based eve.json.

Running both would ship every alert into Loki twice, with no dedupe - and a duplicated security alert is worse than a missing one, because it silently inflates every count anyone builds on it. The exporter therefore refuses to start with --logs.syslog.enabled and --logs.ids.enabled both set, rather than guessing which you meant.

syslog receiverids poll lane
deliverypush, lossless, immediatepolled, up to one interval late
cost on the firewallnonean API call per poll
event typesalerts onlyalerts only
payloadnever (OPNsense forces payload: no on the syslog copy)available

Take the receiver unless you need the payload. Then turn syslog_eve on in the IDS settings and leave --logs.ids.enabled off.

Records from both paths carry the same attribute names (alert_sid, signature, src_ip, …), so a dashboard or alert rule does not care which path you chose.

Security

Syslog is unauthenticated. Anything that can reach the port can inject arbitrary log records into your observability stack. This is inherent to syslog, not to this implementation.

  • Bind the receiver to a trusted interface.
  • On a shared network, set --logs.syslog.allowed-peers to the firewall's address (--logs.syslog.allowed-peers=10.0.0.254/32). Senders outside the list are dropped and counted in opnsense_exporter_logs_rejected_total{reason="peer"}.
  • Messages are capped at 64KB, concurrent TCP connections at --logs.syslog.max-conns, and idle connections time out - a peer cannot exhaust memory or goroutines.

Migrating from the poll lanes

--logs.firewall.enabled, --logs.diaglog.enabled and --logs.scopes are removed. The exporter fails to start with an error naming the replacement if any of them (or their environment variables) are still set.

--logs.diaglog.enabled used to default to true

If you had --logs.enabled set, you were shipping the audit/configd/gateway/ portal trail without asking for it. That stops at upgrade and does not come back until you configure a syslog target on the firewall as described above. This is the one disruptive part of the change.

Troubleshooting

Nothing arrives. Check opnsense_exporter_logs_shipped_total{source="syslog"} is climbing. If it is flat: confirm the port is published for both UDP and TCP, confirm the target on the firewall is enabled and points at the right address, and check opnsense_exporter_logs_rejected_total{reason="peer"} in case an allowlist is dropping it.

Records arrive but aren't enriched. Check opnsense_exporter_logs_enrich_refresh_errors_total and opnsense_exporter_logs_enrich_last_refresh_timestamp_seconds - the API client may be failing while the receiver keeps working. Enrichment failure never drops a record, so this shows up as plainer logs rather than missing ones.

Rules aren't labelled. A steady opnsense_exporter_logs_enrich_misses_total{table="rules"} means the rule snapshot is behind the box. It refreshes every 60s and on a miss (rate-limited), so a persistent rate points at an API permission problem on diagnostics/firewall/list_rule_ids.