DocsHow hosts are read

Host agent

View source on GitHubdocs/AGENT.md

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 collect interval, 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 on ack. After a reconnect the hub's welcome.last_seq says what it already holds; the rest is replayed oldest first at 10 batches per second.
  • Batch seq is 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 (or YARRIX_INSECURE=1 in 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 probe is 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) and X-Yarrix-Host-Id (the host id saved at enrollment, else the host_id setting; 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 line agent.auth_failed and 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/refused returns it; POST /api/v1/agents/refused/{id}/reenroll makes 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 .../dismiss hides 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:

  1. VERSION in 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.
  2. 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:

ReasonMeaning
disabled_locallyupdate: { allow: false } in this machine's agent.yaml. Only that file can change it.
containerThe agent runs from a container image (YARRIX_CONTAINER). Update the image instead.
unsupervisedNothing restarts it: not systemd (INVOCATION_ID), not launchd, not the Windows service, and YARRIX_SUPERVISED=1 is not set.
no_trusted_keyThe build carries no release key and update.trusted_keys is empty.
state_dir_not_writable, no_exec_state_dirThe state directory cannot hold a staged build, or is mounted noexec.
platform_unsupported, no_build_for_platformA 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_concurrent at 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 (state failed or rolled_back, any reason), automatic updates and "Update all" stop for that release (paused) and agent_update.paused is audited once. GET /api/v1/agents/updates names the hosts and their reasons in paused. 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 notify are still left alone). Because of the canary it starts one host first; the rest come back in skipped with 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.

CollectorSourceOn 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
smartsmartctl -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-rootwhen smartctl is on PATH and can read a disk
unraidmdcmd status, var.ini, disks.inion Unraid
dockerEngine API over the Unix socket (DOCKER_HOST, /var/run/docker.sock, ~/.docker/run/docker.sock) or \\.\pipe\docker_enginewhen 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}.

CollectorSourceReports
services (Linux)systemctl list-units --type=service --allevery failed unit, plus units matching services: globs
services (Windows)one fixed powershell.exe Get-CimInstance Win32_Service queryservices 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 battpower.{source, on_battery, battery_pct, sleep_disabled, sleep_minutes, display_sleep_minutes}
vpntailscale status --json (also the macOS app's CLI), WireGuard interfaces from /sys/class/net/*/ueventvpn.tailscale.{online, backend_state, ip}, vpn.wireguard[{iface, up}]; a key exists only when that VPN does
gpunvidia-smi --query-gpu, amdgpu sysfs counters, macOS ioreg IOAcceleratorgpu_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):

  • path is read only when it resolves, through symlinks and .., under one of the agent's allow_paths. The last 8 MiB of the file are counted. A path outside allow_paths is refused and logged (log source <id> refused: path not allowed by agent policy); the other sources still apply.
  • command is the name of a command in the agent's own commands:; 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 regex syntax: JavaScript's without look-around or backreferences. A pattern that does not compile refuses that source with the reason in the agent log. Named groups level and ts are 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 (path sources are refused); command sources 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#

metriclabelsunit
cpu_pctpercent busy over the last interval
load1 load5 load15load average
mem_total_bytes mem_used_bytes mem_used_pctLinux: total minus MemAvailable; macOS: active + wired + compressed
swap_total_bytes swap_used_bytes swap_used_pct
uptime_sseconds
disk_total_bytes disk_used_bytes disk_used_pctmountused / (used + available), as df
disk_read_bps disk_write_bps disk_read_iops disk_write_iopsdevper second
net_rx_bps net_tx_bpsifacebytes per second
temp_csensorLinux 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, join asks for an address at a terminal, or says to use --hub when there is no terminal. A PIN comes from the prompt, or from YARRIX_SETUP_PIN for a script.
  • --no-service writes the files and stops, for Synology, Unraid and other boot-task setups; it prints the command to start. The token then goes in enroll.token, as on macOS.
  • --replace-config connects a host that already has agent.yaml to a hub with a new PIN; the old file is kept as agent.yaml.bak. Without it join changes 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 DIR writes everything under DIR and starts nothing, to stage an install or try join without 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 stamps yarrix-agent-setup-windows-x64_hub-<base32>.exe with its own address, so the file's bytes and hash are the same for every hub.
  • yarrix agent redeem [--hub <address>]... reads the PIN from YARRIX_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 prints hub=<url> and token=<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 --plain prints 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.