Wiring Enroll drift detection to Signal
A small Node-RED webhook that catches Enroll diff payloads, formats the changes into a readable summary, and pushes it to your phone via Signal.
When a server drifts
Enroll can diff two harvests of the same host and tell you exactly what changed: packages added or removed, users modified, services stopped or started, config files altered. That’s useful as a command you run manually. It’s more useful as something that happens automatically and tells you about it.
Node-RED is a really fun way to develop automation workflows with an open-source project that isn’t ‘open core’ (unlike n8n). It’s a little crusty at times compared to those newer, shiner solutions, but it’s totally free.
Node-RED, like n8n and others, lets you define HTTP ‘ingress’ listeners and then bolt on
the automation flows that occur behind that entry point. It’s a perfect use case for webhooks,
including the sort that Enroll’s diff command supports.
The idea is simple. Run Enroll’s diff on a schedule, point the output at a webhook, and get a
notification on your phone the moment a server drifts. No inbox to check, no dashboard to
visit - just a Signal message that says “these things changed on this host.”
The webhook endpoint
The flow starts with an HTTP input node listening for POST on a long, unguessable
UUID-based URL. Enroll sends its diff payload as JSON to this endpoint, along with an
X-Enroll-Secret header. The URL itself is the first layer of authentication - it’s a
capability URL, not a password. If you don’t know the URL, you can’t send to it.
When a payload arrives, the first stop is a function node called “Verify X-Enroll-Secret”
that checks the header against the flow’s environment variable of the same name. If the
header is missing or doesn’t match, the flow returns 401 Unauthorized and stops - the
payload never reaches the formatter. Only valid requests continue to the “Parse” function
that does the real work: turning the structured diff into a human-readable summary.
Formatting the diff
An Enroll diff can be large. A server might have dozens of changed packages, several modified users, and a handful of service changes - all in one payload. Signal messages are not the place for a firehose.
The Parse function enforces two limits: MAX_CHARS at 2000 and
MAX_ITEMS at 25. Lists are truncated and annotated with “… (+N
more)” so you know something was cut. The formatting is terse and scannable:
Changed users are listed by name with the changed keys in parentheses - alice (shell, groups); bob (password) - so you can see at a glance who changed and how. Changed
services follow the same pattern: sshd (enabled); cron (status). Package lists are
joined with commas and truncated. The result is a summary you can read in a few seconds, not a
JSON document you have to parse.
const r = msg.payload || {};
const MAX_CHARS = 2000;
const MAX_ITEMS = 25;
function formatList(title, items, prefix = "• ") {
items = (Array.isArray(items) ? items : []).map(s => s ?? "").filter(Boolean);
if (items.length === 0) return "";
const shown = items.slice(0, MAX_ITEMS);
const more = items.length - shown.length;
let line = `${title}: ${shown.join(", ")}`;
if (more > 0) line += ` … (+${more} more)`;
return line;
}
function formatChangedUsers(changed) {
changed = Array.isArray(changed) ? changed : [];
if (changed.length === 0) return "";
const shown = changed.slice(0, MAX_ITEMS).map(u => {
const name = u.name ?? "";
const keys = Object.keys(u.changes || {});
return keys.length ? `${name} (${keys.join(", ")})` : name;
});
return `Users changed: ${shown.join("; ")}`;
}
function trimToMax(text) {
if (text.length <= MAX_CHARS) return text;
return text.slice(0, MAX_CHARS - 20).trimEnd() + "\n…(truncated)";
}
// Build the summary lines
const lines = [`enroll diff @ ${r.new?.host || r.old?.host || ""}`];
lines.push(formatList("Packages added", r.packages?.added));
lines.push(formatList("Packages removed", r.packages?.removed));
lines.push(formatList("Services enabled +", r.services?.enabled_added));
lines.push(formatChangedUsers(r.users?.changed));
lines.push(formatList("Files added", r.files?.added));
// Join non-empty lines, cap at 2000 chars
msg.payload = trimToMax(lines.filter(s => s?.trim()).join("\n"));
return msg;
Notification via Signal
The formatted summary is wrapped into a Signal message and posted to
http://signal_cli:8080/v2/send - the same REST endpoint used by the
Signal AI assistant
. The message goes out from the
bot’s number to a hardcoded recipient. No email, no Slack webhook, no dashboard - just Signal,
which means it shows up on your phone the same way any message does.
The 201 response
The HTTP response is sent immediately - 201 Created - in parallel with
the Signal send. Enroll doesn’t wait for the Signal message to be delivered; it gets an
acknowledgement as soon as the webhook receives the payload. The Signal send is fire-and-forget,
monitored only by debug nodes. This is the right design: the webhook’s job is to receive and
acknowledge, not to block on a third-party notification service.
Shared-secret authentication
The webhook uses two layers of authentication: the unguessable capability URL and a shared
secret sent in the X-Enroll-Secret header. The verify function is a two-output function
node - valid requests go out port 1 to the Parse function, invalid requests go out port 2 to
an HTTP response node that returns 401:
const expected = env.get("X-Enroll-Secret");
const received = msg.req && msg.req.headers ? msg.req.headers["x-enroll-secret"] : null;
if (!expected || expected !== received) {
msg.statusCode = 401;
msg.payload = { error: "unauthorized" };
return [null, msg];
}
return [msg, null];
The secret is stored as a flow-tab environment variable, not hardcoded in the function. This keeps it out of the exported JSON and lets you rotate it without editing the flow.
Import the flow
The complete flow - webhook HTTP in, secret verification, Parse function, Signal send, and 201 response - is available as a sanitised JSON file with the webhook UUID and phone numbers replaced by placeholders:
Download enroll-diff-webhook.json
In Node-RED: hamburger menu → Import → select the file. After importing, replace
<your-webhook-uuid> in the HTTP in node’s URL, set <bot-number> and
<recipient-number> in the Signal send function, and configure the X-Enroll-Secret
environment variable on the flow tab.
Where to from here
Signal notifications are cool for simple one-person infra setups (hey, I’m one of those!).
At a later date, I’ll show a more sophisticated system that ingests Enroll data into an ’evidence engine’ I’ve been developing, called KEEN - designed to automatically map data like the sort Enroll produces, against security framework Controls such as those of ISO27001:2022 and others. Stay tuned…
- Trigger: Enroll diff mode
- Transport: HTTP POST webhook
- Auth: Capability URL + X-Enroll-Secret header
- Notification: Signal (via signal-cli-rest-api)
- Response: 201 Created (immediate)
- Nodes: 10 (including debug)