Skip to content

Linux agent ​

ferrite-agent reports an embedded Linux device to ferrite-server: which image it runs, how the system is doing, why it last rebooted, and whether the services you care about (for example the CODESYS Control runtime) are up. It is the Linux counterpart of the MCU SDK and speaks the same chunk format over POST /ingest/chunks. Linux devices therefore show up in the device list, metrics, OTA campaigns and update-reach reports with no server changes.

It runs as an unprivileged systemd service (a static ferrite-agent system user) and uses no database. On x86_64 the stripped binary is 3.7 MB with the default release profile, or 2.3 MB with lto = true, opt-level = "s", codegen-units = 1 and panic = "abort".

Install ​

bash
cargo build -p ferrite-agent --release            # or cross-compile, see below
sudo install -m 0755 target/release/ferrite-agent /usr/bin/
sudo install -D -m 0644 ferrite-agent/dist/ferrite-agent.sysusers /usr/lib/sysusers.d/ferrite-agent.conf
sudo systemd-sysusers                             # creates the ferrite-agent system user
# 0640: the config may hold the device's credential
sudo install -D -m 0640 -g ferrite-agent ferrite-agent/dist/agent.conf.example /etc/ferrite/agent.conf
sudo install -m 0644 ferrite-agent/dist/ferrite-agent.service /etc/systemd/system/
sudoedit /etc/ferrite/agent.conf                  # set server_url (and api_key, credential)
sudo systemctl enable --now ferrite-agent

The unit deliberately avoids DynamicUser=: on images without nss-systemd in /etc/nsswitch.conf (Raspbian 10, many minimal builds), D-Bus can't resolve dynamic UIDs and refuses systemctl show, so unit metrics would be missing. If systemd-sysusers isn't available, create the user with useradd --system --home-dir /var/lib/ferrite-agent --shell /usr/sbin/nologin ferrite-agent.

A Yocto layer (meta-ferrite, #85) will package the same files.

For 32-bit ARM, cargo build --target armv7-unknown-linux-gnueabihf needs an ARM linker. Add --no-default-features to leave out TLS: the agent then speaks plain HTTP only, for example to a ferrite-gateway on the local network, and needs no cross C compiler.

Configuration ​

/etc/ferrite/agent.conf is TOML. Only server_url is required:

toml
server_url = "https://ferrite.example.com"
api_key = "..."            # when the server sets INGEST_API_KEY
interval_secs = 60
disks = ["/", "/data"]

# [[units]] tables go last: TOML assigns any key after them to the table.
[[units]]
name = "codesyscontrol.service"
key = "codesys"
KeyDefaultMeaning
server_url—ferrite-server, or a gateway, base URL
api_keynoneSent as X-API-Key
credentialnonekey_id and key (hex) of this device's credential; every report is sealed with it
interval_secs60Seconds between reports
disks["/"]Filesystems to report
unitsnonesystemd units to watch (name, optional key)
device_id, firmware_version, build_idderivedIdentity overrides, see below
state_dir/var/lib/ferrite-agentBoot tracking and offline spool
max_spooled_reports1440Reports kept while offline (one day at 60 s)
timeout_secs15HTTP timeout

Device credential ​

With a credential the server knows a report came from this device, not from anything else that holds the API key (see Per-device credentials). Issue one for the device ID that ferrite-agent identity prints, then add it to the config before any [[units]] table:

toml
[credential]
key_id = 2895710042
key = "5f1c0d…"           # 32 hex characters, from POST /devices/<id>/credential

The agent seals every chunk with it and picks a random boot epoch each time it starts, so nonces never repeat. Keep the file readable by root and the ferrite-agent group only (0640).

ferrite-agent identity prints what the server will see. ferrite-agent once sends a single report and exits non-zero if it couldn't be delivered. RUST_LOG=debug raises the log level.

Identity ​

The server tracks devices by ID, firmware version and a numeric build_id. Campaigns target a build_id, and update-reach reports group by it. The agent derives all three; set them in the config to override.

FieldSource
firmware_versionIMAGE_VERSION from /etc/os-release, else VERSION_ID, else VERSION
build_idBUILD_ID, else IMAGE_VERSION, else VERSION_ID, mapped as below
device_idlinux- + the first 16 hex digits of SHA-256(ferrite-agent: + /etc/machine-id)

build_id mapping: a value of decimal digits that fits a u64 is used as is. Yocto's default BUILD_ID is the build's DATETIME (e.g. 20261018093000), which is numeric and increases with every build. Any other value maps to the first 8 bytes of its SHA-256, read as a little-endian u64:

bash
python3 -c 'import hashlib,sys; print(int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:8], "little"))' 2.3.1

To roll out a new image with a campaign, use the build_id that ferrite-agent identity prints on a device running that image.

The device ID hashes the machine ID because systemd asks applications not to expose it directly. Rename devices on the server, or set device_id (at most 127 bytes).

What gets reported ​

Every report is one batch of chunks: DeviceInfo (marked as a Linux device, so the dashboard shows a Linux badge), a RebootReason on the first report after a reboot, a Heartbeat and the metrics.

MetricTypeMeaning
uptime_secondsgaugeSeconds since boot
cpu_pctgaugeCPU busy % since the last report
load1gauge1-minute load average
mem_used_pct, mem_avail_mibgaugeMemory use (from MemAvailable)
cpu_tempgaugeHottest thermal zone, °C
disk_used_pct, disk_<name>_used_pctgaugeFilesystem use, as df counts it
<key>_activegauge1 while the unit is active
<key>_restartscountersystemd's NRestarts for the unit
units_failedgaugeUnits in the failed state
coredumpscountersystemd-coredump files written this boot

The heartbeat's free-memory field carries available memory in bytes, capped at 4 GiB.

Reboot reasons ​

The agent stores the kernel's boot_id. When it changes, it reports why the previous boot ended, using the SDK's reboot codes:

EvidenceReported as
Kernel panic/oops record in pstore since the last runHardFault
Watchdog bootstatus has WDIOF_CARDRESETWatchdogTimeout
build_id changedFirmwareUpdate
The agent saw an orderly system shutdownSoftwareReset
None of these (power cut, hang, reset button)Unknown

The previous boot's uptime is recorded every 10 minutes and at shutdown, so uptime_before_reboot can be up to 10 minutes short after a power cut. Reading pstore usually needs root. Under the shipped unit, panics show up as Unknown unless the agent can read /sys/fs/pstore or /var/lib/systemd/pstore.

Offline behaviour ​

When the server can't be reached, or answers 401, 403, 404 or 429 (a fixable key, URL or rate-limit problem), reports are written to <state_dir>/spool/. They're sent oldest first once the server answers. Beyond max_spooled_reports, the oldest are dropped. Any other 4xx means the server will never accept that report, so it's dropped and logged.

Spooled metrics carry the device's uptime as their timestamp. The server stamps them with the time they arrive.

Not yet covered ​

  • Crash details (process, signal, backtrace) from systemd-coredump. Only the count is reported until the server gains a Linux crash record.
  • Image updates. Use RAUC or SWUpdate; #87 tracks driving them from ferrite campaigns.