Skip to content

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.dev over 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.

PlatformFile
Linux, x86-64https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-linux-amd64
Linux, ARM64https://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-linux-arm64
macOS, Intelhttps://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-darwin-amd64
macOS, Apple Siliconhttps://github.com/mr-jablon/runjet-agent/releases/latest/download/runjet-agent-darwin-arm64
checksumshttps://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-job

The 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.

/etc/systemd/system/runjet-agent.service
[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.target
systemctl daemon-reload

Two 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

  1. 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.

  2. The dialog prints a finished command. Copy the whole of it; it has this shape:

    from the Register agent dialog
    runjet-agent enroll \
      -url https://api.runjet.dev \
      -key rj-1:BASE64KEY \
      -token ONE-TIME-TOKEN

    The 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.

  3. 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.

  4. 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-agent

    Where agent.env already 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 -f

The 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-agent

Enrolment 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-agent is 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_USER to 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_TOKEN after 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.