[code] [blog] [Repos] [GPS Tracker] [Music] [VNC] [Restart VNC] [E-Mail me]
git clone https://coredump.ws/repos/workshop-fleet.git
# push (client certificate):
git remote set-url --push origin https://coredump.ws:8443/repos/workshop-fleet.git

Branches and tags

masterbranch41 min agozip

Files at HEAD (0633a5bbb2) · download zip

agent/
control/
init/
nftables/
server/
tools/
.gitignore227 B
README.md11.5 KB

Recent commits · all

0633a5bbMove workshop-server to hyper 1 / tokio / rustls 0.23no-body-in-particular41 min ago
625b8f59workshop-server: read the certificate through an ACL, not the mail groupno-body-in-particular1 h ago
36fffa94Run workshop-server as an unprivileged userno-body-in-particular1 h ago
ee24b1bdFan shell commands across Macs that cannot be reached from the networkno-body-in-particular26 days ago

README.md

workshop-fleet

Run shell commands across a room full of Macs that cannot be reached from the network.

Built for a Claude workshop: a set of Mac minis needed setting up together, but they were on a network where they could not connect to each other and nothing could be pushed to them. So they dial out instead. Each Mac long-polls a server over HTTPS; the lead Mac submits commands to that same server. One inbound port, on one host, and no Mac needs a reachable address of its own.

 lead Mac                     server (one host)                every other Mac
 ┌──────────────┐  HTTPS      ┌───────────────────┐  HTTPS     ┌──────────────┐
 │ workshop-ctl │ ──────────► │  workshop-server  │ ◄────────── │ workshop-    │
 │              │  :9443      │  Rust + SQLite    │  :9443      │ agent        │
 └──────────────┘  submit     └───────────────────┘  long-poll  └──────────────┘
   control token                                     agent token   bash + curl
                                                                   LaunchDaemon

Nothing listens on any Mac. All connections are outbound from the Macs.

Why the pieces are what they are

Agent is bash + curl. Both are on a stock macOS. A fresh Mac can be enrolled with no Xcode Command Line Tools, no Homebrew and no Python. On workshop morning that matters more than elegance.

Commands and output travel base64 in both directions. Not decoration: a bash agent cannot reliably JSON-escape arbitrary command output, and the first command containing a quote, a brace or a newline is what breaks naive shell agents. This was found the hard way — see Bugs worth knowing about.

Server is Rust with an embedded SQLite database and no async runtime. One thread per request, a fixed pool. There is no separate control-plane socket: state is the database.

Long-poll, not an interval. The server holds a poll open for up to 25 seconds looking for work, so a command reaches a Mac in about the time it takes to run rather than on a polling delay, while still being outbound-only.

Security model

Read this before exposing the port.

This hands a root shell on every enrolled Mac to whoever holds the control token. That is the point of the tool, and it is the risk. The design compensates where it can:

MeasureWhy
TLS only, no plaintext listenera bearer token over HTTP would put root for the whole fleet in every intermediate router's log
Two separate tokensthe agent token is on every Mac, so assume it is widely known by the end of a workshop — anyone who can read a LaunchDaemon plist has it. It can only register, poll and report. Submitting a command needs the control token, which lives only on the lead Mac. Without the split, every attendee machine could run root commands on its neighbours
Constant-time token comparisona wrong guess cannot be narrowed down by timing
Refuses to start on a weak tokenunder 32 chars, a placeholder, or two identical tokens are all fatal at startup
Audit logevery registration, dispatch, result and auth failure with agent id and source address, to /var/log/workshop.log
Per-source rate limit at the firewallsee nftables/ — an agent holds one or two connections, so a burst is either a broken agent or someone guessing tokens
Output cap256 KB per result, so one chatty command cannot fill the disk
Command timeout900s per command; macOS has no timeout(1), so the agent uses a watchdog. One command waiting on input cannot stall an agent for the whole workshop

What it does not do: it cannot stop a leaked token being used from anywhere. There is no per-machine identity, so one machine cannot be revoked without re-tokening all of them. If that matters for your use, use mutual TLS with a per-machine client certificate instead of a shared bearer token.

Tokens are rotated by replacing the files and restarting the server — every agent must then be re-enrolled.

Layout

server/src/main.rs            the server (Rust)
server/workshop-server.py     the original Python implementation, kept as a
                              reference and a fallback: same endpoints, same
                              schema, byte-compatible with the same clients
agent/workshop-agent          runs on every Mac, root, from a LaunchDaemon
control/workshop-ctl          runs on the lead Mac
tools/workshop-make-installer emits a self-contained macOS installer
init/workshop                 OpenRC service (supervised)
nftables/workshop.nft         firewall snippet

Setting up the server

The server runs as its own unprivileged workshop user and refuses to start as root: it takes commands off the network, so a bug in it should not hand out root on the server too.

# service user, in no other groups. It reads /etc/cert.pem through an ACL entry
# rather than the certificate's group, which on this host is also the desktop
# user's primary group and would open their home directory to the service.
useradd --system --no-create-home --home-dir /var/lib/workshop \
        --shell /sbin/nologin --user-group workshop
setfacl -m u:workshop:r /etc/cert.pem

# tokens - these are the credentials; readable by the service, never commit them
install -d -m 750 -o root -g workshop /etc/workshop
umask 077
openssl rand -hex 32 > /etc/workshop/token           # agent token, every Mac
openssl rand -hex 32 > /etc/workshop/control-token   # control token, lead Mac only
chgrp workshop /etc/workshop/token /etc/workshop/control-token
chmod 640 /etc/workshop/token /etc/workshop/control-token

# build and install
cd server && cargo build --release
install -m 755 target/release/workshop-server /usr/local/sbin/workshop-server
install -m 755 ../init/workshop /etc/init.d/workshop
rc-update add workshop default && rc-service workshop start

The init script runs it as workshop:workshop and creates /var/lib/workshop and the log files owned by that user.

The server reads its TLS certificate from /etc/cert.pem, expecting the certificate chain and private key concatenated in one file (as Let's Encrypt users commonly keep it). It splits them itself.

Open the port with a rate limit — see nftables/workshop.nft.

Enrolling the Macs

The Macs cannot fetch an authenticated file before they hold a token, so the installer is one self-contained script carrying the agent, its config and the LaunchDaemon inline. Generate it on the server and copy it over by USB, AirDrop or scp:

workshop-make-installer        > enroll-mac.sh        # every Mac
workshop-make-installer --lead > enroll-lead-mac.sh   # the lead Mac only

Then on each machine:

sudo bash enroll-mac.sh        # installs the agent as a root LaunchDaemon

Both installers contain a token. Delete them from the Mac afterwards. The installer is safe to re-run: it bootouts any existing daemon first.

Using it

workshop-ctl agents                              # list the fleet, with online state
workshop-ctl run 'softwareupdate -l'             # every machine
workshop-ctl run --on ws-03 'uptime'             # one machine
workshop-ctl run --on a-c8d9b7e3abb404f1 'id -un' # by agent id
workshop-ctl results 7                           # re-read a job's output

--on accepts an agent id, an exact hostname, or an unambiguous prefix. Ambiguity is an error, not a guess — picking a machine at random to run a root command on is not a helpful default. The error lists the candidates with their agent ids so you can pick one.

run waits until results stop arriving rather than for a fixed count, because a Mac that is asleep or powered off simply never reports.

How it works

  1. Register. POST /register with the machine's hostname; the server issues an agent id, which the agent keeps in /var/db/workshop/agent-id so a reboot returns as the same machine rather than a new one.
  2. Poll. GET /poll?agent=<id> is held open up to 25s. When a job matching * or this agent has no claim from it, the server writes a claim row and returns the command. Claims are why two polls from one agent cannot run the same job twice.
  3. Run. The agent runs it under a watchdog and POST /results the exit code and base64 output.
  4. Read. The operator polls GET /results?job=N.

Endpoints

MethodPathTokenPurpose
GET/healthnoneliveness; reveals nothing
POST/registeragentannounce hostname, get an id
GET/pollagentlong-poll for work
POST/resultagentsubmit exit code and output
POST/submitcontrolqueue a command
GET/agentscontrollist the fleet
GET/resultscontrolread a job's results

Operational notes

Forcing a re-register. The server records a hostname at registration, not on every poll. After renaming a machine, delete its row from the agents table — its next poll returns 404, and the agent re-registers itself with the new name, keeping its agent id. No action needed on the Mac.

Duplicate hostnames. Macs imaged from one template often share a ComputerName, which makes --on <name> ambiguous. Rename them through the fleet itself:

workshop-ctl run --on <agent-id> "
  scutil --set ComputerName ws-01
  scutil --set HostName     ws-01
  scutil --set LocalHostName ws-01"

then delete that agent's row to make it re-register. Note that launchctl kickstart -k from inside the daemon's own job is racy — it kills the shell invoking it — which is why the row-deletion route is preferred.

Crash recovery. The OpenRC service uses supervise-daemon, so the server is restarted within about two seconds of exiting. With plain command_background it stayed down until whatever watchdog the host runs noticed.

Bugs worth knowing about

Both were found by running the thing rather than reading it, and both are fixed:

A third, in the macOS installer: /usr/local/sbin does not exist on a stock macOS, so the first enrolment failed at cat > /usr/local/sbin/workshop-agent: No such file or directory. The agent had only been tested on Linux, where it does. The installer now creates its own destination directories. In the same pass, curl --fail-with-body was made conditional — it needs curl 7.76 (2021), and an older macOS would have aborted with option --fail-with-body: is unknown.

Requirements

Server: Rust 1.70+, a TLS certificate, SQLite (bundled by rusqlite). Developed on Gentoo with OpenRC; the service file is the only init-specific part.

Macs: nothing. bash and curl are enough. Tested on macOS 26.x (Darwin 25.x) on Apple M1/M2/M4 Mac minis. The agent avoids mapfile, readarray, ${x^^}, declare -A and coproc, so it runs on the bash 3.2 that macOS ships.