Magnemite
Features

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

Windows: ./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 reboot

In 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.txt

Without a baked-in config, write /data/adb/magnemite/config.json before rebooting:

/data/adb/magnemite/config.json
{
  "serverUrl": "https://magnemite.example.com",
  "enrollmentToken": "…"
}

The same values exist as flags. Full list in the environment reference.

What it does at boot

  1. Reads config.json (or its flags).
  2. Fixes DNS if /etc/resolv.conf is missing (see DNS on Android).
  3. Enrolls if it has no device token, then rewrites the config without the enrollment token.
  4. Opens the WebSocket and sends hello: serial, model, Android version, ABI, installed versions, etc.
  5. 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 hub

On 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:

  1. A brand-new box (obviously no agent to update).
  2. A box that never comes online, it cannot be told anything. Check troubleshooting first.
  3. service.sh or customize.sh changed (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.

On this page