v0.1.4
-
Publish multi-arch (
linux/amd64+linux/arm64) container images to
ghcr.io/zyvorai/device-agenton every tagged release, keyless-signed
with cosign and attested for build provenance, alongside the existing
.deb/.rpm/tarball artifacts — newcontainerjob in
.github/workflows/release.yml.ci.yml's existing per-push build+smoke-test
job (--load, local only) is unchanged; this is the first job that
actually publishes an artifact. Adds a systemd-supervised alternative to
the bare-metal deployment path (packaging/container/zyvor-device-agent-container.service,
Podman-based) and a new tutorial,
docs/guides/07-container-deployment.md, covering device/bus passthrough
flags, config/state volumes, and running it under systemd — aimed at
small ARM64 edge devices where an on-device Rust/Node build is
impractical and the amd64-only.deb/.rpmpackages don't apply yet. -
Add a numbered
docs/guides/tutorial series (getting started;
configuration and the API; securing the agent; industrial buses; writing
a sensor plugin; deploying to production) and a README banner/badge
header, replacing the plain-text architecture diagram with an SVG in the
same visual family as the new banner. Docs only — no behavior change.
Every command, endpoint, config key, and CLI flag referenced in the new
guides was cross-checked against the current source
(src/api.rs's route table,config/device-agent.example.toml,
scripts/deploy-remote.sh's flag parsing) rather than written from
memory. -
Add optional Linux hotplug event source:
--features hotplug(off by
default) opens a rawNETLINK_KOBJECT_UEVENTsocket (netlink-sys, pure
Rust) alongside the existing polling inventory refresh, and a kernel
uevent for a tracked bus subsystem (gpio/i2c/spidev/net/usb/
tty) triggers an immediatehardware::collect_inventory+
state.update_inventoryre-scan instead of waiting for the next poll
tick - reusing the existing SSE hardware-change stream and threshold
evaluation as-is. Deliberately notudev/tokio-udev: those need
libudev.soat runtime, which the container image/.deb/.rpmdon't
otherwise depend on. Opening the socket is never fatal - a warning is
logged once and the daemon falls back to polling-only. New
src/hardware/hotplug.rs;rust-native's CI job gains a second
clippy/test/build pass with--features hotplug(no extra system
library needed, unliketpm2, so it didn't need its own job). Verified
for real: sent a synthetic but correctly-formatted uevent over the real
netlink multicast group and confirmed the daemon's inventory-refresh
timestamp jumps immediately (well under the poll interval, which was
set to 300s for the test) for a tracked subsystem (i2c) and does not
move for an untracked one (cpu), proving both the trigger and the
filter are real, not just compiled. -
Add optional TPM2-backed mTLS identity:
identity.backend = "tpm"
(--features tpm2, off by default, must never affect a plaincargo build) generates and signs the mTLS private key inside a TPM2 via
tss-esapi'sTransientKeyContextinstead of a PKCS#8 file on disk -
what's persisted toauth.mtls.key_fileis a small JSON envelope (the
TPM's public key plus an encrypted private-key blob only that TPM can
unwrap), and every signature (CSR at enroll time, every TLS handshake
while serving) re-opens a TPM session rather than loading a private key
into process memory. Falls back to a software key at runtime (with a
warning) if the TPM can't be opened, since most dev/test boxes have none.
Fixed to ECC P-256/ECDSA-SHA256, the combination every TPM2 chip and
swtpmsupport well. Newsrc/identitymodule:DeviceIdentitywraps
either backend behind the samercgen::SigningKey(CSR generation) and
rustls::sign::SigningKey(TLS handshake signing) interfaces the
existing enroll/mtls code already used, so neither needed a rewrite -
only a backend behind them changed. CI gains a dedicatedrust-tpm2job
(installslibtss2-dev, builds/clippies/tests with--features tpm2);
rust-native's clippy step drops--all-featuresso it no longer
silently depends on that job's system library. Live-verified against
swtpm(the TPM2 emulator used where no physical TPM is available):
ranenrollfor real withidentity.backend = "tpm", confirmed the
persisted key file is the JSON envelope (not a PKCS#8 key) andidentity
reportsbackend: tpm, then startedserveinauth.mode = "mtls"and
confirmed a real mTLS handshake succeeds - i.e. the TLS signature the
emulated TPM produced verifies against the certificate's public key. See
docs/TPM2_IDENTITY.md. -
Add
auth.mode = "mtls"andzyvor-device-agent enroll/identity:
client-side enrollment generates a keypair and CSR, submits them to
enrollment.server_urlwith a single-use token, and persists the issued
certificate/key.servethen terminates TLS itself (viaaxum-server's
rustls integration) using that identity and, when
auth.mtls.require_client_cert = true, rejects any connection without a
client certificate signed byclient_ca_fileat the handshake. Device
Agent implements only this client side of the protocol - Fleet's
enrollment-token system today issues an opaque bearer token, not a signed
certificate, and a production CA (key custody, revocation, rotation) is a
separate, security-sensitive project of its own; see
docs/MTLS_ENROLLMENT.md. New dependencies:rcgen(CSR generation),
reqwest(rustls-backed, for submitting the CSR),axum-server+rustls
(serving mTLS),rustls-pemfileandx509-parser(reading the resulting
identity back foridentity). Automatic rotation and CRL/OCSP revocation
are explicit non-goals for this milestone - reissue manually with
enroll --force. TPM2/secure-element-backed key storage is a separate,
optional follow-up. -
Add signed
.deb/.rpmpackages (amd64) to the release pipeline, built
viacargo-deb/cargo-generate-rpmfrom new[package.metadata.deb]/
[package.metadata.generate-rpm]tables inCargo.toml(both fully
Cargo-metadata-driven, no separate packaging manifest). Asset layout
mirrorsscripts/install.sh. Installing the package never enables or
starts the systemd unit —cargo-deb'ssystemd-unitsintegration is
configured withenable = false/start = false, and the generated
.debcarries no maintainer scripts at all as a result; the rpm's only
scriptlet issystemctl daemon-reload./etc/zyvor/device-agent.toml
is a conffile (dpkg) /%config(noreplace)(rpm), so a locally-modified
config survives both an upgrade and a straight reinstall, and
dpkg -r/rpm -eleave it and the profiles/plugin manifests on disk —
all verified for real: built both packages on the reference Linux host,
dpkg -i'd the.debon top of an already-running manually-deployed
instance (confirmed--force-confold's "Keeping old config file as
default" preserves an operator edit), thendpkg -r'd it and confirmed
the config/profiles remained; the.rpmwas verified via an isolated
rpm --rootinstall (file layout, permissions, no enable/start
scriptlet) since this host runs a Debian-family package manager, not
rpm, day to day. arm64 packages aren't built yet — cross-packaging
wasn't validated in this pass, so it's tracked as a follow-up rather
than blocking amd64 on it. -
Add config hot-reload:
SIGHUPre-reads the config file and applies
auth.*,thresholds.*,plugins.*,fleet.*and the parts of
industrial.*/nodra.*read fresh per-request/tick, without a restart.
server.listen/unix_socket/dashboard_dir(bound once at startup) and
the Nodra MQTT connection/CAN-capture socket set (opened once at their own
startup) still need a restart —SIGHUPlogs a clear warning when one of
those changed. A config file that fails to parse is logged and ignored,
keeping the daemon on its last-known-good config.AppState.configmoves
from a plain field to anarc_swap::ArcSwap<Config>to make this possible. -
Add opt-in
plugins.seccomp_enabled(Linux only): installs a seccomp-bpf
denylist in the plugin subprocess (ptrace,mount/umount2/
pivot_root,reboot/kexec_load, module loading/unloading,acct,
swapon/swapoff,bpf,perf_event_open,keyctl/add_key/
request_key,setns,unshare→EPERM, everything else allowed) on
top of the existing identity drop and rlimits, as defense-in-depth against
a compromised or malicious plugin binary. -
Add bearer-token API auth (
auth.mode = "bearer"); every route except
/api/v1/healthis unauthenticated by default (mode = "none") — same as before. -
Add an additional Unix-domain-socket API listener with kernel peer-credential
(uid/gid) RBAC, alongside the existing TCP listener. -
Add opt-in plugin privilege drop (
plugins.run_as_uid/run_as_gid) so sensor
plugin subprocesses no longer have to inherit the daemon's root identity. -
scripts/deploy-remote.sh --auth-mode bearergenerates and installs a bearer
token the same way../fabrichandles its admin password; refuses to bind a
non-loopback address with no auth configured. -
Add
docs/HARDWARE_PERMISSIONS.mdcovering GPIO/I2C/SPI/CAN device-node
permissions for dropped-privilege plugins. -
CI: the
containerjob now actually boots the built amd64 and arm64 (via QEMU)
images and checks--version/doctorrun correctly, instead of only
cross-building them. -
Release pipeline: generate CycloneDX SBOMs (Rust + dashboard), sign checksums
and SBOMs with keylesscosign, and attach a SLSA build-provenance attestation. -
Add opt-in plugin hardening:
allowed_owners/allowed_directoriescommand
allowlists, andmax_memory_bytes/max_cpu_seconds/max_processesrlimits. -
Fix: the bundled dashboard now supports bearer auth (token entry UI, sends
Authorization: Beareron every request, and a scoped?token=fallback for
the two SSE streams) — previously turning onauth.mode = "bearer"silently
broke the dashboard with no way to authenticate. -
Fix: the dashboard's own static shell (
index.html, JS, CSS) is now exempt
from bearer auth — it was previously gated along with the API, which meant
the browser couldn't even load the app far enough to show the token prompt
above. Only/api/*and/metricsrequire auth; the shell carries no data. -
Add graceful shutdown: SIGINT/SIGTERM now drain in-flight HTTP/SSE
connections on both the TCP and Unix-socket listeners before exiting, and
stop the inventory-refresh, plugin-scheduler, CAN-capture and Nodra-publisher
background loops cleanly instead of just dropping them. systemd unit gains
TimeoutStopSec=15to give the drain time to finish before SIGKILL. -
Add
GET /api/v1/ready: readiness (is the background inventory refresh loop
still ticking?), distinct from/api/v1/health(pure liveness) and
/api/v1/doctor(deep hardware diagnostics). Exempt from auth by default,
alongside/api/v1/health, so probes never need a token. -
scripts/deploy-remote.sh --auth-mode bearernow also prints a ready-to-paste
Prometheus scrape-config snippet for/metrics, which requires the same
bearer token. -
Add opt-in CORS (
server.cors, off by default) and rate limiting
(server.rate_limit, on by default with generous per-IP limits) to the TCP
listener. Neither applies to the Unix-socket listener. -
Container image now runs as a non-root
zyvoruser by default and adds a
HEALTHCHECKagainst/api/v1/ready; real GPIO/I2C/CAN bus access still
goes through the systemd deployment, which intentionally stays root. -
Fix: the
containerCI job's smoke test passed the entrypoint binary's own
path as a CLI argument to itself (docker run <image> /usr/bin/zyvor-device-agent --versionwhen the image'sENTRYPOINTis already that binary), so it had
been failing since it was added in this release and was never actually green. -
CI:
rust-nativenow runscargo fmt --all -- --checkand
cargo clippy --all-features -- -D warnings(previouslycargo clippy --all-targetsalone, which didn't fail the build on warnings). -
Add ESLint (flat config, typescript-eslint + react-hooks) to the dashboard,
wired into CI; also adds Prettier as an availablenpm run format(not yet
enforced in CI — the existing code predates it and a full reformat is a
separate decision). -
Add
dockerto.github/dependabot.ymlso theDockerfile's base images
get automated update PRs too. -
Release notes now surface the matching
CHANGELOG.mdsection as the GitHub
Release body, instead of only the file list. -
Add opt-in configurable health thresholds (
[thresholds]): thermal zones
and SocketCAN controller error counters are checked once per inventory
refresh, emittingthreshold.breached/threshold.recoveredon the SSE
event stream (and the Nodra agent-event topic, if enabled) only on the
edge transition. -
Fix:
src/plugins.rshad an unusedstd::os::unix::process::CommandExt
import on Linux (tokio::process::Command::pre_execdoesn't need it) that
CI'scargo clippynever actually caught until-D warningswas added in
this release — and that only Linux CI can catch at all, since the import
is#[cfg(target_os = "linux")]-gated and this session's local clippy
verification runs on macOS. -
Split
src/main.rsinto asrc/lib.rslibrary crate (re-exportingapi,
auth,config,hardware,integrations,model,plugins,profile,
state) plus a thin binary, sotests/can build the realRouterand
drive it end-to-end viatower::ServiceExt::oneshot- previously every
module was private to the binary target and unreachable from outside it. -
Add unit tests for
plugins::validate()(owner/directory allowlists,
world-writable/non-executable/non-absolute rejection) and the plugin
hardening config-gating logic,auth::check_bearer(missing/wrong/correct
token, non-Bearer scheme, unconfigured-hash fail-closed), and
auth::uds::peer_is_allowed(uid/gid allow-list matching, the empty-list
deny-everyone default) - previously all at zero coverage despite being the
highest-risk code (external process execution, network auth). -
Add
tests/api_auth.rs: the first true HTTP-level integration test,
proving the auth middleware wiring works end-to-end against the real
Router(health/dashboard-shell reachable without a token, inventory
correctly gated in bearer mode) rather than only unit-testing each piece. -
Add
docs/QUICKSTART.md(packaged/systemd install, bearer auth, dashboard
token connection, Prometheus scrape config, end to end) and
docs/observability/(a Prometheus scrape-config example and a starter
Grafana dashboard built from the real/metricsmetric names). -
docs/ROADMAP.md: movev0.4(OTA executor) andv0.5(Edge AI bridge)
into a "Later / not yet scoped" section, resolving a contradiction with
docs/BACKLOG.md's "explicitly outside Device Agent v0.x" list (which
disclaimed both) — neither doc change implements anything, just removes
the disagreement between them. Markv0.2's Unix-socket/RBAC, plugin
privilege separation and health-threshold lines done. -
docs/BACKLOG.md: check off everything shipped this release; split the
bearer/mTLS P1 line now that bearer is done and mTLS is separately
scoped; add previously-untrackedseccompprofiles (mentioned only in
docs/ARCHITECTURE.mduntil now) and config hot-reload as open items.