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,standbyordown) 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:
hasHandshakeaccompanieslastHandshakeSeconds. A peer that has never completed a handshake must not report one zero seconds ago, which would render a dead tunnel as healthy.loggingBlockRulesis deliberately notomitempty. 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".
Uplink transitions are events, not state¶
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.
Related¶
- Config generation: the other direction
- Agent structure: where these packages sit
- Realtime: the Durable Object and its relay
- Proto: gateway v1: the wire schema