# push (client certificate):
git remote set-url --push origin https://coredump.ws:8443/repos/workshop-fleet.git
Branches and tags
Files at HEAD (0633a5bbb2) · download zip
Recent commits · all
| 0633a5bb | Move workshop-server to hyper 1 / tokio / rustls 0.23 | no-body-in-particular | 41 min ago |
| 625b8f59 | workshop-server: read the certificate through an ACL, not the mail group | no-body-in-particular | 1 h ago |
| 36fffa94 | Run workshop-server as an unprivileged user | no-body-in-particular | 1 h ago |
| ee24b1bd | Fan shell commands across Macs that cannot be reached from the network | no-body-in-particular | 26 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:
| Measure | Why |
|---|---|
| TLS only, no plaintext listener | a bearer token over HTTP would put root for the whole fleet in every intermediate router's log |
| Two separate tokens | the 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 comparison | a wrong guess cannot be narrowed down by timing |
| Refuses to start on a weak token | under 32 chars, a placeholder, or two identical tokens are all fatal at startup |
| Audit log | every registration, dispatch, result and auth failure with agent id and source address, to /var/log/workshop.log |
| Per-source rate limit at the firewall | see nftables/ — an agent holds one or two connections, so a burst is either a broken agent or someone guessing tokens |
| Output cap | 256 KB per result, so one chatty command cannot fill the disk |
| Command timeout | 900s 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
- Register.
POST /registerwith the machine's hostname; the server issues an agent id, which the agent keeps in/var/db/workshop/agent-idso a reboot returns as the same machine rather than a new one. - 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. - Run. The agent runs it under a watchdog and
POST /results the exit code and base64 output. - Read. The operator polls
GET /results?job=N.
Endpoints
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /health | none | liveness; reveals nothing |
| POST | /register | agent | announce hostname, get an id |
| GET | /poll | agent | long-poll for work |
| POST | /result | agent | submit exit code and output |
| POST | /submit | control | queue a command |
| GET | /agents | control | list the fleet |
| GET | /results | control | read 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 brace in command output broke result parsing. The results response originally carried both raw
outputandoutput_b64. The bash control client separates records on{, so a command whose output contained one tore the record apart and the exit code came back silently empty. Fixed by removing raw output from the wire entirely: every arbitrary byte is confined to base64, so the JSON holds only server-controlled characters. - The result count was always 1.
grep -ccounts matching lines, and the server's reply is a single line. Nowgrep -o … | wc -l.
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.