On your sites and servers

Server agent

Current version v1.8.0

A shell script that reads a handful of numbers about a Linux server and posts them to CheckRoost once a minute. That is the whole program.

Read checkroost-agent.sh before you install it. It is about 150 lines and that is the point — you are being asked to put someone else’s code on your production server, and you should be able to see exactly what it does in the time it takes to drink a coffee. If we ever ship a compiled binary instead, it will be because we have earned that, not because it was convenient.

What it is

One POSIX shell script, one systemd service, one systemd timer. The timer wakes the service every 60 seconds; the service takes a reading, makes a single outbound HTTPS request, and exits. Nothing stays resident, nothing listens, and nothing runs as root.

Because the connection is outbound only, the agent works behind NAT and needs no firewall change and no inbound port. If it cannot reach us, the reading is dropped and the next one is attempted a minute later — CheckRoost notices the gap itself, so a server that stops reporting raises the same kind of incident as one reporting bad news.

What it collects

ReadingWhy
Hostname, OS, kernel, uptime, core countIdentification and context
Load average — 1, 5 and 15 minuteIs it busy
Memory and swap used, as a percentageIs it under pressure
Every real filesystem: space used, inodes usedThe one that takes sites down
Pending rebootFrom /var/run/reboot-required, or needs-restarting -r
Outstanding updates — counts, and which packagesRead from the package cache your system already refreshes. Once a day, not every minute — see below.
Failed systemd unitsSomething stopped and nobody noticed
Installed packages — source names and versionsOnce a day, with the update scan (1.7.0), so they can be matched against your distribution’s own security notices.
Security settings (1.8.0)Derived facts for the server’s security scorecard — below. Never a file’s contents.

The security settings it reads

FactFrom
TCP ports listening on a public address/proc/net/tcp and tcp6. Loopback and private addresses are left out.
SSH: running, password logins, root loginsshd_config and its Includes, read the way sshd reads them — the first value wins, and a Match block that turns passwords on is reported. Each value is sent as one word. A file sshd reads that this account cannot — DigitalOcean’s 50-cloud-init.conf is 0600 — makes the reading incomplete, and those settings are then not scored.
Firewalls runningufw (its setting and its unit), firewalld, nftables or netfilter-persistent. Names only; the rules need root and are never read.
Automatic security updatesunattended-upgrades installed and switched on in apt.conf.d, or dnf-automatic’s install timer.
Login-guessing protectionfail2ban, CrowdSec or sshguard running. Names only.
Clock synchronisedtimedatectl.
Accounts with uid 0 other than root/etc/passwd. The one place a user name is sent: an extra root account is itself the finding.

CheckRoost then tries, from outside and once a day, only the listening ports that are not SSH, the web or mail — usually none — at the address the agent’s own connection came from, to see whether the internet actually reaches them. A server cannot answer that about itself: a connection to its own public address never leaves the machine, so it passes the firewall, and a cloud firewall in front of it is invisible from inside. Only a TCP connection is made; nothing is sent.

That is the entire payload. There is no file listing, no process list, no command output beyond the above, and no access to anything your application holds. You can see precisely what your machine would send, without sending it:

# Prints the exact JSON body and exits. Nothing leaves the machine.CHECKROOST_DRY_RUN=1 ./checkroost-agent.sh# Including the once-a-day package scan, which an ordinary# reading skips:CHECKROOST_DRY_RUN=1 CHECKROOST_FORCE_SCAN=1 ./checkroost-agent.sh

Packages are scanned once a day, not every minute

Asking the package manager what is outstanding costs about a second and a half of one core. On a one-core droplet, doing that every minute burns roughly 3% of the machine continuously to answer a question whose answer changes once a day — so the agent scans in one minute out of 1,440, at 04:00 UTC by default.

On every other reading the package fields are left out entirely rather than sent as zero. CheckRoost carries the last real answer forward and shows when it was taken. A reading that reported “0 security updates” because it never looked would be a lie that reads as good news, and that is the one thing this agent is written not to do.

SettingDefaultWhat it does
CHECKROOST_SCAN_HOUR4Hour, UTC, at which packages are scanned.
CHECKROOST_SCAN_MINUTE0Minute within that hour.
CHECKROOST_MAX_PACKAGES100Cap on the list. Security updates are kept first, so a machine far behind never drops the ones that matter.
CHECKROOST_FORCE_SCANunsetScan on this reading whatever the clock says. The installer sets it so the first reading arrives complete.

Set them in /etc/checkroost/agent.conf. The decision is made from the clock rather than from a marker file on disk, on purpose: a cached list of outstanding package names is precisely the “which published vulnerabilities apply to this machine” map that the logging section below refuses to write down.

What it will not do

  • It never runs a command you send it. There is no channel that carries one. Since 1.2.0 the agent does read one thing from the response — a single boolean meaning “somebody pressed Scan now” — and the only thing it can do about it is run the same read-only package scan it already runs nightly, at the same privilege, from the script you reviewed. No command string, no name of anything to run, nothing else expressible. If this dashboard were compromised, the most it could make your fleet do is simulate apt-get upgrade more often than it would have.
  • It never updates itself on a schedule. An agent that fetches code on a timer is remote execution with extra steps: compromise the dashboard, serve a modified script, own every server inside one interval. Nothing here runs on a timer, and nothing we send can cause an update. sudo checkroost-agent --update exists and is not that — a person with root asks for it, once. And from 1.6.0 it installs only a release signed with one of the two CheckRoost release keys built into the agent, which are not on this server: somebody who took over the dashboard could serve a different script with matching checksums, but could not sign it.
  • It never needs root. Everything it reads is world-readable. It runs as a system account with no login shell and no home directory. Root is needed once, to install it.
  • It never runs apt update. Package metadata is refreshed by your system’s own timer. A monitoring loop has no business hammering mirrors.
  • It does not collect Docker containers. docker ps requires membership of the docker group, and that group is root equivalence — anyone who can reach the socket can mount the host filesystem into a container. Taking a carefully unprivileged agent and handing it root is not a trade we will make for a status column.

Requirements

Operating systemAny Linux with systemd. Tested on Debian and Ubuntu (apt) and on Alma and Rocky (dnf).
PackagesOnly curl and a POSIX shell. No runtime, no interpreter, no library.
NetworkOutbound HTTPS to app.checkroost.com:443. No inbound ports.
PrivilegeRoot to install. The agent itself runs unprivileged.
FootprintA few hundred milliseconds of one core per minute, capped at MemoryMax=64M.

Install

You need a credential, and there are two kinds. Nearly always you want the first.

What it isUse it when
Install link
cre_…
Reusable across a fleet, expiring, revocable. Each server enrols itself and is issued its own reporting token, which is what lands in agent.conf — the link is used once per machine and never written to disk.Almost always. One link, any number of servers.
Server token
cra_…
Bound to one server, issued to it directly.A server that already has a credential of its own.

Create an install link under Servers → Install links. You can copy it again whenever you need it — it is stored encrypted, under a key that is not in the database.

1. Download the files and verify them

curl -fsSLO https://app.checkroost.com/agent/checkroost-agent.shcurl -fsSLO https://app.checkroost.com/agent/checkroost-agent.servicecurl -fsSLO https://app.checkroost.com/agent/checkroost-agent.timercurl -fsSLO https://app.checkroost.com/agent/install.shcurl -fsSLO https://app.checkroost.com/agent/README.mdcurl -fsSLO https://app.checkroost.com/agent/SHA256SUMScurl -fsSLO https://app.checkroost.com/agent/SHA256SUMS.sigcurl -fsSL -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=_release-keys.checkroost.com&type=TXT' \  | grep -oE 'MCowBQYDK2VwAyEA[A-Za-z0-9+/]{43}=' > release-keystr -d ' \n\r' < SHA256SUMS.sig | openssl base64 -d -A -out SHA256SUMS.binfor k in $(cat release-keys); do  printf '%s\n' '-----BEGIN PUBLIC KEY-----' "$k" '-----END PUBLIC KEY-----' > release-key.pem  openssl pkeyutl -verify -pubin -inkey release-key.pem -rawin \    -in SHA256SUMS -sigfile SHA256SUMS.bin >/dev/null 2>&1 && echo signeddone | grep -q signed && sha256sum -c SHA256SUMS \  || { echo 'STOP: not signed by a CheckRoost release key, or a file does not match' >&2; false; }

Five files, each OK. Anything that prints STOP means do not install. Checking the signature needs OpenSSL 3 (Ubuntu 22.04, Debian 12, RHEL 9 and later); on an older machine, verify on one that has it and carry the files across, as in installing without internet access.

The checksums for v1.8.0, so you can compare them against a copy of this page you trust:

FileSHA-256Bytes
checkroost-agent.shad94a5b531375a965314fd6a06303639da457293534bec8825bb860edece143737,152
checkroost-agent.service38c380ac551c4ace4df6001b1cbb9adfa4219810215a3d45a79f0de02a356c402,081
checkroost-agent.timer53391c03fb43541731a82d21eb69da52a4ae869b180dbad525ea8a7ee8c2964a1,799
install.sh6d2cfa8dc258919e2dcd2f2d20c8d9a1a59918329f97a5d6a9405a8bfab850049,189
README.md4ff16a5413fb140051009c3e6d2db51f59104bd0226b5eb015219c70826298d710,828

What the check proves, and what it does not

The sums alone prove only that nothing broke on the way, because they come from the same host as the files. So the steps also check that SHA256SUMS is signed by a CheckRoost release key — the check every --update makes — and they read the keys from the TXT records at _release-keys.checkroost.com, which are kept in DNS, not on this server. Someone who took this server over could not sign files or change those records. They could change this page, though, so keep a copy of these steps, or compare the keys below with the ones in DNS:
  • MCowBQYDK2VwAyEA3AgWRlB/SGOa3jemBOKGBWJ6scvey0llNR/S+Kvt31Q=
  • MCowBQYDK2VwAyEA1c3LXYFEMXZMoSm+a70o2fS/34Fy984F322f+rOZ3TI=

2. Read the script

less checkroost-agent.sh

Actually do this. It is the only step in this document that we cannot do for you, and it is the reason the agent is shell rather than a binary.

3. Run the installer

chmod +x install.shsudo ./install.sh --url https://app.checkroost.com/api/agent \  --enroll-token YOUR_LINK_TOKEN

With a server token instead, swap the last line for --token cra_….

Enrolment happens before anything is written, and the installer then takes one reading synchronously before it schedules anything. A dead link, a rejected token or blocked egress is an error you see immediately rather than silence you discover next week — and a failure at either step leaves the machine exactly as it was found.

There is no one-liner

There is no curl … | sudo bash and there will not be one. Piping a URL into a root shell defeats the only real advantage of shipping shell — that you can read it first — and it means a momentary compromise of our web server executes as root on every machine that installs during the window.

Confirm it works

systemctl status checkroost-agent.timertail -f /var/log/checkroost/agent.log

A healthy log is one line a minute and looks like this:

2026-09-22T12:28:01Z posted ok (485 bytes)2026-09-22T12:29:02Z posted ok (485 bytes)2026-09-22T12:30:01Z posted ok (485 bytes)

In CheckRoost, the server’s page starts showing readings within a minute or two of the first successful post. Until the first one lands, the check reads No sample yet rather than healthy — silence is never reported as good news.

What it warns about

The agent itself makes no judgements — it reports numbers and CheckRoost evaluates them. These are the defaults:

ConditionDefaultResult
Disk space used85%Warning
Disk space used95%Failure. Below 95% there is still room to write the logs that explain what went wrong.
Inodes used90%Warning. Running out presents as a full disk with gigabytes free, which is a confusing hour at 3am.
Memory used90%Warning. Measured from MemAvailable, not MemFree — free memory on a healthy Linux box is near zero.
Swap used50%Warning
Load (5 minute)2 × coresWarning. Five-minute, not one-minute: the one-minute figure spikes for a backup and recovers.
Security updates outstanding7 daysWarning. The clock starts when we first see them, not when you first look.
Reboot pending7 daysWarning
Any failed systemd unitimmediatelyWarning
No reading received3 minutesFailure. Three missed intervals, measured on our clock rather than the server’s — a machine with a wrong clock would otherwise look alive.

A single bad reading does not open an incident. CheckRoost escalates on consecutive confirmed failures, so a one-minute spike during a backup resolves itself without anyone being told about it.

Deploying across a fleet

One reporting token per server, and one link to get them. Those are two different credentials doing two different jobs, and the split is the whole design. A reporting token is an identity — the hostname in the payload is recorded for display and never trusted for identification — so sharing one would make every machine report as the same server and revoking it would silence all of them at once. An install link is not an identity: it can create a server and have us mint that server a token, and nothing else.

So the command is identical on every host. Nothing per-machine to prepare, nothing per-machine to keep track of:

# hosts.txt: one hostname per line. Same link for all of them.LINK=YOUR_LINK_TOKENwhile read -r host; do  dir=$(ssh "$host" mktemp -d)  scp checkroost-agent.sh checkroost-agent.service \      checkroost-agent.timer install.sh "$host":"$dir"/  ssh "$host" "cd $dir && chmod +x install.sh && \    sudo ./install.sh --url https://app.checkroost.com/api/agent --enroll-token $LINK && \    cd / && rm -rf $dir"done < hosts.txt

A fresh directory from mktemp, never /tmp itself: in a shared, sticky /tmp any local account could have placed a file of the same name first, and install.sh installs what sits beside it.

Each server is matched on /etc/machine-id, so re-running this is safe: an already-enrolled machine has its token rotated rather than being added a second time, and it does not spend one of the link’s uses.

Read it once, not once per server

The review step in Install is per version, not per machine. Verify the checksums once, keep the reviewed copy, and push that copy everywhere — which is also what makes the fleet auditable: every host runs bytes you have a hash for.

The timer carries RandomizedDelaySec=20s, so a fleet installed by the loop above does not all report on the same second.

Installing without internet access

The installer never downloads anything — it only copies files that are already in the directory you run it from. So an air-gapped or egress-restricted install needs no special build: fetch the same files on a machine that can reach us, verify them there, carry them in, and run the same command.

# On a machine with internet access:curl -fsSLO https://app.checkroost.com/agent/checkroost-agent.shcurl -fsSLO https://app.checkroost.com/agent/checkroost-agent.servicecurl -fsSLO https://app.checkroost.com/agent/checkroost-agent.timercurl -fsSLO https://app.checkroost.com/agent/install.shcurl -fsSLO https://app.checkroost.com/agent/README.mdcurl -fsSLO https://app.checkroost.com/agent/SHA256SUMScurl -fsSLO https://app.checkroost.com/agent/SHA256SUMS.sigcurl -fsSL -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=_release-keys.checkroost.com&type=TXT' \  | grep -oE 'MCowBQYDK2VwAyEA[A-Za-z0-9+/]{43}=' > release-keystr -d ' \n\r' < SHA256SUMS.sig | openssl base64 -d -A -out SHA256SUMS.binfor k in $(cat release-keys); do  printf '%s\n' '-----BEGIN PUBLIC KEY-----' "$k" '-----END PUBLIC KEY-----' > release-key.pem  openssl pkeyutl -verify -pubin -inkey release-key.pem -rawin \    -in SHA256SUMS -sigfile SHA256SUMS.bin >/dev/null 2>&1 && echo signeddone | grep -q signed && sha256sum -c SHA256SUMS \  || { echo 'STOP: not signed by a CheckRoost release key, or a file does not match' >&2; false; }tar czf checkroost-agent.tar.gz checkroost-agent.* install.sh README.md SHA256SUMS# On the target, after carrying the archive across:tar xzf checkroost-agent.tar.gz && sha256sum -c SHA256SUMSchmod +x install.shsudo ./install.sh --url https://app.checkroost.com/api/agent --enroll-token YOUR_LINK_TOKEN

Offline install, not offline operation

This removes the need for the installation to reach us. The agent still has to reach app.checkroost.com every minute to report — a server that genuinely cannot make outbound HTTPS requests cannot be monitored this way at all, and the installer will refuse rather than schedule something that can never succeed.

What is installed

PathWhat it is
/usr/local/bin/checkroost-agentThe script, mode 0755
/etc/checkroost/agent.confYour URL and token. Mode 0600, owned by the agent account — the one secret of ours on this machine.
/etc/systemd/system/checkroost-agent.serviceA oneshot unit
/etc/systemd/system/checkroost-agent.timerEvery 60s, with a randomised delay
/var/log/checkroost/agent.logThe agent’s own log
checkroost-agentA system account. No login shell, no home directory.

The unit is confined to what a reading actually needs:

  • NoNewPrivileges, and an empty CapabilityBoundingSet — it cannot gain privilege it was not given.
  • ProtectSystem=strict with ReadWritePaths=/var/log/checkroost — the log directory is the only writable path on the machine.
  • ProtectHome, PrivateTmp, PrivateDevices — no user data, no shared temp, no device nodes.
  • RestrictAddressFamilies=AF_INET AF_INET6 and IPAddressDeny=link-local multicast — outbound IP only, and nothing on the local network segment.
  • MemoryDenyWriteExecute, SystemCallFilter=@system-service, MemoryMax=64M, TimeoutStartSec=45 — if it hangs, it dies.

If the script were ever compromised, that is the blast radius.

The log

The agent writes its own log file rather than to the journal, so that a future Windows agent logs the same way in the same format — one agent, one place to look, whatever it is running on.

That choice could let a monitoring agent fill your disk, so it rotates its own log: 1 MiB, then one generation kept. No dependency on logrotate being configured, and no way for this to become the outage it was installed to prevent.

The log records what the agent did — posted ok, refused, curl failed. It never records what it collected. Kernel version, OS version and a count of outstanding security updates together describe precisely which published vulnerabilities apply to this machine; that belongs in one encrypted channel, not in a file on disk that every backup will copy.

Updating

There is no automatic update and never will be. Updating is one command, run by you, on the machine:

sudo checkroost-agent --update

That fetches the published files from the same host this agent reports to, checks that SHA256SUMS is signed by a CheckRoost release key (1.6.0 on, with OpenSSL 3), checks the files against it, and replaces the script only if both pass. On a machine whose OpenSSL is older than 3.0 it refuses rather than install something it cannot check; update the long way below. It prints Already on … and stops when there is nothing to do, refuses anything but HTTPS, refuses to run as anyone but root, and finishes by taking one reading with a package scan forced — so the dashboard shows the new version straight away.

The long way round still works and is what --updatedoes for you:

curl -fsSLO https://app.checkroost.com/agent/checkroost-agent.shcurl -fsSLO https://app.checkroost.com/agent/SHA256SUMScurl -fsSLO https://app.checkroost.com/agent/SHA256SUMS.sigcurl -fsSL -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=_release-keys.checkroost.com&type=TXT' \  | grep -oE 'MCowBQYDK2VwAyEA[A-Za-z0-9+/]{43}=' > release-keystr -d ' \n\r' < SHA256SUMS.sig | openssl base64 -d -A -out SHA256SUMS.binfor k in $(cat release-keys); do  printf '%s\n' '-----BEGIN PUBLIC KEY-----' "$k" '-----END PUBLIC KEY-----' > release-key.pem  openssl pkeyutl -verify -pubin -inkey release-key.pem -rawin \    -in SHA256SUMS -sigfile SHA256SUMS.bin >/dev/null 2>&1 && echo signeddone | grep -q signed && sha256sum --ignore-missing -c SHA256SUMS \  || { echo 'STOP: not signed by a CheckRoost release key, or a file does not match' >&2; false; }sudo install -m 0755 checkroost-agent.sh /usr/local/bin/checkroost-agentsudo systemctl start checkroost-agent.service

If the unit files changed too, copy them into /etc/systemd/system/ and run systemctl daemon-reload. The version each server is running is in every sample, so CheckRoost can tell you which hosts are behind.

Removing it

# Stop reporting, immediately and completely.sudo systemctl disable --now checkroost-agent.timer# Remove everything the installer created.sudo ./install.sh --uninstall

disable --now is a complete kill switch — nothing else of ours is running on the machine. The uninstaller removes the script, the config, both units and the system account, and leaves the log in place so you can read what happened after you have removed the thing that was writing it.

Revoke the token in CheckRoost as well. Uninstalling stops the server from reporting; revoking stops anyone who copied the token from reporting in its place.

Troubleshooting

What you seeWhat it meansWhat to do
--url must be httpsThe installer refused a plaintext URL.Deliberate, and not overridable. The token is a bearer credential; over http it is handed to every hop between you and us.
Installer prints FAILED — the agent could not reportThe synchronous first reading did not get a 2xx. Nothing was scheduled.Read the log lines it printed. Almost always a wrong token or blocked egress — the next two rows.
ERROR refused: the token was rejectedA 401. The token is unknown, has been revoked, or the server it belongs to has no agent check configured. We deliberately do not say which.Confirm the token was pasted whole, then have it re-issued. Check /etc/checkroost/agent.conf for a truncated value.
ERROR curl failedThe request never completed — DNS, egress filtering, or a proxy.Test it directly: curl -sS -o /dev/null -w '%{http_code}' https://app.checkroost.com/api/agent. A 401 here is good news — it means you reached us.
Nothing in /var/log/checkroost at allThe timer is not enabled, or the unit failed before the script ran.systemctl status checkroost-agent.timer then journalctl -u checkroost-agent.service -n 20. The journal holds systemd’s own errors; the agent’s log only exists once the script starts.
Log says posted ok, dashboard says no heartbeatThe readings are arriving but being credited to a different server.The token belongs to another server record. Re-issue a token from the right one.
WARN rate limited (HTTP 429)Too many readings, usually two agents sharing one token.Harmless and self-correcting, but check for a token reused across hosts — see Across a fleet.
A disk you care about is missingtmpfs, devtmpfs, squashfs, overlay and efivarfs mounts are excluded — they are RAM or read-only images, and a “full” one is normal.If a real filesystem is missing, send us the output of CHECKROOST_DRY_RUN=1 and df -P.

For anything else, CHECKROOST_DRY_RUN=1 checkroost-agent prints exactly what the machine would send, without sending it. That output contains no credentials and is the fastest thing to attach to a support message.

Security summary

DirectionOutbound only, HTTPS. No listening socket, no inbound port.
PrivilegeUnprivileged system account. No shell, no home directory.
Remote executionNone, by design, permanently.
Self-updateNone, by design, permanently.
CredentialPer-server bearer token, revocable, stored only as a SHA-256 hash on our side.
Token file0600, owned by the agent account.
If the token leaksSomeone can write false readings for that one server. It grants no read access to anything.
If our ingest is breachedIt accepts readings and returns 204. There is no command channel to abuse and no response body to poison.
Dependenciescurl, systemd, POSIX shell. Nothing else.

Found a problem?

Tell us before you tell anyone else and we will fix it quickly: [email protected].