Back to Home
post-thumb

Homelab Rebuild Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts

| October 19, 2026 | 17 min to read

Intro

Everything security-related in the lab so far looks at the cluster: Tetragon watches processes in containers, Kyverno checks what gets admitted, and the kube-apiserver audit log records who did what (Part 34). None of it sees the machines around the cluster: my MacBook, which holds the keys to all of it, and the three Proxmox hosts that run it.

The tool I knew from work for that is Wazuh: an agent on each machine that watches files, checks the configuration against CIS benchmarks, reads the security-relevant logs, and sends everything to a manager that rates each event with a level. The catch is that a standard Wazuh install wants about 8 GB of RAM, most of it for its search engine. The lab has nowhere near that to spare.

TL;DR: I run the Wazuh manager alone (no indexer, no dashboard) as one pod in k0s, using about 340 MB. Its state is embedded SQLite on a 5Gi volume, its config comes from git and OpenBao, and agents reach it over the LAN or the tailnet. A sidecar tails alerts.json to stdout, so alerts flow through the existing OpenTelemetry pipeline into VictoriaLogs, a Grafana dashboard and a level 10+ alert to Telegram. Agents run on my Mac and on pve-01/02/03. Giving up the indexer costs Wazuh’s CVE detection, so debsecan covers the hosts and a Tailscale device posture refuses tailnet access to an outdated macOS. Seven things went wrong on the way, the worst one silently: the manager ran its default config for the first hour.

The Figure

MACPVE-01PVE-02PVE-03M412.2 184.2L412.2 179.7M410.9 184.7L410.9 180.3M409.3 185L409.3 180.5M407.7 185L407.7 180.5M406.1 184.7L406.1 180.3M404.8 184.2L404.8 179.7M404 183.5L404 179M413 183.5L413 179
Level 10 or more: the bell rings (Telegram)Lanes, outside in: Mac, pve-01, pve-02, pve-03Keys 1 to 4 turn a station's key in the clock: Mac, pve-01, pve-02, pve-03. Each turn embosses a mark in that station's lane on the paper disc, and the mark grows with the Wazuh rule level: a dot for 3 or 4, a dash for 5 to 7, a heavy mark for 10 and up. At the desk every mark is read at once, and level 10 or more rings the bell, which is the Telegram alert. A takes the clock away from the desk, like an agent with no network: marks still go on the disc but stay hollow until the disc is read (R, or coming back), and only then does the bell ring. The marks are real alerts from the first day, such as the pve-03 brute force test (level 10) and the Mac file limit (level 12).

The figure is a watchman’s clock on a guard’s desk, because that is what a Wazuh agent does: a guard walks a round, turns each station’s key in the clock, and the clock embosses a mark on a turning paper disc. Press 1 to 4 to turn the key for the Mac, pve-01, pve-02 or pve-03; each mark lands in that station’s lane and grows with the rule level, and a mark of level 10 or more rings the bell on the desk, which is the Telegram alert. Press A to take the clock away from the desk, like a laptop with no network: marks still go on the disc but stay hollow, and only when the disc is read (R, or coming back) do they count and ring. The marks are real alerts from the first day, such as the pve-03 brute force test at level 10 and the Mac file limit at level 12. I built it with the anatomy skill (see References).

The Situation

  • Endpoints: one MacBook Pro (macOS 26.7.1) and three Proxmox hosts (PVE 9, Debian 13 trixie). The other Macs at home already run their own endpoint security, so they stay out.
  • Cluster: k0s with Flux, Cilium, OpenBao with External Secrets, VictoriaLogs and Grafana (Parts 14, 16, 28), alerts to Telegram and email (Part 32).
  • Free RAM: about 8 to 9 GB on pve-01 and pve-02, 1.2 GB on pve-03, and the k0s workers are already busy.
  • Nights: every VM, and with it the cluster, is off from 23:30 to 06:00 WIB. A laptop used at night can’t reach anything in the cluster.
  • Rules I set myself: no new database engine, and no new third-party scanner in the trust chain.

The Selection

I went through five options before Wazuh came back in a smaller form.

OptionServerCovers the MacCVE detectionWhy not
Full Wazuh (manager, indexer, dashboard)about 8 GB, OpenSearchyesyesRAM
Tetragon, Falco, Tracee (eBPF)nonenonoeBPF is Linux only; macOS uses Apple’s EndpointSecurity framework
osquery + FleetFleet + MySQL + Redis, about 1 GByesyesFleet supports only MySQL, and I didn’t want a new database engine
osquery + Vector, no servernoneyesnoper-machine config, no live queries, no CVE matching
Velociraptorsingle binaryyesnobuilt for forensics and hunting, alerting is do-it-yourself
Wazuh manager onlyabout 1 GB, embedded SQLiteyesno (needs the indexer since 4.8)picked

Two questions settled it.

Can Wazuh keep its state in Postgres, like everything else here? No. Everything the manager keeps lives in local files under /var/ossec: the agent registry (client.keys, global.db), per-agent SQLite databases, queues. But that state turns out to be cheap. Config can come from git, alerts go to VictoriaLogs anyway, and the rest rebuilds itself (more on that in Results).

What about CVEs? The obvious host scanner was Trivy, which I had used before. In 2026 Trivy itself was compromised (CVE-2026-33634, a supply-chain attack on its releases and GitHub Actions). I’d rather not run a third-party scanner as root on the hypervisors. debsecan is Debian’s own tool, installed from the Debian archive I already trust, and it checks packages against the Debian Security Tracker. For the Mac, the practical control is “patched or not”, and Tailscale can enforce that.

The Design

flowchart LR
  MAC[MacBook agent] -->|1514/1515 LAN or tailnet| LB[LoadBalancers<br/>LAN .162 + tailnet]
  PVE[pve-01/02/03 agents] -->|1514/1515 LAN| LB
  LB --> MGR[wazuh-manager<br/>StatefulSet, 1 pod]
  GIT[git: ossec.conf<br/>agent.conf] --> MGR
  BAO[OpenBao: enrollment<br/>password] --> MGR
  MGR -->|alerts.json| SC[alerts sidecar<br/>tail to stdout]
  SC --> VL[otel-agent<br/>VictoriaLogs]
  VL --> GF[Grafana dashboard<br/>rule: level 10+]
  GF --> TG[Telegram + email]
  • One pod, Wazuh 4.14.9. A StatefulSet with requests of 100m CPU and 512Mi memory, limits of 1 CPU and 1Gi, on a worker with a data disk. It settles at about 342 MB.
  • Config from git. ossec.conf for the manager and agent.conf for the agents come from a ConfigMap. The agents pull agent.conf from the manager, so I change what the Mac and the hosts watch with a commit, not a visit.
  • The enrollment password lives in OpenBao and reaches the pod through External Secrets.
  • State on one 5Gi volume: agent keys, file-integrity baselines, check results, queues.
  • Two LoadBalancers. One on the LAN at 192.168.101.162 (Cilium LB IPAM), one on the tailnet through the Tailscale operator. Agents list the LAN first and the tailnet second, so the Mac works at home and away.
  • No Filebeat. The image ships Filebeat to feed the indexer. Without an indexer it exits, and its s6 finish script takes the whole container down. Two tiny overrides replace run with sleep infinity and finish with exit 0.
  • Alerts out through stdout. A busybox sidecar runs tail -n0 -F /alerts/alerts.json. The existing OpenTelemetry agent ships container stdout to VictoriaLogs, so Wazuh needs no output plugin at all.

What the agents watch:

MacProxmox hosts
Logsunified log: sudo, sshd, loginwindow, securityd, authorization, TCC, XProtect, syspolicydjournald: ssh, pveproxy, pvedaemon, systemd-logind, sudo
File integrityLaunchAgents and LaunchDaemons (realtime), /etc, Homebrew and /usr/local/bin, /Applications top level/etc, /usr/bin, /usr/sbin, /root/.ssh (realtime), /boot
CIS checks (SCA)CIS macOS 26 Tahoe, every 12 hCIS Debian 13, every 12 h
Rootcheckyes, without the port checkyes

The overnight gap. When the cluster is off, an agent keeps events in a local buffer and sends them when the manager is back. The default buffer holds 5,000 events; I raised it to 100,000 so a long evening on the Mac fits:

<client_buffer>
  <disabled>no</disabled>
  <queue_size>100000</queue_size>
  <events_per_second>500</events_per_second>
</client_buffer>

Issue 1: Kyverno Refused the LoadBalancers

Symptom

The Flux Kustomization for Wazuh stayed not ready:

Policy restrict-service-types failed: LoadBalancer Services are only allowed in gateway, pihole and kube-system

Cause

My own guardrail from Part 11. LoadBalancer Services are an exposure decision, so Kyverno allows them only in an explicit list of namespaces.

Fix

Add wazuh to the list, in its own PR, so the exception is visible in history.

Issue 2: The Manager Ran Its Default Config, Quietly

Symptom

The pod was 2/2 Ready with no restarts, and the log looked healthy. But it was running modules I had turned off: SCA was scanning the container against cis_amazon_linux_2023.yml, syscollector was inventorying it, and an indexer connector kept retrying. The first line of /var/ossec/etc/ossec.conf gave it away:

<!--
  Wazuh - Manager - Default configuration for amzn 2023

Cause

The image copies everything in /wazuh-config-mount over /var/ossec at start. I mounted a projected volume there, and a projected volume puts its files behind a ..data symlink. So /wazuh-config-mount/etc was a symlink, and cp refused to put it over the real directory. The init script logged the error and the manager started anyway with the image defaults:

cp: cannot overwrite directory '/var/ossec/etc' with non-directory
Error executing command: 'cp --verbose -r /wazuh-config-mount/* /var/ossec'.
[cont-init.d] 0-wazuh-init: exited 1.
[cont-init.d] 2-manager: executing...

Fix

Mount each file with subPath, which gives plain files. The ConfigMap name carries a content hash, so a config change still restarts the pod.

- {name: config, mountPath: /wazuh-config-mount/etc/ossec.conf, subPath: etc/ossec.conf, readOnly: true}
- {name: config, mountPath: /wazuh-config-mount/etc/shared/default/agent.conf, subPath: etc/shared/default/agent.conf, readOnly: true}
- {name: config, mountPath: /wazuh-config-mount/etc/authd.pass, subPath: etc/authd.pass, readOnly: true}

“Ready” only meant the port was open. I now check the first line of the running config, not just the pod status.

Issue 3: Indexer Warnings With No Indexer

Symptom

Even with the right config, the manager logs this once per agent sync:

indexer-connector: WARNING: Failed to sync agent '001': No available server

Cause

The inventory harvester in 4.14 tries to sync agent inventory to the indexer whether one is configured or not, and I found no setting to switch it off.

Fix

None needed. With no agents the manager log was silent (0 lines in two minutes); while the agents enrolled it logged about 20 of these in ten minutes, then went quiet again.

Issue 4: The Mac Hit the File Limit on Its First Scan

Symptom

The first real alert in Telegram was level 12, from the Mac:

The maximum limit of files monitored has been reached. At this moment there are 100000 files and the limit is 100000.

Cause

I had /Applications under full file-integrity monitoring. App bundles are directories with thousands of files each, and they filled the 100,000-file limit in one scan.

Fix

Watch only the top level: an app added, removed or replaced still shows up, and code signing plus XProtect already cover tampering inside a bundle.

<directories check_all="yes" recursion_level="1">/Applications</directories>

Issue 5: Hidden Ports That Weren’t

Symptom

Rootcheck on the Mac kept raising level 7 alerts like Port '55555'(tcp) hidden. Kernel-level rootkit or trojaned version of netstat., each time for a different port.

Cause

Rootcheck binds every port to see if it is in use, then compares the result with netstat. On a laptop, short-lived sockets open and close between the two checks, so the lists never agree.

Fix

<check_ports>no</check_ports> for macOS only. The other rootcheck tests stay.

Issue 6: The Full Disk Access Picker Showed an Empty Folder

Symptom

The agent needs Full Disk Access to read protected files and logs. In System Settings, the file picker opened /Library/Ossec and showed nothing.

Cause

The folder is drwxr-x--- root:wazuh, and the picker runs as my user, who is in neither.

Fix

Open it for a moment, add the binaries, close it again:

sudo chmod o+rx /Library/Ossec /Library/Ossec/bin
# Cmd+Shift+G in the picker, /Library/Ossec/bin:
# add wazuh-syscheckd, wazuh-logcollector, wazuh-modulesd
sudo chmod o-rx /Library/Ossec /Library/Ossec/bin
sudo /Library/Ossec/bin/wazuh-control restart

The unified log confirmed it right away: three level 5 alerts, /Library/Ossec/bin/wazuh-syscheckd has been granted permission to kTCCServiceSystemPolicyAllFiles, one per binary.

Issue 7: The Posture Broke the Policy Tests

Symptom

After adding the macOS posture to the tailnet policy, saving failed:

[acl test error]: address "<host>:443" (protocol "tcp"): want: Accept, got: Drop

Cause

A policy test runs as a user with no device behind it, so it has no posture attributes, and every grant with srcPosture drops it.

Fix

Give the test the attributes with srcPostureAttrs, and add the case that should fail:

{
  "src": "you@example.com",
  "srcPostureAttrs": {"node:os": "macos", "node:osVersion": "26.6.0", "node:tsReleaseTrack": "stable"},
  "deny": ["<host>:443"]
}

Client Rollout

The Proxmox Hosts

proxmox/wazuh-agent/install.sh adds the Wazuh apt repository, installs the agent 4.14.9-1, and holds it, because an agent must never be newer than its manager. It then rewrites the <client> block, writes the enrollment password, and starts the agent. The password never appears on a command line: it is read from the cluster and passed on stdin.

scp proxmox/wazuh-agent/install.sh root@pve-03:/root/wazuh-agent-install.sh
kubectl -n wazuh get secret wazuh-authd -o jsonpath='{.data.authd\.pass}' | base64 -d \
  | ssh root@pve-03 TAILNET_NAME=wazuh.<tailnet>.ts.net bash /root/wazuh-agent-install.sh
# wazuh-agentd: INFO: Valid key received
# wazuh-agentd: INFO: (4102): Connected to the server ([192.168.101.162]:1514/tcp).

Twenty-five seconds after connecting, the agent pulled agent.conf from the manager, reloaded, and started reading the journal. I did pve-03 first, then pve-01 and pve-02.

The Mac

The Mac script does the same with the .pkg, plus one check first: it refuses to install a package that isn’t signed by Wazuh’s developer ID.

pkgutil --check-signature "$PKG" | grep -q 'Developer ID Installer: Wazuh Inc (KLZK8P68R5)' \
  || { echo "package signature is not Wazuh's" >&2; exit 1; }
installer -pkg "$PKG" -target /

The real package is also notarized by Apple. After the install comes the Full Disk Access step from Issue 6. The agent picked the CIS macOS 26 Tahoe benchmark by itself.

CVEs Without the Indexer

debsecan on the Hosts

A daily timer runs debsecan and writes the counts as Prometheus text for Alloy’s textfile collector, the same pattern as the SMART metrics from Part 32:

debsecan_vulnerabilities{fixed="true",urgency="other"} 9
debsecan_vulnerabilities{fixed="false",urgency="low"} 2
debsecan_vulnerabilities{fixed="false",urgency="other"} 1130
apt_upgrades_pending 4
apt_last_upgrade_timestamp_seconds 1791970872

Most of the 1,141 open CVEs on pve-03 have no Debian fix yet. The 9 with fixed="true" are the ones an upgrade clears. debsecan only knows Debian packages, not Proxmox’s own (kernel, qemu-server, pve-*), so the last upgrade time covers those. Four rules in Grafana Cloud use the metrics:

RuleFires when
PVE security fixes not appliedfixable CVEs and no upgrade for 7 days
PVE high-urgency security fix availablea high-urgency CVE has a fix
PVE host not upgraded in 30 dayscovers the Proxmox packages
PVE debsecan metrics staleno successful run for 2 days

One catch: the existing “SMART metrics stale” rule took the age of every textfile on the host. A file written once a day would have tripped it within the hour, so it now looks at smart.prom only. Also, the debsecan package turns on a daily mail report to root by default; the script sets REPORT=false.

Tailscale Posture for the Mac

Tailscale’s built-in posture attributes (node:os, node:osVersion) are free on every plan. The obvious version, a posture on the existing member grant, would have cut off the Proxmox hosts too, since they are member devices running linux, and they talk to each other over the tailnet. Grants add up, so two of them express “not a Mac, or a current Mac”:

"postures": {
  "posture:macosCurrent": [
    "node:os == 'macos'",
    "node:osVersion >= '26.7'",
    "node:tsReleaseTrack == 'stable'"
  ],
  "posture:notMac": ["node:os != 'macos'"]
},
"grants": [
  {"src": ["autogroup:member"], "srcPosture": ["posture:notMac"],       "dst": ["*"], "ip": ["*"]},
  {"src": ["autogroup:member"], "srcPosture": ["posture:macosCurrent"], "dst": ["*"], "ip": ["*"]}
]

There is no “latest macOS” attribute, so 26.7 is a floor I raise by hand after each point release. If the Mac falls below it, it loses tailnet access until it updates; at home the LAN still works.

Results

End to end. Ten SSH attempts with made-up users on pve-03 gave nine level 5 alerts (rule 5710) and one level 10 (rule 5712, brute force). The Grafana rule picked it up on its next one-minute run and sent it to Telegram; it resolved by itself once the event left the five-minute window.

Losing the state costs nothing. I deleted pve-03’s key on the manager. The agent noticed, enrolled again with the password, and was back about 60 seconds later as agent 005. Alerts and the dashboard group by name, so a lost volume only renumbers the agents, and I don’t back it up.

AgentCIS benchmarkScorePassed / failed
pve-01, pve-02, pve-03CIS Debian Linux 1343%82 / 105 (20 not applicable)
MacBook ProCIS macOS 26 Tahoe57%37 / 27

The host scores are low because CIS assumes a general-purpose server: separate partitions for /var/log, auditd rules, AIDE. Some of that is worth doing, most of it is not for a hypervisor. On the Mac, the failed checks gave me a short to-do list: confirm FileVault, firewall stealth mode, and sudo logging.

Value
Manager memoryabout 342 MB (limit 1Gi)
Alerts in the first scan622 (363 level 3, 258 level 7, mostly CIS results)
Steady manager logquiet after enrollment
Grafanadashboard Homelab / Wazuh, 13 panels from VictoriaLogs

Lessons

  1. Ask what the heavy part buys you. Wazuh’s 8 GB is search and a UI. The detection lives in a manager that fits in a few hundred MB, and Grafana was already here.
  2. Ready is not configured. The pod was healthy while running the wrong config. Check the config the process actually loaded.
  3. Projected volumes are symlinks. Anything that copies a mounted directory will trip on ..data; mount files with subPath.
  4. Scope file monitoring to what changes on purpose. Recursing into app bundles watched 100,000 files that code signing already protects.
  5. Test the cheap recovery before paying for a backup. One deleted key showed that the manager’s state rebuilds itself in a minute.
  6. Posture conditions are AND, grants are OR. “Not a Mac, or a current Mac” needs two grants, and the policy tests need srcPostureAttrs.
  7. Use the distro’s own scanner on the hypervisor. debsecan trusts the same keys as the OS; a compromised third-party scanner would run as root.

Series

This post is part of Homelab Rebuild:

  1. Part 1: Upgrading Proxmox VE 8 to 9
  2. Part 2: Starting Over When the IaC Is Broken
  3. Part 3: When Tailscale Breaks Your Cilium Gateway
  4. Part 4: Living on the Tailnet
  5. Part 5: Secrets Without Secrets in Git
  6. Part 6: Certificates, DNS and a Private CA
  7. Part 7: Self-Hosted CI and Getting Back to IaC
  8. Part 8: Stateful Workloads on Unequal Hardware
  9. Part 9: Going Public Safely with Cloudflare Tunnel
  10. Part 10: When the Hypervisor Went Quiet
  11. Part 11: Hardening the Cluster in Five Layers
  12. Part 12: Observability with Coroot, and Losing pve-01
  13. Part 13: The Host That Kept Losing Power
  14. Part 14: Observability, Take Two with OpenTelemetry
  15. Part 15: Requests and Limits for Everything
  16. Part 16: Handing the Cluster to FluxCD
  17. Part 17: Making the Guardrails Earn Their Keep
  18. Part 18: When the API Server Outgrew Its VM
  19. Part 19: Logging In to OpenBao with Cloudflare Access and Google
  20. Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
  21. Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
  22. Part 22: Turning Four Days of Fixes into AI Runbooks
  23. Part 23: microVM Sandboxes on Proxmox
  24. Part 24: Pi-hole Without a Home Host
  25. Part 25: It Was the Router
  26. Part 26: TLS on the Front, Plaintext Behind It
  27. Part 27: One Bad Power Adapter, Two Different Deaths
  28. Part 28: Observability, Take Three: Making It Worth Looking At
  29. Part 29: One Meter for Every Model, LiteLLM on Workers AI
  30. Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
  31. Part 31: First Backups, Postgres, OpenBao and etcd to R2
  32. Part 32: Alerts That Reach Me, Even When the Lab Is Dead
  33. Part 33: The Firewall Held, the Tailnet Did Not
  34. Part 34: From Watching to Blocking, and a Record of Who Did What
  35. Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts (this post)

References