Configuration
CSM is configured via /etc/csm/csm.yaml, with --config <path> to override. Legacy installs that only have /opt/csm/csm.yaml keep working; packaged upgrades migrate that file into /etc/csm/csm.yaml and leave the old path as a compatibility link. Optional drop-in fragments under /etc/csm/conf.d/*.yaml are merged on top of the main file at startup; see conf.d drop-ins below.
Platform & Web Server
CSM auto-detects the host OS (Ubuntu, Debian, AlmaLinux, Rocky, RHEL, CloudLinux), control panel (cPanel, Plesk, DirectAdmin, or none), and web server (Apache, Nginx, LiteSpeed, or none) at daemon startup. The detected platform is logged as:
[2026-04-10 08:13:37] platform: os=ubuntu/24.04 panel=none webserver=nginx
The daemon then chooses the correct log paths, config candidates, and check set without any configuration from you. Verify with:
journalctl -u csm.service | grep platform:
Web server overrides
For hosts with a custom layout (reverse proxy, non-standard package locations, chroot), add a web_server: section to csm.yaml. Every field is optional – anything left blank falls back to auto-detection.
web_server:
type: "nginx" # apache | nginx | litespeed -- overrides auto-detect
config_dir: "/etc/nginx" # for info/diagnostics only
access_logs: # tried in order until one exists
- "/var/log/nginx/access.log"
- "/srv/logs/nginx/access.log"
error_logs: # used by ModSecurity deny watcher
- "/var/log/nginx/error.log"
modsec_audit_logs:
- "/var/log/nginx/modsec_audit.log"
modsec_error_log (legacy single-path override) is still honored and takes precedence over web_server.error_logs for the ModSecurity watcher only:
modsec_error_log: "/opt/myapp/logs/modsec_audit.log"
Account roots (plain Linux web-scan coverage)
By default, the account-scan based checks (perf_error_logs, perf_wp_config, perf_wp_transients, and related) iterate /home/*/public_html which is the cPanel layout. On plain Ubuntu / AlmaLinux with Nginx or Apache, point CSM at your actual web roots:
account_roots:
- "/var/www/*/public" # e.g. Laravel/Symfony sites
- "/srv/http/*" # Arch / generic layouts
- "/home/*/public_html" # add if you also have cPanel-style accounts
Each entry is a glob pattern expanded at scan time. Non-existent matches are silently dropped. If account_roots is empty and CSM is not on a cPanel host, the account-scan checks return no findings (they run but find nothing, which is the correct behavior for a plain-Linux host with no configured web roots).
Today, three checks consume this: perf_error_logs, perf_wp_config, perf_wp_transients. The remaining account-scan checks (WordPress core integrity, phishing kit detection, htaccess tampering, fileindex, etc.) still assume the cPanel /home/*/public_html layout and will be migrated in a follow-up release.
Minimal Config
hostname: "csm.example.com"
alerts:
email:
enabled: true
to: ["admin@example.com"]
disabled_checks: [] # optional: suppress these checks from email only
smtp: "localhost:25"
webui:
enabled: true
listen: "0.0.0.0:9443"
auth_token: "your-secret-token"
infra_ips: ["10.0.0.0/8"]
Full Reference
hostname: "csm.example.com"
# --- Alerts ---
alerts:
email:
enabled: true
to: ["admin@example.com"]
from: "csm@csm.example.com"
smtp: "localhost:25"
disabled_checks: [] # check names to keep in web/history but exclude from email
webhook:
enabled: false
url: ""
type: "slack" # slack, discord, generic, phpanel
hmac_secret: "" # phpanel webhook signing secret
hmac_secret_env: "" # env var containing phpanel signing secret
per_finding: false # phpanel sends one signed POST per finding
heartbeat:
enabled: false
url: "" # healthchecks.io, cronitor, dead man's switch
max_per_hour: 10 # default: 10
audit_log: # SIEM-friendly per-finding stream
file:
enabled: false
path: /var/log/csm/audit.jsonl # default; logrotate fragment ships with the package
syslog:
enabled: false
network: udp # udp | tcp | unix | unixgram | tls
address: 127.0.0.1:514 # host:port, or filesystem path for unix variants
facility: local0 # default: local0
tls_ca: "" # optional CA cert for tls transport
# --- Integrity ---
integrity:
binary_hash: "" # auto-populated by install/rehash
config_hash: "" # auto-populated by install/rehash
confd_hash: "" # auto-populated by install/rehash
immutable: false # prevent config changes at runtime
# --- Thresholds ---
thresholds:
mail_queue_warn: 500 # default: 500
mail_queue_crit: 2000 # default: 2000
state_expiry_hours: 24 # default: 24
deep_scan_interval_min: 60 # minutes between deep scans (default: 60)
wp_core_check_interval_min: 60 # WordPress core checksum interval (default: 60)
webshell_scan_interval_min: 30 # webshell scan interval (default: 30)
filesystem_scan_interval_min: 30 # filesystem scan interval (default: 30)
multi_ip_login_threshold: 3 # IPs per account before alert (default: 3)
multi_ip_login_window_min: 60 # time window for multi-IP check (default: 60)
cred_stuffing_distinct_accounts: 5 # failed accounts from one IP before credential_stuffing (default: 5)
plugin_check_interval_min: 1440 # WordPress plugin check interval (default: 1440)
brute_force_window: 5000 # failed auth attempts window (default: 5000)
domlog_max_files: 500 # per-domain access logs per WP brute-force scan (default: 500)
domlog_tail_lines: 500 # trailing lines tailed from each domlog per scan (default: 500)
domlog_max_age_min: 30 # skip per-domain access logs untouched in this many minutes (default: 30)
mail_log_tail_lines: 500 # trailing lines of /var/log/exim_mainlog read by the mail-per-account scanner (default: 500)
syslog_messages_tail_lines: 200 # trailing lines of /var/log/messages read by the FTP login scanner (default: 200)
account_scan_max_files: 10000 # account and mail-domain paths per scanner cycle (default: 10000)
# If this cap clips /home/<account>/ paths, account_scan_truncated names the affected account.
crontab_base64_blob_max_bytes: 16384 # encoded bytes per crontab base64 candidate before decoded-content matching; must be a multiple of 4 (default: 16384)
# HTTP request flood, User-Agent spoof, and distributed HTTP detection.
# These detectors scan the same per-vhost access-log stream as the WP
# brute-force scanner; no extra log tailer is needed.
#
# http_flood_threshold: minimum per-IP request count inside the window
# that emits http_request_flood. 0 disables the detector. The detector
# ships disabled so operators can sample local baseline traffic first.
# Adjust up for CDNs or CGNAT-heavy visitor pools before enabling.
http_flood_threshold: 0 # 0 = disabled; set after sampling baseline traffic
http_flood_window_min: 5 # rate window in minutes (default: 5)
# http_ua_spoof_threshold: per-IP per-window count for non-browser UA
# kinds before http_ua_spoof fires. Claimed search-engine bots (Googlebot,
# Bingbot, Applebot) that fail reverse-DNS confirmation fire regardless of
# this threshold once the rDNS cache confirms the IP is not the real bot.
http_ua_spoof_threshold: 30 # default: 30
# http_distributed_min_ips: distinct already-abusive source IPs that hit
# the same vhost in one scan window before a per-vhost distributed flood
# finding fires. 0 disables the rollup for existing configs that do not
# opt in.
http_distributed_min_ips: 10 # sample setting; omit or set 0 to disable
# These three opt-in flags extend UA spoof detection to additional UA
# classes. Leave disabled on busy shared hosts; scripting-language agents
# and headless browsers appear on many legitimate monitoring stacks.
http_ua_scripting_enabled: false # flag curl/wget/python-requests/Go-http style UAs
http_ua_headless_enabled: false # flag Puppeteer/Playwright/PhantomJS UAs
http_ua_empty_enabled: false # flag requests with no UA at all
# SMTP brute-force tracker (Exim mainlog, dovecot SASL on submission ports)
smtp_bruteforce_threshold: 5 # per-IP failed auths before block (default: 5)
smtp_bruteforce_window_min: 10 # sliding window in minutes (default: 10)
smtp_bruteforce_suppress_min: 60 # cooldown between repeat findings (default: 60)
smtp_bruteforce_subnet_threshold: 8 # unique IPs per /24 before subnet block (default: 8)
smtp_account_spray_threshold: 12 # unique IPs targeting one mailbox before visibility finding (default: 12)
smtp_bruteforce_max_tracked: 20000 # soft cap on tracked entries; oldest evicted (default: 20000)
# SMTP probe-abuse tracker (raw connect-rate per IP; catches scanners that
# never reach AUTH). Threshold sized well above any legitimate MUA usage.
smtp_probe_threshold: 100 # per-IP connects before block (default: 100; explicit 0 disables)
smtp_probe_window_min: 5 # sliding window in minutes (default: 5)
smtp_probe_suppress_min: 60 # cooldown between repeat findings (default: 60)
smtp_probe_max_tracked: 20000 # soft cap on tracked entries; oldest evicted (default: 20000)
# Mail brute-force tracker (IMAP/POP3/ManageSieve via mail_logs source)
mail_bruteforce_threshold: 5 # per-IP failed auths before block (default: 5)
mail_bruteforce_window_min: 10 # sliding window in minutes (default: 10)
mail_bruteforce_suppress_min: 60 # cooldown between repeat findings (default: 60)
mail_bruteforce_subnet_threshold: 8 # unique IPs per /24 before subnet block (default: 8)
mail_account_spray_threshold: 12 # unique IPs targeting one mailbox before visibility finding (default: 12)
mail_bruteforce_max_tracked: 20000 # soft cap on tracked entries; oldest evicted (default: 20000)
mail_brute_account_key: "builtin:dovecot-user" # builtin:dovecot-user | builtin:postfix-sasl | regex:<capture>
modsec_escalation_hits: 3 # denies from one IP before ModSecurity escalation (default: 3)
modsec_escalation_window_min: 10 # ModSecurity escalation window in minutes (default: 10)
# --- Web server overrides ---
# Leave these empty to use auto-detected paths for the running platform.
web_server:
# Override the per-vhost access-log glob patterns. Empty uses the
# auto-detected default for the panel (cPanel, Plesk, DirectAdmin,
# bare Apache, or bare Nginx).
domlog_globs: []
# IPs or CIDRs whose X-Forwarded-For header is trusted for client-IP
# extraction. Leave empty to ignore XFF and use RemoteIP as-is.
trusted_proxies: []
# --- Infrastructure ---
infra_ips: [] # management IPs/CIDRs/hostnames - never blocked
# --- Mail Logs ---
# Packaged releases include journald support. Custom builds need
# `make JOURNAL=1 build-yara` before `source: journal` can be selected.
mail_logs:
source: auto # auto | file | journal
file: "" # optional path override for file source
units: ["postfix", "dovecot"] # journal units for source=journal or auto fallback
# --- State ---
state_path: "/var/lib/csm/state" # bbolt DB and state files
# --- Suppressions ---
suppressions:
upcp_window_start: "00:30" # cPanel nightly update window start
upcp_window_end: "02:00" # cPanel nightly update window end
known_api_tokens: [] # API tokens to ignore in auth logs (e.g. ["phclient"])
ignore_paths: # glob patterns to skip in filesystem scans
- "*/cache/*"
- "*/vendor/*"
suppress_webmail_alerts: true # don't alert on webmail logins
suppress_cpanel_login_alerts: false # don't alert on cPanel direct logins
suppress_blocked_alerts: true # don't alert on IPs that were auto-blocked
trusted_countries: ["RO"] # ISO 3166-1 alpha-2 - suppress cPanel login alerts from these
# --- Auto-Response ---
auto_response:
enabled: false
kill_processes: false # kill malicious processes
quarantine_files: false # move malware to quarantine
block_ips: false # block attacker IPs via firewall
block_expiry: "24h" # duration for temp blocks (e.g. "24h", "12h")
max_blocks_per_hour: 50 # per-IP blocks per hour; 0/omitted uses default
enforce_permissions: false # auto-chmod 644 world/group-writable PHP files
block_cpanel_logins: false # block IPs on cPanel/webmail/FTP/API thresholded brute findings (multi-IP login, webmail/API brute, FTP brute). Single direct cPanel form logins stay audit-only regardless of this flag.
netblock: false # auto-block IPv4 /24 or IPv6 /64 subnets
netblock_threshold: 3 # IPs from same IPv4 /24 or IPv6 /64 before subnet block
permblock: false # promote temp blocks to permanent
permblock_count: 4 # temp blocks before promotion
permblock_interval: "24h" # window for counting temp blocks
clean_database: false # auto-drop confirmed malicious DB objects after backup
clean_htaccess: false # auto-clean .htaccess directives flagged by hardened detectors (backups under /opt/csm/quarantine/pre_clean/)
disable_enforce_af_alg: false # suspend periodic AF_ALG hardening re-assertion
copy_fail_kill_process: false # kill processes caught opening AF_ALG sockets via the live listener
dry_run: true # safe default; logs intended IP blocks without mutating nftables
verdict_callback:
enabled: false # call panel before each auto-block
url: "" # POST target for verdict requests
hmac_secret: "" # signing secret, or use hmac_secret_env
hmac_secret_env: "" # env var read at call time
allow_unsigned: false # true only for staged unsigned rollouts
require_response_signature: true # reject unsigned callback replies
timeout_sec: 2 # callback request timeout
# PHP-relay auto-freeze. Off by default; only kicks in on cPanel hosts
# where email_protection.php_relay.enabled is true. dry_run defaults to
# true even when freeze is true, so an operator who enables freeze
# without thinking gets a dry-run rather than a live exim -Mf storm.
# Override at runtime with `csm phprelay dry-run on|off|reset`.
php_relay:
freeze: false # opt in to wire the exim -Mf hook into the alert pipeline
dry_run: true # safe default; flip with `csm phprelay dry-run off [--persist]`
max_actions_per_minute: 60 # rolling 60s cap on exim -Mf invocations
# --- Detection ---
detection:
# db_object_scanning is tri-state: omit for the default (on),
# `false` to explicitly disable. When off, the MySQL persistence
# scanner emits no findings; the manual `csm db-clean --drop-object`
# CLI keeps working for operator-driven cleanup.
# db_object_scanning: true
db_object_allowlist: [] # entries: <account>:<schema>:<type>:<name> -- suppresses db_unexpected_* warnings only
admin_overlap_min_accounts: 2 # raise only if routine shared-admin accounts are expected on this host
admin_overlap_trusted_emails: [] # exact reviewed admin emails that may manage multiple cPanel accounts
admin_overlap_trusted_domains: [] # exact reviewed email domains for developer or reseller admin accounts
# rescan_on_signature_update: true # tri-state; omit for default-on, false to disable retroactive sweeps
af_alg_backend: "auto" # auto | bpf | auditd | none
connection_tracker_backend: "auto" # auto | bpf | legacy | none
connection_poll_interval: 30s # legacy connection tracker interval
exec_monitor_backend: "auto" # auto | bpf | legacy | none
exec_monitor_poll_interval: 30m # legacy process monitor interval
sensitive_files_backend: "auto" # auto | bpf | legacy | none
sensitive_files_poll_interval: 5m # sensitive-file poll/watchset refresh interval
direct_smtp_egress:
enabled: false # detect non-MTA local processes opening outbound SMTP
backend: "auto" # auto | bpf | legacy | none
dry_run: true # safe default for detector-scoped action
ports: [25, 465, 587] # destination ports to inspect
# --- BPF Enforcement ---
bpf_enforcement:
enabled: false # master switch for in-kernel denial
dry_run: true # log intended denials, allow the connect
direct_smtp_egress: false # gate enforcement on direct SMTP egress matches
verdict_callback: false # userspace advisory callback after the BPF decision
# --- Challenge Pages ---
challenge:
enabled: false # enable PoW challenge pages instead of hard block
listen_addr: 127.0.0.1 # bind address; use 0.0.0.0 for public direct redirects
listen_port: 8439 # port for challenge server; must fit the TCP port range
tls_cert: "" # optional HTTPS cert for direct/public challenge listener
tls_key: "" # optional HTTPS key for direct/public challenge listener
public_url: "" # required by webserver-integration, e.g. https://host:8439/challenge
secret: "" # HMAC secret for tokens (auto-generated if empty)
difficulty: 2 # SHA-256 proof-of-work difficulty 0-5 (default: 2)
trusted_proxies: [] # IPs/CIDRs allowed to supply X-Forwarded-For
port_gate:
enabled: false # nftables gate for non-loopback challenge listener
captcha_fallback: # widget for JS-disabled visitors (default off)
provider: "" # "turnstile" | "hcaptcha" | "" (off)
site_key: "" # public key embedded in the widget
secret_key: "" # verified server-side
timeout: 10s
verified_session: # signed-cookie bypass for authenticated operators
enabled: false
cookie_name: csm_admin_session
ttl: 4h
admin_secret: "" # POST'd to /challenge/admin-token to mint cookie
verified_crawlers: # reverse-DNS forward-confirm for search crawlers
enabled: false
providers: [] # names: googlebot | bingbot
cache_ttl: 15m
# --- PHP Shield ---
php_shield:
enabled: false # watch the PHP Shield event log for alerts
# --- Reputation ---
reputation:
abuseipdb_key: "" # AbuseIPDB API key for IP reputation lookups
whitelist: [] # IPs to never flag as malicious
# Async PTR + forward-A verification for IPs that claim search-engine
# bot UAs (Googlebot, Bingbot, Applebot). When an IP claims a bot UA
# but reverse DNS does not confirm it, the request counts toward
# http_ua_spoof. Transient DNS lookup failures fail open and are
# retried later. Set false only if your resolver is unreliable. See
# docs/src/auto-response.md for the always-block behavior.
bot_verify_enabled: true # default: true
rspamd:
enabled: false # include rspamd rolling history in IP reputation
url: "http://127.0.0.1:11334" # rspamd controller URL
token: "" # controller password, or use token_env
token_env: "" # env var read at query time
upstream:
enabled: false # include panel-side threat-intel cache scores
url: "" # HTTPS base URL; HTTP only allowed for loopback
token: "" # bearer token, or use token_env
token_env: "" # env var read at query time
cache_ttl_min: 15 # local cache TTL for upstream scores
timeout_sec: 5 # upstream request timeout
report:
enabled: false # opt-in abuse report delivery; restart required
classes: [] # bruteforce | php_relay | credential_stuffing | bad_asn_egress
spool_path: "" # default: <state_path>/abuse_reports.db
spool_max: 10000 # max queued reports per target
targets:
- name: "" # stable target name
url: "" # HTTPS collector URL; HTTP only allowed for loopback
transport: "hmac" # hmac | ed25519
node_id: "" # sender node ID
key_id: "" # receiver key ID
key_env: "" # HMAC secret or Ed25519 private key env var
token_env: "" # optional bearer token env var for HMAC targets
central:
enabled: false # opt-in central scored-set consume; restart required
set_url: "" # HTTPS scored-set endpoint; HTTP only for loopback
pubkey_env: "" # env var with Ed25519 public key hex
refresh_interval: 6h # pull interval; default 6h
action: "challenge" # off | challenge | block_if_local_corroborated
block_threshold: 80 # score needed before local corroboration can block
# --- Signatures ---
signatures:
rules_dir: "/opt/csm/rules" # YAML signature rules directory
update_url: "" # remote URL to fetch rule updates
auto_update: false # auto-download rules on schedule
update_interval: "" # how often to check (e.g. "24h")
signing_key: "" # required for any remote rule update path; 64-char hex Ed25519 public key
yara_forge:
enabled: false # auto-fetch YARA Forge community rules
tier: "core" # "core", "extended", "full" (default: "core")
update_interval: "168h" # how often to check for updates (default: weekly)
download_url: "" # signed ZIP URL/template; supports {tier} and {version}
disabled_rules: [] # YARA rule names to exclude from Forge downloads
# yara_worker_enabled: true # tri-state: omit for the default (on), `false` to explicitly disable
# signatures.signing_key is mandatory whenever either signatures.update_url
# is set or signatures.yara_forge.enabled is true. It must be the hex
# Ed25519 public key used to verify detached .sig files for rule bundles.
# Remote update URLs must use HTTP or HTTPS and must not point at localhost,
# loopback, link-local, unspecified, or RFC1918 / ULA private addresses.
#
# YARA Forge upstream GitHub releases do not publish CSM detached signatures.
# To enable automatic Forge updates, mirror the ZIPs, sign each ZIP, publish
# the signature at the ZIP URL plus .sig, and set yara_forge.download_url to
# that signed mirror. Otherwise leave update_url empty and yara_forge.enabled
# false.
# --- Web UI ---
webui:
enabled: true
listen: "0.0.0.0:9443" # address:port for HTTPS server
auth_token: "" # Bearer/cookie auth token (auto-generated on install)
tokens: [] # optional scoped tokens: name/token/scope (admin or read)
metrics_token: "" # optional Bearer token for /metrics only
tls_cert: "" # path to TLS certificate PEM file
tls_key: "" # path to TLS private key PEM file
ui_dir: "" # path to UI files on disk (default: /opt/csm/ui)
# --- Email AV ---
email_av:
enabled: false
clamd_socket: "/var/run/clamd.scan/clamd.sock" # path to ClamAV daemon socket
scan_timeout: "30s" # per-attachment scan timeout
max_attachment_size: 26214400 # max single attachment size in bytes (25MB)
max_archive_depth: 1 # max nested archive extraction depth
max_archive_files: 50 # max files extracted from a single archive
max_extraction_size: 104857600 # max total extraction size in bytes (100MB)
quarantine_infected: true # quarantine emails with infected attachments
scan_concurrency: 4 # parallel scan workers
# --- Email Protection ---
email_protection:
password_check_interval_min: 1440 # how often to audit email passwords (default: 1440)
high_volume_senders: [] # accounts expected to send high volume (skip rate alerts)
rate_warn_threshold: 50 # emails per window before warning (default: 50)
rate_crit_threshold: 100 # emails per window before critical (default: 100)
rate_window_min: 10 # rate check window in minutes (default: 10)
known_forwarders: [] # expected plain mail forwarders
# PHP-relay detector (cPanel only; gated by platform.IsCPanel at startup).
# Off by default. When enabled, the daemon spawns the inotify spool
# watcher, runs a startup spool walk, and starts the Path 2b retro scan
# on /var/log/exim_mainlog. See docs/src/detection-realtime.md#php-relay
# for what each path actually triggers on.
php_relay:
enabled: false # opt in to start the watcher
rate_window_min: 5 # Path 1 rolling window
header_score_volume_min: 5 # Path 1: don't score until script has emitted N msgs
absolute_volume_per_hour: 30 # Path 2 threshold per script
account_volume_per_hour: 0 # Path 2b operator override; 0 = auto-derive from cpanel.config maxemailsperhour
reputation_failures_per_24h: 3 # Path 3 threshold (Stage 2)
fanout_distinct_scripts: 3 # Path 4 threshold
fanout_window_min: 5 # Path 4 window
baseline_sigma: 3.0 # Path 5 (Stage 3)
baseline_observation_days: 7 # Path 5 (Stage 3)
policies_dir: "/opt/csm/policies/php_relay" # mailer_classes.yaml + http_proxy_ranges.yaml; SIGHUP-reloadable
cloud_relay:
allow_users: [] # full mailbox opt-outs for cloud-relay detection
allow_domains: [] # domain-wide opt-outs for cloud-relay detection
# Email forward guard (cPanel only). Opt-in MTA-native enforcement for
# external forward copies. Enforce mode can hold null-sender backscatter and
# bad-sender-IP copies before they relay to an external provider, while the
# local mailbox copy still delivers. Spam, malware, and auth-fail signals are
# accounted in dry-run until Exim content scanning is wired. CSM is not in the
# live mail path; an installed Exim rule can keep holding matching copies even
# if the daemon is down. Held copies can be released or deleted from the Email page.
forward_guard:
enabled: false # master switch (default off)
dry_run: true # account/log only, do not actually hold (default true)
quarantine_retention_days: 14 # held-copy retention window
skip_forwarders: [] # reserved forwarder exemptions; not enforced yet
hold_signals: # signal toggles, each default true
bounce_backscatter: true # null-sender bounce backscatter (enforceable)
spam_flagged: true # message flagged as spam (dry-run/accounting only)
malware: true # message carries malware (dry-run/accounting only)
bad_sender_ip: true # originating IP has bad reputation (enforceable)
auth_fail: true # sender failed SPF/DKIM/DMARC auth (dry-run/accounting only)
# --- Firewall ---
firewall:
enabled: false
# Open ports (IPv4). SSH (22) is intentionally absent; uncomment in
# the YAML lists if sshd listens on 22. TCP 853 is DNS-over-TLS;
# UDP 853 is DNS-over-QUIC.
# 6277/24441 are DCC/Pyzor network checks used by SpamAssassin.
tcp_in: [20,21,25,26,53,80,110,143,443,465,587,853,993,995,2077,2078,2079,2080,2082,2083,2091,2095,2096]
tcp_out: [20,21,25,26,37,43,53,80,110,113,443,465,587,853,873,993,995,2082,2083,2086,2087,2089,2195,2325,2703]
udp_in: [53,443,853]
udp_out: [53,113,123,443,853,873,6277,24441]
# IPv6
ipv6: false
tcp6_in: [] # if empty, uses tcp_in
tcp6_out: [] # if empty, uses tcp_out
udp6_in: [] # if empty, uses udp_in
udp6_out: [] # if empty, uses udp_out
# Restricted ports (infra IPs only)
restricted_tcp: [2086,2087,2325] # WHM ports
# Passive FTP range
passive_ftp_start: 49152
passive_ftp_end: 65534
# Infra IPs/CIDRs/hostnames for firewall rules
infra_ips: []
# Rate limiting
conn_rate_limit: 200 # new connections/min per IP (CGNAT-tolerant)
syn_flood_protection: true
conn_limit: 400 # max concurrent connections per IP (0 = disabled)
# Per-port flood protection: rate-limit new connections per source IP and IP family.
# Defaults are sized for a busy mail host: 600/300s = 120 new conns/min/IP,
# which tolerates a Thunderbird/iPhone client opening 5-15 parallel sessions
# while still capping single-IP flood storms.
port_flood:
- port: 25
proto: tcp
hits: 600
seconds: 300
- port: 465
proto: tcp
hits: 600
seconds: 300
- port: 587
proto: tcp
hits: 600
seconds: 300
# UDP flood protection
udp_flood: true
udp_flood_rate: 100 # packets per second
udp_flood_burst: 500 # burst allowance
# Country blocking
country_block: [] # ISO country codes to block
country_db_path: "" # path to MaxMind DB (uses geoip config if empty)
# Silent drop (no logging)
drop_nolog: [23,67,68,111,113,135,136,137,138,139,445,500,513,520]
# IP limits
deny_ip_limit: 3000 # max permanent blocked IPs
deny_temp_ip_limit: 500 # max temporary blocked IPs
# Outbound SMTP restriction
smtp_block: false # block outgoing mail except allowed users
smtp_allow_users: [] # usernames allowed to send
smtp_ports: [25,465,587]
# Dynamic DNS
dyndns_hosts: [] # hostnames to resolve and whitelist periodically
# Logging
log_dropped: true # log dropped packets
log_rate: 5 # log entries per minute
# --- GeoIP ---
geoip:
account_id: "" # MaxMind account ID
license_key: "" # MaxMind license key
editions: # MaxMind database editions
- GeoLite2-City
- GeoLite2-ASN
auto_update: true # auto-update GeoIP databases (default: true when credentials set)
update_interval: "24h" # update check interval
# --- ModSecurity ---
modsec_error_log: "" # path to Apache/LiteSpeed error log for ModSec parsing
modsec:
rules_file: "" # path to modsec2.user.conf
overrides_file: "" # path to csm-overrides.conf
reload_command: "" # command to reload web server (e.g. "/usr/sbin/apachectl graceful")
# --- Performance ---
performance:
enabled: true
load_high_multiplier: 1.0 # load average / CPU cores multiplier for warning (default: 1.0)
load_critical_multiplier: 2.0 # load average / CPU cores multiplier for critical (default: 2.0)
php_process_warn_per_user: 20 # per-user PHP process count warning (default: 20)
php_process_critical_total_multiplier: 5 # total PHP processes / CPU cores for critical (default: 5)
error_log_warn_size_mb: 50 # error log size warning threshold (default: 50)
mysql_join_buffer_max_mb: 64 # MySQL join_buffer_size warning threshold (default: 64)
mysql_wait_timeout_max: 3600 # MySQL wait_timeout warning threshold (default: 3600)
mysql_max_connections_per_user: 10 # per-user MySQL connections warning (default: 10)
redis_bgsave_min_interval: 900 # minimum seconds between Redis BGSAVE (default: 900)
redis_large_dataset_gb: 4 # Redis dataset size warning threshold in GB (default: 4)
wp_memory_limit_max_mb: 512 # WordPress memory_limit warning threshold (default: 512)
wp_transient_warn_mb: 1 # WordPress transient data warning in MB (default: 1)
wp_transient_critical_mb: 10 # WordPress transient data critical in MB (default: 10)
# --- Cloudflare ---
cloudflare:
enabled: false # auto-whitelist Cloudflare IP ranges
refresh_hours: 6 # how often to refresh Cloudflare IPs (default: 6)
# --- Threat Intel ---
c2_blocklist: [] # known C2 server IPs to block permanently
backdoor_ports: [4444,5555,55553,55555,31337] # ports indicating backdoor activity
# --- Update check ---
updates:
check_enabled: true # notify only; CSM never downloads or applies updates
interval: "24h" # release check interval
github_api_url: "" # optional release API mirror or test endpoint
package_name: "csm" # apt/dnf package name for package-manager fallback
# --- Incidents ---
incidents:
auto_close:
enabled: true # auto-close idle open/contained incidents
dry_run: false # log decisions without writing status changes
by_kind:
mailbox_takeover: 24h
credential_spray: 24h
web_account_compromise: 168h
spray_suppression:
enabled: false # collapse one-source credential spray into one incident
dry_run: true
distinct_mailboxes: 10
severity_escalate_at: 50
per_check: [email_auth_failure_realtime, pam_auth_failure, ssh_bruteforce]
max_tracked_ips: 10000
block_at_severity: "" # "" | high | critical
auto_block:
enabled: false # block source IPs from incident correlations
block_at_severity: "" # "" | high | critical
kinds: [] # empty means all non-spray kinds with remote_ip
# --- Disabled checks (skip whole categories per host) ---
# Listed finding names disable the scheduled check runner(s) that emit them,
# including sibling findings from the same runner. Realtime findings are not
# affected. Use for whole categories that don't apply to a host (e.g. WAF/web
# checks on DNS-only cPanel servers, where httpd is installed but no virtual
# hosts serve traffic).
# For email-only suppression, use `alerts.email.disabled_checks` instead.
disabled_checks: [] # e.g. [waf_status, waf_rules, waf_detection_only]
# --- Retention (bbolt growth control) ---
retention:
enabled: false # opt-in; when true, a daily sweep prunes old entries and compacts bbolt
findings_days: 90 # keep active findings this long (0 disables the findings sweep)
history_days: 30 # keep findings-history entries this long
reputation_days: 180 # keep IP reputation/attack entries this long
sweep_interval: "24h" # how often the retention goroutine runs
compact_min_size_mb: 128 # don't consider compaction below this file size
compact_fill_ratio: 0.5 # compact when used_bytes / file_size drops below this
# --- Sentry (error reporting) ---
sentry:
enabled: false # ship panics and selected errors to a Sentry server
dsn: "" # Sentry project DSN
environment: "production" # e.g. "production", "staging"
sample_rate: 1.0 # 0.0 -> 1.0 (capture all errors)
debug: false # SDK debug logs to stderr
TLS Certificates
The Web UI serves over HTTPS. Configure TLS certificates under webui:
webui:
tls_cert: "/var/cpanel/ssl/cpanel/mycpanel.pem" # certificate PEM file
tls_key: "/var/cpanel/ssl/cpanel/mycpanel.pem" # private key PEM file
On cPanel servers, you can reuse the cPanel self-signed certificate (both cert and key are in the same PEM file). For production, use a proper certificate from Let’s Encrypt or your CA.
If tls_cert and tls_key are empty, the Web UI will not start.
Validation
csm validate # syntax check
csm validate --deep # syntax + connectivity probes (SMTP, webhooks)
csm config show # display config with secrets redacted
Editing csm.yaml by hand
CSM stores a sha256 of the main config in integrity.config_hash and
a separate digest of loaded drop-ins in integrity.confd_hash. It
refuses to start if the on-disk files disagree with those values. This
is a tamper-detection feature. There are two supported edit workflows
depending on which fields you touch.
Fast path: SIGHUP reload (safe fields only)
For fields tagged as hot-reload-safe (alerts, thresholds,
detection, suppressions, auto_response, bpf_enforcement,
reputation, email_protection, disabled_checks), the daemon can
accept the change without a restart:
sudo cp /etc/csm/csm.yaml /etc/csm/csm.yaml.bak-$(date +%s)
# edit /etc/csm/csm.yaml with your favourite editor
sudo systemctl reload csm
sudo journalctl -u csm -n 20 --no-pager
systemctl reload sends SIGHUP (wired via ExecReload= in the unit
file). The daemon re-reads the file, validates it, diffs it against
the running config, and if every change is on a field tagged
hotreload:"safe" it swaps the new values into
the live config and re-signs the integrity hashes on disk. The
next check tick sees the new thresholds; fanotify marks are not
dropped.
The tagged-safe top-level fields are alerts, thresholds,
detection, suppressions, auto_response, bpf_enforcement,
reputation, email_protection, and disabled_checks. The Settings
API derives its restart hints from the same manifest that drives
config.Diff, so UI hints and SIGHUP behavior cannot drift silently.
Changes to their sub-keys are picked up on the next tick by the
periodic scanners, the auto-response helpers
(block/kill/quarantine/challenge/permission-fix), alert dispatch, and
the heartbeat.
Two sub-keys are exceptions. They live under a safe-tagged parent but seed a long-lived in-memory structure at daemon startup; the reload accepts the edit and re-signs the hash, but the running structure keeps the old value until the next restart:
reputation.whitelist– seeded into the threat database at startup. The threat database exposes its own runtime API for adding and removing whitelist entries (via the Threat Intelligence page in the Web UI or the/api/v1/threat/*endpoints); those paths survive restarts because the threat database persists the runtime list to disk. Reloadingreputation.whitelistfrom csm.yaml does not automatically propagate to the running threat database.email_protection.known_forwarders– captured by the forwarder watcher at startup and read by scheduled forwarder and mail-filter checks. No runtime API yet; send a restart if you edit this list.
If you change either of the above, send systemctl restart csm
instead of a reload. The rest of the sub-keys in every safe-tagged
section are read per-call (inside check functions, auto-response
helpers, alert dispatchers) and hot-reload cleanly on the next
tick.
Look for one of three log shapes in the journal:
SIGHUP: config reloaded; safe fields updated: [thresholds]– success. The new values are live.config_reload_restart_required: SIGHUP reload: restart-required fields changed: [hostname ...]; live config unchanged– the edit touched a field that cannot be hot-swapped. A Warningconfig_reload_restart_requiredfinding is also emitted. Fall back to the restart path below.config_reload_error: SIGHUP reload: parse failed ...or... validation error ...– the file on disk is not loadable or failscsm validate. A Criticalconfig_reload_errorfinding is emitted. The live config is unchanged; fix the file and repeat.
Restart path: unsafe fields
Fields not tagged hotreload:"safe" (the majority, including
hostname, state_path, webui.listen, firewall.*, email_av.*
and anything that survives only one re-init per daemon lifetime)
require a full restart. The integrity check must be re-signed first:
sudo cp /etc/csm/csm.yaml /etc/csm/csm.yaml.bak-$(date +%s)
# edit /etc/csm/csm.yaml with your favourite editor
sudo /opt/csm/csm rehash # re-signs integrity hashes
sudo /opt/csm/csm validate # syntax + value sanity
sudo systemctl restart csm
sudo systemctl status csm # confirm active, no crash-loop
If the restart fails (most commonly because rehash was skipped),
roll back with
sudo cp <backup> /etc/csm/csm.yaml && sudo systemctl restart csm.
The backup carries its own matching hash so no second rehash is
needed.
Config-management tools
Config-management workflows (Ansible, Puppet, Chef) should:
- For safe changes, notify
systemctl reload csminstead ofrestart. The daemon re-signs the hash itself; no separatecsm rehashstep is required. - For any change that may touch a restart-required field, run
csm rehashbefore the restart notify fires. Or always sendreloadfirst, read the journal, and promote torestartonly when the reload logsrestart-required.
conf.d drop-ins
Files matching /etc/csm/conf.d/*.yaml are loaded after the main config and deep-merged on top of it. Override with --config-dir <path> or CSM_CONFIG_DIR; the flag wins when both are set.
- Order: lexicographic by filename. Scalar keys in
20-overrides.yamloverride the same keys in10-base.yaml. Use a numeric prefix. - Merge semantics: maps merge recursively; scalars replace the value from the main file; lists append in fragment order. All-scalar lists drop duplicate entries while keeping the first occurrence; structured lists such as
webui.tokenskeep every entry. - Trust: override directories must be absolute, must exist, and must be owned by root or the running process. The directory and every loaded fragment must not be group- or world-writable. Safe symlinked fragments are allowed, so packaged profiles can still be linked into
/etc/csm/conf.d/. - Integrity ownership: drop-ins cannot set the
integrityblock. Integrity metadata is stored only in the main config. - Hash:
integrity.config_hashcovers the main file andintegrity.confd_hashcovers loaded drop-ins. After editing a drop-in by hand, runcsm rehashbefore restarting, or usesystemctl reload csmso the daemon can re-sign after validating the merged config. Web settings saves refuse to bless a drop-in change that has not already been re-signed. - Use cases: packaged integration profiles (e.g.
/usr/lib/csm/profiles/phpanel-agent.yamlsymlinked intoconf.d/), per-host automation that should not touch the operator’scsm.yaml, secret material rendered from a vault.
ls /etc/csm/conf.d/
# 10-phpanel-agent.yaml 20-tenant-overrides.yaml
csm validate # validates the merged config
csm config show # prints the merged, redacted config
csm config schema --json # JSON Schema for editor / CI validation
csm validate and csm config show always operate on the merged config so you can audit the effective state without grepping fragments.
detection.direct_smtp_egress
Phase 3 detector. backend accepts auto, bpf, legacy, or none;
ports must contain TCP ports in the 1-65535 range. See
Direct SMTP egress.
bpf_enforcement
Phase 4 enforcement. Requires a BPF-capable connection tracker at
runtime; auto falls back to legacy detection on older servers. See
BPF enforcement.