Tutorial: forward the macrdp audit stream to a SIEM (OpenSearch, or lighter Grafana + Loki)
July 10, 2026 · View on GitHub
A complete, copy-paste walkthrough that stands up a real open-source SIEM on your Mac and detects an RDP brute-force against macrdp — end to end, in about 15 minutes.
The main path uses OpenSearch + OpenSearch Dashboards (the Apache-2.0 fork of Elasticsearch / Kibana). OpenSearch ships a bundled Security Analytics plugin — a genuine SIEM with Sigma-based detection rules, findings, and alerts — so this is "forward to a SIEM," not just "ship logs to a search box." Everything here is free, self-hosted, and runs on one laptop.
Want a lighter box? A fully worked Grafana + Loki variant (≈⅓–½ the RAM, LogQL + Grafana Alerting) is at the end — it reuses Step 1 and swaps only the backend. Both variants were validated end-to-end against live stacks.
macrdp ──(--audit-file)──▶ ~/Library/Logs/macrdp-audit.log
│
Fluent Bit (tail) ← the collector
│ HTTPS
▼
OpenSearch :9200 ← store + Security Analytics (the SIEM)
│
▼
OpenSearch Dashboards :5601 ← search, detectors, findings
This is the pipeline from siem-forwarding.md made concrete. For what
each event and field means, keep audit-log.md open alongside.
Demo posture, not production. This tutorial uses a single OpenSearch node, self-signed TLS, and a throwaway admin password so it runs on one machine with zero external setup. The Hardening for production section at the end says exactly what to change before this touches real traffic. Don't run the demo config as-is on anything but a lab box.
Prerequisites
- Docker Desktop (for OpenSearch + Dashboards). ~2 GB free RAM for the stack.
- Fluent Bit — the log collector:
brew install fluent-bit. - macrdp — a build with
--audit-file(v0.8.33+):cargo build --release. - sdl-freerdp (to generate test connections):
brew install freerdp. - macOS. Replace
YOUwith your username (whoami) wherever it appears below.
Step 1 — turn on macrdp's JSON audit stream
Run macrdp on loopback with the audit stream pointed at a stable path. Loopback is exempt
from the auth guard (so a wrong-password loop can't lock you out mid-tutorial), but it still
emits the accept / auth / disconnect audit events — which is exactly what we want to
forward. --skip-auth still makes CredSSP validate against the throwaway --username /
--password, so a wrong password produces a real auth failure.
mkdir -p ~/Library/Logs
./target/release/macrdp \
--bind 127.0.0.1:3390 --skip-auth --allow-sleep \
--username audittest --password 's3cret-good' \
--audit-file ~/Library/Logs/macrdp-audit.log
Leave it running in this terminal. In a second terminal, prove the file gets written:
sdl-freerdp /v:127.0.0.1:3390 /u:audittest /p:'s3cret-good' /cert:ignore +auth-only /log-level:ERROR
tail -n 3 ~/Library/Logs/macrdp-audit.log
You should see JSON lines like:
{"timestamp":"2026-07-10T18:22:04.117Z","level":"INFO","target":"macrdp::audit","schema_version":1,"macrdp_version":"0.8.33","host":"your-mac","event":"accept","src_ip":"127.0.0.1","src_port":54132}
{"timestamp":"2026-07-10T18:22:04.402Z","level":"INFO","target":"macrdp::audit","schema_version":1,"macrdp_version":"0.8.33","host":"your-mac","event":"auth","src_ip":"127.0.0.1","src_port":54132,"outcome":"success"}
+auth-onlyruns just the CredSSP exchange and exits — no window. It makes two TCP connections per invocation (a pre-NLA probe + the NLA connection), so you'll see an extraaccept/disconnectpair with noauthevent. That's normal; ignore it.
Step 2 — stand up OpenSearch + Dashboards
Create a working directory and drop in a compose file:
mkdir -p ~/macrdp-siem && cd ~/macrdp-siem
~/macrdp-siem/docker-compose.yml:
services:
opensearch:
image: opensearchproject/opensearch:2.17.1
container_name: macrdp-opensearch
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
# Demo-only credential. Must satisfy OpenSearch's strong-password policy.
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=MacrdpSiem!2026
ulimits:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 65536, hard: 65536 }
ports:
- "9200:9200"
networks: [siem]
dashboards:
image: opensearchproject/opensearch-dashboards:2.17.1
container_name: macrdp-dashboards
environment:
- 'OPENSEARCH_HOSTS=["https://opensearch:9200"]'
ports:
- "5601:5601"
depends_on: [opensearch]
networks: [siem]
networks:
siem:
Bring it up:
docker compose up -d
OpenSearch takes ~60–90 s to go green on first run. Verify (self-signed TLS, hence -k):
curl -sk -u admin:'MacrdpSiem!2026' https://localhost:9200/_cluster/health | python3 -m json.tool
Wait for "status": "green" or "yellow" (yellow is fine for a single node). Dashboards will
be at http://localhost:5601 shortly after — log in with admin / MacrdpSiem!2026.
Step 3 — ship events with Fluent Bit
Fluent Bit tails the native log file and writes each JSON line as one OpenSearch document. Run it natively (via brew) rather than in a container — a native tail on a native file has reliable file-change notification, whereas tailing a bind-mounted macOS file from inside a container is flaky.
~/macrdp-siem/parsers.conf:
[PARSER]
Name macrdp_json
Format json
Time_Key timestamp
Time_Format %Y-%m-%dT%H:%M:%S.%L%z
~/macrdp-siem/fluent-bit.conf (replace YOU):
[SERVICE]
Flush 1
Log_Level info
Parsers_File parsers.conf
[INPUT]
Name tail
Path /Users/YOU/Library/Logs/macrdp-audit.log
Tag macrdp.audit
Parser macrdp_json
Read_from_Head true
Refresh_Interval 5
[OUTPUT]
Name opensearch
Match macrdp.audit
Host localhost
Port 9200
HTTP_User admin
HTTP_Passwd MacrdpSiem!2026
TLS On
TLS.Verify Off
Index macrdp-audit
Suppress_Type_Name On
Run it (leave it in its own terminal):
cd ~/macrdp-siem && fluent-bit -c fluent-bit.conf
Confirm the documents landed:
curl -sk -u admin:'MacrdpSiem!2026' 'https://localhost:9200/macrdp-audit/_count?pretty'
count should be non-zero and climb each time you run another sdl-freerdp connection.
Suppress_Type_Name Onis required — OpenSearch (like ES 7+) removed mapping types and rejects the legacy_doctype otherwise. If theTime_Formatline ever logs a parse warning, it's non-fatal: the doc still ingests, just stamped at collection time instead of event time.
Step 4 — see the events in Dashboards
- Open http://localhost:5601 → log in (
admin/MacrdpSiem!2026). - Left menu → Dashboards Management → Index patterns → Create index pattern.
- Pattern:
macrdp-audit*→ Next → Time field:@timestamp→ Create. (Fluent Bit's OpenSearch output writes the event time to@timestamp; the originaltimestampstring stays on the document too.) - Left menu → Discover, pick the
macrdp-audit*pattern. Every connection you make withsdl-freerdpnow appears here. Try a search bar filter:event:auth and outcome:did_not_complete.

You now have searchable RDP auth telemetry. The next step turns it into a detection.
Step 5 — a real SIEM detection (Security Analytics)
We'll alert on repeated CredSSP auth failures from one source IP — the RDP brute-force signature. This uses the bundled Security Analytics plugin: a custom log type (so it knows about macrdp events), a Sigma rule, and a detector that runs the rule on a schedule.
5a. Create a custom log type
Left menu → Security Analytics → Log types → Create log type (in older builds this lives under the Detectors area).
- Name:
macrdp - Category: Other (or Access Management) → Create.
5b. Create the detection rule
Security Analytics → Rules → Create detection rule, switch to the YAML editor, and
paste (generate your own UUID with uuidgen):
title: macrdp repeated CredSSP auth failures (possible RDP brute force)
id: 8f9e2c14-6b3a-4d5e-9f1a-2c7b8e0d4a11
status: experimental
description: More than 5 CredSSP auth failures from one source IP within the detector window.
author: you
date: 2026/07/10
logsource:
product: macrdp
detection:
sel:
event: auth
outcome: did_not_complete
condition: sel | count(*) by src_ip > 5
level: high
Set the rule's log type to macrdp and save. This is an aggregation rule — it fires
when a single src_ip produces more than 5 auth did_not_complete events within the
detector's evaluation window (set by the detector schedule in the next step).
Two things that will bite you if you skip them (both confirmed the hard way):
id:must be a valid UUID. Security Analytics rejects a rule without one (Sigma rule identifier must be an UUID). Runuuidgenand paste the result — don't hand-type a UUID-shaped string.- Use
count(*), notcount(). OpenSearch's aggregation backend requires the*; a plain Sigmacount()fails on import.
Want a finding on every failure while you learn (no aggregation)? Use
condition: sel(drop the| count(*) …pipe). Switch back to the aggregated version for the realistic brute-force detection.
Once saved, your custom rule sits at the top of the Detection rules library alongside the bundled Sigma rules:

5c. Create the detector
Security Analytics → Detectors → Create detector.
- Data source: index
macrdp-audit. - Log type:
macrdp. - Detection rules: enable the
macrdp repeated CredSSP auth failuresrule you just made (disable the noise of unrelated built-in rules). - Field mapping: map the rule fields (
event,outcome,src_ip) to the same-named index fields. Because the names match, the auto-suggested mapping is usually correct — accept it (or map each field to itself if prompted). - Detector schedule: every 1 minute (so the tutorial fires quickly).
- Alert trigger: add one — name it
macrdp brute force, and set its condition to match on rule severityhigh(not the specific rule), alert severity1 (Highest). Keying on severity matters: an aggregation rule's findings are attributed to an auto-generated "chained" rule, so a trigger keyed on your rule's name/id won't match them — severity does. A notification channel (email/Slack/webhook) is optional; the alert is recorded in Dashboards regardless. - Create.
The created detector shows its full configuration — data source, schedule, log type, and the associated rule with its severity:

Step 6 — trigger the detection
Order matters: create the detector (Step 5c) first, then generate this traffic. Like an alerting monitor, the detector only evaluates events that arrive after it starts — a burst you ran before the detector existed won't produce a finding.
With macrdp (Step 1) and Fluent Bit (Step 3) still running, fire a burst of wrong-password connections from a third terminal:
for i in $(seq 1 8); do
sdl-freerdp /v:127.0.0.1:3390 /u:audittest /p:'WRONG-PASSWORD' \
/cert:ignore +auth-only /log-level:ERROR
done
Each is a real CredSSP failure → an auth/did_not_complete event → shipped by Fluent Bit →
matched by the detector on its next 1-minute run.
Watch it land (typically within ~1 minute of the detector's next run):
- Security Analytics → Alerts — the authoritative signal: the
brute-forcealert is Active, severity 1 (Highest), detectormacrdp-brute-force. This is what proves the detection fired. - Discover:
event:auth and outcome:did_not_completeshows the underlying failures. - Security Analytics → Findings — heads-up: for an aggregation rule the Findings tab
often renders empty even though findings exist and the alert fired (the bucket-level
findings carry no detector id for the tab to group on — the alert is the reliable UI signal).
The non-aggregation variant (
condition: selfrom Step 5b) does populate the Findings tab if you want to see findings there.

That's the full loop: an RDP attack against macrdp surfaced as a SIEM alert. Correlate the
attacker by src_ip (Discover → filter on the finding's IP) to see its accept attempts and
that no auth success ever occurred — the signature of a failed brute-force.
Troubleshooting
_countstays 0 in Step 3 — check the Fluent BitPath(did you replaceYOU?), that macrdp is actually writing the file (tail -f ~/Library/Logs/macrdp-audit.log), and the Fluent Bit terminal for an[output:opensearch]connection error (usually the admin password orTLS.Verify Offmissing).- No findings — the detector only sees events that arrive after it's created (generate the burst after Step 5c), and the aggregation needs more than 5 failures from one IP within the window; run the 8-connection burst in one go.
- Findings but no alert — the trigger must match on rule severity
high, not the rule name/id (aggregation findings are attributed to a chained rule). Re-check Step 5c's trigger. - Aggregation never counts — the
by src_ipgrouping needs an aggregatable field. The default dynamic mapping gives yousrc_ip(text) andsrc_ip.keyword; make sure the detector's field mapping resolvessrc_ipto the keyword field (the auto-mapping normally does). You can confirm the underlying logic independently with a direct query:
A non-emptycurl -sk -u admin:'MacrdpSiem!2026' 'https://localhost:9200/macrdp-audit/_search' \ -H 'Content-Type: application/json' -d '{"size":0, "query":{"bool":{"filter":[{"term":{"event.keyword":"auth"}},{"term":{"outcome.keyword":"did_not_complete"}}]}}, "aggs":{"by_ip":{"terms":{"field":"src_ip.keyword","min_doc_count":6}}}}'by_ipbucket is an IP the rule would fire on.
Cleanup
# Stop Fluent Bit (Ctrl-C in its terminal) and macrdp (Ctrl-C in its terminal), then:
cd ~/macrdp-siem && docker compose down -v # -v also drops the OpenSearch data volume
Hardening for production
The demo cuts every corner that a lab tolerates and production doesn't. Before this pipeline touches real traffic:
- Real TLS, verified. Replace the self-signed demo certs and set Fluent Bit
TLS.Verify Onwith the CA bundle. Never shipTLS.Verify Offoff a lab box. - Real credentials. Change the OpenSearch admin password, create a least-privilege
ingest user for Fluent Bit (write-only to
macrdp-audit*), and keep secrets out of the config file (Fluent Bit env vars / a secrets store). - Bind macrdp off-loopback for real telemetry. Loopback is guard-exempt, so you only get
reject/lockoutevents (and real threat traffic) once--bindexposes the server to the network. See the guard knobs inconfiguration.md. And never put an RDP server on a raw public IP — reach it over a VPN / RD Gateway. - Right-size the store. A single OpenSearch node with 512 MB heap is a demo. Real deployments want a multi-node cluster (or a managed OpenSearch), index lifecycle management to age out old audit indices, and snapshots.
- Run the collector as a service. Turn Fluent Bit into a
launchdagent so it survives reboots and starts before macrdp. Its file tail resumes from a stored offset, and macrdp's audit file self-rotates under a stable path, so the collector never needs reconfiguring. - Schema-version your rules. Key detections off
schema_version— it only bumps on a breaking field change, so a rule pinned toschema_version: 1won't silently mis-parse a future format. - (Grafana + Loki variant) Turn off
GF_AUTH_ANONYMOUS_ENABLEDand set a real admin password / OAuth on Grafana, put TLS + auth in front of Loki's push and query endpoints (they are unauthenticated in the demo), and switch the collector's Loki output tohttpswith credentials. The off-loopback-bind and collector-as-a-service points above apply identically.
Lighter alternative: Grafana + Loki
Prefer the smallest possible footprint? Grafana + Loki runs the same end-to-end pipeline in roughly a third to a half of the RAM — both are Go (no JVM), and Loki indexes only stream labels, not the log body (no full-text index). The trade-off: it's a log platform with alerting, not a Sigma-rule SIEM — you query with LogQL and detect with Grafana Alerting instead of Security Analytics detectors/findings. For the low-volume, structured macrdp audit stream it's an excellent fit. (This is the validated, concrete version of Adapting to a different SIEM below.)
Reuse Step 1 unchanged — macrdp writing the JSON audit file. Everything here replaces Steps 2–6.
L1 — Stand up Loki + Grafana
mkdir -p ~/macrdp-loki && cd ~/macrdp-loki
~/macrdp-loki/docker-compose.yml:
services:
loki:
image: grafana/loki:3.2.1
container_name: macrdp-loki
command: -config.file=/etc/loki/local-config.yaml # the image's built-in single-node config
ports: ["3100:3100"]
networks: [obs]
grafana:
image: grafana/grafana:11.3.0
container_name: macrdp-grafana
environment:
# Demo-only: anonymous Admin, no login. See hardening before real use.
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
ports: ["3000:3000"]
volumes:
- ./grafana-datasource.yaml:/etc/grafana/provisioning/datasources/loki.yaml:ro
depends_on: [loki]
networks: [obs]
networks:
obs:
~/macrdp-loki/grafana-datasource.yaml (auto-adds the Loki datasource, so you skip the click):
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
Bring it up — it's ready in seconds, not the minute-plus OpenSearch needs:
docker compose up -d
until [ "$(curl -s http://localhost:3100/ready)" = "ready" ]; do sleep 2; done; echo "loki ready"
Grafana is at http://localhost:3000 with anonymous Admin — no login (demo-only). Loki has no UI of its own; you query it through Grafana.
L2 — Ship events with Fluent Bit → Loki
Reuse the same native Fluent Bit from Step 3 — only
the output changes. Copy that fluent-bit.conf (its [SERVICE]/[INPUT]/parser are identical)
and replace the [OUTPUT] block with Loki:
[OUTPUT]
Name loki
Match macrdp.audit
Host localhost
Port 3100
Labels job=macrdp-audit
Line_format json
Keep the JSON fields (src_ip, outcome, …) inside the log line and use just one
low-cardinality stream label (job) — Loki labels must be low-cardinality, so making
src_ip a label would explode the stream count. You'll pull src_ip out of the line at query
time with LogQL | json. Run it and confirm ingestion:
cd ~/macrdp-loki && fluent-bit -c fluent-bit.conf # in its own terminal
curl -sG http://localhost:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="macrdp-audit"}' --data-urlencode 'limit=3' | python3 -m json.tool | head -30
L3 — Explore in Grafana
Open Grafana → Explore → the Loki datasource, and query with LogQL:
- All audit events:
{job="macrdp-audit"} | json - Just auth failures:
{job="macrdp-audit"} | json | event="auth" | outcome="did_not_complete"
(| json parses the JSON line so you can filter on event, outcome, src_ip, etc. Loki 3.x
also auto-attaches detected_level / service_name labels — harmless.)

L4 — Alert on the brute-force (Grafana Alerting)
Alerting → Alert rules → New alert rule. Give it a Loki query with this metric LogQL — the LogQL equivalent of the Sigma aggregation rule:
sum by (src_ip) (count_over_time({job="macrdp-audit"} | json | event="auth" | outcome="did_not_complete" [5m]))
- Condition:
IS ABOVE 5— fires persrc_ipwith more than 5 failures in 5 minutes. - Evaluation: every
1m,for: 0s(fire on the first breach). Pick a folder + evaluation group, name itmacrdp brute force. A contact point (email/Slack/webhook) is optional; the rule shows under Alerting → Alert rules regardless.
Running that same metric query in Explore shows the per-src_ip failure count spiking past the
threshold during the burst — the value the alert condition tests:
![Grafana Explore graph of the metric LogQL sum by (src_ip) count_over_time(...[5m]) — the series {src_ip="127.0.0.1"} spikes to 8, above the alert's "IS ABOVE 5" threshold](images/siem-loki-detection.png)
L5 — Trigger it
Create the alert rule first (Grafana, like any alerting engine, only evaluates data that arrives after the rule exists), then fire the same burst as Step 6:
for i in $(seq 1 8); do
sdl-freerdp /v:127.0.0.1:3390 /u:audittest /p:'WRONG-PASSWORD' \
/cert:ignore +auth-only /log-level:ERROR
done
Watch Alerting → Alert rules flip macrdp brute force to Pending → Firing (one series
per offending src_ip), while Explore shows the underlying failures. Same detection as the
OpenSearch path, on a fraction of the resources.
You can confirm the detection logic straight from Loki's API, no UI needed (the same query the alert runs):
curl -sG http://localhost:3100/loki/api/v1/query --data-urlencode \
'query=sum by (src_ip) (count_over_time({job="macrdp-audit"} | json | event="auth" | outcome="did_not_complete" [5m]))'
Any src_ip whose value is above 5 is one the alert fires on.
Cleanup
# Ctrl-C Fluent Bit + macrdp, then:
cd ~/macrdp-loki && docker compose down -v
OpenSearch or Loki — which?
- OpenSearch + Security Analytics — a genuine SIEM: Sigma detection rules, a rule library, findings, correlation. Heavier (~2 GB+). Pick it when you want real SIEM workflows.
- Grafana + Loki — lightest (~400 MB–1 GB), LogQL + Grafana Alerting, and doubles as your observability stack. Pick it for a small footprint or if you already run Grafana.
Both ingest the identical macrdp audit stream — the macrdp side never changes.
Adapting to a different SIEM
The macrdp side never changes — it's just a JSON file. Swap Step 3's Fluent Bit output (or
use Vector / rsyslog instead) for your SIEM's sink; siem-forwarding.md
has Splunk-HEC / Elasticsearch / syslog examples. Everything upstream of the collector is
identical. The Grafana + Loki section above is a fully
worked, validated example of exactly this swap.
See also
audit-log.md— what each event and field means, with worked interpretation examples.siem-forwarding.md— the JSON schema (v1) + Vector / Fluent Bit / rsyslog collector configs.configuration.md— the--audit-fileflag and the auth-guard (rate-limit / lockout) knobs.