Skip to content

Config Reference ​

Complete reference for /etc/ezyshield/config.yaml — log sources, enforcement backends, notifications, AI, enrichment, and the dashboard. The file is strictly validated: unknown keys are rejected with exact line numbers.

ezyshield init and the ezyshield config <component> wizards write to /etc/ezyshield and must run with sudo — they fail fast with a hint before asking any question. Validate any manual edit with ezyshield config validate.

Top level ​

FieldTypeDefaultDescription
data_dirstring/var/lib/ezyshieldRequired (config validate rejects an empty value). State directory for the dashboard command — its auth database is <data_dir>/dashboard.db. It does not set the daemon's SQLite database path: that is the run --db flag (default /var/lib/ezyshield/ezyshield.db).
socket_pathstring/run/ezyshield/ezyshield.sockControl-socket path the dashboard connects to (unix socket — there is never a TCP listener for control). It does not set the daemon's socket: the daemon binds the run --socket flag (default /run/ezyshield/ezyshield.sock), so a custom value must match run --socket or the dashboard points at a socket the daemon never creates.
rules_dirstring/etc/ezyshield/rules.dDrop-in rule customizations: every *.yaml here merges over the built-in rules by name and survives updates (see the rules guide)
rules_pathstring—Deprecated. Replaces the built-in rules entirely (no merge; rules.d ignored) — freezes the install out of upstream rule tuning
log.levelstringinfodebug | info | warn | error
collectorslist[]Log sources to tail (see below). An empty list is valid — config validate warns and the daemon simply tails nothing.
dockerobject—Docker Engine endpoint used by every docker consumer (see below). Optional; absent means the default unix socket.
enforceobject—Enforcement backends (optional — without it, decisions are log-only)
notifyobject—Notification channels (optional)
aiobject—AI provider for ambiguous traffic (optional)
enrichobject—GeoIP/ASN enrichment (optional)
dashboardobject—Dashboard bind address and auth DB (optional)

The daemon ignores data_dir and socket_path; the dashboard command consumes them (and data_dir is additionally required by config validate). The daemon (ezyshield run) takes its database path and control socket from its own --db and --socket flags (defaults /var/lib/ezyshield/ezyshield.db and /run/ezyshield/ezyshield.sock) and does not read these two keys. Setting them in config.yaml moves the dashboard's auth DB and its connection target, not the daemon's files — so keep socket_path in step with run --socket. See the CLI reference for the run and dashboard flags.

collectors ​

Each entry tails one log source. kind selects the source; one extra field is required per kind.

yaml
collectors:
  - kind: journald
    unit: ssh                    # systemd unit to follow

  - kind: file
    path: /var/log/nginx/access.log

  - kind: docker
    container: wordpress-nginx   # name, short ID, or full ID
    parser: nginx                # optional parser override
FieldRequiredDescription
kindyesfile | journald | docker
pathfor filefile to tail
unitfor journaldsystemd unit to follow
containerfor dockercontainer name, short ID, or full ID
parsernoforce a parser: nginx | ssh | apache | apache-error | traefik | caddy | postfix | dovecot (default: routed automatically from the source). apache reads the Apache access log (combined format, shared with nginx); apache-error reads the Apache error_log (error.log / error_log); postfix reads Postfix smtpd lines (mail.log / maillog, or the postfix / postfix@* journald units — SASL auth failures, relay-denied rejects, and connection-abuse signatures become smtp_auth_fail / smtp_relay_denied / smtp_abuse events); dovecot reads Dovecot login-process lines (dovecot.log, or the dovecot journald unit — IMAP/POP3 auth failures become imap_auth_fail and credential-less probes imap_probe; on shared mail.log setups, which route to postfix first, use the journald unit or this explicit override). Honored only for file and docker collectors — journald ignores it and always routes its parser from the unit.

SSH collector (unit name varies by distro) ​

The SSH systemd unit name depends on the distro: it's ssh on Debian/Ubuntu and sshd on RHEL/CentOS/Fedora/Rocky/Alma, Arch, and SUSE. Use whatever name systemctl status <unit> resolves on your host — an alias that journalctl -u doesn't recognize collects zero events.

yaml
collectors:
  - kind: journald
    unit: ssh    # Debian/Ubuntu; use "sshd" on RHEL/CentOS/Arch/SUSE

To read SSH from a file instead of journald, point at your distro's auth log — /var/log/auth.log (Debian/Ubuntu) or /var/log/secure (RHEL family). Both timestamp formats are accepted: the legacy syslog format (Jan 1 12:00:00) and modern ISO-8601 (2026-07-13T22:57:35+00:00).

Every collector starts at the live tail of its source when the daemon (re)starts — the file tail seeks to the end, and the journald readers follow with no backlog. Lines written during the seconds of a restart are not replayed: a missed line at worst delays a detection by one event, whereas replaying already-processed lines would count the same evidence twice.

The file tail follows log rotation without replay: when the file is renamed and recreated it reopens the new file and stops watching the old inode, so the rotated copy being deleted later (logrotate compress, Docker's max-file pruning) does not re-read the live file; and when the file is truncated in place (copytruncate) it rewinds to the start and reads only the new lines, with no fragment of the old content.

Configure only one SSH collector per host — journald or the file it feeds, never both. Reading both ingests every event twice, which double-counts toward detection thresholds. (An already-banned IP is never banned again, so this never causes duplicate bans, only earlier detection.)

docker ​

Selects the Docker Engine API endpoint. One endpoint serves every docker consumer — the docker log collectors, the exec watcher and on-demand evidence extraction — because a host runs one engine. Omit the section entirely to use the default unix socket.

yaml
docker:
  host: tcp://127.0.0.1:2375     # a read-only socket proxy on loopback
  # allow_remote: false          # accept a non-loopback tcp host
FieldTypeDefaultDescription
hoststringunix:///var/run/docker.sockunix:///absolute/path or tcp://host:port. Any other scheme is rejected.
allow_remoteboolfalseAccept a tcp:// host that is not a loopback IP literal.

This is a privilege decision, not a connectivity detail:

  • unix:///var/run/docker.sock is the engine itself. Reaching it means the service user is in the docker group, which is root-equivalent on the host.
  • tcp://127.0.0.1:2375 is meant for a filtering, read-only proxy in front of the engine — it serves container logs and events and refuses container creation, exec and mounts. That is the scoped alternative to the group. See the security overview and Docker + nginx + WordPress.

EzyShield is only ever a client here; it opens no listener for this. The proxy is a container in your own stack.

A tcp:// host must be a loopback IP literal (127.0.0.0/8 or ::1). Hostnames — including localhost — are not accepted as loopback, because what they resolve to is decided by /etc/hosts and DNS rather than by this file. Anything else is refused unless allow_remote: true, which accepts an unauthenticated plaintext Engine endpoint reachable from off-host. TLS to a remote engine is not supported.

Verify a TCP endpoint with ezyshield doctor: it must answer GET /_ping and must refuse POST /containers/create. An endpoint that accepts container creation grants root-equivalent access to the host over the network, and doctor FAILs on it.

enforce ​

yaml
enforce:
  nftables: {}                   # local enforcement on; defaults are fine

  cloudflare:
    api_token: env:CLOUDFLARE_API_TOKEN  # secrets are env: references, never inline — this NAME is what init writes
    account_id: "abc123..."      # required in the default "lists" mode
    # mode: lists                # "lists" (default) or "rulesets"
    # list_name: ezyshield_blocked
    # zone_ids: [ ... ]          # required only when mode: rulesets
    # action: block              # default

nftables ​

FieldDefaultDescription
tableinet ezyshieldnftables table (all EzyShield rules live inside it). <name> or inet <name>; the inet family is the only one supported (dual-stack v4+v6 layout). Names: letters, digits, underscore
setblockedset holding banned IPv4 addresses; the IPv6 twin is derived automatically as <set>6 (default blocked6). allowed/allowed6 are reserved for the allowlist sets
socket/run/ezyshield-enforcer/enforcer.sockprivileged enforcer helper socket

Both are optional and genuinely honored: the daemon passes them to the privileged enforcer, which re-validates them independently before any rule is written. Two operational notes for custom names:

  • The enforcer must support them (same version as the daemon). Against an older ezyshield-enforcer, the daemon refuses to enforce with a clear error instead of silently using the defaults.
  • The enforcer applies one name set per run. After changing table/set, restart both services (sudo systemctl restart ezyshield-enforcer ezyshield); a previous table left behind by a rename can be removed with nft delete table inet <old-name>.

cloudflare ​

FieldRequiredDescription
api_tokenyesenv:VARNAME reference to a scoped API token
modenolists (default — account-level IP List + WAF rules) or rulesets (per-zone rules)
account_idwhen mode: listsCloudflare account ID
list_namenoIP list name (default ezyshield_blocked)
instancenoIdentity of this server when several servers share one Cloudflare account (the free plan allows a single list): each daemon tags its list items ezyshield:<instance> and manages only its own — bans from all servers add up instead of overwriting each other. Defaults to the hostname; must match [A-Za-z0-9._-]{1,32} and stay stable across restarts
adopt_legacy_itemsnoSet on exactly one server sharing the account to take ownership of items written by older versions (bare ezyshield comment) so they expire again. Remove once those items are gone
zone_idswhen mode: rulesetszones to attach rules to
actionnoblock (default), challenge, or js_challenge
namenolabel shown in status/test output
debouncenohow long rapid ban/unban mutations are coalesced before one batched API push (Go duration, default 15s)
expire_flush_intervalnocadence for batched item removals in lists mode (Go duration, default 3m) — expired bans and unbans accumulate and go out in one API call per interval

Multiple Cloudflare accounts are supported: cloudflare also accepts a list of these objects. See the Cloudflare guide.

Tuning the two cadences trades edge-propagation speed for fewer API calls. The defaults keep a busy server comfortably inside Cloudflare's Lists API throttle; raise them if ezyshield status still reports throttling (ratelimited in the enforcement detail), lower debounce if a new ban must reach the edge faster. Removals are deliberately the slow path: an expired IP staying blocked at the edge for up to expire_flush_interval is fail-closed and harmless, while a delayed ban is real exposure — which is why bans ride debounce and only removals wait for the flush interval. Manual ezyshield unban also propagates to the edge on the flush cadence (the local nftables unban is immediate).

bunny ​

bunny.net edge enforcement via each pull zone's blocked-IP list. Presence of the section enables it. EzyShield takes ownership of the blocked-IP list on the configured zones — entries added by hand in the bunny panel are removed on reconcile. See the bunny.net guide.

yaml
enforce:
  bunny:
    api_key: env:BUNNY_API_KEY   # secrets are env: references, never inline
    pull_zones: [123456, 234567] # numeric pull zone IDs
FieldRequiredDescription
api_keyyesenv:VARNAME reference to the bunny.net account API key
pull_zonesyesnumeric pull zone IDs (at least one, positive, unique)
namenolabel shown in logs as bunny[<name>]

The enforcer caps each zone at 500 blocked IPs (bunny does not document a provider limit); beyond that the most recent bans win, with a warning.

aws_waf ​

AWS WAFv2 edge enforcement via dedicated IPSets referenced from your WebACL rules (ADR-0012). Presence of the section enables it. EzyShield only mutates the member addresses of the IPSets designated here — it never touches WebACLs. Credentials come from the standard AWS chain (env vars, ~/.aws/credentials, IMDSv2) and must never appear in this file; validation rejects pasted key material. See the AWS WAF guide.

yaml
enforce:
  aws_waf:
    scope: regional            # regional (needs region) or cloudfront (pins us-east-1)
    region: eu-west-1
    ipset_v4: {name: ezyshield-v4, id: aaaabbbb-cccc-dddd-eeee-ffff00001111}
    ipset_v6: {name: ezyshield-v6, id: aaaabbbb-cccc-dddd-eeee-ffff00002222}
FieldRequiredDescription
scopeyesregional (ALB/API Gateway) or cloudfront (global)
regionfor regionalAWS region of the IPSets
ipset_v4 / ipset_v6at least onename + id of each designated IPSet
namenolabel shown in logs as awswaf[<name>]

Each IPSet holds at most 10,000 addresses (AWS limit); beyond that the most recent bans win, with a warning.

notify ​

yaml
notify:
  rate_limit_per_minute: 5       # default — cap on notifications per minute
  dedup_window_sec: 600          # default — identical alerts collapsed
  notify_only_window_sec: 3600   # default — repeat notify_only per (IP, rule) folds into one summary

  telegram:
    bot_token: env:TELEGRAM_BOT_TOKEN
    chat_ids: ["123456789"]
    severity: [warn, critical]   # optional filter: info | warn | critical

  email:
    host: smtp.example.com
    port: 587
    username: alerts@example.com
    password: env:SMTP_PASSWORD
    tls: starttls                # starttls (default) | tls | none
    from: alerts@example.com
    to: [admin@example.com]

  slack:
    webhook_url: env:SLACK_WEBHOOK_URL
    channel: "#security"         # optional override

  discord:
    webhook_url: env:DISCORD_WEBHOOK_URL

  webhook:
    url: env:WEBHOOK_URL
    headers:
      Authorization: env:WEBHOOK_AUTH_TOKEN   # value must be a full env: reference

Shared fields: rate_limit_per_minute (default 5) and dedup_window_sec (default 600) protect against notification storms. The per-minute cap applies to info and warn messages; critical messages use a separate, reserved quota (20 per channel per minute), so a burst of strike warnings can never silence the one enforcement DEGRADED alert. A message a rate limit dropped does not claim the dedup window — the same alert is delivered as soon as the quota frees up — and every dropped delivery is counted in the ezyshield_notifications_dropped_total metric. Repeated enforcer failures within one window are folded into a single systemic critical (the first failing target is in the body). At least one channel must accept critical; ezyshield doctor warns when none does. notify_only_window_sec (default 3600) additionally windows below-threshold notify_only events per (IP, rule): the first event notifies immediately and repeats within the window fold into a single summary notification — set it negative to disable. This window affects notifications only; the audit log keeps its own, shorter cadence for the observe band: the first notify_only for an (IP, rule) pair writes an audit row and repeats of the same rule for the same IP within the next 60 seconds do not, so a scanner that stays above a rule's threshold leaves one row per minute rather than one per request (the event still counts in metrics and the live stream, and a ban resets the cadence). Every channel accepts an optional severity list (info | warn | critical).

Secret-typed fields (bot_token, password, webhook_url, webhook url) only accept env:VARNAME references — inline values are rejected at load time. They are also required for their channel: a telegram block without bot_token, an email block without password, or a slack/discord/webhook block without its URL fails validation (the daemon resolves them at startup). Webhook header values are sent verbatim unless the entire value is an env: reference, which is resolved.

Email tls: starttls fails closed: if the SMTP server does not advertise STARTTLS (or a capability-stripping proxy hides it), the send errors instead of silently downgrading to plaintext. Set tls: none explicitly if you really intend to send unencrypted.

ai ​

Optional — with no ai block, the deterministic rule engine handles everything.

yaml
# Single provider
ai:
  provider: anthropic            # anthropic | openai | ollama
  model: claude-haiku-4-5-20251001
  api_key: env:ANTHROPIC_API_KEY
  ambiguous_band: [30, 69]       # scores in this band consult the AI (keep high < ban_threshold)
  token_budget_daily: 50000      # hard daily cap; rule engine takes over beyond it
  cache_ttl: 15m                 # identical-verdict cache (default 15m)
yaml
# Or multi-provider failover
ai:
  providers:
    - name: anthropic
      priority: 1
      model: claude-haiku-4-5-20251001
      api_key: env:ANTHROPIC_API_KEY
    - name: ollama
      priority: 2
      model: llama3
      endpoint: http://localhost:11434
FieldDescription
provideranthropic | openai | ollama (single-provider form)
modelmodel name
api_keyenv:VARNAME reference (never inline)
endpointbase URL for the ollama provider only (default http://localhost:11434). The anthropic and openai providers ignore it and always call their official APIs (https://api.anthropic.com, https://api.openai.com) — there is no OpenAI-compatible-endpoint override. Same in the single-provider and providers failover forms.
ambiguous_band[low, high] — only scores inside the band consult the AI. Omitted (or [0, 0]) defaults to [30, ban_threshold − 1], following the ban_threshold CONFIGURED in policy.yaml ([30, 69] with the default threshold of 70) — so raising the threshold automatically widens the omitted band instead of silently leaving an unconsulted gap; any other band with low >= high or values outside 0–100 is rejected at load. Keep high below the policy ban_threshold: a score at or above the threshold has already decided a ban on rules alone, so the daemon never consults the AI for it — a band reaching into the threshold only triggers a startup/validate warning
token_budget_dailydaily token cap; when exhausted, decisions fall back to rules
cache_ttlverdict cache duration; omitted or 0 means the default 15m (the cache cannot be disabled — it is the second brake on repeated consults for the same behavior). Entries are keyed by behavior signature (event kind counts + window), not by IP, so identical attack patterns from different IPs reuse one verdict; on a hit the cached verdict is re-targeted to the IP being evaluated. Allowlist-clamped verdicts are never cached
providersmulti-provider failover list (name, priority, model, api_key, endpoint, token_budget_daily); takes precedence over the single-provider fields

The AI verdict is always advisory: schema-validated, clamped by policy, and never able to ban an allowlisted IP.

Async second layer (async: true) ​

yaml
ai:
  provider: anthropic
  api_key: env:ANTHROPIC_API_KEY
  async: true                # analyze grey-zone traffic in the background
  # async_queue_size: 256    # bounded queue; overflow drops the OLDEST episode

With async: true the pipeline never waits for a provider: grey-zone episodes (scores inside the ambiguous band) are queued — one entry per IP at a time, held until its analysis has finished — and a background worker drains them, rate-capped at one provider call per second and at most one analysis per IP per minute (a failed call is retried after ten seconds). How the layer stays token-frugal:

  1. The rule engine decides the obvious cases; only the ambiguous band ever enqueues (the same #419 gates apply — decisive scores and already-banned IPs never spend tokens).
  2. The Log Cleaner runs before every call: static-asset noise is dropped from the samples, already-decided episodes (banned/allowlisted since queueing) are skipped, and the reduction is exposed as ezyshield_ai_cleaner_reduction_permille.
  3. The provider receives only compact aggregates — counts, kind distributions, enrichment, and a sanitized behavior summary (top paths with querystrings cut, methods, status classes, capped user agents). Raw log lines never enter a payload.

Returned verdicts flow through the decision engine like any other verdict source: allowlist-wins, anti-lockout, and policy clamps all apply. A slow or dead provider degrades to rules-only detection — the bounded queue drops the oldest episode on overflow (ezyshield_ai_queue_dropped_total). The agreement-rate metric ezyshield_ai_agreement_total (<provider>_agree / <provider>_disagree, compared against the rule engine at the ban threshold) is the published proof the layer earns its tokens.

Token cost expectations: one grey-zone episode costs one compact prompt (typically a few hundred input tokens) per un-cached behavior signature; the cache, the per-IP dedupe, and the #419 gates mean bursts do not multiply spend. For a fully local deployment use provider: ollama — the async layer works identically with zero outbound traffic and no API key.

Every AI call is recorded in the ai_usage table with the analyzed IP, so cost attribution is a single query — the top spenders (an IP draining the budget is itself a leakage symptom):

bash
sudo sqlite3 /var/lib/ezyshield/ezyshield.db \
  "SELECT ip, COUNT(*) calls, ROUND(SUM(cost_usd), 4) usd
   FROM ai_usage WHERE ip IS NOT NULL
   GROUP BY ip ORDER BY usd DESC LIMIT 10;"

self_check ​

Periodic hardening self-check — on by default, no section needed. Every 6 hours the daemon re-runs the read-only systemd checks from ezyshield doctor (effective RestrictAddressFamilies/AF_NETLINK for the enforcer, RuntimeDirectory for both services — drop-in aware via systemctl show) plus the functional netlink probe against the running enforcer helper. This catches the silent-non-enforcement drift (a hand-edited unit) before the next service restart makes it bite, without you remembering to run doctor.

Notification semantics — transitions only:

  • healthy → degraded: one CRITICAL notification naming the failing check (plus an append-only selfcheck_degraded audit entry);
  • degraded → healthy: one INFO notification (selfcheck_recovered);
  • steady state: silence.

Hosts without systemd, units not installed, or an older helper: those checks report N/A and count as healthy — script/manual installs stay quiet. The self-check is strictly read-only: it never edits units and never restarts services; the fix always remains a copy-paste hint.

Disabling it (minimal installs, or hosts where periodic systemctl show calls are undesirable):

yaml
self_check:
  enabled: false     # opt out entirely — doctor remains available on demand
  # interval: 6h     # cadence when enabled; default 6h, floor 10m
FieldDescription
enabledomitted = true; false disables the periodic self-check entirely
intervalGo duration between runs; default 6h, floor 10m

enrich ​

GeoIP/ASN enrichment — enables block_countries / block_asns in policy and the country/ASN columns in list and report. Optional: without an enrich: section the daemon runs normally with empty enrichment (no country/ASN anywhere, and those policy keys never match).

FieldDescription
db_pathpath to GeoLite2-Country.mmdb
asn_pathpath to GeoLite2-ASN.mmdb
auto_updatelet the daemon download and refresh the databases (weekly)
license_keyenv:VARNAME reference to a MaxMind license key — required when auto_update: true, inline values are rejected

The easiest path is the wizard, which walks through all of the below:

bash
sudo ezyshield config enrich maxmind
sudo systemctl restart ezyshield

Where the databases come from. EzyShield uses MaxMind's free GeoLite2 databases, which require a (free) MaxMind account: sign up, then generate a license key under Manage License Keys. With auto_update: true the daemon downloads both databases itself on startup when the files are missing and refreshes them weekly — you never handle the files:

yaml
enrich:
  db_path: /var/lib/ezyshield/GeoLite2-Country.mmdb
  asn_path: /var/lib/ezyshield/GeoLite2-ASN.mmdb
  auto_update: true
  license_key: env:MAXMIND_LICENSE_KEY

The key is a secret like any other: put MAXMIND_LICENSE_KEY=... in /etc/ezyshield/.env (mode 0600 — the wizard does this for you) and reference it as env:MAXMIND_LICENSE_KEY. It is only ever used in the download URL and never logged.

Manual alternative. With auto_update: false no account key is needed at runtime: download GeoLite2-Country.mmdb and GeoLite2-ASN.mmdb from your MaxMind account (or mirror them from a host that has them) and place them at the configured paths. Missing or unreadable files are not an error — the daemon logs a warning and runs with empty enrichment until they appear.

dashboard ​

FieldDefaultDescription
addr127.0.0.1:9090Bind address — loopback only; non-loopback binds are refused at startup
auth_db_path<data_dir>/dashboard.dbDashboard auth database

webshell_watch ​

Opt-in webshell-drop tripwire: sweeps web roots for new or modified executable web files. Purely observational — audit + notification, never a ban. See the Webshell Tripwire guide.

FieldDefaultDescription
enabledfalseOpt-in switch
roots—Absolute web-root directories to sweep (required when enabled)
extensions.php, .phtml, .php5, .php7, .pharWatched extensions (leading dot)
ignore[]Path patterns to skip — path.Match globs, or substring when the pattern has no glob metacharacters
interval_sec10Sweep cadence in seconds (floor 5)

Minimal example ​

yaml
data_dir: /var/lib/ezyshield

collectors:
  - kind: journald
    unit: ssh

enforce:
  nftables: {}

Secrets ​

Every secret field takes an env:VARNAME reference and is resolved by the daemon (ezyshield run) from its environment. The wizards write secret values to /etc/ezyshield/.env (mode 0600), which the systemd unit loads via EnvironmentFile=. Secrets never appear in config.yaml, logs, or error messages.

This is also enforced in reverse: if a value pasted into a non-secret field (provider, model, endpoint, ...) looks like a credential — a known key prefix such as sk-, or a long high-entropy token — the config is rejected at load with an error that names the field but never prints the value. Webhook header values are the one exemption (raw values are legal there and are redacted in config show).

Validation ​

bash
sudo ezyshield config validate   # strict schema + constraints, exact line numbers on errors
sudo ezyshield doctor            # environment check (files, permissions, sockets)
sudo ezyshield test enforcer all # exercise enforcement backends for real
sudo ezyshield test notifier all # send a test notification to every channel