Skip to content

CLI Reference ​

Complete command reference for the ezyshield CLI.

Global conventions ​

Exit codes ​

Every command follows the same exit-code contract:

CodeMeaning
0Success
1Runtime error — the command started but failed (invalid config, API error, write failure)
2Usage error — unknown command/flag, bad argument, or an input file that does not exist / cannot be read
3Daemon unreachable — the control socket refused the connection (is the daemon running?)

Two deliberate exceptions: status exits 0 even when the daemon is stopped (it successfully reports the state), and doctor exits 0 even when individual checks fail (its output is the report).

JSON output (--json) ​

Every read command supports --json with stable field names, safe to script against:

CommandShape
statusObject: daemon, enforcer, mode, uptime, version, active_bans, bans_by_strike, message
listArray of rows — active bans, --allow entries, or --audit events ([] when empty)
report <ip>Object: versioned abuse report (schema_version, ip, country, asn, current_ban, strikes, actions, plus evidence with --evidence)
reportArray of offender summaries (ip, first_seen, last_seen, total_strikes, banned, permanent, country, asn)
report --sinceObject: versioned digest (schema_version, since, totals, events_by_kind, categories, rules, top_offenders, patterns, plus narrative with --narrative)
rule testObject: results array (rule, since, detections, unique_ips, allowlisted_hits, ips, per_day, upper_bound, warnings, limitation)
watchNDJSON: one event object per line
scanObject: listeners, new_listeners (each a socket, all fields of the scan.Listener struct: Addr, Protocol, UID, Inode, PID, ExePath, UserName, IsPublic, OwnerType, UnitName, ContainerID, ContainerName, ContainerImage, LogSource)
doctorObject: checks (name, status, hint) and summary (total, pass, fail, warn)
config showObject: config, policy (effective values, secrets redacted)
versionObject: version, commit, build_date

With --json, stdout carries only JSON; warnings and connection notices go to stderr, so piping into jq is always safe.

Color ​

Colored/styled output is enabled only when all of these hold: stdout is an interactive terminal, the NO_COLOR environment variable is unset, and --no-color was not passed. Piped or redirected output is always plain text, so ezyshield watch | grep ban never sees escape codes.

ezyshield init ​

Interactive setup wizard. Configures log sources, enforcement backends, AI providers, and notifications.

bash
sudo ezyshield init

Creates /etc/ezyshield/config.yaml and /etc/ezyshield/policy.yaml with secure permissions (0600).

The wizard walks through named sections — Environment (what was detected on the host), Collectors, Allowlist, Edge enforcers, AI analysis, Notifications (zero or more of telegram, email, slack, discord, webhook — the same per-channel prompts as config notifier <name>), Policy, Files, and System services — with ✓/✗/! status marks per line. Styling follows the global color conventions; piped output stays plain.

When Docker is detected, the Environment section enumerates the docker bridge network subnets that actually exist on the host and allowlists only those — never a blanket RFC1918 range. If enumeration fails, it falls back to Docker's default bridge subnet (172.17.0.0/16) alone and prints a ! warning. Hosts without Docker get no docker-related allowlist entry at all. See the allowlist section in Policy Reference for the trade-off if you want to broaden this deliberately, and re-run ezyshield doctor afterwards — it warns on any private allowlist entry /16 or broader.

At the end it prints a Summary section:

  • what was configured (collectors, enforcers, AI) and what was skipped, with the reason;
  • every file written (including the .env that holds secret tokens, mode 0600 — tokens never go into config.yaml);
  • the current mode (DRY-RUN by default — nothing is blocked until you set armed: true in policy.yaml);
  • numbered next steps (doctor, status, watch).

The summary complements — never replaces — warnings printed during the run, such as the loud banner shown when Cloudflare enforcer setup aborts.

Flags:

  • --yes — non-interactive: accept every default, skip CDN detection.
  • --config-dir <dir> — write files to a different directory; skips systemd unit installation and service start (next steps then use foreground run).
  • --non-interactive, -n — scripted setup (no TTY), driven by --answers and/or the override flags below. Produces the same validated config the wizard produces (always armed: false). See the unattended install guide for the answers-file schema and Ansible/cloud-init examples.
  • --answers <path> — YAML answers file (implies --non-interactive; flags override file values). Unknown keys are rejected.
  • --force — overwrite an existing config.yaml/policy.yaml (non-interactive only; without it a re-run refuses, same as the wizard).
  • --docker-group — add the ezyshield service user to the docker group so docker log collectors can reach the Engine socket. That group is root-equivalent on the host (its members can start a privileged container), so the grant is never implied: interactively the wizard asks and defaults to no, --yes alone never grants it, and this flag is the explicit opt-in for scripted runs (answers key: collectors.docker_group). It only takes effect when the run configures at least one docker collector; without it those collectors are still written but cannot read container logs. ezyshield doctor warns whenever the service user is in the group. To revoke: sudo gpasswd -d ezyshield docker && sudo systemctl restart ezyshield.
  • --docker-host <endpoint> — the scoped alternative to that group: point the docker collectors, the exec watcher and evidence extraction at a filtering, read-only proxy in front of the Engine socket, e.g. --docker-host tcp://127.0.0.1:2375. It writes docker.host into config.yaml (answers key: collectors.docker_host) and grants the service user nothing. --docker-host and --docker-group are mutually exclusive — the proxy replaces the group rather than accompanying it. A tcp:// endpoint must be a loopback address. init never starts the proxy (that is a change to your own stack): it prints the compose snippet to run and leaves verification to ezyshield doctor. Interactively, the wizard offers three paths in privilege order — a host log file read by a kind: file collector, this proxy, or the docker group — and pre-selects the least-privileged one that fits.
  • --admin-ips, --monitor-ssh, --enable-ai, --ai-provider, --ai-model, --ai-key-env — per-answer overrides for the non-interactive path. --ai-key-env takes an env var NAME, never the key itself; a literal secret in any flag or answers-file value is rejected.
  • --json — with --non-interactive, emit the summary as JSON on stdout (progress goes to stderr).

ezyshield run ​

Start the daemon in the foreground. Reads logs, makes decisions, enforces bans.

bash
sudo ezyshield run
FlagDefaultDescription
--config/etc/ezyshield/config.yamlpath to config.yaml
--policy/etc/ezyshield/policy.yamlpath to policy.yaml
--db/var/lib/ezyshield/ezyshield.dbpath to the SQLite database
--socket/run/ezyshield/ezyshield.sockcontrol socket path

Runs in dry-run mode by default (armed: false in policy.yaml).

ezyshield watch ​

Stream live security events from the running daemon: detections, strike escalations, bans, dry-run bans, unbans, and allowlist changes. This is a live view — for a point-in-time snapshot of active bans, use list.

bash
# Stream everything
ezyshield watch

# Only bans and dry-run bans
ezyshield watch --kind ban,dry_ban

# Only events for one address or CIDR block
ezyshield watch --ip 203.0.113.0/24

# NDJSON: one JSON object per line, for jq or a log shipper
ezyshield watch --json | jq .kind

Flags:

  • --kind — filter by event kind: detection, record, notify_only, dry_ban, ban, already_banned, unban, allow (repeatable or comma-separated)
  • --ip — filter by IP address or CIDR block
  • --socket — daemon control socket path

Each event carries a timestamp, kind, IP, and context fields (score, category, rule, strike, TTL, enforcer, reason, source). Event text derived from log lines is sanitized before display — ANSI escape sequences and control characters are stripped so hostile log content cannot spoof your terminal.

If the daemon connection drops (e.g. a restart), watch reconnects automatically with backoff. Press Ctrl-C to exit. The daemon must be running (ezyshield run or sudo systemctl start ezyshield).

ezyshield arm ​

Arm enforcement after a mandatory pre-flight (issue #228). The daemon flips from dry-run to live blocking; the transition is persisted to policy.yaml and audited — no config editing, no restart.

bash
sudo ezyshield arm [--for 1h] [--keep] [--force]

The pre-flight reports pass/warn/fail for: enforcer configured, admin_cidrs and allowlist coverage, a self-ban simulation for your own SSH client IP, and recent dry-run activity. Failing checks refuse the transition.

FlagMeaning
--for <dur>Arm temporarily (1m–7d): unless confirmed with --keep, the daemon reverts to dry-run when the window expires and notifies. The revert is daemon-side — it survives losing your session.
--keepConfirm the active window; armed becomes unconditional
--forceOverride failing checks — except the self-ban check, which is never bypassable
--socketDaemon control socket path

ezyshield status shows the auto-revert deadline while a window is active.

ezyshield disarm ​

Return to dry-run mode. No pre-flight — moving toward dry-run is always the safe direction. Persisted to policy.yaml and audited.

bash
sudo ezyshield disarm

ezyshield disable / enable ​

The panic button. When to reach for this: false positives banning real users, a locked-out colleague, a broken rule blocking production traffic — any moment where "make it all stop, now" beats surgical fixes.

bash
sudo ezyshield disable --all          # disarm + remove EVERY active block
sudo ezyshield disable --local-only   # flush local nftables only (works with the daemon down)
sudo ezyshield disable                # disarm only (same as 'disarm')
sudo ezyshield enable                 # re-arm (alias for 'arm', full pre-flight)

--all disarms the daemon (no new bans), clears every active ban from the store, and reconciles the enforcers against the now-empty state — the local nftables sets are emptied via the enforcer helper and edge enforcers (Cloudflare) empty through their normal reconcile path. Ban history and strikes are preserved — this is an enforcement reset, not a data wipe — and enable re-arms later without re-applying anything. A confirmation prompt guards the command (--yes skips it); the disarm and a summary row (disable_all, with the count) land in the audit log.

--local-only is the break-glass variant: it talks directly to the enforcer helper socket and flushes the local blocked sets, so it works even when the daemon is unhealthy. Edge blocks stay as-is, and a running daemon re-applies active bans on its next reconcile (~1 min) — follow up with allow <your-ip>, unban <ip>, or disable --all.

If neither socket is reachable, the command prints the exact manual recovery steps (systemctl stop ezyshield, nft flush set inet ezyshield blocked / blocked6, and the Cloudflare list note) on stderr.

Both variants honor --json and the standard exit codes. There is deliberately no config flag for any of this — only a socket-capable operator (root or the ezyshield group) can trigger it.

ezyshield status ​

Show daemon and enforcer status.

bash
ezyshield status

# JSON output
ezyshield status --json
FlagDescription
--socketdaemon control socket path override
--enforcer-socketenforcer socket path override

Output:

  • Daemon and enforcer reachability
  • Mode (enforce / dry-run), uptime, version
  • Active bans total and per-strike breakdown

Stable --json fields beyond those: enforcement_state / enforcement_detail (the honest enforcement health, same vocabulary as doctor), collectors_state / collectors_detail, simulated_bans (dry-run bans that would be enforced if armed), and armed_until (the auto-revert deadline while an arm --for window is active).

ezyshield list ​

List active bans (default) or the allowlist.

bash
# Active bans
ezyshield list

# Grouped by country / by ASN
ezyshield list --by-country
ezyshield list --by-asn

# Allowlist entries
ezyshield list --allow

# Historical action log (bans, expiries, unbans, allows) — newest first
ezyshield list --audit
ezyshield list --audit --ip 203.0.113.42
ezyshield list --audit --limit 50

# JSON output — always a bare array of entries (works with --audit and --allow too)
ezyshield list --json
FlagDescription
--allowlist allowlist entries instead of bans
--by-countryaggregate bans by country (requires GeoIP enrichment)
--by-asnaggregate bans by ASN (requires GeoIP enrichment)
--auditshow the historical action log instead of active bans
--ipwith --audit: filter the history to one IP address
--limitwith --audit: maximum rows to return (default 100)
--socketcontrol socket path override

Ban columns: IP / STRIKE / TTL / COUNTRY / ASN / REASON. Allowlist columns: IP/CIDR / EXPIRES / REASON. Audit columns: TIME / IP / ACTION / STRIKE / TTL / REASON (newest first; TTL is perm for a permanent ban, - for actions that carry none).

list --audit shows only the audit trail (timestamps, actions, strikes, reasons). For a single offender's full history with detection verdicts and evidence, use ezyshield report.

ezyshield feeds ​

Inspect and refresh the configured IP reputation feeds (config.yaml feeds: section) via the running daemon. See the Reputation Feeds guide.

bash
ezyshield feeds status            # per feed: action, entries, skipped, last/next refresh
ezyshield feeds status --json
ezyshield feeds refresh           # re-fetch every feed now
ezyshield feeds refresh <name>    # re-fetch one feed

Feed entries never create strikes or bans and never appear in ezyshield list — they live in the dedicated blocked_feeds nftables sets (action block) or only in memory as a score boost (action observe, the default).

ezyshield report ​

Generate a complete abuse report for one offender IP from the daemon's records: identity and enrichment (country, ASN), the current ban, the full strike history with detection verdicts, and the action trail. Without an IP, list every offender on record.

bash
# Full report for one IP (terminal text)
ezyshield report 203.0.113.7

# Markdown document, ready to attach to an abuse@ complaint
ezyshield report 203.0.113.7 -o md > abuse-203.0.113.7.md

# Same, including raw log excerpts mentioning the IP as evidence
ezyshield report 203.0.113.7 --evidence -o md > abuse-203.0.113.7.md

# Machine-readable (versioned schema, safe to script against)
ezyshield report 203.0.113.7 --json

# List all offenders on record / only permanently banned ones
ezyshield report
ezyshield report --permanent

Flags:

  • -o, --output — output format: text (default) or md (markdown abuse report; requires an IP)
  • --evidence — include raw log excerpts mentioning the IP, extracted on demand from the daemon's configured log sources (requires an IP). File sources are scanned directly, journald sources through journalctl, and docker sources through the Docker Engine socket. Excerpts are bounded (most recent window, 50 lines per source) and never persisted; a source that cannot be read (log rotated away, journal empty, engine socket unreachable, container removed) degrades to an explanatory note instead of failing the report
  • --permanent — listing mode: only offenders with a permanent active ban
  • --limit — max strike/action rows (0 = server default of 100)
  • --no-footer — omit the "Generated by EzyShield" footer from markdown output
  • --socket — daemon control socket path

The report is read-only and works in both enforce and dry-run modes. Fields derived from log lines (reasons, categories) are sanitized before display — ANSI escapes and control characters are stripped, and markdown table cells are escaped — so hostile log content cannot spoof your terminal or break the document. Evidence excerpts are rendered as indented code blocks in markdown, so a log line cannot inject formatting into the report. Timestamps are UTC (RFC 3339).

Incident digest (report --since) ​

Without an IP but with --since, report switches to the server-wide incident digest: "what happened on this server since yesterday?" as one readable document. It reads the store directly (the daemon does not need to be running) and is fully offline and deterministic — no AI dependency.

bash
# Terminal digest of the last 24 hours
ezyshield report --since 24h

# Weekly digest as markdown, ready to paste into a ticket/email
ezyshield report --since 7d -o md > weekly-digest.md

# Machine-readable
ezyshield report --since 24h --json

# With a short AI-generated prose summary (requires a configured provider)
ezyshield report --since 24h --narrative

The markdown structure is stable and documented — sections always appear in this order (empty ones are omitted; Totals and Enforcement always render): Totals, Events by kind, Strikes by category, Strikes by rule, Top offenders (strike-ladder position, new vs repeat), Enforcement, Notable patterns, AI narrative (only with --narrative). Sample:

markdown
# EzyShield digest — last 24h0m0s

## Totals
| Metric | Count |
|---|---|
| Strikes | 12 |
| Dry-run bans | 7 |

## Top offenders
| IP | Strikes (window) | Max strike | Total strikes | New |
|---|---|---|---|---|
| 203.0.113.7 | 4 | 3 | 9 | repeat |

## Notable patterns
- coordinated bruteforce activity: 5 distinct IPs struck in the window
- dry-run mode: 7 ban(s) were simulated but not enforced in this window

Digest-mode flags: --since (window; d suffix allowed), --db (SQLite path), --narrative, --config (AI provider settings for --narrative).

--narrative sends ONLY the aggregated digest JSON (counts, categories, rule names, IPs — never raw log lines) to the configured AI provider, respects the daily token budget, and degrades to the plain digest with a note on any failure. The narrative renders under a clearly-labeled advisory section. Event totals come from the persisted hourly counters, which only track kinds referenced by long-window rules.

ezyshield rule test ​

Dry-evaluate one rule against the stored event history, before enabling it. Strictly read-only: no strikes, no bans, no audit writes, and the daemon does not need to be running.

bash
# A loaded rule by name (built-in, rules.d drop-in, or rules_path override)
ezyshield rule test ssh_bruteforce_daily --since 7d

# A standalone YAML file — test a drop-in BEFORE copying it into rules.d
ezyshield rule test ./60-admin-panel.yaml --since 24h

# Machine-readable
ezyshield rule test ssh_bruteforce_daily --json

The rule passes the same fail-closed validation as the daemon's loader first; an invalid rule is a clear error and a non-zero exit. Output: how many times the rule would have fired (rising-edge counting — consecutive saturated hours are one incident), unique IPs (sampled), per-day distribution, and the allowlisted hits false-positive early warning (would-be detections on allowlist/admin_cidrs or runtime-allowlist addresses).

Flags:

  • --since — history window to evaluate (Go duration, d suffix allowed; default 24h)
  • --db — SQLite database path (default /var/lib/ezyshield/ezyshield.db)
  • --config — config.yaml path, used to resolve rules.d/rules_path
  • --policy — policy.yaml path, used for the allowlist check

Honest limitation (also printed on every run): evaluation uses the stored hourly aggregates, so granularity is bounded by 1-hour buckets and by retention; only kinds referenced by long-window (>1h) rules are persisted. A field-level matcher on a window of 1h or less cannot be applied to counts — such rules are reported as a loudly-marked kind-level upper bound. A field-level rule with a window above 1h is evaluated exactly, from the matcher counter the daemon keeps under the rule's own name (written only while a rule of that name is loaded). The daemon additionally discounts the history a strike already consumed for an address; rule test reports the raw stored history, so it can say "would fire" for an address the daemon would not strike again until it has gathered a full threshold of new events.

ezyshield ban ​

Manually ban an IP or CIDR.

Manual bans pass the same safety guards as automatic decisions (issue #211): a target that overlaps the allowlist, admin_cidrs, or a runtime allow entry is refused; a target covering an active SSH session (yours included — the CLI forwards your client IP) is refused; and manual bans count against max_bans_per_minute. Refusals name the guard, exit non-zero, and are recorded in the audit log as ban_refused. There is no override — allowlist and anti-lockout are hard rules, and the rate-limit knob is the policy's max_bans_per_minute.

bash
# Ban permanently
sudo ezyshield ban 203.0.113.42

# Explicit duration
sudo ezyshield ban --ttl 24h --reason "abuse report" 203.0.113.42

# Ban a subnet
sudo ezyshield ban 203.0.113.0/24
FlagDescription
--ttlban duration (5m, 24h, 7d); empty = permanent
--foralias of --ttl, matching allow --for and arm --for
--forcebypass the shared-CDN-range guard (never the allowlist/anti-lockout guards) — see below
--reasonfree-text reason stored in the audit log
--socketcontrol socket path override

Manual bans bypass the rule engine, not the allowlist — an allowlisted IP can never be banned, manually or otherwise (safety invariant: allowlist always wins).

Manual bans are also checked against the built-in table of shared CDN edge ranges — blocking a shared edge IP blocks legitimate traffic for everyone behind that CDN. A target inside a known range is refused, and when the range table is unavailable the ban is refused as unverifiable; --force overrides only these two refusals (the table is a shipped snapshot that can go stale), never the allowlist or anti-lockout guards.

ezyshield unban ​

Remove an active ban.

bash
sudo ezyshield unban 203.0.113.42

# Unban a subnet
sudo ezyshield unban 203.0.113.0/24

Does not delete audit history. (--socket overrides the control socket path.)

ezyshield allow ​

Add an IP or CIDR to the runtime allowlist.

bash
# Add IP (permanent)
sudo ezyshield allow 192.0.2.100

# Add CIDR
sudo ezyshield allow 192.0.2.0/24

# Temporary entries
sudo ezyshield allow --for 2h --reason "vendor maintenance" 198.51.100.7
sudo ezyshield allow --until 2026-08-01T00:00:00Z 198.51.100.8
FlagDescription
--forrelative expiry (e.g. 2h, 7d); mutually exclusive with --until
--untilabsolute expiry (RFC 3339 timestamp)
--reasonfree-text reason stored with the entry
--socketcontrol socket path override

Allowlist is checked first. No rule can ban an allowlisted IP.

ezyshield unallow ​

Remove an IP or CIDR from the runtime allowlist.

bash
sudo ezyshield unallow 192.0.2.100
sudo ezyshield unallow --reason "office moved" 192.0.2.0/24
FlagDescription
--reasonfree-text note recorded in the audit log
--socketcontrol socket path override

The target must match the stored entry exactly (the same IP or CIDR that was allowed). Only runtime entries (added with allow) can be removed; entries from the static config allowlist require a config edit and daemon restart. Removing an entry also drops it from the enforcer's @allowed set.

ezyshield scan ​

Discover every listening TCP socket on the host and detect drift against the previous baseline. This is a read-only reconnaissance command — it answers "what is exposed, which process owns it, and where are its logs?" and never bans.

bash
# Scan and print the listener table
sudo ezyshield scan

# Machine-readable output
sudo ezyshield scan --json

For every socket in the LISTEN state (parsed from /proc/net/tcp and /proc/net/tcp6) it resolves the owning PID and binary, the user, the owner (systemd unit or Docker container), and a log source. Results are stored as a baseline in the SQLite database; on later runs, listeners that were not in the previous baseline are flagged [NEW]. A public listener with no resolvable log source is reported as ⚠ no logs, so an internet-facing service that EzyShield cannot watch stands out.

FlagDefaultDescription
--db/var/lib/ezyshield/ezyshield.dbpath to the SQLite database that holds the baseline

Table columns: PROTO / ADDR:PORT / PID / BINARY / USER / OWNER / UNIT/CONTAINER / LOG SOURCE.

Run as root. With the default --db path the command creates and reads the database under /var/lib/ezyshield, which needs privileges — so an unprivileged run hard-fails there unless you point --db at a writable location. Given a writable database, per-socket metadata you lack permission to resolve (the binary, owner, and log source of processes you do not own) then degrades gracefully — e.g. PID 0, empty binary — rather than failing the scan. The command follows the global exit-code contract; with --json it prints only JSON on stdout, with the newly detected sockets also collected under new_listeners.

ezyshield plugins ​

Inspect tier-1 plugins: external executables under /etc/ezyshield/plugins.d/<name>/module.yaml speaking JSON over stdio. Plugins are opt-in twice: plugins.enabled: true in config.yaml AND the plugin's name listed in plugins.allow[] — dropping files into plugins.d is never enough to execute code.

bash
# Every plugins.d entry with name, version, type and status
# (ready / not-allowed / invalid / disabled). Never executes anything.
ezyshield plugins list

# Author helper: strict manifest validation (schema, permissions,
# ownership, exec resolution) plus ONE handshake dry-run, then kill.
ezyshield plugins validate /etc/ezyshield/plugins.d/my-plugin

Manifest schema: docs/schemas/plugin/module.schema.json. The exec path is relative to the plugin directory only (no PATH lookup, no absolute paths, no traversal, no symlinks), must not be world-writable, and must belong to root or the plugin dir's owner.

Honest note: the manifest's network: declaration is advisory in v1 — EzyShield does not yet isolate plugin network access. The field exists so authors declare intent and a future version can enforce it.

yaml
plugins:
  enabled: true
  allow: [my-plugin]        # explicit by-name allowlist, required
  # dir: /etc/ezyshield/plugins.d

ezyshield doctor ​

Validate config, permissions, and log sources.

bash
sudo ezyshield doctor
FlagDefaultDescription
--config-dir/etc/ezyshieldconfiguration directory to check
--db/var/lib/ezyshield/ezyshield.dbdatabase for the read-only ban_ineffective check
--socket/run/ezyshield/ezyshield.sockdaemon control socket (queried for the live enforcement state)

Checks:

  • config.yaml / policy.yaml exist, parse, and have safe permissions/ownership

  • nft binary present

  • journald readable

  • enforcer socket reachable

  • docker socket present (when Docker collectors are configured)

  • service user docker group: WARN when ezyshield is a member of the docker group — that group is root-equivalent on the host, so the hint says whether the configured collectors justify it or it should be revoked (gpasswd -d ezyshield docker); N/A when the host has no docker group

  • docker endpoint (when docker.host is a tcp:// endpoint; N/A for a unix socket, where the socket check above covers it): two checks — reachable (PASS when GET /_ping answers 200) and read-only (PASS when POST /containers/create is refused, FAIL when the endpoint accepts it, which means root-equivalent access to this host over the network). Both probes are bounded by a timeout and never print a response body. The probe body names no image, so nothing is ever created.

  • .env secret file permissions

  • allowlist breadth: WARN (not FAIL) when policy.yaml's allowlist contains a private (RFC1918/ULA) range at /16 or broader — such a range can never be banned, so it silently exempts a large chunk of address space from enforcement forever. See the allowlist section in Policy Reference.

  • ban_ineffective diagnostics: FAIL when an active ban is flagged ineffective and still leaking — a suppressed event from that IP within the last 24 hours — naming the IPs and pointing at the systemic remedy (edge enforcement / real-IP parsing / enforcer health); WARN when the only flagged bans leaked in the past — quiet for 24 hours or more (listed with their silence length), or flagged before the last-leak timestamp existed (listed as "last leak time unknown") — or when no ban is currently flagged but some offender was flagged historically; PASS otherwise. A flagged ban that goes quiet for 24 hours is re-armed: a new leak on it fires ban_ineffective again, so a permanent ban is never a one-time signal. Read-only query against the database at --db.

  • cdn range data: FAIL when the embedded shared-CDN-range table (backing the ban-path anti-lockout guard, issue #178) fails to load — bans then proceed marked [cdn-ranges-unverified] in the audit log; PASS shows the loaded range count.

  • Enforcement state (issue #174) — the honest health of the enforcement path, derived from real enforcer outcomes, not config alone, and re-verified by a periodic reconcile probe (every 5 minutes) so it stays honest on quiet hosts. Shown loudly in text output and as the stable enforcement_state field in --json:

    • ACTIVE — armed, enforcer healthy, bans are applied
    • DRY-RUN — detection running but nothing is enforced
    • DEGRADED — armed but the enforcer's recent Ban/Sync failed; bans may not be applied (with the failure detail)
    • DISABLED — no enforcer configured; detection only

    As a check it is FAIL when armed but the enforcer is DEGRADED (bans not being applied); WARN on DRY-RUN or DISABLED; N/A when the daemon isn't running.

To exercise enforcers and notification channels for real, use ezyshield test enforcer and ezyshield test notifier.

ezyshield config ​

Inspect and validate configuration.

ezyshield config show ​

Render the effective configuration — after parsing, strict validation, and defaults — as YAML, or JSON with --json. Secret values never appear in the output: credential fields hold env:VARNAME references by design, and webhook header values (which may carry raw tokens) are shown as <redacted>.

bash
ezyshield config show

# JSON output
ezyshield config show --json

# Non-default file locations
ezyshield config show --config ./config.yaml --policy ./policy.yaml

Exit codes: 0 rendered, 1 configuration invalid, 2 file not found / unreadable.

ezyshield config validate ​

Validate config.yaml and policy.yaml without starting the daemon: strict YAML parsing, field constraints, strike-table monotonicity, allowlist CIDRs, and warnings for unreadable log paths or unset env vars.

bash
ezyshield config validate

# Non-default file locations
ezyshield config validate --config ./config.yaml --policy ./policy.yaml

The top-level ezyshield validate is kept as an alias and behaves identically.

Exit codes: 0 valid (may have warnings), 1 errors found, 2 file not found / unreadable.

ezyshield config enforcer <name> ​

Interactive wizard to add or reconfigure one enforcer on an existing installation — the same prompts and dry token validation the init wizard runs, without regenerating anything else.

bash
sudo ezyshield config enforcer cloudflare
  • The write is atomic (temp file + rename); the previous file is kept as config.yaml.bak and the merged configuration is re-validated before anything touches disk. Comments are not carried over — recover them from the .bak if needed.
  • Secret tokens go to the .env file next to config.yaml (mode 0600), never into config.yaml itself (api_token: env:CLOUDFLARE_API_TOKEN).
  • Multiple Cloudflare accounts are supported: with accounts already configured, the wizard asks whether to reconfigure an existing one or add another; each account keeps its own token env var (CLOUDFLARE_API_TOKEN_<NAME>). See the Cloudflare guide's multi-account section.
  • On success the command prints the changed keys and next steps (config validate, restart the daemon). If the wizard aborts, nothing is written.

Available names: cloudflare.

Exit codes: 0 saved, 1 wizard aborted or write failed, 2 config.yaml not found (run init first).

ezyshield config notifier <name> ​

Interactive wizard to add, reconfigure, or remove one notification channel on an existing installation.

bash
sudo ezyshield config notifier telegram
sudo ezyshield config notifier email
sudo ezyshield config notifier slack
sudo ezyshield config notifier discord
sudo ezyshield config notifier webhook
  • Each channel asks for its own settings (telegram: chat IDs; email: from/to/SMTP host/port/TLS/username; slack: optional channel override; webhook: optional auth header) plus a severity filter (info,warn,critical; empty = all).
  • Credential values — bot tokens, webhook URLs (capability URLs are secrets), SMTP passwords, auth header values — are read with input hidden and offered two ways: paste the value (stored in the .env file next to config.yaml, mode 0600, merged without touching other lines) or reference an env var you already manage (e.g. from sops/vault) — then the wizard writes env:YOUR_VAR and never touches .env. Secrets never land in config.yaml; it only carries references like bot_token: env:TELEGRAM_BOT_TOKEN.
  • Pressing ENTER at the paste prompt is fine: an existing value in .env is kept as is; otherwise a placeholder is written for you to fill in later.
  • For the generic webhook channel the auth header value is a secret too: config.yaml gets Authorization: env:WEBHOOK_AUTH_HEADER and the daemon resolves the reference at startup. Plain (non-env:) header values in hand-written configs keep working unchanged.
  • Reconfiguring replaces that channel's entry; shared tuning (rate_limit_per_minute, dedup_window_sec) and other channels are preserved. To disable a channel, answer n at the configure prompt: the wizard then offers to remove the existing entry (default no). Declining leaves the file untouched.
  • Write semantics match the other wizards: atomic write, config.yaml.bak, re-validation before saving, changed-keys summary on success. Verify delivery afterwards with the notification test command shown in the next steps.

Available names: telegram, email, slack, discord, webhook.

Exit codes: 0 saved, 1 wizard aborted or write failed, 2 config.yaml not found (run init first).

ezyshield config ai <provider> ​

Interactive wizard to configure (or switch) the AI provider on an existing installation — the same model and API-key prompts the init wizard runs, without regenerating anything else.

bash
sudo ezyshield config ai anthropic
sudo ezyshield config ai openai
sudo ezyshield config ai ollama
  • The API key is read with input hidden and offered two ways: paste it (stored in the .env file next to config.yaml, mode 0600, merged without touching other lines) or reference an env var you already manage (e.g. from sops/vault) — in that case the wizard writes api_key: env:YOUR_VAR and never touches .env. Keys never land in config.yaml.
  • Pressing ENTER at the paste prompt is fine: an existing key in .env is kept as is; otherwise a placeholder is written for you to fill in later. ollama runs locally and has no key.
  • Reconfiguring replaces the provider fields (provider, model, api_key) but preserves your tuning (ambiguous_band, token_budget_daily). Write semantics match config enforcer: atomic write, config.yaml.bak, re-validation before saving.

Available providers: anthropic, openai, ollama.

Exit codes: 0 saved, 1 write failed, 2 config.yaml not found (run init first).

ezyshield config collector <name> ​

Interactive wizard to add, reconfigure, or remove one log collector on an existing installation — the same prompts the init wizard runs for that source, without regenerating anything else.

bash
sudo ezyshield config collector sshd
sudo ezyshield config collector nginx
sudo ezyshield config collector apache
  • sshd manages the journald collector (confirm, then optionally override the systemd unit). Web server names (nginx, apache, traefik, caddy) first ask for the log source: file (host access-log path, default suggested per server) or docker (container name, reading its stdout).
  • Reconfiguring replaces the existing entry for that source (matched by parser for web servers, by SSH unit for sshd) — the wizard never appends duplicates. Setups with several sources for the same server (e.g. two nginx vhost logs) are edited in config.yaml directly.
  • To disable a source, answer n at the configure prompt: the wizard then offers to remove the existing entry (default no). Declining leaves the file untouched.
  • Collectors carry no secrets; everything stays in config.yaml. Write semantics match the other wizards: atomic write, config.yaml.bak, re-validation before saving, changed-keys summary on success.

Available names: sshd, nginx, apache, traefik, caddy.

Exit codes: 0 saved, 1 wizard aborted or write failed, 2 config.yaml not found (run init first).

ezyshield config enrich maxmind ​

Interactive wizard to set up (or remove) GeoIP/ASN enrichment with the free MaxMind GeoLite2 databases — the workflow that enables block_countries / block_asns in policy.yaml and the country/ASN columns in list and report.

bash
sudo ezyshield config enrich maxmind
  • Asks for the two database paths (defaults under /var/lib/ezyshield/) and whether the daemon should keep them updated (auto_update, default yes).
  • With auto_update on, the wizard asks for your MaxMind license key (free GeoLite2 signup) via the standard secret prompt: paste it (stored in .env next to config.yaml, mode 0600) or reference an env var you already manage — config.yaml only ever carries license_key: env:MAXMIND_LICENSE_KEY. On the next daemon start the databases are downloaded automatically if missing, then refreshed weekly.
  • With auto_update off no key is needed: download GeoLite2-Country.mmdb and GeoLite2-ASN.mmdb from your MaxMind account yourself and place them at the configured paths. Missing files are not an error — the daemon runs with empty enrichment until they appear.
  • To disable enrichment, answer n at the configure prompt: the wizard then offers to remove the existing enrich: section (default no).
  • Write semantics match the other wizards: atomic write, config.yaml.bak, re-validation before saving, changed-keys summary on success.

Available names: maxmind.

Exit codes: 0 saved, 1 wizard aborted or write failed, 2 config.yaml not found (run init first).

ezyshield update ​

Self-update the binaries from GitHub Releases. checksums.txt is signature-verified with cosign against the pinned release-workflow identity (see Verifying releases), then each binary is checksum-verified. Signature verification is fail-closed: a missing checksums.txt.sig/.pem, a missing cosign binary, or a failed verification aborts the update and nothing is installed. Pass --allow-unsigned to proceed without a signature (missing signature assets — e.g. a pre-signing release — or no cosign on the host); a failed verification of a present signature always aborts, with or without the flag.

bash
# Check whether a newer release exists
sudo ezyshield update --check

# Update to the latest stable
sudo ezyshield update

# Update/downgrade to a specific version
sudo ezyshield update --version v0.1.0

--version is also the official rollback path: when the tag is older than the running version, the command warns you — the database schema is never reverted, so keep a backup — and asks for confirmation ([y/N], default no). Pass --yes (-y) for unattended rollbacks; without it, a non-interactive run refuses the downgrade.

If you installed via apt/dnf, prefer the package manager instead (see the install guide).

ezyshield dashboard ​

Serve the localhost-only web dashboard. Full reference (auth, pages, remote access): dashboard.md.

FlagDescription
--configpath to config.yaml
--addrbind address override (loopback only — non-loopback is refused)
--auth-dbauth database path override
--socketdaemon control socket path override

ezyshield completion ​

Generate shell completion scripts (bash, zsh, fish, powershell):

bash
ezyshield completion zsh > "${fpath[1]}/_ezyshield"

ezyshield version ​

Show version info.

bash
ezyshield version

# JSON output
ezyshield version --json

ezyshield test ​

Run connectivity tests against configured components. Like config, the group follows the <kind> <name> pattern, so future component kinds plug into the same verbs. Both subcommands accept --config-dir (default /etc/ezyshield) to point at the directory holding config.yaml.

ezyshield test enforcer <name> ​

Test the configuration and permissions of an enforcer backend: token validity, account/zone access, and the exact API permissions the enforcer needs — with a fix suggestion for every failing check.

bash
sudo ezyshield test enforcer cloudflare

# Test all configured enforcer backends
sudo ezyshield test enforcer all

Available names: all, cloudflare, nftables.

Exit code is 0 if all checks pass, non-zero if any check fails.

ezyshield test notifier <name> ​

Send a synthetic alert to verify a notification channel end to end (secrets resolved from the environment, message actually delivered).

bash
sudo ezyshield test notifier telegram

# Test all configured channels
sudo ezyshield test notifier all

Available names: all, discord, email, slack, telegram, webhook.

Exit code is non-zero on failure.

Deprecated aliases ​

The pre-1.0 verbs test-enforce <name> and test-notify <name> keep working as hidden aliases of test enforcer / test notifier — same flags, same behavior — and print a one-line migration notice on stderr. They will be removed in 1.0.

Global flags ​

FlagDescription
--jsonOutput as JSON (see Global conventions for shapes)
--no-colorDisable colored output (the NO_COLOR env var is also honored)
-h, --helpShow help text

--config / --policy are not global — they exist on the commands that read those files (run, config show, validate; dashboard takes --config only — it never reads policy.yaml), with defaults under /etc/ezyshield.

Root-command-only flags ​

FlagDescription
-v, --versionPrint version and exit

Unlike --json/--no-color, -v/--version is wired only on the root ezyshield command and is not inherited by subcommands — ezyshield ban --version fails with unknown flag: --version. Use ezyshield version (or ezyshield --version) to check the version.

Examples ​

Monitor daemon activity live:

bash
ezyshield watch --kind ban,dry_ban

Export per-IP history with evidence to JSON:

bash
ezyshield report 203.0.113.42 --json > report.json

Check if an IP is currently banned:

bash
ezyshield list --json | jq '.[] | select(.ip == "203.0.113.42")'

Permanently ban a botnet subnet:

bash
sudo ezyshield ban --ttl 0 203.0.113.0/24

Add your office to allowlist:

bash
sudo ezyshield allow 192.0.2.0/24