DocsHow hosts are read
Host agent
On this page
yarrix agent is a single static binary that reports a host's metrics to the hub over an
outbound WebSocket. It needs no inbound port and, for the base metrics, no root.
Run#
yarrix-hub enroll create --host nas-1 # on the hub: prints a one-time token (no hosts/nas-1.yaml needed)
yarrix agent --hub https://yarrix.example.net --enroll-token <token>
The token is exchanged on first connect for a per-host token kept in the state directory
(state.json, mode 0600). After that the enrollment token is not needed. Settings can live in
agent.yaml (see deploy/agent.example.yaml); flags and YARRIX_* variables override the file.
Docker image, release downloads and the Synology/UGOS/Unraid recipes: install.md. Service templates: deploy/systemd/yarrix-agent.service and deploy/launchd/io.yarrix.agent.plist. On Windows the
agent registers itself: yarrix agent install | uninstall | start | stop, the yarrix-agent-setup-windows-x64.exe installer (an NSIS wrapper around that command), or deploy/windows/install-agent.ps1 for a
checksummed download and enrollment (see the Windows page).
Behaviour#
- Samples are taken on wall-clock ticks (a multiple of the hub's
collectinterval, at most 60 s), on their own thread, so a slow or absent hub never causes a gap. - Batches are appended to a capped on-disk ring (
buffer.jsonl, default 6 h / 8 MB) and removed onack. After a reconnect the hub'swelcome.last_seqsays what it already holds; the rest is replayed oldest first at 10 batches per second. - Batch
seqis the epoch time in milliseconds (kept strictly increasing), so it needs no stored counter and stays monotonic across restarts. - Reconnects back off 1 s, 2 s ... 30 s. A hub silent for 90 s is treated as lost.
ws://and unverified TLS need--insecure(orYARRIX_INSECURE=1in the environment, for a container) and are meant for development. A hub on plain http needs it; the agent refuses to start otherwise.- Probes run in two places: a relay agent runs the hub's SSH probe for its neighbours (Relay mode), and
yarrix probeis the pinned command on an agentless host (Forced-command mode). - Not implemented yet: permessage-deflate.
- Who is knocking. Every connection (enrolled or not) sends two request headers on the WebSocket upgrade:
X-Yarrix-Hostname(the machine's name) andX-Yarrix-Host-Id(the host id saved at enrollment, else thehost_idsetting; absent before the agent has one). They are claims, printable ASCII, at most 128 characters, and the hub never authenticates by them. The hub reads them before it looks at the bearer token, so even an agent it refuses (an unknown or revoked token, for example one enrolled with a different hub) is named in the log lineagent.auth_failedand listed under Refused agents on the Systems tab (admins), with the address (and its reverse-DNS name when one resolves), the attempt count and the first and last time seen. The list is capped at 200 rows, oldest first out, because nobody has to sign in to add to it.GET /api/v1/agents/refusedreturns it;POST /api/v1/agents/refused/{id}/reenrollmakes a one-time 24-hour enrollment token (shown once) for the host id in the request body and hides the row. The hostname is only a suggestion (suggested_host): the Systems tab asks the admin to confirm or edit the host id, and the hub refuses with 409 when that id is already a host, a config host, or has an enrollment or token, so an agent claiming an existing name cannot get that host taken over. And.../dismisshides it without one. A dismissed row stays hidden if the same agent keeps knocking.
Agent versions#
The hub shows, for every host that runs an agent, whether the agent is up to date: a flag on the Systems list and cards, a line in the host drawer, an Agents panel on the Overview, and the same data in the API (agent on each host, agents in GET /status) and in yarrix status. Yarrix never calls home, so "up to date" means "equal to the version this hub expects", decided from what the hub holds:
VERSIONin the folder you serve agent files from (YARRIX_AGENT_DIST). Release packaging writes it, so it names the exact agent files the hub hands out. It is re-read when it changes; drop in a newer release and the fleet view updates without a restart.- Otherwise the hub's own version. A release ships both in step.
Versions compare as semantic versions: 0.10.0 is newer than 0.9.0, and 1.0.0-rc.1 is older than 1.0.0. An agent can be current, outdated, ahead (newer than the hub expects, which is fine), or unknown (not reported yet, or not a version number). SSH hosts have no agent (none). A relayed host has none of its own either (relayed, with the relaying host in via); upgrade the relaying host's agent. The hub also records when each agent began running its current version (since), so "behind for 12 d" is on screen without a history to dig through.
An agent that is behind is information, not an alert: it never changes a host's state or the overall status, and yarrix status still exits by health alone.
Where a build's version comes from. A release build takes its version from the release tag: YARRIX_VERSION=v0.4.2 at build time is compiled into the agent (yarrix --version, the hello it sends, the update trial) and into the hub (/healthz, the API document, the MCP server). Release packaging does this for you (scripts/release/cut.sh <version>, which runs scripts/release/dry-run.sh). A build without it reports 0.0.0-dev, which is older than every release. The update trial only succeeds when the staged binary reports exactly the version it was staged as, so an agent built without the tag could never finish an update. scripts/release/test-self-update.sh proves the whole path on one machine: it builds two versions with a throwaway signing key, runs a hub and an agent from scratch directories, and checks that an update goes through and that a bad signature or altered binary is refused.
Updating the agent#
The hub can move an agent to the release it serves, over the agent's own connection. It decides when; it never decides what runs. The update message carries a version number and an id and nothing else. The agent fetches MANIFEST.json and MANIFEST.json.minisig from its own hub (/download/agent/), checks the signature against release keys it already trusts, checks the version is newer than the one it runs, checks its own binary's size and SHA-256 against the signed manifest, and only then stages it. A hub that has been taken over can at most move agents to a genuine, newer release. See Security.
Who is offered it. An agent says in its hello whether it can update itself (capabilities.self_update). If it cannot, the host shows why, and no update is sent:
| Reason | Meaning |
|---|---|
disabled_locally | update: { allow: false } in this machine's agent.yaml. Only that file can change it. |
container | The agent runs from a container image (YARRIX_CONTAINER). Update the image instead. |
unsupervised | Nothing restarts it: not systemd (INVOCATION_ID), not launchd, not the Windows service, and YARRIX_SUPERVISED=1 is not set. |
no_trusted_key | The build carries no release key and update.trusted_keys is empty. |
state_dir_not_writable, no_exec_state_dir | The state directory cannot hold a staged build, or is mounted noexec. |
platform_unsupported, no_build_for_platform | A platform the release does not cover. |
An agent from before this feature sends no self_update at all; it is too old to update itself and needs one reinstall.
Modes. agent_updates.mode is automatic, one_click (default) or notify. Set the hub default in yarrix.yaml and override it per host in hosts/<id>.yaml; a host's own value wins. It is a guarded field: changing it needs a signed-in admin, never an API token. notify shows the update and offers no button, and the API refuses with updates_notify_only. automatic adds the scheduler described below. agent_updates.max_concurrent (default 2) caps updates in flight, for automatic mode and "update all" alike.
What happens. Staging goes to <state_dir>/update/<version>/, which works under systemd ProtectSystem=strict. The installed binary stays untouched. On every start it checks the staged build again (signature, hash, size), and if it is still valid and newer it execs it. That first run of a new build is a trial: it is kept once it has been connected to the hub for 60 s, and given up after 300 s without a hub. A build that crashes at start is tried twice and then abandoned. Either way the agent runs the old version again and reports rolled_back with the reason (crashed_at_start, no_hub_in_trial). Nothing is asked for twice: a rolled-back build is not retried until someone asks again.
On Windows the service control manager runs one fixed path, so there is no launcher to exec the staged build. The service re-checks the staged build (signature, hash, size), copies it next to yarrix.exe and checks the copy again, renames yarrix.exe to yarrix.exe.prev, moves the copy into place and exits with service-specific code 75, which the service's recovery actions (restart after 5 s, 5 s, 30 s, also on non-crash failures) turn into a restart. The new yarrix.exe is on trial exactly as above: promoted after 60 s connected, and if it is started more than twice without being promoted, or never reaches the hub, it renames yarrix.exe.prev back (the failed file is parked as yarrix.exe.failed), exits the same way and reports rolled_back. The Windows service counts as supervised. When a build is promoted it writes its version into the Add/Remove Programs entry (DisplayVersion) if the setup.exe created one; a failure there is logged and never fails the update. A build that cannot start at all (a missing DLL, say) cannot roll itself back: there is no launcher, so that case needs a hand rename of yarrix.exe.prev. The same is true of a power cut or hard kill in the instant between the two renames of the swap: Windows will not let a running file be replaced in one step, so for that moment there is no yarrix.exe, and the service cannot start until yarrix.exe.prev is renamed back to yarrix.exe (in an elevated PowerShell: Move-Item "$env:ProgramFiles\yarrix\yarrix.exe.prev" "$env:ProgramFiles\yarrix\yarrix.exe"). Windows self-update has not run on a real Windows machine. The file swap and rollback are tested over temporary directories on Linux and macOS, and the service code is cross-compiled and linted only.
States. An attempt is queued (host offline, delivered at its next connection), updating, done, failed, rolled_back or cancelled. done comes from the agent's own report, or from a hello showing a newer version, or from a hello at the target version when no report arrives within 2 minutes. failed is decided 15 minutes after the agent started restarting without coming back (did_not_come_back) or reporting (no_result), or without ever answering (no_answer). A queued attempt can be cancelled; one that has been delivered cannot.
API. POST /api/v1/hosts/{id}/agent/update (admin; 202 with the attempt, or 409 with a code: updates_notify_only, self_update_unavailable with the agent's reason, agent_files_not_signed, agent_files_mismatch, hub_serves_no_agent_files, agent_too_old, already_current, update_in_progress), POST .../agent/update/cancel, GET .../agent/updates (history), and POST /api/v1/agents/update (every host that is behind and can update, up to max_concurrent; body {"hosts": [...]} narrows it). Each host carries agent.update (mode and where it came from, whether it can be updated now, the target, the block, manual steps with copyable commands for notify-only, container, unsupervised and too-old agents, and the latest attempt). An attempt's reason is a fixed code and reason_text says it in words; the drawer shows the words and keeps the code as a detail. An attempt for an older version than the current offer is shown as an earlier attempt, not as the state of the new offer. Requests, results and refusals are audited as agent_update.requested, .result and .refused. The host drawer has a one-click Update action.
Automatic updates. A host in automatic mode is updated by the hub without a click. About once a minute the scheduler looks at every connected host in that mode whose agent is behind and can update itself (the same checks as the button), and starts what the rules below allow. It sends the same version-only update an admin's click sends, so the agent's own signature check is still the gate (D-19). Everything it holds back is visible: agent.update.scheduled on the host carries a code and a sentence, and GET /api/v1/agents/updates (also agents.updates in GET /status) carries the hub-wide picture.
- Window.
agent_updates.window: { start: "02:00", end: "05:00" }(hub local time; a window that ends before it starts runs across midnight; equal ends mean all day; absent means any time). Outside it nothing is started automatically (waiting_for_window; the reason says when the window next opens, in hub local time). A host that is offline or asleep is picked up when it next connects inside the window (host_offline). Updates already running when the window closes finish. - At most
max_concurrentat once (default 2), counting every update in flight, however it was started (waiting_for_slot). The cap is also what spreads a fleet out over the window. - One canary first. The first host of a new release goes alone; the others start only once an update to that release has finished
done(waiting_for_canary). A hub restart does not forget it: the rule reads the attempt history. - Relaying hosts last. A host that relays for others (
collect.relay_for) waits until no other eligible host is left to start or still updating (relay_last), because a failed relay takes its relayed hosts with it. Hosts that are offline do not hold a relay back. - Circuit breaker. When two hosts have failed or rolled back to the same release (
statefailedorrolled_back, any reason), automatic updates and "Update all" stop for that release (paused) andagent_update.pausedis audited once.GET /api/v1/agents/updatesnames the hosts and their reasons inpaused.POST /api/v1/agents/update/resume(admin; harmless when nothing is paused) lets the rollout go on: a fresh canary is chosen, and two more failures after the resume are needed to pause it again. A new release is a new start; the pause belongs to the version it was raised on. A person can still start any single host by hand while it is paused. - Retries. A host that failed on a release is tried once more automatically six hours later (
retry_later); after a second failure it stays failed for that release (failed_for_version) until someone retries it. - "Update all" follows the same rules except that it ignores the window and each host's mode (hosts set to
notifyare still left alone). Because of the canary it starts one host first; the rest come back inskippedwith the reason, and clicking again after the first has finished starts the next ones. - Automatic mode never uses SSH: it would need stored credentials (D-23).
Reinstalling over SSH. An agent from before self-update, or one whose self-updates keep failing, cannot move itself. agent.update.ssh says when the reinstall is the way (why: agent_too_old, self_update_failing, hub_serves_no_agent_files) and is not affected by the mode: an admin asking for it is an explicit install. It is the "Install it for me" flow with mode: "upgrade" in POST /api/v1/ssh/install-agent: same login, host-key confirmation, sudo handling and download check, but the host must already be enrolled with an agent, no new enrollment token is minted, and agent.yaml, the token file and the agent's state are left alone. The new binary replaces the old, the service file is rewritten (the old one is kept as yarrix-agent.service.bak, or io.yarrix.agent.plist.bak), anything a failed self-update staged under /var/lib/yarrix/update is removed, and the service is restarted. The hub then waits up to 90 seconds for the agent to say hello on the new version and answers reconnected. It is recorded as an attempt with channel: "ssh" that ends done at that hello (or failed after 15 minutes without one) and as an agent_install audit row with mode: "upgrade". A self-update that is queued is cancelled; one in progress blocks it (update_in_progress). Linux with systemd and macOS with launchd, as for install; the launchd path has run only against stubs.
Release keys. Releases are signed with minisign. The public keys an agent trusts are a build-time input: set YARRIX_RELEASE_PUBKEYS to the key line(s) from minisign.pub (the base64 line starting RW, separated by whitespace or commas) when building. A build from source without it can still update itself if you add the key to its own agent.yaml: update: { trusted_keys: [/etc/yarrix/release.pub] }. That list adds signing keys for that host alone: anything signed by one of them will be installed there. Writing agent.yaml already needs root on the host, which is the trust boundary; the hub has no say over it. The hub checks the same signature before it offers anything, against the built-in release key by default; set YARRIX_RELEASE_PUBKEYS on the hub only to replace that key (for a build signed with your own key). A manifest whose signature does not verify is not offered (agent_files_not_signed). No private key is ever committed. The project's own public key is committed as minisign.pub at the repo root, and a release build bakes it into the agents and the hub image as YARRIX_RELEASE_PUBKEYS (scripts/release/pubkey.sh prints the key line). Releases are built, signed and published from a maintainer's own machine with scripts/release/cut.sh (RELEASING.md); no CI runner builds or signs anything, so the private key never leaves that machine (MINISIGN_SECRET_KEY=<secret key file>, and MINISIGN_PASSWORD if the key has one, otherwise minisign asks). Before signing, scripts/release/sign-release.sh --local checks that every release file matches SHA256SUMS (none missing, none unlisted, MANIFEST.json, read as JSON with jq, agreeing on hashes and sizes); any failure exits non-zero with nothing signed (see docs/security.md). It then signs the two files and checks both signatures against minisign.pub, so a wrong key stops the release before anything is uploaded. cut.sh uploads the files and signatures to a draft GitHub release, which a maintainer publishes by hand. Until then no hub offers the release. To sign your own builds, make a key with minisign -G, build with YARRIX_RELEASE_PUBKEYS set to its public key line, run scripts/release/package.sh <dir> with MINISIGN_SECRET_KEY set (it signs when a key is given), and use MINISIGN_PUBKEY_FILE=<your .pub> with sign-release.sh if you publish through GitHub releases. To check a download by hand: minisign -Vm SHA256SUMS -p minisign.pub. scripts/release/test-package.sh proves the signing steps, including that a wrong key is refused, with throwaway keys.
Storage and container collectors#
Run on their own thread every 15 to 60 s, so a slow tool never opens a gap in the base series.
| Collector | Source | On by default |
|---|---|---|
mdraid | /proc/mdstat, /sys/block/md*/md/ | yes, when the md driver is loaded |
nas_info | /etc.defaults/VERSION (DSM), /etc/unraid-version or the mounted var.ini (Unraid), /etc/os-release (UGOS: its OS_VERSION key, or the id/name when it names itself) | yes |
smart | smartctl -j -H -A -n standby per device, at most every 5 min; a sleeping disk keeps its last reading. At start the agent tries once to read a disk; if none can be opened (no permission) or none gives SMART data, the smart capability is unknown with smartctl's own reason and nothing more is run. If every disk answered "asleep" and none has returned data yet it is unknown ("all N disks asleep; SMART not read yet") until one does. The agent reads CapEff from /proc/self/status: without CAP_SYS_RAWIO a SATA disk's "asleep" answer is not believed (the kernel refuses the power check and smartctl reports an awake disk as asleep), and without CAP_SYS_ADMIN an NVMe drive cannot be read, so the capability is unknown with "needs root and SYS_RAWIO to read SMART (see docs)" or "needs root and SYS_ADMIN to read NVMe SMART (see docs)". In a container that means opting in to running as root with SYS_RAWIO (and SYS_ADMIN for NVMe), as the deploy files and the Unraid and UGOS pages describe; the default stays non-root | when smartctl is on PATH and can read a disk |
unraid | mdcmd status, var.ini, disks.ini | on Unraid |
docker | Engine API over the Unix socket (DOCKER_HOST, /var/run/docker.sock, ~/.docker/run/docker.sock) or \\.\pipe\docker_engine | when a socket is found |
With a collectors: list in agent.yaml (or the host's collect.collectors in the hub), only the listed extras
(smart, unraid, docker) run. hello.os is unraid, synology or ugos when nas_info recognises the box.
The version is OS_VERSION from a real UGOS Pro file, or unknown when only a marker directory identifies it.
The inventory message goes out after welcome, when it changes, and every 5 minutes. Besides the base fields it
carries optional md, unraid, dsm and ugos, in the same shape the SSH collector stores in facts, so the hub reads
an agent host and an agentless host alike. Samples use the SSH collector's names: md_degraded, md_active_disks,
md_sync_pct, smart_ok, smart_temp_c, smart_power_on_hours, smart_reallocated, unraid_*.
The Windows named pipe is covered by unit tests of its name handling only; it has not run against a Windows engine.
Services, power, VPN, GPU and sleep#
More extras on the same thread and the same collectors: rules (services, power, vpn, gpu). Each is a
pure parser over captured command output, tested against crates/yarrix/tests/fixtures/{services,power,vpn,gpu}.
| Collector | Source | Reports |
|---|---|---|
services (Linux) | systemctl list-units --type=service --all | every failed unit, plus units matching services: globs |
services (Windows) | one fixed powershell.exe Get-CimInstance Win32_Service query | services whose name or display name matches a services: glob (case-insensitive); failed is "start mode Automatic and not Running" |
services (macOS) | launchctl list, then launchctl print <domain>/<label> per match (system as root, gui/<uid> otherwise) | jobs matching services: globs; failed is "not running and last exit code non-zero (or killed by a signal)" |
power (macOS) | pmset -g, pmset -g batt | power.{source, on_battery, battery_pct, sleep_disabled, sleep_minutes, display_sleep_minutes} |
vpn | tailscale status --json (also the macOS app's CLI), WireGuard interfaces from /sys/class/net/*/uevent | vpn.tailscale.{online, backend_state, ip}, vpn.wireguard[{iface, up}]; a key exists only when that VPN does |
gpu | nvidia-smi --query-gpu, amdgpu sysfs counters, macOS ioreg IOAccelerator | gpu_pct, gpu_mem_used_pct, gpu_temp_c (the busiest card) and inventory.gpu[] per card |
services: in agent.yaml lists the globs (io.example.*). The hub's asserts read the facts:
asserts:
- { fact: services.failed, matching: ["io.example.*"], max: 0 } # reason: "services.failed io.example.sync is 1, above max 0"
- { fact: power.sleep_disabled, equals: true, severity: warn }
- { fact: vpn.tailscale.online, equals: true }
The macOS SSH probe sends pmset -g too and stores the same power keys, so one assert reads an agent host and an
agentless host. A machine that lacks a source (no VPN, no GPU counter) reports no fact and an unknown capability with
the reason; an assert on it reads "can't evaluate", never a failure. Launchd job labels come from the machine's
agent.yaml only: the hub does not push globs to the agent.
Sleep. The agent sends sleep when the OS says it is about to sleep and wake after, so the host shows asleep
with its last-seen time (never red, no incident) instead of unreachable. macOS registers with IOKit
(IORegisterForSystemPower) and holds the sleep (IOAllowPowerChange) for up to 3 s until the message is on the wire;
Linux reads logind's PrepareForSleep from dbus-monitor and holds a systemd-inhibit delay lock the same way; each
signal's sender must equal logind's unique bus name, looked up with GetNameOwner (dbus-send) when the signal arrives
(if the lookup fails the signal is dropped; without dbus-send the bus-assigned unique name alone is accepted, the bus
having already limited delivery to logind). Windows receives SERVICE_CONTROL_POWEREVENT in the service and holds the
suspend for up to 1.5 s. Without
dbus-monitor and systemd-inhibit (or outside the Windows service), capabilities.sleep_detection is unknown and the host's asleep_policy applies.
The hub keeps a connection that said sleep from flipping the host back to up on the frames sent just before the
machine suspends; a wake, or a new hello, returns it to up.
Relay mode#
The relay runs the system's ssh, so its machine needs an OpenSSH client. The agent container image is empty (one binary, no ssh) and cannot relay; run the agent binary on a host that has ssh.
An agent can also pull hosts the hub cannot reach (a NAS on a LAN with no route from the hub). List them on the relay host in the hub config:
# hosts/relay-1.yaml
id: relay-1
collect: { method: agent, relay_for: [nas-2] }
---
# hosts/nas-2.yaml (separate file)
id: nas-2
collect: { method: relay }
The hub's config message then carries relay_targets: {host_id, target?, os?, sudo?} per host and
nothing else. The relay agent runs its own ssh for each target every collect interval (at least 15 s)
with its own keys and ssh config, and sends the raw probe output back in a relay message; the hub
parses it exactly as it does an agentless poll. The target shows as method: relay with
facts.via: "relay". A failed pull shows the target unreachable with the relay named. While the hub
is away, pulls are dropped rather than buffered (each is a snapshot).
Where the address and key live is the relay's business:
# /etc/yarrix/agent.yaml on the relay
relay:
ssh_config: /etc/yarrix/relay_ssh_config # -F; absent means ~/.ssh/config
identity_file: /etc/yarrix/relay_key # -i; optional
# /etc/yarrix/relay_ssh_config
Host nas-2
HostName 192.168.10.20
User mon
If the hub gives a target (ssh.target: mon@192.168.10.20 on the host) the agent uses it; otherwise it
passes the host id to ssh as an alias for this file to resolve, so the hub never needs the address.
Host keys are trusted on first use into <state_dir>/relay_known_hosts.
What a relay will not do (decision D-04). It runs only the three probe scripts compiled into its
binary (probes/linux.sh, macos.sh, windows.ps1), chosen by the target's OS. A relay_targets
entry with any other field (a script, command, ssh options, anything) is rejected as a whole, logged
(relay target <id> refused ...) and skipped; the other entries in the same message still apply.
target must be user@host[:port] and cannot begin with -. A relay does not carry hub-defined
custom checks; for a relay-only host, define named commands in the relay's own agent.yaml.
Log error-rate#
A kind: log check with run_on: agent:<host> is read by that host's agent, not by the hub. The hub puts one
entry per such check in the logs list of the agent's config (id is the check id, host:app; plus
path or command, format, line_pattern, error_levels, error_pattern, window_s, every_s). The
agent reads the log, counts lines and errors over the window the same way the hub does, and sends
log_stats with source_id equal to that id, at least every every_s (15 to 300 s). The hub turns the
newest report into the check: errors_24h is the error count, and a log where no line parses reads
unknown — log not parseable, never 0. No report for three intervals (or 3 minutes) reads unknown.
The hub only names a source; the agent decides what it may read (decision D-04):
pathis read only when it resolves, through symlinks and.., under one of the agent'sallow_paths. The last 8 MiB of the file are counted. A path outsideallow_pathsis refused and logged (log source <id> refused: path not allowed by agent policy); the other sources still apply.commandis the name of a command in the agent's owncommands:; the agent runs its argv and counts its stdout (first 64 KiB). The hub cannot supply an argv. An entry with any field beyond those above is refused.- Patterns are Rust
regexsyntax: JavaScript's without look-around or backreferences. A pattern that does not compile refuses that source with the reason in the agent log. Named groupslevelandtsare read as on the hub; a date-time with no offset is read as the agent's local time. - A source that cannot be read (missing file, failing command) sends nothing and is logged once per reason;
the hub then shows
unknown. - The Windows agent does not read log files yet (
pathsources are refused);commandsources work.
The hub sends the config again to every connected agent whenever the config it would send changes, so a new log check starts being read without restarting the agent.
Forced-command mode (agentless, hardened)#
For a pull-only host, install yarrix there and pin the monitoring key to the probe in
~/.ssh/authorized_keys of the monitoring account:
command="/usr/local/bin/yarrix probe",restrict ssh-ed25519 AAAA... yarrix-hub
Whatever the hub (or anyone holding the key) asks for, sshd runs yarrix probe, which prints the same
sectioned output as the hub's piped probe, so polling works with no change on the hub (os: auto
detects it from the output). yarrix probe never reads the client's command (SSH_ORIGINAL_COMMAND) or
stdin. Root-only reads stay off unless the pinned line says yarrix probe --sudo (and the account has the
NOPASSWD rule); the hub's ssh.sudo cannot switch it on. Custom checks cannot run on such a key and say
so ("key is limited to the probe"); use a second, unrestricted key for them if you want both. Linux and
macOS today; Windows OpenSSH forced-command support has not been verified.
Base metrics#
| metric | labels | unit |
|---|---|---|
cpu_pct | percent busy over the last interval | |
load1 load5 load15 | load average | |
mem_total_bytes mem_used_bytes mem_used_pct | Linux: total minus MemAvailable; macOS: active + wired + compressed | |
swap_total_bytes swap_used_bytes swap_used_pct | ||
uptime_s | seconds | |
disk_total_bytes disk_used_bytes disk_used_pct | mount | used / (used + available), as df |
disk_read_bps disk_write_bps disk_read_iops disk_write_iops | dev | per second |
net_rx_bps net_tx_bps | iface | bytes per second |
temp_c | sensor | Linux only for now (hwmon, then thermal zones) |
hello.capabilities lists every metric above. A platform that cannot provide one reports
unknown with a reason (macOS temp_c) instead of omitting it.
Join with a PIN (finding the hub)#
An agent finds the hub on its own network, so a person types only the PIN. The hub shows the PIN under Add a host.
yarrix agent discover lists what is found: it asks for _yarrix._tcp over mDNS and probes the default port (7790) on the
host's own private subnets (/22 or smaller, at most 4 seconds, never in the background). The probe is what finds a hub behind
Docker or NAS bridge networking, where multicast does not cross. --no-scan (or YARRIX_DISCOVER=mdns) turns the probe
off, --probe-port N moves it, and --json or --plain change the output. It exits 1 when nothing is found.
Discovery only names hubs. The PIN goes to every hub found through the PIN exchange (SPAKE2), so only the hub that issued it accepts it, and a hub that did not issue it learns nothing. With several hubs on the network the PIN still picks the right one. Each hub that is tried and is not the right one counts the try as one wrong guess. A hub that advertised https is skipped, not offered over plain http, when its https address fails a check. A hub that cannot be found (another network or VLAN, or a network that blocks multicast and the probe) is reached by typing its address, which always works.
Linux and macOS: sudo yarrix agent join. It finds the hub, asks for the PIN at the terminal, trades it for an enrollment
token, and then does what the by-hand recipes do: copies itself to /usr/local/bin/yarrix, writes /etc/yarrix/agent.yaml
(with insecure: true when the hub address is plain http), and installs and starts the service from the files under
deploy/. On Linux that is the systemd unit, with the token in the root-only /etc/yarrix/agent.env, which join deletes
once the hub has accepted the host. On macOS it is the LaunchDaemon, with the token in /etc/yarrix/enroll.token, which the
agent deletes itself.
--hub ADDRESS(repeatable) skips the search. With no hub found,joinasks for an address at a terminal, or says to use--hubwhen there is no terminal. A PIN comes from the prompt, or fromYARRIX_SETUP_PINfor a script.--no-servicewrites the files and stops, for Synology, Unraid and other boot-task setups; it prints the command to start. The token then goes inenroll.token, as on macOS.--replace-configconnects a host that already hasagent.yamlto a hub with a new PIN; the old file is kept asagent.yaml.bak. Without itjoinchanges nothing and exits 3, before the PIN is used.--host-id ID,--no-wait(do not wait for the hub to accept the host),--probe-port N.--root DIRwrites everything underDIRand starts nothing, to stage an install or tryjoinwithout root.- Exit codes: 0 done, 1 failed (wrong PIN, no hub), 2 needs root or systemd, or no PIN given, 3 settings already exist, 5 installed but the hub had not accepted the host in 60 seconds.
Containers. With YARRIX_SETUP_PIN set and no YARRIX_HUB, no token and no saved identity, the agent does the same
search and exchange once at its first start, and saves the hub (and the token until the hub has accepted the host) in
join.json in the state directory, so a restart never needs the PIN again. Run the container with network_mode: host
(the recipes do) so mDNS reaches it. Set YARRIX_HUB as well when the hub is on another network: the PIN is then tried against
that hub alone. If no hub is found the agent logs that and searches again every 30 seconds. If the hub refuses the PIN it logs
which hubs it tried and waits instead of trying again, so a restart loop never uses up the hub's five tries. A hub found by
the search is plain http in most homes; the agent then connects with insecure: true, as join does.
Windows enrollment by PIN#
yarrix.exe has two subcommands for the Windows setup.exe. Neither needs Windows; both run anywhere the binary does.
yarrix agent installer-hub <setup file name>prints the hub address the file was stamped with, or exits 1 when the name has no stamp. The hub stampsyarrix-agent-setup-windows-x64_hub-<base32>.exewith its own address, so the file's bytes and hash are the same for every hub.yarrix agent redeem [--hub <address>]...reads the PIN fromYARRIX_INSTALL_PASSCODE(never the command line) and exchanges it for an enrollment token. With several--hub, or none (it then finds the hubs first), the hub that issued the PIN is the one that accepts it. On success it printshub=<url>andtoken=<yxe_...>. On failure it prints one line (wrong, used or expired; too many tries; or cannot reach the hub) and exits 1, or 3 when no hub was found and 4 when the hub could not be reached (the PIN was not used in either case).yarrix agent discover --plainprints the number of hubs that take a PIN, then<address><TAB><name>for each.
The hub side is the PIN exchange, POST /api/v1/agent/pin/start then POST /api/v1/agent/pin/finish (unauthenticated, SPAKE2 per
RFC 9382, so the PIN never crosses the wire), and the admin's POST /api/v1/enrollments/passcode (session only, returns the PIN
once). A PIN is six digits shown as NNN-NNN (458-283); the hub accepts it with or without the dash or spaces. It is bound to one
host, valid 15 minutes and used at most once. The hub keeps a SHA-256 of the six digits and the SPAKE2 scalar derived from them
(pake_w, migration 21). The SHA-256 was never hard to reverse, six digits being a million values, and pake_w adds no exposure
beyond it: a copy of the hub database lets someone check a live PIN offline, so keep the database private.
A hub without the exchange cannot enroll an agent: the agent stops with an error and does not send the PIN another way. Because there are only a million PINs, guesses are limited. Each start counts as a guess: five unfinished starts from one address lock that address for 15 minutes, and 30 starts from any addresses in 15 minutes burn every unused PIN. The counts survive a hub restart.
The hub also serves a public page, GET /connect, with no secret in it. On the Windows PC, open http://<hub address>/connect,
download the installer (stamped with the hub's own address) and run it; type the PIN from the web UI when it asks. If the hub has
no admin yet the page says so. The plain installer from GitHub works too: it finds the hub itself.