# Firewall

### IP Blocks Are Server-wide

Every block — from the Firewall page or from the account-level endpoints — is one rule on the server firewall (UFW on Debian, firewalld on RHEL) and affects every site on the server. There is no per-account scoping; the account-level endpoints say so in their `warnings` and record `scope: server` in the audit log. Tenants cannot add, remove or list blocks.

The API refuses a deny that covers the address the request came from (`SELF_LOCKOUT`), so an operator cannot close the panel on themselves from the panel.

A block is written twice: as a source deny in the packet filter and as an entry on the HTTP-layer list fail2ban uses (`/etc/whost/http-banlist`, rendered into every installed webserver), so it also holds for traffic that arrives through a CDN. On UFW the comment given with the block is kept and shown in the list; firewalld (the RHEL family) does not store it, so there the list shows no comment. Blocking a whitelisted address is refused with `409 IP_WHITELISTED` — the allow rule would sit above the deny — so remove the address from the whitelist first. Lifting a block removes only the deny and the HTTP-layer entry, never the address's whitelist rule; lifting a block that does not exist answers `404 NOT_FOUND`, and the account-level endpoints answer `404 ACCOUNT_NOT_FOUND` for an account that does not exist.

**Clear All Bans** lifts every block the panel can produce — plain and port-scoped source denies, the HTTP-layer list, the rate-limit ban sets and fail2ban's bans — and answers the per-layer counts (`ufw_rules`, `http_bans`, `rate_limit_bans`, `fail2ban_jails`, `removed`). It leaves the security log alone; **Clear Log** on the Security Logs tab (`DELETE /firewall/logs`, audit `firewall_logs_cleared`) empties the event tables.

### System Rules

UFW (Debian) or firewalld (RHEL) abstraction. Lists every rule, allowed/denied/limit/reject, and protected ports (`PROTECTED_PORTS`, plus the SSH port set under Settings) the API refuses to close or deny globally (so an operator cannot lock themselves out of SSH, HTTP/HTTPS or the agent itself) — a port **range** that contains a protected port is refused as well, and the rule form asks for the same confirmation for such a range as for the exact port. The list carries source-scoped rules too, with their source, their comment (UFW only — firewalld keeps none) and an id of the form `port-protocol--source`. Editing replaces the rule and puts the old one back when the new one is refused; editing or deleting a rule the firewall does not carry answers `404 NOT_FOUND` (ufw's "non-existent rule" answer is read, not its exit status).

**Whitelist:** an allowlisted address is let through on every port and is exempt from auto-bans. The list shows `applied` per entry — whether the firewall really carries the rule — and the agent re-installs missing rules at startup, so a firewall reset no longer leaves entries that exist only on paper. An entry is saved only after the firewall accepted the rule. An entry whose rule is missing is marked **Rule missing** in the list; adding the address again re-installs the rule (and updates the comment when one is given).

**Rate-limit rules:** the threshold counts the new connections one address opens within the window — a packet filter cannot see HTTP requests, so the requests a kept-alive connection carries count once. An address over the threshold is banned for the ban duration and loses every port on the server until the ban ends. The rules sit in their own chain (`WHOST-RATELIMIT`), jumped to from the top of INPUT ahead of ufw / firewalld, so they see the traffic the host firewall lets through; the server's own traffic (loopback) and addresses on the firewall whitelist are never counted or banned. The chain exists only while at least one rule is enabled. Rules apply to IPv4. A rule with several ports (`80,443`) is applied with one multiport match; up to 15 ports or ranges per rule, and a port filter needs `tcp` or `udp`. The rule is recorded only once the kernel carries its objects: a refused rule answers `500 RATE_LIMIT_APPLY_FAILED` with the kernel's reason and leaves nothing behind, and an update the kernel refuses puts the previous rule back. The rule store is root-only; the ban set of a rule is `WHOST_RL_<RULE ID>` — the rule id in upper case. The untargeted unban answers how many ban sets carried the address; an address that is not an IP is refused with `422` and an unknown rule answers `404`.

### Fail2Ban

Jail list (every jail fail2ban loads plus the ones the panel has disabled), ban list, manual ban/unban, jail-level enable/disable, the jail's ban time / find time / max retry, and the whitelist (`ignoreip`).

**Jails.** The list reads every loaded jail from the daemon and the disabled ones from the configuration files (`jail.local`, `jail.d/*`) with the filter, log path and tunables they would load with. Switching a jail off asks for confirmation — its current bans are released — and writes the panel's override (`jail.d/<jail>.local`: `enabled`, plus the ban time / find time / max retry set through **Edit**); a later edit keeps the values it does not name, and switching the jail off and on keeps them too. Ban time 60–604800 s, find time 60–86400 s, max retry 1–100. The jail list asks fail2ban-client per jail, so the read takes a few seconds on a host with many jails.

**Bans.** A ban in an `allports` jail (sshd, postfix, dovecot, pure-ftpd, recidive) is a packet-filter reject; a ban in an HTTP-facing jail (`whost-<user>-webscan`, `nginx-http-auth`, `whost-agent`) goes through the HTTP-layer list described below, and a network (CIDR) is accepted there too. Banning an address the whitelist ignores is refused with `409 IP_WHITELISTED`; unbanning an address that is not banned answers `404 NOT_FOUND`. **Unban All** releases every ban in every jail. The address the request comes from — or a network that covers it — cannot be banned (`422 SELF_LOCKOUT`): the ban would close the panel on the operator who asked for it.

**The agent jail.** `whost-agent` counts failed admin and client sign-ins and refused API signatures from the agent log. The sign-in name is written there as one escaped field, so it cannot add lines of its own and the count goes to the address the attempt came from.

**Whitelist.** One list for every jail: an added address is ignored by every loaded jail at once, persisted in `jail.local`'s `ignoreip`, and any ban it currently carries is released. Removing an address that is not on the list answers `404 NOT_FOUND`. Entries written by hand into `jail.d/*.conf` are neither shown nor edited by the panel.

**Under-attack mode** tightens sshd (bantime 86400, maxretry 3, findtime 300) through a `jail.d/whost-attack-mode.local` override that survives the reloads the panel's jail writes cause; the file goes away when the mode is disabled.

**The agent's own jail (`whost-agent`)** reads `/var/log/whost/agent.log` with the filter the agent renders from its template at every start (failed admin and client logins, invalid API signatures); a filter written by an older installer is replaced.

**Endpoints used:** `GET /fail2ban/status`, `GET /fail2ban/jails` + `GET/PUT /fail2ban/jails/{jail}` + `POST /fail2ban/jails/{jail}/{enable,disable}`, `GET /fail2ban/banned`, `POST /fail2ban/ban`, `DELETE /fail2ban/ban/{jail}/{ip}`, `POST /fail2ban/unban-all`, `GET/POST /fail2ban/whitelist`, `DELETE /fail2ban/whitelist/{ip}`.

**Per-account web jail (`whost-<user>-webscan`).** Every hosting account gets a jail over its own access logs (`whost-web-scan` filter: a burst of 4xx responses). A hit is banned at the **HTTP layer** rather than in the packet filter: the `whost-http-deny` action keeps one list (`/etc/whost/http-banlist`) and renders it into every installed webserver (nginx, Apache, OpenLiteSpeed, LiteSpeed Enterprise), so the ban holds whichever server owns the public port. Behind Cloudflare the banned address is the visitor's own: the installer has nginx restore it from Cloudflare's header (`/etc/nginx/conf.d/whost-cloudflare-realip.conf`). Behind another CDN, or where Apache, OpenLiteSpeed or LiteSpeed Enterprise serves the public port, the webserver sees the edge address — add that CDN's ranges to `ignoreip`. The action itself only queues the request (`/var/lib/fail2ban/whost-http-ban/queue`, the one place a confined fail2ban may write on SELinux hosts); the `whost-http-ban.path` systemd unit applies the queue as root within a second, so a ban shows up in the webserver after a short delay rather than inside the fail2ban call. `whost-http-ban list` prints the applied list; `whost-http-ban sync` re-applies it after a manual webserver config change; `journalctl -u whost-http-ban-apply` shows a webserver that rejected the list.

### WAF (ModSecurity)

The **ModSecurity WAF** page (Security → WAF) shows the engine (installed, active, mode, OWASP CRS, paranoia level, audit log, rule counts), the configuration form, the CRS rule list and the audit log.

**Configuration.** Engine mode `on` (block), `detection_only` (log, never block) or `off`; paranoia level 1–4; audit logging on/off. The rendered files are tested with the web server's own configuration test before the values are persisted; a change reloads the web server, a save that changes nothing does not. **Test Configuration** runs `nginx -t` (and `apachectl configtest` on nginx + Apache hosts); on a web server without such a test it answers `409 WAF_TEST_UNAVAILABLE`. WHost enforces ModSecurity at the nginx tier only; on Apache-only and OpenLiteSpeed hosts the page says so and the settings are saved but not applied to traffic.

**Restricted headers.** The CRS setup restricts the `Content-Encoding`, `Proxy`, `Lock-Token`, `Content-Range` and `If` request headers but not `Accept-Charset` (CRS 4 checks it only from paranoia level 2): search engine and AI crawlers send it. An existing install receives the list at the next agent start, through the same `nginx -t`-checked step as the shared rule set; a list you set yourself (rule `900250` in `/etc/modsecurity/crs/crs-setup.conf`) is kept.

**Argument limit.** The CRS setup lets a request carry up to 1,000 arguments (rule `900300`, `tx.max_num_args` — PHP's own `max_input_vars` default) instead of 255: at a limit of 255, multi-language administration forms, such as a WISECP product or add-on edit form with several hundred fields, are refused with `403` (rule `920380`). An existing install that still carries the previous value receives the new one at the next agent start, through the same `nginx -t`-checked step; a value you set yourself is kept.

**Engine mode of a new installation.** A new installation starts in `detection_only`: requests are written to the audit log and none is refused. Switch to `on` on this page only after the audit log has shown your sites' rarer requests — payment provider webhooks, refunds, 3-D Secure returns, card saving, API and licence calls — without matches that would refuse them. An existing installation keeps the mode it has.

**Request bodies.** XML and JSON request bodies are parsed by their own processors (rules `200000` and `200001` in `/etc/modsecurity/modsecurity.conf`), so each JSON value is inspected on its own. An existing installation receives the two rules at the next agent start, through the same `nginx -t`-checked step; a JSON rule you wrote yourself (`id:200001`) is kept.

**Active means nginx holds the rules.** The page shows the WAF as *Active* only when nginx has loaded the rule set: the connector module and the shared include `/etc/nginx/conf.d/whost-modsecurity.conf`, which the installer and the agent keep only once `nginx -t` has accepted the rule set. While nginx has not loaded it, the header reads *Inactive* and a **WAF rule set not loaded** note says that requests are not checked although the engine is on; the agent tries again at every start and writes the reason to `/var/log/whost/agent.log`. On Ubuntu 22.04 one CRS file is left out of the rule set — see [Known limitations](known-limitations.md).

**Rules.** Every id-bearing rule of the loaded rule set is listed, with its message where it carries one. A rule switched off is written to `/etc/modsecurity/disabled-rules.json` and as `SecRuleRemoveById` in `modsecurity.conf`, and the web server is reloaded; an id that is not in the rule set answers `404 WAF_RULE_NOT_FOUND`; a toggle to the state already in force writes nothing.

**Audit log.** The newest entries of `/var/log/modsecurity/audit.log`: client address, host, URI, the first rule that matched, severity, and whether the request was *blocked*, *detected* (detection-only mode) or only *logged* (a 4xx/5xx no rule matched). The rotation copies and truncates the file (`copytruncate`) because libmodsecurity never reopens it; the agent writes that logrotate stanza at every start. **Auto Refresh** polls every 10 s; **Load More** widens the window up to 1000 entries.

**Per-account WAF** (API and the reseller's sub-account page; no admin page): `GET /accounts/{username}/waf/status` and `PUT /accounts/{username}/waf/toggle` switch the WAF off or on for one account: the state is written to `/etc/modsecurity/accounts/<user>.json`, the account's rule file is re-rendered and every vhost of the account is re-rendered so its `modsecurity` switch follows the state (a vhost rendered while the WAF was off is enforced again after the switch on); `POST /accounts/{username}/waf/rules/exclude` and `DELETE /accounts/{username}/waf/rules/exclude/{rule_id}` (`?uri=` for a URI-scoped one; without it the account-wide entry goes, or the only URI-scoped one when that is all there is — several URI-scoped entries need the URI, `422`) exclude one CRS rule for the account, globally or for one URI; `GET /accounts/{username}/waf/audit-log` lists the entries whose Host header names one of the account's domains. An account that does not exist answers `404 ACCOUNT_NOT_FOUND`. While `modsecurity.per_account_overrides` is `false` the three writes (toggle, exclude, remove) answer `409 FEATURE_DISABLED` and nothing is written; the reads keep answering. The account's rule file (`/etc/modsecurity/accounts/<user>.conf`) is generated: it is rendered again from the state file on every change and at every agent start, so a rule written into it by hand is dropped. When an agent start rewrites a file that held such lines, the agent log carries one WARNING per account (how many lines were dropped and the folder that keeps the previous file, `/var/lib/whost/nginx-modsec-shared-<stamp>/`) and the admin panel shows a notification naming the accounts. A server-specific rule that the exclude endpoints cannot express (one that exempts a single argument, for example) goes into `/etc/modsecurity/custom/<name>.conf` — the main WAF file includes that folder and the agent does not write to it; run `nginx -t` and reload nginx after adding one.

**Endpoints used:** `GET /waf/status`, `PUT /waf/config`, `GET /waf/rules`, `PUT /waf/rules/{rule_id}`, `GET /waf/audit-log`, `POST /waf/test`; the account routes above.

### Under-Attack Mode

Top-right toggle. With the **Auto Enable** switch on (it is off by default) the mode arms itself when the threat score reaches 5 (driven by FirewallLogService scoring of recent blocks); with the switch off, such a score only raises a *High Threat Level Detected* notification. The sysctl hardening is handed to `systemd-sysctl` through the drop-in `/etc/sysctl.d/90-whost-attack-mode.conf` (the agent runs with `ProtectKernelTunables`, so it cannot write `/proc/sys` itself); the drop-in is removed and the previous values are written back when the mode is disabled, and `sysctl_hardening` is claimed only when at least one value actually changed. The packet rules — the SYN-flood limit and the connlimit on 80/443 — live in the `WHOST-ATTACK` chain that the first `INPUT` rule jumps to, ahead of ufw's chains; every verdict in it is RETURN, DROP or REJECT, so the firewall's own policy still decides what a connection that survived the flood check may reach, and the management ports are never dropped. A reboot empties the packet filter while the mode stays on; the agent puts the chain back when it starts. The sources nginx trusts as real-ip proxies (`set_real_ip_from`, the CDN's ranges) are exempt from the connection limit: one edge address carries every visitor, so counting it as a single client would cut them all off. Nginx `limit_req` and tighter Fail2Ban thresholds complete the set; on disable the Fail2Ban values read before the mode was enabled are restored. The card lists the applied protections. Manual mode persists across restarts; auto mode disarms when the threat score drops. Enabling an already-active mode (or disabling an inactive one) answers `409 ATTACK_MODE_ACTIVE` / `ATTACK_MODE_INACTIVE`; every state change answers the new state in `data`.

### Security Logs

The **Security Logs** tab shows the blocked events, grouped by address and type — `count` is the number of blocks, taken from the per-minute counters when the rows were sampled under load, and such a group is marked *sampled* — and the suspicious requests the access-log scanner found. Filters: an exact address, a fragment of one (`0.2.1` narrows with a partial match; anything that cannot name a host answers `422`), the block type (`rate_limit`, `under_attack`, `manual`, `autoban`), the category, and `since` / `until` as ISO-8601 timestamps. The list is paged on the agent (100 per page). The hour chart covers the last 24 hours in the viewer's local time; every hour is present and an empty one reads zero. The **banned** badge is true only while the address is banned right now — in a fail2ban jail (every jail is asked), on the HTTP-layer list, or by an auto-ban younger than its hour. **Unban & Whitelist** lifts the block first and then adds the address to the whitelist; when the second step is refused the toast says so, and the address stays unblocked but untrusted. **Refresh**, the **TXT** export, **Clear Log** (empties both tables; audit `firewall_logs_cleared`) and the **Clear All Bans** twin sit in the toolbar. Retention is 24 hours, capped at 50,000 blocked and 10,000 suspicious rows; the cleanup commits its deletes before the WAL checkpoint, so a busy writer only defers the checkpoint. Firewall notifications go through the shared notification service in the `firewall` category, so the operator's e-mail and panel preferences apply to them.

**Endpoints used:** `GET /firewall/status`, `GET/POST /firewall/rules` + `PUT/DELETE /firewall/rules/{rule_id}`, `GET/POST /firewall/blocked-ips` + `DELETE /firewall/blocked-ips/{ip}` + `POST /firewall/blocked-ips/clear-all`, `GET/POST /firewall/whitelist`, `GET/POST /firewall/rate-limits`, `POST /firewall/attack-mode/{enable,disable,auto-enable,auto-disable}`, `GET /firewall/logs/{stats,blocked,suspicious}`, `DELETE /firewall/logs`; the account-level `/accounts/{username}/firewall/blocked-ips` endpoints apply the same server-wide rule.

---
