Shipping reports to a server

--server <url> POSTs Delta’s JSON to a collector — once in one-shot mode, every cycle in monitor mode — so a fleet can stream its integrity / drift state to a central endpoint.

delta --cfg policy.json --processes --ports --netif \
      --server http://collector.internal:8080/ingest

delta --processes --netif --interval 60 \
      --server http://collector.internal:8080/ingest --spool /var/spool/delta
  • One-shot sends the full report (the same document as JSON output); --server implies report generation even without --json.

  • Monitor (Continuous monitoring) sends one compact NDJSON object per cycle.

A non-2xx response or transport failure prints a warning but does not change the exit code (which reflects the checks); a monitor keeps running.

Identifying the source (--token)

--token <token> attaches an identification token so the collector can attribute the signals to a specific device / agent. The token is sent two ways:

  • embedded in the JSON report as a top-level "token" field (and in every monitor NDJSON cycle), so it travels with the data — including through the retry spool;

  • as an Authorization: Bearer <token> HTTP header, so a gateway or the server can authenticate / route the request before parsing the body.

delta --processes --netif --interval 60 \
      --server https://collector.example.com/ingest --token "$DEVICE_TOKEN"

The token is opaque to Delta — use a per-device key, a fleet/tenant id, or a bearer credential as your collector expects.

Reliable delivery (--spool <dir>)

By default a failed POST is dropped. With --spool <dir> an undeliverable report is written there and retried on later cycles and runs, surviving transient collector outages without losing data. Each run drains the backlog oldest-first, then sends the current report; on failure the current report is spooled too. The directory is bounded (oldest dropped past a cap).

HTTPS / TLS

Build with the libcurl backend for https:// (see Build- & run-time features):

cmake -S . -B build -G Ninja -DDELTA_HTTP_BACKEND=curl
delta --processes --server https://collector.example.com/ingest
  • TLS is verified by default (peer + hostname).

  • --cacert <file> — verify against a custom CA bundle (private / embedded CAs).

  • --insecure — skip verification. Unsafe; self-signed testing only.

The default socket backend has no TLS and rejects an https:// URL with a clear message; on an untrusted network with it, front Delta with a TLS-terminating reverse proxy.

NAT reachability beacon (--stun)

--stun <host[:port]> turns a public STUN server into a liveness / reachability beacon. Delta’s reports already traverse NAT outbound, so STUN is not used for transport — it answers “can this device still reach the outside, from what public address, and how fast”. Each probe is a single UDP Binding Request (RFC 5389/8489, retransmitted within ~3 s); the reply yields the server-reflexive (public) ip:port and the round-trip time:

delta --interval 60 --server https://collector/ingest \
      --stun stun.l.google.com:19302 --json

The result is reported as a stun block (one-shot) and on every monitor cycle (so the collector sees the device is alive and behind which NAT):

"stun": { "reachable": true, "public": "[2001:db8::1]:54321", "rtt_ms": 21 }

Reachability and the public address are part of the monitor keyset (nat:reachable=… / nat:public=…), so a lost beacon or a changed public IP shows up as drift; the RTT feeds the behavioral model, so a latency spike can surface as an anomaly. It is a signal, never a gate — an unreachable STUN server does not add a violation or change the exit code. Zero-dependency (raw UDP), so it works in any build, independent of DELTA_FEATURE_HTTP.

In monitor mode --stun upgrades the beacon to a persistent keepalive + wake channel (next section).

Server-driven control (commands + wake)

In monitor mode the sync is bidirectional: the collector answers each report with a JSON body of commands, which the agent executes. This turns Delta into a remotely-steerable agent without any inbound listener on the telemetry path — commands always ride the authenticated, token-bearing outbound POST.

Response schema:

{ "commands": [
    { "action": "disable" },
    { "action": "enable" },
    { "action": "reconfigure", "config": { "collect": { "processes": true } } },
    { "action": "reconfigure", "config_url": "https://srv/policy.json" },
    { "action": "collect", "domains": ["processes", "conns"] },
    { "action": "stop" }
] }
  • enable / disable — toggle telemetry collection. While disabled the agent still sends a tiny {"status":"disabled"} heartbeat each cycle, so the command channel (and a later enable) keeps working.

  • reconfigure — apply a new policy, either config inline or fetched from config_url; written to a local file and used from the next cycle (no restart).

  • collect — run an immediate out-of-schedule cycle, optionally widening the collected domains.

  • stop — exit cleanly.

Signed commands. By default commands are trusted because the transport is (HTTPS + a bearer token). Where that is not enough — a device that must not obey a compromised or impersonated collector — --verify-key <hex> pins an Ed25519 public key and the agent then acts only on commands wrapped in a signed envelope:

{ "signed": "<hex of the exact command-JSON bytes>",
  "sig":    "<hex Ed25519 signature over those bytes>" }

The agent verifies sig against the pinned key over the exact payload bytes (no canonicalization gap), then parses the inner JSON for commands. A seq field inside the signed payload gives replay protection: the agent remembers the highest sequence it has accepted and drops any envelope at or below it, so a captured “disable” cannot be re-played later. When the key is set, a reconfigure that fetches config_url requires the fetched config to be a signed envelope too — otherwise a host at that URL could inject arbitrary policy. Anything unsigned, stale or forged is dropped and the agent carries on; the switch is fail-closed.

Only verification runs on the device (vendored TweetNaCl, ~8 KB). Generate the keypair and sign envelopes server-side with tools/delta-sign.py:

tools/delta-sign.py keygen --out-prefix delta-ctl        # -> .sk (secret) + .pk
echo '{"commands":[{"action":"disable"}]}' \
    | tools/delta-sign.py sign --key delta-ctl.sk --seq 7 > envelope.json
# deploy: delta --interval N --server … --verify-key $(cat delta-ctl.pk)

Active response

The commands above only steer collection. A separate, destructive set lets the operator act on a host through the agent, each action naming its target explicitly:

{ "action": "kill",       "pid": 1234 }
{ "action": "kill",       "process": "evilagent" }
{ "action": "quarantine", "path": "/tmp/dropper" }
{ "action": "restore",    "path": "/tmp/dropper" }
{ "action": "block",      "ip": "203.0.113.5" }
{ "action": "unblock",    "ip": "203.0.113.5" }
{ "action": "fetch",      "path": "/etc/ld.so.preload" }
  • kill — SIGKILL a pid, or every process with a given name.

  • quarantine — move a file into the quarantine directory (--quarantine-dir, default /var/lib/delta/quarantine), renamed to a non-executable <name>.<inode>.quar and chmod’d 0000. The move is recorded in a .manifest sidecar.

  • restore — undo a quarantine: move the file back to its original path and mode, using the manifest. The inverse of quarantine.

  • block — install a drop rule for an IP via nft (iptables / ip6tables fallback). The IP is validated as a bare literal first, since it reaches a shell. Note: the rule is not persistent — a reboot clears it.

  • unblock — remove the drop rule for an IP (nft by handle, iptables -D). The inverse of block.

  • fetch — read a file (size-capped at 4 MiB) and upload it to the collector as an {"artifact": …} object with the bytes hex-encoded, for off-device forensics.

These are remote-administration primitives, not detections. Delta never decides that something is malicious and acts on it: nothing the agent observes — a policy violation, an indicator match, a drift finding — triggers any of these. The operator reads the report, decides, and names the pid, path or IP in a command. Detection and action are separate code paths and separate decisions, the second of which is a human’s.

Because these change or destroy host state, they are gated twice and fail-closed:

  1. the command must arrive in a valid signed envelope (--verify-key) — an unsigned or impersonated server can never trigger one; and

  2. the operator must explicitly opt in with --allow-response.

With either missing, a response command is logged and dropped; the read-only commands keep working regardless. Response actions are POSIX-only and are refused under --root (kill/block have no meaning against an offline image).

Both gates are runtime settings. To remove the capability from the binary altogether — a build in which no code can modify host state at all — compile with DELTA_FEATURE_RESPOND=OFF (see Report-only builds).

Acks. Each executed response is reported back to the collector as an acks array, so the operator sees programmatically whether it took effect:

{ "acks": [
    { "action": "quarantine", "target": "/tmp/dropper",
      "ok": true, "detail": "quarantined '/tmp/dropper' -> …" } ] }

The array is POSTed to --server right after the batch runs (a fetch also uploads the file itself as a separate {"artifact": …} object).

STUN wake. With --stun the monitor holds one UDP socket open (kept alive by STUN keepalives) and listens on it. An inbound datagram — a “poke” the collector sends to the device’s last-reported public ip:port — makes the agent sync immediately instead of waiting out the interval. The poke only triggers a sync; the actual commands still come back over the authenticated HTTPS response, so a forged poke can at worst cause an extra sync (and, when a --token is set, the poke datagram must contain it). The poke reaches the device only through cone / endpoint-independent NAT; behind symmetric NAT the interval is the fallback — the agent syncs on its own timer regardless.

End-to-end, the operating model is: the server cross-builds Delta for the target arch (Cross-compilation), copies and ssh-launches it as delta --interval N --server https://… --stun … --token …; the agent reports periodically, holds the STUN keepalive, syncs immediately on a poke, and applies whatever the server returns.

Notes

  • Connect / send / receive are each bounded (~5 s), so an unreachable collector cannot stall a monitor cycle. The STUN probe is likewise bounded (~3 s).

  • Compiled only when DELTA_FEATURE_HTTP=ON (default); an OFF build rejects --server. --stun has no such dependency.