diff --git a/.gitignore b/.gitignore index b181d6d..723c2c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,3 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -# Cargo.lock - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# Environment variables -.env - -# Editor settings -.vscode/ -.idea/ -*.swp +# Scratch PR/issue bodies used when filing GitHub issues +.issue-bodies/ +.pr-bodies/ diff --git a/README.md b/README.md index 4806eac..0e07b2c 100644 --- a/README.md +++ b/README.md @@ -57,18 +57,27 @@ names and troubleshooting. ## Verified -The screenshots below are real output from a live run: real Loki, a real -syslog receiver, and AuditBridge's actual binary, with only the upstream -NetBird API stubbed to fixed sample data (no live account involved). - -![Live delivery to Loki and Wazuh](docs/images/live-delivery.png) -![Delivered events queried back from Loki](docs/images/live-loki-query.png) -![Live /healthz, /readyz, and /metrics output](docs/images/live-health-metrics.png) +Every sink type is verified end to end in [examples/local-demo](examples/local-demo/README.md): +real Loki, real TCP and UDP syslog receivers, a real HTTP webhook receiver, and +AuditBridge's actual binary — only the NetBird API is stubbed to fixed sample +data, no live account involved. The evidence below is real output captured from +that compose stack, reproduced with one `docker compose up -d --build`. + +| Sink | Delivery | Captured evidence | +|---|---|---| +| Grafana Loki | `SINKS=loki` | Loki query response, one stream per activity, nanosecond timestamps | +| Wazuh | `SINKS=wazuh`, `SINK_WAZUH_ADDR` | RFC 3164 frames received over TCP | +| Generic HTTP | `SINK__TRANSPORT=http` | `application/x-ndjson` `POST /ingest` received at the webhook | +| Generic syslog | `SINK__TRANSPORT=syslog` | RFC 5424 frames received over UDP | + +Each section in [Sinks](docs/SINKS.md) pairs the complete configuration with +its verification commands and the captured output, including readyz/metrics +showing per-sink delivered totals. > [!TIP] -> Don't take the screenshots' word for it: `examples/local-demo/` reproduces -> this exact setup with one `docker compose up`. See -> [examples/local-demo/README.md](examples/local-demo/README.md). +> Don't take this page's word for it: `examples/local-demo/` reproduces the +exact setup with one command, and every block above is re-capturable from its +logs and endpoints. See [examples/local-demo/README.md](examples/local-demo/README.md). ## License diff --git a/docs/SINKS.md b/docs/SINKS.md index 4d31816..373042b 100644 --- a/docs/SINKS.md +++ b/docs/SINKS.md @@ -2,21 +2,161 @@ Set `SINKS` to a comma-separated list. Each sink maintains its own cursor, so a failing destination does not block successful deliveries to another one. +Every scenario below is verified end to end in +[examples/local-demo](../examples/local-demo/): the evidence blocks are real +output captured from that compose stack against a real Loki, real TCP and UDP +syslog receivers, and a real HTTP webhook receiver. Only the NetBird API is +stubbed to fixed sample data, no live account is involved. -## Loki +## Grafana Loki -`loki` is enabled by default. Set `LOKI_URL` to a Loki base URL or use -`SINK_LOKI_URL` for an exact push URL. +`loki` is enabled by default. Events are pushed to Loki's +`/loki/api/v1/push` endpoint as per-label-set streams with nanosecond +timestamps, which is what Loki requires to accept a batch at all. + +| Variable | Default | Meaning | +|---|---|---| +| `LOKI_URL` | `http://loki:3100` | Loki base URL; the push path is appended | +| `SINK_LOKI_URL` | unset | Exact push URL, used verbatim when set (overrides `LOKI_URL`) | +| `SINK_LOKI_ENCODING` | `loki` | Keep the `loki` encoding unless you have a reason not to | + +The Loki label set is fixed: `job="netbird-events"`, `account_id` (defaults to +`unknown` when missing), `activity`, and `activity_code`. The higher-cardinality +`initiator_*` fields stay in the JSON log line rather than as labels, so streams +fragment per activity, not per user. + +### Worked example + +```bash +docker run -d --rm --name auditbridge \ + -v "$PWD/netbird-token:/run/secrets/netbird-token:ro" \ + -e NETBIRD_API_TOKEN_FILE=/run/secrets/netbird-token \ + -e SINKS=loki \ + -e LOKI_URL=https://loki.example.com \ + -p 9090:9090 \ + ghcr.io/onelrian/auditbridge: +``` + +### Verify + +Query Loki for the delivered stream: + +```bash +docker compose exec loki wget -qO- \ + 'http://localhost:3100/loki/api/v1/query_range?query=%7Bjob%3D%22netbird-events%22%7D' +``` + +Real response from the demo (three events, one stream per activity; Loki 3.x +adds the `service_name` label it derives from `job`): + +```json +{"status":"success","data":{"resultType":"streams","result":[ + {"stream":{"account_id":"acc-demo-01","activity":"User login","activity_code":"user.login","job":"netbird-events","service_name":"netbird-events"}, + "values":[["1786538589000000000","{\"account_id\":\"acc-demo-01\",\"activity\":\"User login\",\"activity_code\":\"user.login\",\"event_id\":\"evt-1003\",\"initiator_email\":\"alice@example.com\",\"initiator_id\":\"user-alice\",\"initiator_name\":\"Alice Example\",\"meta\":null,\"target_id\":null,\"timestamp\":\"2026-08-12T12:43:09Z\"}"]]}, + {"stream":{"account_id":"acc-demo-01","activity":"Peer added","activity_code":"peer.add","job":"netbird-events","service_name":"netbird-events"}, + "values":[["1786538581000000000","{\"account_id\":\"acc-demo-01\",\"activity\":\"Peer added\",\"activity_code\":\"peer.add\",\"event_id\":\"evt-1001\",\"initiator_email\":\"alice@example.com\",\"initiator_id\":\"user-alice\",\"initiator_name\":\"Alice Example\",\"meta\":{\"peer_name\":\"laptop-alice\"},\"target_id\":\"peer-7f3a\",\"timestamp\":\"2026-08-12T12:43:01Z\"}"]]}, + {"stream":{"account_id":"acc-demo-01","activity":"Group created","activity_code":"group.add","job":"netbird-events","service_name":"netbird-events"}, + "values":[["1786538585000000000","{\"account_id\":\"acc-demo-01\",\"activity\":\"Group created\",\"activity_code\":\"group.add\",\"event_id\":\"evt-1002\",\"initiator_email\":\"bob@example.com\",\"initiator_id\":\"user-bob\",\"initiator_name\":\"Bob Example\",\"meta\":{\"group_name\":\"engineering\"},\"target_id\":\"group-eng\",\"timestamp\":\"2026-08-12T12:43:05Z\"}"]]} +]}} +``` + +In Grafana, add a Loki data source and query with LogQL, e.g. all audit events, +then filter to logins: + +```logql +{job="netbird-events"} +{job="netbird-events"} |= "user.login" +``` ## Wazuh -Add `wazuh` to `SINKS` and set `SINK_WAZUH_ADDR` or `WAZUH_ADDR` to the Wazuh -manager `host:port`. The default syslog encoding is RFC3164. +Add `wazuh` to `SINKS` and point it at the Wazuh manager's syslog listener. +The preset uses RFC 3164 framing over TCP, the combination most Wazuh +deployments accept from an agentless sender. + +| Variable | Default | Meaning | +|---|---|---| +| `SINK_WAZUH_ADDR` / `WAZUH_ADDR` | none | Manager `host:port` (required) | +| `SINK_WAZUH_PROTOCOL` | `tcp` | `tcp` or `udp` | +| `SINK_WAZUH_ENCODING` | `syslog3164` | `syslog3164` or `syslog5424` | + +### Worked example (TCP, the default) + +```bash +docker run -d --rm --name auditbridge \ + -v "$PWD/netbird-token:/run/secrets/netbird-token:ro" \ + -e NETBIRD_API_TOKEN_FILE=/run/secrets/netbird-token \ + -e SINKS=wazuh \ + -e SINK_WAZUH_ADDR=wazuh-manager:1514 \ + ghcr.io/onelrian/auditbridge: +``` + +For UDP, add `-e SINK_WAZUH_PROTOCOL=udp` and set the manager's UDP listener +port in `SINK_WAZUH_ADDR`. + +### Wazuh-side configuration + +On the Wazuh manager, register a decoder and ruleset so delivered frames +become alerts. AuditBridge emits the event JSON as the MSG part with keys in +stable alphabetical order, e.g.: + +``` +<134>2026-08-12T12:43:01Z auditbridge netbird-audit: {"account_id":"acc-demo-01","activity":"Peer added","activity_code":"peer.add","event_id":"evt-1001",...} +``` + +`/var/ossec/etc/decoders/local_decoder.xml`: + +```xml + + auditbridge netbird-audit + + "activity_code":"(\S+)" + status + +``` + +`/var/ossec/etc/rules/local_rules.xml`: + +```xml + + + netbird-audit + NetBird audit event delivered + + + netbird-audit + peer.add + NetBird peer added + + + netbird-audit + user.login + NetBird user login + + +``` + +Restart the manager, then confirm alerts land in +`/var/ossec/logs/alerts/alerts.json` (`grep netbird-audit ...`). Adapt the +decoder's `` to your Wazuh version's regex flavour; the wire frames +below are the proof the manager is being fed. -## Generic sinks +### Verify -For every other sink name, use these variables with `` converted to upper -case and underscores: +The demo's `wazuh-receiver` is a TCP listener standing in for the manager. +Real frames received (RFC 3164, one per event): + +``` +[syslog-tcp] <134>2026-08-12T12:43:01Z auditbridge netbird-audit: {"account_id":"acc-demo-01","activity":"Peer added","activity_code":"peer.add","event_id":"evt-1001","initiator_email":"alice@example.com","initiator_id":"user-alice","initiator_name":"Alice Example","meta":{"peer_name":"laptop-alice"},"target_id":"peer-7f3a","timestamp":"2026-08-12T12:43:01Z"} +[syslog-tcp] <134>2026-08-12T12:43:05Z auditbridge netbird-audit: {"account_id":"acc-demo-01","activity":"Group created","activity_code":"group.add","event_id":"evt-1002","initiator_email":"bob@example.com","initiator_id":"user-bob","initiator_name":"Bob Example","meta":{"group_name":"engineering"},"target_id":"group-eng","timestamp":"2026-08-12T12:43:05Z"} +[syslog-tcp] <134>2026-08-12T12:43:09Z auditbridge netbird-audit: {"account_id":"acc-demo-01","activity":"User login","activity_code":"user.login","event_id":"evt-1003","initiator_email":"alice@example.com","initiator_id":"user-alice","initiator_name":"Alice Example","meta":null,"target_id":null,"timestamp":"2026-08-12T12:43:09Z"} +``` + +## Generic HTTP sinks + +Every sink name other than `loki` and `wazuh` is fully configurable: pick the +transport, the encoding, and the destination. HTTP is the `http` transport. | Variable | Required for | Values | |---|---|---| @@ -26,8 +166,6 @@ case and underscores: | `SINK__METHOD` | HTTP | HTTP method, default `POST` | | `SINK__HEADERS` | HTTP | Comma-separated `Name:Value` headers | | `SINK__HEADERS_FILE` | HTTP | File containing headers instead of the direct variable | -| `SINK__ADDR` | syslog | Destination `host:port` | -| `SINK__PROTOCOL` | syslog | `tcp` or `udp`, default `tcp` | > [!TIP] > Use the `_HEADERS_FILE` form for bearer tokens and API keys, mounted from a @@ -47,12 +185,71 @@ docker run -d --rm --name auditbridge \ -e LOKI_URL=https://loki.example.com \ -e SINK_WEBHOOK_TRANSPORT=http \ -e SINK_WEBHOOK_URL=https://collector.example.com/ingest \ - -e SINK_WEBHOOK_ENCODING=json \ + -e SINK_WEBHOOK_ENCODING=ndjson \ -e SINK_WEBHOOK_HEADERS="Authorization:Bearer your-webhook-token" \ ghcr.io/onelrian/auditbridge: ``` -The same pattern applies to any generic sink, HTTP or syslog. -[examples/local-demo](../examples/local-demo/) exercises `loki` and `wazuh` -together against real receivers; the `webhook` example above follows the same -shape for a generic HTTP destination instead. +A replay of the full account history (fresh install, lost cursor) is split +into `BATCH_SIZE`-event requests, default 500, so payloads stay under intake +limits; see [Configuration](CONFIGURATION.md). + +### Verify + +The demo's `webhook-receiver` logs each request. Real delivery with +`ndjson` encoding and an `X-Demo` header: + +``` +[webhook-receiver] POST /ingest content-type=application/x-ndjson +[webhook-receiver] {"id":"evt-1001","timestamp":"2026-08-12T12:43:01Z","activity":"Peer added","activity_code":"peer.add","initiator_id":"user-alice","initiator_email":"alice@example.com","initiator_name":"Alice Example","target_id":"peer-7f3a","account_id":"acc-demo-01","meta":{"peer_name":"laptop-alice"}} +[webhook-receiver] {"id":"evt-1002","timestamp":"2026-08-12T12:43:05Z","activity":"Group created","activity_code":"group.add","initiator_id":"user-bob","initiator_email":"bob@example.com","initiator_name":"Bob Example","target_id":"group-eng","account_id":"acc-demo-01","meta":{"group_name":"engineering"}} +[webhook-receiver] {"id":"evt-1003","timestamp":"2026-08-12T12:43:09Z","activity":"User login","activity_code":"user.login","initiator_id":"user-alice","initiator_email":"alice@example.com","initiator_name":"Alice Example","target_id":null,"account_id":"acc-demo-01","meta":null} +``` + +## Generic syslog sinks + +Syslog is the `syslog` transport. Any address, either protocol, either +framing; RFC 5424 for consumers confirmed to support it, RFC 3164 otherwise. + +| Variable | Required for | Values | +|---|---|---| +| `SINK__TRANSPORT` | every generic sink | `http` or `syslog` | +| `SINK__ENCODING` | every generic sink | `syslog3164` or `syslog5424` | +| `SINK__ADDR` | syslog | Destination `host:port` | +| `SINK__PROTOCOL` | syslog | `tcp` or `udp`, default `tcp` | + +### Worked example: RFC 5424 over UDP + +```bash +docker run -d --rm --name auditbridge \ + -v "$PWD/netbird-token:/run/secrets/netbird-token:ro" \ + -e NETBIRD_API_TOKEN_FILE=/run/secrets/netbird-token \ + -e SINKS=sysloggen \ + -e SINK_SYSLOGGEN_TRANSPORT=syslog \ + -e SINK_SYSLOGGEN_ADDR=collector.example:1515 \ + -e SINK_SYSLOGGEN_PROTOCOL=udp \ + -e SINK_SYSLOGGEN_ENCODING=syslog5424 \ + ghcr.io/onelrian/auditbridge: +``` + +For TCP, drop `SINK_SYSLOGGEN_PROTOCOL` (TCP is the default) and point +`SINK_SYSLOGGEN_ADDR` at the TCP listener; the Wazuh section above shows the +RFC 3164-over-TCP variant in action. + +### Verify + +The demo's `wazuh-receiver` also listens on UDP 1515. Real frames received +(RFC 5424: `VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID +STRUCTURED-DATA MSG`): + +``` +[syslog-udp] <134>1 2026-08-12T12:43:01Z auditbridge netbird-audit - AUDIT - {"account_id":"acc-demo-01","activity":"Peer added","activity_code":"peer.add","event_id":"evt-1001","initiator_email":"alice@example.com","initiator_id":"user-alice","initiator_name":"Alice Example","meta":{"peer_name":"laptop-alice"},"target_id":"peer-7f3a","timestamp":"2026-08-12T12:43:01Z"} +``` + +## Reproduce everything + +`examples/local-demo/` runs all four scenarios at once: one `auditbridge` +instance with `SINKS=loki,wazuh` and a second, `auditbridge-generic`, with +`SINKS=webhook,sysloggen` covering the generic HTTP and generic syslog paths. +See [examples/local-demo/README.md](../examples/local-demo/README.md) for the +one-command setup and the verification commands whose output appears above. diff --git a/docs/images/live-delivery.png b/docs/images/live-delivery.png deleted file mode 100644 index 2035918..0000000 Binary files a/docs/images/live-delivery.png and /dev/null differ diff --git a/docs/images/live-health-metrics.png b/docs/images/live-health-metrics.png deleted file mode 100644 index bd5774f..0000000 Binary files a/docs/images/live-health-metrics.png and /dev/null differ diff --git a/docs/images/live-loki-query.png b/docs/images/live-loki-query.png deleted file mode 100644 index e8e3741..0000000 Binary files a/docs/images/live-loki-query.png and /dev/null differ diff --git a/examples/local-demo/README.md b/examples/local-demo/README.md index c633172..d9ad400 100644 --- a/examples/local-demo/README.md +++ b/examples/local-demo/README.md @@ -1,32 +1,52 @@ # Local demo -Runs AuditBridge end to end against a real Loki, a real syslog receiver, and -a stubbed NetBird API (`mock_netbird.py` serves fixed sample audit events, no -real NetBird account or credentials are used). This reproduces exactly what -the screenshots in [docs/OPERATIONS.md](../../docs/OPERATIONS.md) show. +Runs AuditBridge end to end against a real Loki, real TCP and UDP syslog +receivers, a real HTTP webhook receiver, and a stubbed NetBird API +(`mock_netbird.py` serves fixed sample audit events, no real NetBird account +or credentials are used). Two AuditBridge instances cover every sink type: + +- `auditbridge` — the `loki` and `wazuh` sink presets +- `auditbridge-generic` — a generic `http` sink (`webhook`, ndjson encoding, + custom header) and a generic `syslog` sink (`sysloggen`, RFC 5424 over UDP) + +All four destinations are verified in the logs below; every command works +against this stack. ```bash docker compose up -d --build ``` -Then verify delivery yourself: +## Verify delivery ```bash # Startup and delivery logs docker compose logs auditbridge +docker compose logs auditbridge-generic # Events actually stored in Loki -curl -s 'http://localhost:3100/loki/api/v1/query_range?query={job="netbird-events"}' | jq +docker compose exec loki wget -qO- \ + 'http://localhost:3100/loki/api/v1/query_range?query=%7Bjob%3D%22netbird-events%22%7D' + +# Frames received by the Wazuh-equivalent TCP syslog listener (RFC 3164) +docker compose logs wazuh-receiver | grep syslog-tcp -# Frames received by the Wazuh-equivalent syslog receiver -docker compose logs wazuh-receiver +# Frames received by the UDP syslog listener (RFC 5424) +docker compose logs wazuh-receiver | grep syslog-udp -# Live health and metrics +# NDJSON batches received by the generic HTTP webhook +docker compose logs webhook-receiver + +# Live health and metrics (main instance on 19090, generic on 19091) curl http://localhost:19090/healthz curl http://localhost:19090/readyz curl http://localhost:19090/metrics +curl http://localhost:19091/readyz +curl http://localhost:19091/metrics ``` +`auditbridge_events_delivered_total{sink="loki"}` and friends show the +per-sink delivered counts after the first poll cycle. + Loki's API isn't exposed to the host by default in this compose file; run the query from inside the `loki` container instead if you don't want to add a port mapping: diff --git a/examples/local-demo/docker-compose.yml b/examples/local-demo/docker-compose.yml index 4153d2c..24e4e1c 100644 --- a/examples/local-demo/docker-compose.yml +++ b/examples/local-demo/docker-compose.yml @@ -13,6 +13,15 @@ services: command: ["python3", "-u", "/syslog_receiver.py"] networks: [demo] + # Generic HTTP intake endpoint: proves the generic http sink delivers its + # chosen encoding (ndjson below) with the configured headers. + webhook-receiver: + image: python:3.12-slim + volumes: + - ./webhook_receiver.py:/webhook_receiver.py:ro + command: ["python3", "-u", "/webhook_receiver.py"] + networks: [demo] + loki: image: grafana/loki:3.2.1 command: ["-config.file=/etc/loki/local-config.yaml"] @@ -34,5 +43,30 @@ services: depends_on: [mock-netbird, loki, wazuh-receiver] networks: [demo] + # Second instance proving the generic http and generic syslog sinks: one + # webhook sink (http + ndjson + custom header) and one syslog sink + # (udp + RFC5424). Both are pure configuration, no different binary. + auditbridge-generic: + build: + context: ../.. + environment: + NETBIRD_API_URL: "http://mock-netbird:8080" + NETBIRD_API_TOKEN: "demo-token-not-real" + SINKS: "webhook,sysloggen" + SINK_WEBHOOK_TRANSPORT: "http" + SINK_WEBHOOK_URL: "http://webhook-receiver:9099/ingest" + SINK_WEBHOOK_ENCODING: "ndjson" + SINK_WEBHOOK_HEADERS: "X-Demo:auditbridge" + SINK_SYSLOGGEN_TRANSPORT: "syslog" + SINK_SYSLOGGEN_ADDR: "wazuh-receiver:1515" + SINK_SYSLOGGEN_PROTOCOL: "udp" + SINK_SYSLOGGEN_ENCODING: "syslog5424" + CHECK_INTERVAL: "5" + RUST_LOG: "info" + ports: + - "19091:9090" + depends_on: [mock-netbird, webhook-receiver, wazuh-receiver] + networks: [demo] + networks: demo: {} diff --git a/examples/local-demo/syslog_receiver.py b/examples/local-demo/syslog_receiver.py index 3cb3f7c..aa66ba2 100644 --- a/examples/local-demo/syslog_receiver.py +++ b/examples/local-demo/syslog_receiver.py @@ -1,16 +1,28 @@ #!/usr/bin/env python3 -"""Minimal TCP syslog receiver standing in for a Wazuh manager's listener, -to prove the RFC3164 syslog sink actually delivers framed events over the -wire, not just that it compiles.""" +"""Minimal syslog receivers standing in for a Wazuh manager's listener (TCP, +port 1514, exercised by the `wazuh` sink preset) and a generic syslog intake +(UDP, port 1515, exercised by a generic `syslog` sink), to prove the syslog +sinks actually deliver framed events over the wire, not just that they +compile.""" import socketserver +import threading -class Handler(socketserver.StreamRequestHandler): +class TcpHandler(socketserver.StreamRequestHandler): def handle(self): for line in self.rfile: - print(f"[wazuh-receiver] {line.decode().rstrip()}") + print(f"[syslog-tcp] {line.decode(errors='replace').rstrip()}") + + +class UdpHandler(socketserver.BaseRequestHandler): + def handle(self): + data = self.request[0].decode(errors="replace") + for line in data.splitlines(): + print(f"[syslog-udp] {line}") if __name__ == "__main__": - with socketserver.ThreadingTCPServer(("0.0.0.0", 1514), Handler) as server: - server.serve_forever() + tcp = socketserver.ThreadingTCPServer(("0.0.0.0", 1514), TcpHandler) + udp = socketserver.ThreadingUDPServer(("0.0.0.0", 1515), UdpHandler) + threading.Thread(target=tcp.serve_forever, daemon=True).start() + udp.serve_forever() diff --git a/examples/local-demo/webhook_receiver.py b/examples/local-demo/webhook_receiver.py new file mode 100644 index 0000000..630beaa --- /dev/null +++ b/examples/local-demo/webhook_receiver.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Minimal HTTP receiver standing in for any generic webhook/HTTP intake +endpoint, to prove the generic HTTP sink delivers encoded batches over the +wire, not just that it compiles. Logs each request's method, path, content +type, and body so delivery is visible in `docker compose logs`.""" +import http.server + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode(errors="replace") + print( + f"[webhook-receiver] {self.command} {self.path} " + f"content-type={self.headers.get('Content-Type')}" + ) + for line in body.splitlines(): + print(f"[webhook-receiver] {line}") + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, fmt, *args): + # Silence the default per-request access log line; the prints above + # are the evidence we want in the compose logs. + pass + + +if __name__ == "__main__": + http.server.HTTPServer(("0.0.0.0", 9099), Handler).serve_forever()