The agent on a box
Build the Magisk module with the hub URL baked in, flash it once, and let it update itself...
A ~6 MB static Go binary, started as root at boot by a Magisk module. It dials out over WSS, so boxes work behind any NAT or CGNAT with no open ports and no VPN.
Build the module
Bake the hub URL and an enrollment token in, so flashing is the only per-box step:
# SERVER is the hub URL — the same value as MAGNEMITE_PUBLIC_URL.
# compose: https://magnemite.example.com
# Coolify: https://agents.magnemite.example.com
make module SERVER=https://magnemite.example.com TOKEN=<enrollment token>
# → dist/magnemite-agent-1.0.0.zipWindows: ./scripts/build-agent.ps1 -Server https://… -Token <token>.
Everything Go-related runs inside the golang image, so the host needs no
toolchain. The version comes from the agent= line of VERSION at the repo
root; both build paths stamp it into module.prop, and the versionCode
Magisk compares is derived from it (1.0.0 → 10000).
SERVER is the hub URL, never the dashboard's. A box pointed at the dashboard's domain fails
the handshake.
Flash it
adb push dist/magnemite-agent-1.0.0.zip /data/local/tmp/
adb shell "su -c 'magisk --install-module /data/local/tmp/magnemite-agent-1.0.0.zip'"
adb rebootIn bulk, this is what Fleet → Add boxes does for you — it scans for the boxes, mints a token each and installs the module over adb without a laptop in the loop. The script is still there for a box neither the hub nor a machine can reach:
./scripts/enroll.sh dist/magnemite-agent-1.0.0.zip 192.168.1.10 192.168.1.11
./scripts/enroll.sh dist/magnemite-agent-1.0.0.zip -f hosts.txtWithout a baked-in config, write /data/adb/magnemite/config.json before
rebooting:
{
"serverUrl": "https://magnemite.example.com",
"enrollmentToken": "…"
}The same values exist as flags. Full list in the environment reference.
What it does at boot
- Reads
config.json(or its flags). - Fixes DNS if
/etc/resolv.confis missing (see DNS on Android). - Enrolls if it has no device token, then rewrites the config without the enrollment token.
- Opens the WebSocket and sends
hello: serial, model, Android version, ABI, installed versions, etc. - Heartbeats every 20 seconds (configurable) and waits for work.
make agent cross-compiles linux/arm64, linux/arm (GOARM=7),
linux/amd64 and windows/amd64. The module zip carries the two ARM builds
and picks at install time; the other two are for a fake
fleet.
A box does not have to be hardware. The same binary runs in a Redroid
container, started by Android's own init instead of Magisk — no zip, no
flashing, one service per box.
The agent updates itself, oh yeah!
Thank god. Nothing is flashed again. The hub image cross-compiles the agent during its own build, so a running hub always carries the exact binary it wants the fleet on.
Releasing
sed -i 's/^agent=.*/agent=1.0.1/' VERSION
git commit -am "agent 1.0.1" && git push
docker compose --env-file .env -f deploy/docker-compose.yml up -d --build hubOn hub boot
The binaries are copied into ARTIFACT_DIR under agent/<version>/, the
volume the edge already serves /files/* from, hashed, and earlier versions
removed.
On every hello
A box reporting a different agentVersion, not running a job, is sent an
agent_update with the URL, sha256 and version. Boxes on the target are left
alone.
This is decided before the box is handed any job, and a box told to update gets none until it is back on the new version: the swap ends in a re-exec, and an install started in the meantime would die with the old process. On a fresh container whose image carries an older agent, that is the first thing it does.
An update that is not going to land is noticed rather than waited out. A box
that comes back as a new agent process still on its old version lost it, and
is told again at once; one found running a job set it aside — an agent
never swaps under an install — and is told again once it is idle. Either way the
attempt is closed as failed, saying which, and an attempt overtaken by a later
one is closed as that instead of being left updating.
On the box
It downloads with its own device token, verifies the sha256, keeps the old
binary as .old, swaps the new one in with an atomic rename and re-execs. The
fresh hello is what confirms the update.
Updates go out in batches, capped by concurrent agent updates in Settings
→ Tuning (5). A box powered off for a month catches up when it dials in.
AGENT_AUTO_UPDATE=false pins the fleet instead.
Every attempt is a row in the device's Update history, tagged agent:
updating until the box comes back on the new version, then done, or
failed with the reason the agent reported. A row stuck on updating is a box
that never returned, no power, or a binary that cannot reach the hub.
The swap is a rename on one filesystem, so a box that loses power mid-update comes back on either
the old binary or the new one, never half of one. If the new one cannot start, the module's
respawn loop puts .old back. tail /data/adb/magnemite/agent.log for self-update: lines.
A checksum mismatch means the box was served a different file than the hub published.
When the module is needed again
Scenarios that require a new module zip:
- A brand-new box (obviously no agent to update).
- A box that never comes online, it cannot be told anything. Check troubleshooting first.
service.shorcustomize.shchanged (those live in the module, not in the binary).
Reinstalling an old zip over an updated box puts the old binary back, so keep a freshly built one.