Intro
Everything security-related in the lab so far looks at the cluster. Tetragon watches processes in containers, and Kyverno checks what gets admitted. The kube-apiserver audit log records who did what (Part 34). None of it sees the machines around the cluster. That means 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. It puts an agent on each machine. The agent watches files, checks the configuration against CIS benchmarks, and reads the security-relevant logs. It 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, and it uses about 340 MB. Its state is embedded SQLite on a 5Gi volume, and its config comes from git and OpenBao. Agents reach it over the LAN or the tailnet. A sidecar tails
alerts.jsonto 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. Without the indexer, I lose Wazuh’s CVE detection. So debsecan covers the hosts, and a Tailscale device posture (a rule on device attributes such as OS version) refuses tailnet access to an outdated macOS. Seven things went wrong on the way. The worst one gave no warning: the manager ran its default config for the first hour.
The Figure
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 and turns each station’s key in the clock. 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. 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. They count and ring only when the disc is read (R, or coming back). 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 cannot 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.
| Option | Server | Covers the Mac | CVE detection | Why not |
|---|---|---|---|---|
| Full Wazuh (manager, indexer, dashboard) | about 8 GB, OpenSearch | yes | yes | RAM |
| Tetragon, Falco, Tracee (eBPF) | none | no | no | eBPF is Linux only, and macOS uses Apple’s EndpointSecurity framework |
| osquery + Fleet | Fleet + MySQL + Redis, about 1 GB | yes | yes | Fleet supports only MySQL, and I did not want a new database engine |
| osquery + Vector, no server | none | yes | no | per-machine config, no live queries, no CVE matching |
| Velociraptor | single binary | yes | no | built for forensics and hunting, alerting is do-it-yourself |
| Wazuh manager only | about 1 GB, embedded SQLite | yes | no (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 used before. In 2026, Trivy itself was compromised (CVE-2026-33634, a supply-chain attack on its releases and GitHub Actions). I prefer not to run a third-party scanner as root on the hypervisors. debsecan is Debian’s own tool, installed from the Debian archive that I already trust. 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.conffor the manager andagent.conffor the agents come from a ConfigMap. The agents pullagent.conffrom 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
finishscript takes the whole container down. Two tiny overrides replacerunwithsleep infinityandfinishwithexit 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:
| Mac | Proxmox hosts | |
|---|---|---|
| Logs | unified log: sudo, sshd, loginwindow, securityd, authorization, TCC, XProtect, syspolicyd | journald: ssh, pveproxy, pvedaemon, systemd-logind, sudo |
| File integrity | LaunchAgents 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 h | CIS Debian 13, every 12 h |
| Rootcheck | yes, without the port check | yes |
When the cluster is off overnight, an agent keeps events in a local buffer. It 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 ran
modules that I turned off. SCA scanned the container against
cis_amazon_linux_2023.yml, syscollector made an inventory of 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. 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 that the port was open. I now check the first line of the running config, not only 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. 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 put /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 that is added, removed or replaced still shows up. Code signing plus XProtect already cover tampering inside a bundle.
<directories check_all="yes" recursion_level="1">/Applications</directories>
Issue 5: Hidden Ports That Were Not
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. It 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. The picker runs as my user, and my user
is neither the owner nor in the group.
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 showed the result 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 I added 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
must 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. An agent must never be newer than its
manager. The script then rewrites the <client> block, writes the enrollment
password, and starts the agent. The password never appears on a command line.
The script reads it from the cluster and gets it 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 is not 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 /
Apple also notarized the real package. 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. It is 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 that 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:
| Rule | Fires when |
|---|---|
| PVE security fixes not applied | fixable CVEs and no upgrade for 7 days |
| PVE high-urgency security fix available | a high-urgency CVE has a fix |
| PVE host not upgraded in 30 days | covers the Proxmox packages |
| PVE debsecan metrics stale | no 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 trips it within the hour. So
the rule 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 is a posture on the existing member grant.
But that also cuts off the Proxmox hosts. They are member devices that run
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 that 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 after 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 do not back it up.
| Agent | CIS benchmark | Score | Passed / failed |
|---|---|---|---|
| pve-01, pve-02, pve-03 | CIS Debian Linux 13 | 43% | 82 / 105 (20 not applicable) |
| MacBook Pro | CIS macOS 26 Tahoe | 57% | 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, but
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 memory | about 342 MB (limit 1Gi) |
| Alerts in the first scan | 622 (363 level 3, 258 level 7, mostly CIS results) |
| Steady manager log | quiet after enrollment |
| Grafana | dashboard Homelab / Wazuh, 13 panels from VictoriaLogs |
Lessons
- 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.
- Ready is not configured. The pod was healthy while it ran the wrong config. Check the config that the process loaded.
- Projected volumes are symlinks. Anything that copies a mounted
directory trips on
..data. Mount files withsubPath. - Scope file monitoring to what changes on purpose. Recursing into app bundles watched 100,000 files that code signing already protects.
- Test the cheap recovery before paying for a backup. One deleted key showed that the manager’s state rebuilds itself in a minute.
- Posture conditions are AND, grants are OR. “Not a Mac, or a current
Mac” needs two grants, and the policy tests need
srcPostureAttrs. - Use the distro’s own scanner on the hypervisor. debsecan trusts the same keys as the OS. A compromised third-party scanner runs as root.
Series
This post is part of Homelab Rebuild:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers
- Part 12: Observability with Coroot, and Losing pve-01
- Part 13: The Host That Kept Losing Power
- Part 14: Observability, Take Two with OpenTelemetry
- Part 15: Requests and Limits for Everything
- Part 16: Handing the Cluster to FluxCD
- Part 17: Making the Guardrails Earn Their Keep
- Part 18: When the API Server Outgrew Its VM
- Part 19: Logging In to OpenBao with Cloudflare Access and Google
- Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
- Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
- Part 22: Turning Four Days of Fixes into AI Runbooks
- Part 23: microVM Sandboxes on Proxmox
- Part 24: Pi-hole Without a Home Host
- Part 25: It Was the Router
- Part 26: TLS on the Front, Plaintext Behind It
- Part 27: One Bad Power Adapter, Two Different Deaths
- Part 28: Observability, Take Three: Making It Worth Looking At
- Part 29: One Meter for Every Model, LiteLLM on Workers AI
- Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
- Part 31: First Backups, Postgres, OpenBao and etcd to R2
- Part 32: Alerts That Reach Me, Even When the Lab Is Dead
- Part 33: The Firewall Held, the Tailnet Did Not
- Part 34: From Watching to Blocking, and a Record of Who Did What
- Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts (this post)
- Part 36: The Fans Were Loud, the CPUs Were Idle
- Part 37: A Power Trip, a Search Domain and a Wildcard
- Part 38: An Autonomous AI Pentester in a Sealed Box
- Part 39: Wasm Pods on k0s, and the Label That Kept Them Packed
