Skip to content

State Reporting

How the device tells the control plane what it currently is, as distinct from config generation, which is the control plane telling the device what it should be.

Two transports

  • ConnectRPC to Nexus for one-shot calls: enrollment, WhoAmI, config fetch and status, GetDesiredState (OTA), log upload (UploadLogs), RequestRelay, RequestCertificate, ReportCommandReply. Typed, and available while the realtime channel is reconnecting.
  • WebSocket to the Realtime Durable Object for low-latency traffic: heartbeats, state reports, command results and shell output outbound; config pushes, commands and shell input inbound.

State reports go over the WebSocket and are relayed by the Durable Object into Nexus's StateReport RPC.

The relay drops any field it does not name

realtime/src/state-report-mapping.ts maps the agent's JSON onto the proto field by field. Anything it does not name is discarded without an error, and the symptom points at the wrong component: the device looks as if it is not reporting.

realtime/test/state-report-mapping.test.ts enumerates the generated proto's field list and fails when the mapping misses one. Adding a field to the state report means three places: the agent struct (internal/connector/messages.go), the mapping, and the proto. The test catches a missed mapping.

Cadence

What Interval Where
Heartbeat 30 s (EDGE_AGENT_HEARTBEAT_INTERVAL) internal/connector/ws.go
State report 300 s (EDGE_AGENT_STATE_INTERVAL) internal/connector/ws.go
Carrier poll 5 s internal/connector/ws.go
state.json write 2 s cmd/agent/serve.go

The carrier poll compares the current carrier set with the previous one and pushes a report immediately on a change, so plugging a cable in shows up in the portal at once rather than at the next scheduled report.

What a report carries

stateReportMsg in internal/connector/messages.go:

  • Identity and health: hostname, OS name and version, uptime, agent version, CPU usage and temperature, memory
  • Interfaces: addresses, status, MAC, MTU, carrier, media, byte counters, and uplinkState (carrying, standby or down) on a declared uplink
  • WireGuard, per interface: public key, listen port, and per peer the endpoint, allowed IPs, keepalive, counters and handshake age
  • pf status: enabled, config hash, state count, block counters
  • ARP table and DHCP leases
  • IPsec tunnels: per-tunnel status, absent when none are configured
  • Published services
  • Public IP, IPv6, ISP and country
  • Uplink transitions and uplink health samples since the last report
  • Modems: cellular presence, registration and signal (never the PIN)

Interface collection

state.CollectInterfacesDetailed() parses ifconfig -a for the detailed fields (carrier, media, counters), falling back to Go's net.Interfaces() when ifconfig is unavailable.

Two fields that encode an absence

A zero and a missing value mean different things here:

  • hasHandshake accompanies lastHandshakeSeconds. A peer that has never completed a handshake must not report one zero seconds ago, which would render a dead tunnel as healthy.
  • loggingBlockRules is deliberately not omitempty. Zero is the meaningful answer "this ruleset cannot log a block at all"; omitting it would be indistinguishable from an agent that never sends the field.

pf block counters

Read from pfctl -vs rules by internal/state/pfcounters.go, in two groups:

  • logging blocks: rules carrying log, the headline "traffic your firewall refused";
  • silent blocks: deliberately unlogged broadcast and multicast noise.

Keeping them apart stops the noise inflating the headline, and lets the portal tell nothing was blocked from blocks happen but their log does not arrive.

Counters are cumulative since the last pf load

pf is reloaded on every config apply, so a low reading on a recently updated device means "not for long", not "nothing".

The interface list says which uplink is carrying now. Transitions say when it changed and from what, which is what explains an outage afterwards. The agent reads them from the failover helper's record under /var/db/digilan/wan/ and drains them on collection, so a report that never reaches Nexus loses them. That is deliberate: re-sending would make one failover look like a flapping link. Uplink health samples are drained the same way.

state.json

The agent writes its full state to <datadir>/state.json (/var/lib/edge-agent/state.json) every 2 seconds. edge-agent status reads this file, so the on-device view works without any network, which is when someone standing at the box usually needs it.