The Wazuh manager deployment playbook (at soar-radar/roles/wazuh_manager/tasks/main.yml) automates the deployment and configuration of Wazuh Manager instances with RADAR scenario-specific customizations. It supports three deployment modes (local Docker, remote Docker, and remote host) and ensures idempotent, scenario-aware configuration management.
┌─────────────────────────────────────────────────────────────┐
│ Playbook entry point: initialize variables & resolve paths │
└─────────────────────────────────────────────────────────────┘
↓
┌───────────────────┬───────────────────┐
↓ ↓ ↓
DOCKER_LOCAL DOCKER_REMOTE HOST_REMOTE
(Local Dev) (Remote Container) (Bare Metal)
The playbook consists of four logical parts: one handles initial configuration and flow choices, and the rest branches into three mutually exclusive blocks based on the manager_mode variable set in the inventory, which are as follows:
docker_local: Manager runs in Docker on controller machinedocker_remote: Manager runs in Docker on remote hosthost_remote: Manager runs directly on remote host (no containers)START
↓
┌─────────────────────────────┐
│ Load Variables & Resolve │
│ Scenario Path │
└────────────┬────────────────┘
↓
┌────────────────────┐
│ Determine Manager │
│ Mode │
└─┬──────┬───────┬───┤
│ │ │ └─→ host_remote
│ │ └─────→ docker_remote
│ └──────────────→ docker_local
│
├─ (If docker_local)
│ ├─ Check container running
│ ├─ Upload templates to OpenSearch
│ ├─ Copy config files
│ ├─ Stage RADAR snippets
│ ├─ Append decoders (idempotent)
│ ├─ Append rules (idempotent)
│ ├─ Handle SSH override (conditional)
│ ├─ Modify ossec.conf
│ ├─ Restart Wazuh (if changed)
│ ├─ Configure filebeat
│ └─ Setup filebeat pipelines
│
├─ (If docker_remote)
│ ├─ Create staging dir on remote
│ ├─ Bootstrap manager (if needed)
│ ├─ Check container running
│ ├─ [Same as docker_local, executed on remote]
│
└─ (If host_remote)
├─ Verify target directories
├─ Read snippets on controller
├─ Insert configs via Ansible modules
├─ Copy active response files
├─ Validate XML (optional)
└─ Restart Wazuh (if changed)
↓
END
failed_when: falsedebug taskswazuh-control (not full container)Here we provide a summarized description of the entire automation pipeline in the form of pseudocode.
FUNCTION DeployRadarScenario(scenario_name, manager_mode, bootstrap_flag):
// ============================================
// PHASE 1: INITIALIZATION
// ============================================
scenario_path ← RESOLVE_SCENARIO_DIR(scenario_name)
manager_vars ← LOAD_INVENTORY_VARS(manager_mode)
IF scenario_path DOES NOT EXIST:
FAIL "Scenario path not found"
END IF
// ============================================
// PHASE 2: BRANCH ON DEPLOYMENT MODE
// ============================================
SWITCH manager_mode:
CASE "docker_local":
RETURN DeployLocal(scenario_path, manager_vars)
CASE "docker_remote":
RETURN DeployRemoteContainer(scenario_path, manager_vars, bootstrap_flag)
CASE "host_remote":
RETURN DeployRemoteHost(scenario_path, manager_vars)
END SWITCH
// ============================================
// DOCKER_LOCAL DEPLOYMENT
// ============================================
FUNCTION DeployLocal(scenario_path, manager_vars):
container ← manager_vars.container_name
// Step 1: Verify container running
REPEAT 3 TIMES with 30sec delay:
IF docker.ps(container) == RUNNING:
BREAK
END REPEAT
// Step 2: Upload templates to OpenSearch
template_json ← READ_FILE(scenario_path + "/wazuh-alerts-template.json")
HTTP_PUT("https://indexer:9200/_index_template/wazuh-alerts-*",
auth=(admin_user, admin_pass),
body=template_json,
retries=3)
// Step 3: Copy configuration files
CopyConfigFiles(container, scenario_path)
// Step 4: Stage scenario snippets
DOCKER_EXEC(container, "mkdir -p /tmp/radar_snippets")
COPY_TO_CONTAINER(container, [
scenario_path + "/radar-ossec-snippet.xml",
scenario_path + "/local_decoder.xml",
scenario_path + "/local_rules.xml"
], "/tmp/radar_snippets/")
// Step 5: Append decoders (idempotent)
IF NOT MarkerExists(container, "/var/ossec/etc/decoders/local_decoder.xml",
"RADAR_DECODERS: " + scenario_name):
decoder_content ← READ_FILE("/tmp/radar_snippets/local_decoder.xml")
AppendToFile(container, "/var/ossec/etc/decoders/local_decoder.xml",
markers=SCENARIO_NAME, content=decoder_content)
END IF
// Step 6: Append rules (idempotent)
SAME AS Step 5 but for rules file
// Step 7: Handle SSH decoder override (if applicable)
IF scenario_name IN ["suspicious_login", "geoip_detection"]:
CopySshDecoderOverride(container, scenario_path)
END IF
// Step 8: Modify ossec.conf (idempotent)
ossec_conf ← DOCKER_CP(container, "/var/ossec/etc/ossec.conf", "/tmp/")
IF MarkerNotExists(ossec_conf, "RADAR: " + scenario_name):
snippet ← READ_FILE(scenario_path + "/radar-ossec-snippet.xml")
InsertBefore(ossec_conf, "</ossec_config>",
markers="RADAR: " + scenario_name,
content=snippet)
END IF
AddIfMissing(ossec_conf, "<logall>yes</logall>")
AddIfMissing(ossec_conf, "<logall_json>yes</logall_json>")
DOCKER_CP(ossec_conf, container, "/var/ossec/etc/ossec.conf")
SET_PERMS(container, "/var/ossec/etc/ossec.conf", "root:wazuh", "0640")
// Step 9: Restart if config changed
IF any_file_changed:
DOCKER_EXEC(container, "/var/ossec/bin/wazuh-control restart")
END IF
// Step 10: Configure filebeat
ConfigureFilebeat(container)
// Step 11: Setup filebeat pipelines
DOCKER_EXEC(container,
"filebeat setup --pipelines --modules wazuh --strict.ssl=false",
ignore_errors=TRUE) // Non-critical
RETURN SUCCESS
// ============================================
// DOCKER_REMOTE DEPLOYMENT
// ============================================
FUNCTION DeployRemoteContainer(scenario_path, manager_vars, bootstrap_flag):
remote_host ← manager_vars.ansible_host
remote_user ← manager_vars.ansible_user
container ← manager_vars.container_name
// Step 1: Bootstrap if needed
IF bootstrap_flag:
stage_dir ← CREATE_TEMP_DIR_ON_REMOTE(remote_host)
COPY_DOCKER_COMPOSE(stage_dir, remote_host)
SSH_EXEC(remote_host, "docker compose up -d")
WAIT_FOR_CONTAINER(remote_host, container, retries=20)
END IF
// Step 2: Verify container running
REPEAT 3 TIMES with 30sec delay:
IF docker.ps(container, remote_host) == RUNNING:
BREAK
END REPEAT
// Step 3-11: Same as docker_local, but:
// - Operations run on remote_host via SSH
// - Use stage_dir for file transfers first
// - Use "become: true" for privilege escalation
// Example:
stage_dir ← CREATE_TEMP_DIR_ON_REMOTE(remote_host)
SCP(scenario_path + "/*", remote_host:stage_dir)
SSH_EXEC(remote_host, "docker cp " + stage_dir + "/* " + container + ":/tmp/")
RETURN SUCCESS
// ============================================
// HOST_REMOTE DEPLOYMENT
// ============================================
FUNCTION DeployRemoteHost(scenario_path, manager_vars):
remote_host ← manager_vars.ansible_host
remote_user ← manager_vars.ansible_user
// Step 1: Verify target directories
ENSURE_DIRS_EXIST(remote_host, [
"/var/ossec",
"/var/ossec/etc",
"/var/ossec/etc/lists",
"/var/ossec/active-response/bin"
])
// Step 2: Read scenario snippets on controller
ossec_snippet ← READ_FILE(scenario_path + "/radar-ossec-snippet.xml")
decoder_snippet ← READ_FILE(scenario_path + "/local_decoder.xml")
rules_snippet ← READ_FILE(scenario_path + "/local_rules.xml")
// Step 3: Insert into host files via Ansible modules (idempotent)
INSERT_INTO_FILE(remote_host, "/var/ossec/etc/ossec.conf",
marker="RADAR: " + scenario_name,
content=ossec_snippet)
APPEND_TO_FILE(remote_host, "/var/ossec/etc/decoders/local_decoder.xml",
marker="RADAR_DECODERS: " + scenario_name,
content=decoder_snippet)
APPEND_TO_FILE(remote_host, "/var/ossec/etc/rules/local_rules.xml",
marker="RADAR_RULES: " + scenario_name,
content=rules_snippet)
// Step 4: Copy active response files
IF file_exists(scenario_path + "/email_ar.py"):
SCP(scenario_path + "/email_ar.py",
remote_host + ":/var/ossec/active-response/bin/")
END IF
// Step 5: Handle SSH decoder (if applicable)
IF scenario_name IN ["suspicious_login", "geoip_detection"]:
CopySshDecoderOverride(remote_host, scenario_path)
END IF
// Step 6: Validate ossec.conf XML (if Python available)
TRY:
SSH_EXEC(remote_host, "python3 -m xml.etree.ElementTree " +
"/var/ossec/etc/ossec.conf")
CATCH:
WARN "Could not validate XML syntax"
END TRY
// Step 7: Restart Wazuh if config changed
IF any_file_changed:
SSH_EXEC(remote_host, "/var/ossec/bin/wazuh-control restart",
become=true)
END IF
RETURN SUCCESS
// ============================================
// HELPER FUNCTIONS
// ============================================
FUNCTION CopyConfigFiles(container, scenario_path):
// Active response env vars
IF file_exists(".env"):
DOCKER_CP(".env", container + ":/var/ossec/active-response/bin/active_responses.env")
END IF
// Whitelist (geoip scenario)
IF file_exists(scenario_path + "/whitelist_countries"):
DOCKER_EXEC(container, "mkdir -p /var/ossec/etc/lists")
DOCKER_CP(scenario_path + "/whitelist_countries",
container + ":/var/ossec/etc/lists/whitelist_countries")
SET_PERMS(container, "/var/ossec/etc/lists/whitelist_countries",
"root:wazuh", "0644")
AddToOssecConf(container, "<list>etc/lists/whitelist_countries</list>")
END IF
// Email active response
IF file_exists(scenario_path + "/active_responses/email_ar.py"):
COPY_IF_DIFFERENT(scenario_path + "/active_responses/email_ar.py",
container + ":/var/ossec/active-response/bin/email_ar.py")
END IF
// AD context helpers
FOR each file IN ListDir(scenario_path + "/active_responses/ad_context_*.py"):
COPY_IF_DIFFERENT(file,
container + ":/var/ossec/active-response/bin/" + BASENAME(file))
END FOR
END FUNCTION
FUNCTION ConfigureFilebeat(container):
filebeat_yml ← DOCKER_CP(container, "/etc/filebeat/filebeat.yml", "/tmp/")
// Enable archives
REPLACE_IN_FILE(filebeat_yml, "enabled: false", "enabled: true",
within_section="archives")
REPLACE_IN_FILE(filebeat_yml, "var.paths: []",
"var.paths:\n - /var/ossec/logs/archives/archives.json",
within_section="archives")
IF file_changed:
DOCKER_CP(filebeat_yml, container + ":/etc/filebeat/filebeat.yml")
DOCKER_EXEC(container, "docker restart " + container)
END IF
END FUNCTION
FUNCTION MarkerExists(container, file_path, marker_text):
result ← DOCKER_EXEC(container, "grep -F '" + marker_text + "' " + file_path)
RETURN result.exit_code == 0
END FUNCTION
FUNCTION COPY_IF_DIFFERENT(src, dest_container_path):
src_sum ← HASH_FILE(src)
dest_sum ← DOCKER_EXEC(container, "sha256sum " + dest_container_path)
IF src_sum != dest_sum:
DOCKER_CP(src, container + ":" + dest_container_path)
RETURN TRUE
END IF
RETURN FALSE
END FUNCTION
Establish common variables used across all blocks.
| Variable | Source | Purpose |
|———-|——–|———|
| _scenario_path | Ansible fact | Path to scenario directory (e.g., soar-radar/suspicious_login) |
| _mgr_mode | Inventory host var | Deployment mode: docker_local, docker_remote, or host_remote |
| _mgr_container | Inventory (default: wazuh.manager) | Docker container name |
| _list_marker | Hardcoded | Marker for geoip whitelist insertion |
| _email_ar_dest | Hardcoded | Container path to email active response script |
| _lists_dir | Hardcoded | Container path to Wazuh lists directory |
- Check .env file existence on controller (for active response env vars)
- Set scenario paths and destination paths
- Debug output current manager mode
when: _mgr_mode == 'docker_local'
Deploy scenario to a local Docker container on the controller machine.
What: Verify manager container is running
How: docker ps with retries (3x, 30sec delay)
Why: Ensure container is ready before modifications
Idempotency: Register variable, use changed_when: false (check-only)
What: Upload wazuh-alerts index template to OpenSearch
How: HTTP PUT request to /_index_template/wazuh-alerts-*
When: Always executed (essential for dashboard)
Idempotency: Check HTTP status (200/201 = success)
TLS: validate_certs: no (handles self-signed certs)
Transfers scenario-specific files into container:
| File | Source | Destination | Purpose |
|---|---|---|---|
.env |
Controller | /var/ossec/active-response/bin/active_responses.env |
Active response environment vars |
whitelist_countries |
Scenario dir | /var/ossec/etc/lists/ |
IP whitelist for geoip scenario |
email_ar.py |
Scenario dir | /var/ossec/active-response/bin/ |
Email active response script |
ad_context_*.py |
Scenario dir | /var/ossec/active-response/bin/ |
AD context helpers |
| RADAR snippets | Scenario dir | /tmp/radar_snippets/ |
Temporary staging for config insertion |
Idempotency:
What: Append scenario decoders to /var/ossec/etc/decoders/local_decoder.xml
Idempotency mechanism:
marker: "<!-- RADAR_DECODERS: BEGIN -->"
marker: "<!-- RADAR_DECODERS: END -->"
Key Fix: Uses touch instead of truncation (:> "$f") to preserve multiple scenarios
Flow:
Identical to decoders, but for /var/ossec/etc/rules/local_rules.xml
When: Only for suspicious_login or geoip_detection scenarios
What:
0310-ssh.xml decoder override<decoder_exclude>Why: Custom SSH decoding rules may conflict with Wazuh defaults
Complex multi-step process:
/var/ossec/etc/ossec.conf from container to /tmp/ossec.conf.from_containerblockinfile with scenario-specific markers (idempotent)<decoder_exclude> if custom override present<logall>yes</logall> and <logall_json>yes</logall_json>Idempotency:
blockinfile with markers prevents duplicatesgrep checks before inserting decoder_excludeWhen: If any of these changed:
How: docker exec wazuh.manager /var/ossec/bin/wazuh-control restart
What: Enable archives in filebeat.yml for log collection
Steps:
/etc/filebeat/filebeat.yml from containerIdempotency:
What: Initialize filebeat ingest pipelines and index templates
Command: filebeat setup --pipelines --modules wazuh --strict.ssl=false
Why:
--strict.ssl=false handles self-signed cert validationError Handling: failed_when: false (non-critical if fails)
when: _mgr_mode == 'docker_remote'
Deploy scenario to a Docker container on a remote host via SSH.
Creates temporary directory on remote host using tempfile module
Why: Clean, isolated staging area for all files before copying to container
_stage.path = /tmp/radar_mgr_XXXXX/
When: If manager_bootstrap == true (i.e., manager doesn’t exist yet)
Steps:
docker-compose up -dWhy: Enables automatic Wazuh stack provisioning from scratch
Differences from docker_local:
_stage.path first (not directly to container)docker cp commandPattern:
Controller → (scp) → Remote _stage.path → (docker cp) → Container
When: If manager_bootstrap == true AND webhook container not running
Purpose: Deploy webhook container alongside manager
Scope: Out of manager scope, separate block
Identical logic to docker_local, but executed on remote via docker exec + become: true
Enhanced vs docker_local:
become: truewhen: _mgr_mode == 'host_remote'
Deploy to bare-metal Wazuh Manager on remote host (no Docker).
No Docker intermediate → modify host files directly:
/var/ossec/etc/ossec.conf/var/ossec/etc/decoders/local_decoder.xml/var/ossec/etc/rules/local_rules.xmlEnsures target directories exist (parent creation):
/var/ossec
/var/ossec/etc
/var/ossec/etc/lists
/var/ossec/active-response/bin
Uses lookup() to read scenario snippets on controller
Why: Single file transfer, then insert from variable (fewer SSH ops)
Uses Ansible blockinfile module (preferred for idempotency over shell scripts)
Attempts to validate ossec.conf well-formedness using Python XML parser
Why: Catch config errors early
Graceful: Skips if Python not available
blockinfile:
marker: "<!-- RADAR: {mark} -->"
blockinfile tracks content between markerslsum="$(sha256sum "$f" | awk '{print $1}')"
rsum="$(docker exec ... sha256sum ...)"
if [ "$lsum" != "$rsum" ]; then copy; fi
if grep -Fq "$marker" "$file"; then
echo "NOCHANGE"
else
append content
fi
when:
- file_stat.stat.exists
- previous_task is changed
retries: 3
delay: 30
until: result.rc == 0
Used for:
failed_when: false
Used for:
register: _variable_name
changed_when: "'CHANGED' in stdout"
--extra-vars "scenario_name=suspicious_login"
controller:/home/user/soar-radar//
├── radar-ossec-snippet.xml
├── local_decoder.xml
├── local_rules.xml
├── radar-template.json (if log_volume)
└── active_responses/
├── email_ar.py
└── ad_context_*.py
| Scenario | Conditional Block |
|———-|——————|
| suspicious_login, geoip_detection | 0310 SSH decoder override |
| log_volume | Index template upload + filebeat setup |
| All | Decoders + Rules + ossec.conf modifications |
# From build-radar.sh
--manager local → _mgr_mode = docker_local (block 2)
--manager remote → _mgr_mode = docker_remote (block 3)
--manager host → _mgr_mode = host_remote (block 4)
Each block:
/var/ossec/etc/ossec.conf/var/ossec/etc/decoders/local_decoder.xml/var/ossec/etc/rules/local_rules.xml/var/ossec/etc/lists/whitelist_countries (conditional)/var/ossec/active-response/bin/email_ar.py (conditional)/var/ossec/active-response/bin/ad_context_*.py (conditional)/etc/filebeat/filebeat.yml (conditional)/_index_template/wazuh-alerts-* (HTTP PUT)/_index_template/radar-log-volume (conditional, HTTP PUT)soar-radar//radar-ossec-snippet.xml → ossec.conf insertionslocal_decoder.xml → custom decoderslocal_rules.xml → custom rulesactive_responses/ → scriptsradar-template.json (optional) → index templatewhen: _mgr_mode == 'new_mode'