CR-2026-06-25: Implementation

Implementation

Phase 0: Pre-Flight

Verify connectivity, back up current security configs, and document the baseline posture before any changes.

Set shell variables

DOMAIN="inside.domusdigitalis.dev"
WAZUH_MGR="wazuh.${DOMAIN}"
WAZUH_WORKERS="wazuh-workers.${DOMAIN}"
WAZUH_REG_PORT=1515
WAZUH_EVT_PORT=1514
WAZUH_API_PORT=55000

Verify connectivity

Before — confirm all three ports reachable
nc -zv "${WAZUH_MGR}" "${WAZUH_REG_PORT}" 2>&1
nc -zv "${WAZUH_WORKERS}" "${WAZUH_EVT_PORT}" 2>&1
curl -sk -o /dev/null -w '%{http_code}' "https://${WAZUH_MGR}:${WAZUH_API_PORT}/"

Expected: succeeded for both nc checks, 401 for API (unauthenticated).

Back up current security configs

BACKUP_DIR="/tmp/pre-wazuh-backup-$(date +%Y%m%d)"
mkdir -p "${BACKUP_DIR}"
sudo cp -a /etc/apparmor.d/ "${BACKUP_DIR}/apparmor.d/"
sudo cp -a /etc/nftables.conf "${BACKUP_DIR}/nftables.conf"
sudo aa-status > "${BACKUP_DIR}/apparmor-status.txt" 2>&1
sudo nft list ruleset > "${BACKUP_DIR}/nftables-ruleset.txt" 2>&1

Document baseline with fortress-verification

Paste and run the fortress verification script block from partials/worklog/daily-notes/YYYY-MM-DD/fortress-verification/fortress-script.adoc (or the equivalent sub-partial for the current day’s daily notes):

# Run the fortress verification script block and capture output
bash /tmp/fortress-verify.sh > "${BACKUP_DIR}/fortress-baseline.txt" 2>&1
fortress-verification is not a standalone command β€” it is the script block from the fortress-verification/fortress-script.adoc sub-partial. Copy the script to /tmp/fortress-verify.sh and execute it.

Phase 1: Install Wazuh Agent

NOT the AUR β€” a security agent should come from the vendor, not a community PKGBUILD. The official install script (wazuh-install.sh) only supports RPM/DEB distros. On Arch, extract the agent from the official RPM package.

Step 1.1 β€” Import Wazuh GPG key

Before — check if key already imported
gpg --list-keys | grep -i wazuh
Change — import the Wazuh GPG key
curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH -o /tmp/wazuh-gpg-key.asc
gpg --import /tmp/wazuh-gpg-key.asc
After — verify key imported
gpg --list-keys | grep -i wazuh

Step 1.2 β€” Determine manager version

The agent version MUST match the manager. Version mismatch causes silent failures.

ssh k3s-master-01 'sudo /usr/local/bin/k3s kubectl exec -n wazuh wazuh-manager-master-0 -- /var/ossec/bin/wazuh-control info 2>/dev/null | head -5'

Result: WAZUH_VERSION="v4.14.3" β€” agent must be 4.14.3.

Step 1.3 β€” Install script investigation (failed β€” documented for audit)

The 4.x path on Wazuh’s CDN returns AccessDenied as of 2026-06-25. The 4.14 path serves the script, but it only supports apt/yum/dnf/zypper β€” no pacman/Arch support.

CDN path discovery
# 4.x path β€” AccessDenied on both script and signature
curl -sI https://packages.wazuh.com/4.x/wazuh-install.sh | head -3
curl -sI https://packages.wazuh.com/4.x/wazuh-install.sh.asc | head -3

# 4.14 path β€” script exists, signature does not
curl -sI https://packages.wazuh.com/4.14/wazuh-install.sh | head -3

# Download and check Arch support
curl -sO https://packages.wazuh.com/4.14/wazuh-install.sh
grep -i 'arch\|pacman\|apt\|yum\|dnf\|zypper' wazuh-install.sh | head -20

Result: Script references only apt/yum/dnf/zypper. No Arch/pacman path. The .asc GPG signature file returns AccessDenied from S3 β€” GPG verification not available for the install script.

Step 1.4 β€” RPM extraction method (used)

Download the official RPM and extract with bsdtar (available on Arch by default).

Download the versioned RPM
cd /tmp
curl -sO "https://packages.wazuh.com/4.x/yum/wazuh-agent-4.14.3-1.x86_64.rpm"
file wazuh-agent-4.14.3-1.x86_64.rpm

Expected: RPM v3.0 bin i386/x86_64

Extract to staging directory (inspect before installing)
mkdir -p /tmp/wazuh-staging
cd /tmp/wazuh-staging
bsdtar -xf /tmp/wazuh-agent-4.14.3-1.x86_64.rpm
Review file layout
find . -type f | head -30

Key files: ./usr/lib/systemd/system/wazuh-agent.service (systemd unit) and ./var/ossec/ (entire agent tree).

Step 1.5 β€” Install agent from staging

Before — confirm /var/ossec does not exist
ls -la /var/ossec 2>&1
Change — copy agent files, create service user, reload systemd
sudo cp -a ./var/ossec /var/ossec
sudo cp ./usr/lib/systemd/system/wazuh-agent.service /usr/lib/systemd/system/
sudo groupadd -r wazuh 2>/dev/null
sudo useradd -r -g wazuh -d /var/ossec -s /sbin/nologin wazuh 2>/dev/null
sudo chown -R root:wazuh /var/ossec
sudo chown -R wazuh:wazuh /var/ossec/queue /var/ossec/var /var/ossec/logs /var/ossec/tmp
sudo chmod -R 770 /var/ossec/queue /var/ossec/var /var/ossec/logs /var/ossec/tmp
sudo chmod +x /var/ossec/bin/*
sudo chmod +x /var/ossec/active-response/bin/*
sudo systemctl daemon-reload
After — verify installation
ls -la /var/ossec/bin/wazuh-agentd
/var/ossec/bin/wazuh-agentd -V
systemctl status wazuh-agent 2>&1 | head -5

Phase 2: Configure Agent

Configure ossec.conf with manager address, enrollment settings, and log collection paths appropriate for a hardened Arch Linux workstation.

Back up default config

Before — check for existing ossec.conf
sudo ls -la /var/ossec/etc/
RPM extraction does not include a default ossec.conf. The /var/ossec/etc/ directory contains client.keys, internal_options.conf, local_internal_options.conf, localtime, shared/, and wpk_root.pem β€” but no agent config. Skip backup and write the config fresh.

Configure manager connection and log collection

Change — write agent configuration
sudo tee /var/ossec/etc/ossec.conf > /dev/null << 'OSSEC_EOF'
<ossec_config>

  <client>
    <server>
      <address>wazuh-workers.inside.domusdigitalis.dev</address>
      <port>1514</port>
      <protocol>tcp</protocol>
    </server>
    <enrollment>
      <enabled>yes</enabled>
      <manager_address>wazuh.inside.domusdigitalis.dev</manager_address>
      <port>1515</port>
      <agent_name>modestus-p16g</agent_name>
    </enrollment>
    <notify_time>10</notify_time>
    <time-reconnect>60</time-reconnect>
  </client>

  <!-- Systemd journal (primary log source on Arch β€” covers sshd, PAM, sudo, AppArmor, nftables) -->
  <localfile>
    <log_format>journald</log_format>
  </localfile>

  <!-- Audit log (auditd β€” age_key_read, ssh_key_read, authorized_keys_mod, gpg_key_read rules) -->
  <localfile>
    <log_format>audit</log_format>
    <location>/var/log/audit/audit.log</location>
  </localfile>

  <!-- Pacman log (package installs/updates/removals) -->
  <localfile>
    <log_format>syslog</log_format>
    <location>/var/log/pacman.log</location>
  </localfile>

  <!-- NOTE: Arch uses journald exclusively. No /var/log/auth.log, /var/log/syslog, or /var/log/nftables.log.
       AppArmor denials, nftables drops, and sshd events are captured via the journald localfile above. -->

  <!-- File integrity monitoring -->
  <syscheck>
    <disabled>no</disabled>
    <frequency>43200</frequency>
    <directories check_all="yes" realtime="yes">/etc,/usr/bin,/usr/sbin</directories>
    <directories check_all="yes">/boot</directories>
    <ignore>/etc/mtab</ignore>
    <ignore>/etc/hosts.deny</ignore>
    <ignore>/etc/mail/statistics</ignore>
    <ignore>/etc/random-seed</ignore>
    <ignore>/etc/adjtime</ignore>
    <ignore>/etc/resolv.conf</ignore>
  </syscheck>

  <!-- Rootcheck -->
  <rootcheck>
    <disabled>no</disabled>
    <check_files>yes</check_files>
    <check_trojans>yes</check_trojans>
    <check_dev>yes</check_dev>
    <check_sys>yes</check_sys>
    <check_pids>yes</check_pids>
    <check_ports>yes</check_ports>
    <check_if>yes</check_if>
    <frequency>43200</frequency>
  </rootcheck>

  <!-- Active response -->
  <active-response>
    <disabled>no</disabled>
  </active-response>

  <logging>
    <log_format>plain</log_format>
  </logging>

</ossec_config>
OSSEC_EOF

Fix ownership (wazuh group, not ossec)

Wazuh 4.14.x expects group wazuh, not ossec. If Step 1.5 was run with ossec, re-run ownership fix here.
Before — check current ownership
sudo ls -la /var/ossec/etc/ossec.conf
Change — create wazuh group/user if missing, fix ownership
sudo groupadd -r wazuh 2>/dev/null
sudo useradd -r -g wazuh -d /var/ossec -s /sbin/nologin wazuh 2>/dev/null
sudo chown -R root:wazuh /var/ossec
After — verify ownership and config syntax
sudo ls -la /var/ossec/etc/ossec.conf
sudo /var/ossec/bin/wazuh-logcollector -t 2>&1 | head -5
sudo wc -l /var/ossec/etc/ossec.conf

Phase 3: AppArmor Profile for ossec

Create an AppArmor profile for the Wazuh agent binaries. Deploy in complain mode first — enforce after 48 hours of clean operation.

Create the profile

Before — confirm no existing profile
sudo aa-status 2>&1 | grep -i ossec
ls /etc/apparmor.d/ | grep -i ossec
Change — create AppArmor profile
sudo tee /etc/apparmor.d/usr.local.wazuh-agent > /dev/null << 'AA_EOF'
# AppArmor profile for Wazuh agent
# Mode: complain (48h observation before enforce)

#include <tunables/global>

/var/ossec/bin/wazuh-agentd flags=(complain) {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Agent binaries
  /var/ossec/bin/*                   rix,
  /var/ossec/agentless/*             rix,

  # Configuration
  /var/ossec/etc/**                  r,
  /var/ossec/etc/ossec.conf          r,
  /var/ossec/etc/internal_options.conf r,
  /var/ossec/etc/local_internal_options.conf r,

  # Logs (read for collection, write for agent logs)
  /var/log/audit/audit.log           r,
  /var/log/pacman.log                r,
  /var/ossec/logs/**                 rw,
  /var/ossec/logs/ossec.log          rw,

  # Journald access (primary log source on Arch)
  /run/log/journal/**                r,
  /var/log/journal/**                r,

  # Queue and state
  /var/ossec/queue/**                rw,
  /var/ossec/var/**                  rw,
  /var/ossec/tmp/**                  rw,

  # FIM: read monitored directories
  /etc/**                            r,
  /usr/bin/**                        r,
  /usr/sbin/**                       r,
  /boot/**                           r,

  # System information
  /proc/**                           r,
  /sys/**                            r,
  @{PROC}/[0-9]*/status              r,
  @{PROC}/[0-9]*/fd/                 r,

  # Network (agent -> manager)
  network tcp,

  # Deny everything else
  deny /home/** rw,
  deny /root/** rw,
}
AA_EOF
After — load profile in complain mode and verify
sudo apparmor_parser -r /etc/apparmor.d/usr.local.wazuh-agent
sudo aa-status 2>&1 | grep -i 'wazuh\|ossec'

Expected: /var/ossec/bin/wazuh-agentd appears in the output (complain mode).

Phase 4: nftables Egress Rules β€” SKIPPED

Before — check current egress state
sudo nft list ruleset | grep -A5 'chain output'
sudo nft list tables

Result: Tables are ip filter, ip nat, ip6 filter, ip6 nat, ip raw β€” all managed by iptables-nft (Docker/libvirt). No custom egress filtering chain exists. No inet egress or inet filter table.

Decision: Skip Phase 4. No egress filtering is active β€” all outbound traffic is allowed. The Wazuh agent can reach the manager (1515) and workers (1514) without any nftables changes.

Future action: When egress filtering is enabled (via security-hardening-carryover/nftables-egress-enforce.adoc), add Wazuh rules at that time. Do NOT add inet rules to an iptables-nft managed ruleset β€” they are separate subsystems and will conflict.

Phase 5: Start and Verify Agent

Enable and start the service (auto-enrollment β€” failed)

Before — confirm service exists but is not running
systemctl status wazuh-agent 2>&1 | head -5
Change — enable and start
sudo systemctl enable wazuh-agent
sudo systemctl start wazuh-agent
Auto-enrollment via systemctl start created duplicate agents due to a race condition. The enrollment section in ossec.conf triggers registration on every service start, and timing issues with the manager caused duplicate entries.

Manual registration with agent-auth (used)

Auto-enrollment failed. Use agent-auth for a one-shot manual registration instead.

Cleanup stale/duplicate agents
# Stop the agent
sudo systemctl stop wazuh-agent

# Authenticate to the API
DOMAIN="inside.domusdigitalis.dev"
TOKEN=$(curl -sk -u "wazuh-wui:$(gopass show -o v3/domains/d000/k3s/wazuh/api)" \
  -X POST "https://wazuh.${DOMAIN}:55000/security/user/authenticate" \
  | jq -r '.data.token')

# List agents to find stale/duplicate IDs
curl -sk -H "Authorization: Bearer ${TOKEN}" \
  "https://wazuh.${DOMAIN}:55000/agents" \
  | jq '.data.affected_items[] | {id, name, status, ip}' | tee /tmp/wazuh-agents-stale.json

# Delete stale agent(s) by ID (replace AGENT_ID with actual ID from above)
curl -sk -H "Authorization: Bearer ${TOKEN}" \
  -X DELETE "https://wazuh.${DOMAIN}:55000/agents?agents_list=AGENT_ID&status=all&older_than=0s" \
  | jq '.' | tee /tmp/wazuh-agent-delete.json

# Truncate client.keys to clear local registration state
sudo truncate -s 0 /var/ossec/etc/client.keys
Register with agent-auth
sudo /var/ossec/bin/agent-auth \
  -m wazuh.inside.domusdigitalis.dev \
  -p 1515 \
  -P password \
  -A modestus-p16g
The -P flag takes the registration password as a string, not a file path. The authd.pass file approach failed because -P expects the literal password value, not a path to a file containing it.
The registration password is literally password (the Wazuh default). This requires immediate rotation β€” see Phase 7.
Start agent after manual registration
sudo systemctl start wazuh-agent
After — verify running
systemctl status wazuh-agent 2>&1 | head -10

Verify agent registered with manager

After — check agent log for successful enrollment
sudo tail -30 /var/ossec/logs/ossec.log | grep -E 'Connected|Enrolled|ERROR'

Expected: Connected to the server ([wazuh-workers.inside.domusdigitalis.dev]:1514/tcp). The agent connects to workers for event delivery, not the manager directly. No ERROR lines.

Verify on manager side

DOMAIN="inside.domusdigitalis.dev"
TOKEN=$(curl -sk -u "wazuh-wui:$(gopass show -o v3/domains/d000/k3s/wazuh/api)" \
  -X POST "https://wazuh.${DOMAIN}:55000/security/user/authenticate" \
  | jq -r '.data.token')
curl -sk -H "Authorization: Bearer ${TOKEN}" \
  "https://wazuh.${DOMAIN}:55000/agents" \
  | jq '.data.affected_items[] | {id, name, status, ip}' | tee /tmp/wazuh-agents.json

Expected: modestus-p16g appears with status active.

Rootcheck warnings (non-critical)

The RPM extraction method does not include rootcheck definition files (rootcheck_files, rootcheck_trojans). The agent logs will show warnings:

rootcheck: WARN: File '/var/ossec/etc/shared/rootcheck_files' not found
rootcheck: WARN: File '/var/ossec/etc/shared/rootcheck_trojans' not found

These are non-critical. To resolve, either:

  1. Disable rootcheck in ossec.conf by setting <disabled>yes</disabled> in the <rootcheck> block.

  2. Download definition files from the Wazuh GitHub repository:

    sudo curl -sL "https://raw.githubusercontent.com/wazuh/wazuh/v4.14.3/etc/shared/rootkit_files.txt" \
      -o /var/ossec/etc/shared/rootcheck_files
    sudo curl -sL "https://raw.githubusercontent.com/wazuh/wazuh/v4.14.3/etc/shared/rootkit_trojans.txt" \
      -o /var/ossec/etc/shared/rootcheck_trojans
    sudo chown root:wazuh /var/ossec/etc/shared/rootcheck_files /var/ossec/etc/shared/rootcheck_trojans
    sudo chmod 640 /var/ossec/etc/shared/rootcheck_files /var/ossec/etc/shared/rootcheck_trojans

Phase 6: Validate Security Posture

Confirm that the agent installation introduced no regressions in the hardening stack.

After — re-run fortress verification script
# Run the fortress verification script block from the daily notes sub-partial
# (partials/worklog/daily-notes/YYYY-MM-DD/fortress-verification/fortress-script.adoc)
bash /tmp/fortress-verify.sh | tee /tmp/fortress-post-wazuh.txt
fortress-verification is not a standalone command. Copy the script block from the fortress-verification/fortress-script.adoc sub-partial to /tmp/fortress-verify.sh and execute it.
After — diff against baseline
diff "${BACKUP_DIR}/fortress-baseline.txt" /tmp/fortress-post-wazuh.txt

Expected: No regressions. New entries for the wazuh-agent service are acceptable. Any regression in AppArmor, nftables, auditd, LUKS, USBGuard, or fail2ban status requires investigation before proceeding.

Phase 7: Follow-up Actions

Post-enrollment items that require action but are not blockers for the initial deployment.

7.1 β€” Rotate registration password

The Wazuh authd registration password is the factory default (password). Rotate immediately.

# Generate strong password and store in gopass
gopass generate v3/domains/d000/k3s/wazuh/authd-password 32

# Update on manager (k3s)
ssh k3s-master-01 'sudo /usr/local/bin/k3s kubectl exec -n wazuh wazuh-manager-master-0 -- \
  bash -c "echo $(gopass show -o v3/domains/d000/k3s/wazuh/authd-password) > /var/ossec/etc/authd.pass"'

7.2 β€” Issue proper TLS certificate for wazuh-dashboard

The current dashboard certificate CN is wazuh, not wazuh-dashboard. Browser warnings and API clients may reject the mismatch. Issue a cert with the correct CN via Vault or ACME.

7.3 β€” Download rootcheck definition files

See the rootcheck warnings section in Phase 5. Download from the Wazuh GitHub repository and place in /var/ossec/etc/shared/.

7.4 β€” Move AppArmor profile from complain to enforce

After 48 hours of clean operation with no AppArmor denials from the wazuh-agent:

sudo aa-enforce /etc/apparmor.d/usr.local.wazuh-agent
sudo aa-status 2>&1 | grep -B1 -A1 wazuh

7.5 β€” Create wazuh-agent API scripts

Create reusable API query scripts in data/d000/infra/ for agent management, status checks, and alert queries. These replace the inline curl commands used during enrollment.