Enrolment
An agent is complete on its own. Enrolled to a controller it stays the
same program: it keeps reading, deciding and enforcing locally, and adds
a conversation — its configuration comes from the controller, what it
decides goes up, the fleet's decisions come down. Standalone is a mode,
not a fork: agent.yaml, secrets.yaml, your hooks and your own
exemptions do not change when a machine enrols.
Joining
On the controller, mint a join token — at the console (People →
Mint a join token), with shield token, or at the controller's
terminal (shieldlist-controller token). It is a secret, valid for a
day and one machine by default, and it may carry the controller's
certificate fingerprint (secret@fingerprint).
On the machine:
# /etc/shieldlist/agent.yaml
mode: enrolled
controller: https://controller.example.net:17453
systemctl restart shieldlist-agent # mode is read at start
shield enrol <join-token>
The agent presents the token, keeps the credential it receives in its
state store, pins the fingerprint, and from then on dials the
controller itself: a long-lived connection over which the controller
pushes what changed the moment it changes, and a sync every thirty
seconds that also uploads what this machine decided. Nothing listens on
the machine; where a long connection cannot be held, the sync alone
does the job. Enrolment needs the agent that speaks the protocol
(0.0.4 and newer) and a controller 0.0.1 and newer; a pair too far apart
is refused with a message that names both versions. The controller's
address must be an https:// URL — the agent pins the certificate it
meets and refuses to speak in the clear.
Enrolling again (a reinstall, a lost state store) works with a new
join token, with one guard: while an agent under that hostname is still
talking to the controller — seen in the last two minutes — the
enrolment is refused (an agent by that name is active — revoke it first), so a join token alone cannot take a live machine's place. Stop
the old agent and wait, or revoke it first (POST /v1/agents/{host}/revoke with an operator token), then enrol.
What comes from the controller
Parsers, rules, policies, report presets, trust and the fleet's
exemptions — the tree the controller assembled for this machine at its
level, with its overrides applied. It lands under
/var/lib/shieldlist/received/, a directory the agent replaces whole
on every new revision (a marker file, .shieldlist-received, says it is
the agent's — the agent never replaces a directory it did not write).
shieldlist-agent -t reads it beside your local files, so what you
validate is what runs.
A new revision is applied in place: parsers, rules, policies and the decider are rebuilt between two rounds, without a restart. The rules' windows start over; sanctions, the store and the kernel's sets are untouched. A revision this agent's loader refuses is kept on disk for you to look at, the previous one keeps running, and the refusal is logged and shown to the controller.
Also from the controller, per machine: whether this machine offers its
bans to the fleet and enforces the fleet's, who may lift its bans, who
reports its sanctions (the local reporter stands down when the
controller does it), and test — which relaxes the machine and
never arms it.
What stays yours
agent.yaml (mode, controller, state, level, log inputs, limits,
reporting), secrets.yaml, hooks, and exemptions.yaml,
exemptions.d/, exemptions-cli.yaml — which add to the fleet's. A
hook can never be pushed by a controller.
What goes up, what comes down
Every sanction the machine decides is reported to the controller (with
its rule, the threshold that fired, hits, report state), and every
change to one afterwards. The fleet's sanctions come down when the
machine enforces the fleet's: each passes the local exemptions and the
machine's own addresses first, extends rather than duplicates an active
ban, and is recorded with its origin (fleet:web01/ssh-bruteforce) so
shield why says where it came from. Lifts decided on the controller —
console, API, shield unban — arrive within seconds and are applied
like a local shield unban.
Reading the state of the conversation
shield status agent
controller_version 0.1.0
controller_revision 5f3038a21eb2de3a
controller_sync 9s_ago
controller_error … # only when there is one
The controller's agent list shows the same from its side, plus the machine's active bans and enforcement summary. When the controller is away the agent says so once, keeps enforcing on the last configuration received, and says once more when it is back.
Every sync also compares clocks: past thirty seconds of disagreement the
agent warns in its log (once, and again when it is over) and shield status shows controller_clock; the console warns on its side. Fix NTP.
What travels up
On every sync the agent also sends the offenders it is counting but has not convicted — the nearest to a threshold first, two hundred at most, then the most recently hit among the rest, two hundred at most — so the console shows who is knocking before the ban, the fresh ones included. An offender exempt on this machine travels marked as such (why), past its count too — it is hammering and will never be convicted; the console hides those unless asked. A snapshot: the controller keeps the latest. Beside it, the hits the rules scored since the last sync, by offender — a flow the controller keeps per minute and draws over time; a sync that fails puts them back, nothing is lost. When the controller holds none of this machine's flow yet, the agent sends, once, what its windows still hold, minute by minute — the past as far as the longest window reaches.
Every sanction the machine decides, with the log lines that convicted it (bounded: twenty lines of a kilobyte), and every change to one — a return counted, a report settled, an end brought forward. When the controller holds less than the agent believes — the machine was enrolled again, or an operator asked for its history from the console — the agent sends everything again from what is really held, a thousand a sync, evidence included; nothing doubles, the controller keys each sanction by the machine, its own id and its start.