Installing an agent
The binary, two accounts, a systemd unit, enrolment and a start — how to get an agent onto a host.
The agent is a single static binary. There is nothing to install around it — no runtime, no libc version it has to agree with — and that is the point: it is meant to be copied onto machines you do not otherwise manage. How it works is the companion page; this one is the procedure.
You will need:
- a workspace in Runjet and the admin role in it — only that role sees the Register agent button (roles),
- a Linux host with systemd and root on it, or a container instead,
- a route out of the host to
api.runjet.devover HTTPS. Nothing inwards: the agent opens no port.
Downloading
Every release of the agent carries four binaries and a file of checksums. The
latest/download address always points at the newest release, so there is no
version number in it to go stale.
| Platform | File |
|---|---|
| Linux, x86-64 | https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-linux-amd64 |
| Linux, ARM64 | https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-linux-arm64 |
| macOS, Intel | https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-darwin-amd64 |
| macOS, Apple Silicon | https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-darwin-arm64 |
| checksums | https://github.com/mr-jablon/runjet-agent/releases/latest/download/SHA256SUMS |
On the host:
curl -fsSLO https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-linux-amd64
curl -fsSLO https://github.com/mr-jablon/runjet-agent/releases/latest/download/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
install -m 0755 runjet-agent-linux-amd64 /usr/local/bin/runjet-agent--ignore-missing is there because SHA256SUMS names all four files and you
downloaded one. The line runjet-agent-linux-amd64: OK is what you want to
see; anything else means you are not running that binary.
Two accounts
The agent runs under one account and job commands under another. This is not a
courtesy: a file cannot be hidden from a process running under the same uid, so
for as long as jobs ran as the agent, cat would hand them its private key and
its secrets file. Why this is the whole of
job isolation is on a page of its own; here it
is enough to create both accounts.
useradd --system --no-create-home runjet-agent
useradd --system --create-home --home-dir /var/lib/runjet-job runjet-jobThe agent needs no home — its state lives in /var/lib/runjet-agent, which
systemd creates for it. The runjet-job account does need one: jobs run in
it, it belongs to them, and so a job can write there without anything being
granted. Without a home, jobs run in the agent's working directory, where they
cannot write, and the agent says so at every start.
The systemd unit
Save it as /etc/systemd/system/runjet-agent.service. It is quoted in full,
comments included — they are written for whoever will one day edit it.
[Unit]
Description=Runjet agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=runjet-agent
Group=runjet-agent
# Creates /var/lib/runjet-agent owned by the service user with mode 0700.
# That directory holds the agent's private key, which is the one secret on this
# host that must never be readable by anyone else.
StateDirectory=runjet-agent
StateDirectoryMode=0700
# Holds the scheduler URL, its public key, and — on first start only — the
# enrollment token. Keep it 0600 and owned by root.
EnvironmentFile=/etc/runjet-agent/agent.env
# Job commands run as runjet-job, not as this service's user, so the state
# directory above and the secrets file below stay unreadable from a job. That
# needs the ability to change uid — but not root: these two capabilities are the
# whole of what the agent is trusted with, and the bounding set keeps
# "unprivileged plus two capabilities" from drifting into anything more.
#
# Where ambient capabilities are unavailable (systemd < 229, some hardened
# kernels), set User=root instead and drop both lines. The agent takes the same
# path either way; only who starts it differs.
AmbientCapabilities=CAP_SETUID CAP_SETGID
CapabilityBoundingSet=CAP_SETUID CAP_SETGID
ExecStart=/usr/local/bin/runjet-agent
# An agent that cannot reach its scheduler backs off and retries on its own, so
# a restart here is for the process dying, not for a network blip.
Restart=always
RestartSec=5s
# On sandboxing: there is deliberately very little of it here.
#
# Every process this unit starts is a job command, so any restriction added
# below applies to the jobs as well — ProtectHome would break a backup that
# reads /home, ReadOnlyPaths would break one that writes a dump, PrivateTmp
# would hide files a job expects to find. Hardening an agent means deciding
# what the jobs on this host are allowed to do, which is a decision only the
# operator can make. Add directives here once you know what your jobs need,
# rather than starting strict and debugging the fallout.
#
# The exception is the account. Jobs run as runjet-job, and that is not
# tunable per host in the same way: as long as a command runs under the agent's
# own uid it can read the agent's private key, and no directive here changes
# that. AGENT_JOB_USER= (empty) opts out, and the agent says so at every start.
#
# Jobs are not separated from each other, and that is the model rather than an
# omission: they all run as runjet-job, so they see each other exactly as two
# cron jobs of one user always have. Worth knowing only if a job runs code you
# did not write — an account per job is what that would take, and this is not
# that.
[Install]
WantedBy=multi-user.targetsystemctl daemon-reloadTwo things from that unit are worth repeating outside a comment.
AmbientCapabilities is what lets the agent switch uid to runjet-job without
being root; where systemd cannot do that, set User=root and delete both
capability lines — the agent behaves the same, only somebody else starts it.
And there is almost no sandboxing there on purpose: anything you added would
apply to your jobs too.
Enrolment
-
In Runjet open the Agents screen and click Register agent. Name the agent — the name is what you will see it under in the list and in emails.
-
The dialog prints a finished command. Copy the whole of it; it has this shape:
from the Register agent dialogrunjet-agent enroll \ -url https://api.runjet.dev \ -key rj-1:BASE64KEY \ -token ONE-TIME-TOKENThe token lasts an hour and is spent once. The dialog shows it only now — Runjet stores nothing but its hash — so if it is lost, a new one comes from resetting the enrolment, not from opening the dialog again.
-
Run the command on the host as root. It writes in two places:
/var/lib/runjet-agent/identity.json(the agent's private key,0600) and/etc/runjet-agent/agent.env(the address, Runjet's public key and the name,0600). The token is in neither — it is spent before the command finishes, and there is nothing to clean up. -
Read what the command printed. Success looks like this:
Enrolled as web-01 (ws_…). Key written to /var/lib/runjet-agent/identity.json. Settings written to /etc/runjet-agent/agent.env. Start it: systemctl enable --now runjet-agentWhere
agent.envalready existed, the command does not overwrite it and prints the three lines it must contain instead — it could otherwise delete settings somebody put there deliberately.
The token appears in the host's process list while the command runs. Where that
matters, leave out -token and pass it on standard input (ending it with
^D), or use -token-file.
Starting it
systemctl enable --now runjet-agent
journalctl -u runjet-agent -fThe agent logs one JSON line per event. The first one you want to see is
agent ready, with the name, Runjet's address and the account for jobs. On the
Agents screen the agent's dot goes green within a few seconds and the
Runs jobs as column reads runjet-job. If it reads anything with
not isolated beside it, jobs are running under the agent's own account
— why that matters.
What to do when none of this holds is on the when an agent will not run page.
In a container
Runjet publishes no image of the agent; the Dockerfile is in the agent's
repository and you build the image yourself. It is Alpine with a shell — unlike
the control plane, whose image has none — because running commands is the whole
work of this process. And it sees only what is inside the image: whatever your
jobs call has to be in there.
docker build -t runjet-agent https://github.com/mr-jablon/runjet-agent.git
docker run -d --name runjet-agent \
-v runjet-agent:/var/lib/runjet-agent \
-e RUNJET_URL=https://api.runjet.dev \
-e RUNJET_PUBLIC_KEYS=rj-1:BASE64KEY \
-e AGENT_NAME=web-01 \
-e ENROLLMENT_TOKEN=ONE-TIME-TOKEN \
runjet-agentEnrolment in a container works differently from a host: instead of enroll,
the agent is given the token in ENROLLMENT_TOKEN and spends it on its first
start, while it still has no identity. Three things differ from installing on a
host:
- A volume for
/var/lib/runjet-agentis required. The key lives there; without a named volume the agent tries to enrol afresh on every start, with a token that is already burnt. - Jobs in a container run as the agent, because the image sets
AGENT_JOB_USERto empty. A process that is not root does not gain the right to change uid, not even through--cap-add. If you want isolation here too, start the container with--user root -e AGENT_JOB_USER=runjet-job; the account exists in the image, and root is only root inside the container's namespace, and is dropped for every command. Left as it is, a job can read the agent's key and its secrets file, and the agent says so at every start; in a container the container is often the boundary and this is an acceptable trade. - Delete
ENROLLMENT_TOKENafter the first enrolment from whatever starts the container. Nothing reads it any more and it is one liability more.
The next agent
Runjet's public key is the same for every agent in a workspace — the dialog says so and it is worth keeping. Only the token differs, and you get one per agent from Register agent. How many agents a workspace will hold is said by its plan.