Node-RED

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

sequenceDiagram participant E as Enroll participant N as Node-RED participant S as signal-cli-rest-api participant P as Phone (Signal) E->>N: POST webhook (diff JSON, X-Enroll-Secret) N->>N: Verify X-Enroll-Secret header N->>N: Parse and format (max 2000 chars) par 201 response N-->>E: 201 Created and Signal send (fire-and-forget) N->>S: POST /v2/send S-->>P: Signal message end

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…

At a glance
  • 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)
Drift in your infrastructure?
I do contract sysadmin and DevSecOps work and can help set up drift detection, config management, and alerting pipelines.
Contact me