# alpha.jayfield.org — TODO Outstanding hardening items (see `README.md` for the full service overview). Nothing here is urgent; all are low-risk, no-downtime changes. - [ ] **Mail server IP (`89.58.8.149`) listed on Barracuda's BRBL** — found 2026-07-25 (user report: "jayfield.org is on the Barracuda MX blacklist"). Confirmed via `dig +short 149.8.58.89.b.barracudacentral.org A` → `127.0.0.2` (listed); clean on Spamhaus ZEN, SpamCop, and SORBS — Barracuda-specific. Checked the server for actual abuse before requesting removal: mail queue empty, outbound volume ~11 msgs over the prior week, only known SASL logins (`jens@`/`otto2022@jayfield.org` from the usual home IP), no backscatter, correct PTR (`alpha.jayfield.org`), SPF/DKIM/DMARC (`p=reject`) all already live — no sign of a compromised account or open relay, so most likely a stale/inherited reputation hit on the IP rather than active abuse from this host. Submitted a removal request via `https://www.barracudacentral.org/rbl/removal-request` on 2026-07-25. **Confirmation number: `BBR21785009436-19117-9182`.** Per Barracuda's response, the IP gets a temporary 48h reputation bump to "normal" while they investigate (up to ~1h to propagate), after which it either stays clear or reverts to listed. **Status 2026-07-26: still clear** (`dig +short 149.8.58.89.b.barracudacentral.org A` → NXDOMAIN, no `127.0.0.2`) — matches the 1h check (routine `trig_01F1RMhEtNHtAbuHb9ffe1oX`, fired 2026-07-25 21:02 UTC, also clear). This is expected during the 48h "normal" bump window, not yet proof of permanent delisting. The real test is the 48h check (routine `trig_019GayXSo5TW5eSxiJ99Ghrp`, scheduled 2026-07-27 20:03 UTC) — if it reverts to listed, next step is a manual follow-up to Barracuda citing the confirmation number above. - [x] **dyndns app has no real per-user auth binding** — found 2026-07-20, fixed 2026-07-26. `index.php`'s `pass == '_NO_PASS_'` check is a hardcoded magic string, not a real per-user credential — nothing tied the single shared Apache Basic Auth identity (`jens`, the only `.htpasswd` account) to *which* hostname label got updated. Confirmed live in the access log: a FritzBox ("Fritz!Box DDNS/1.0.3") was authenticating as `jens` while updating the `uschi` record — i.e. every device was already relying on this exact gap, self-policing which host it claimed via an unauthenticated `hostname=`/`user=` param. Fixed with real per-host TSIG binding, not just an app-level check: - Added a dedicated `.htpasswd` account for each of `kack`, `test`, `uschi`, `vpn` (`jens`'s existing account/password untouched). - Generated 5 per-host TSIG keys (`hmac-sha512`, `tsig-keygen`), one per hostname, each in its own `/etc/bind/.dyndns.jayfield.org.key` (`root:bind 640` + `setfacl` grant for `www-data`, same pattern as the original key). - Replaced the zone's flat `allow-update { key dyndns.jayfield.org; };` in `named.conf.default-zones` with an `update-policy` granting each new key permission to update *only* its own name (`grant "" name . A AAAA TXT;`), plus one explicit legacy fallback grant for the original shared key (`subdomain` rule, zone-wide) — kept temporarily so unmigrated devices don't break (see below). - `nsupdate.php`/`nsupdate_ipv4.php`/`nsupdate_ipv6.php` now sign with the key belonging to `$_SERVER['PHP_AUTH_USER']` (the *authenticated* identity), not whatever hostname was requested. If they match, the new per-host key is used — BIND itself then refuses the update if that key isn't granted that name, independent of any PHP-side check. If the authenticated user is specifically the legacy `jens` account and doesn't match, it falls back to the old shared key (logged via `syslog(LOG_WARNING, ...)` for migration tracking) — every other account gets rejected outright (`-1`) on a mismatch, so newly-created per-host logins can never touch another host, even via the fallback. - Also fixed a pre-existing bug found in `nic/update.php` while testing this: it printed `good ` unconditionally, without ever checking `nsupdate`'s return code — so a rejected update silently looked successful to the calling device. Now prints the real dyndns2-protocol response (`good ` / `badauth`). All files backed up to `/root/removed-configs-backup/dyndns-per-host-auth-20260726094500/`. Validated with `named-checkconf`/`apache2ctl configtest`, applied via `rndc reload`/`systemctl reload apache2`. Tested both by driving `nsupdate()` directly as `www-data` and end-to-end over HTTPS with the new `vpn` credentials: own-host updates succeed, cross-host attempts from new accounts are rejected (`-1`/`badauth`) with the target record provably unchanged, and the legacy `jens`→`uschi` pattern still succeeds unchanged. Test IPs used during verification were restored to the real production values (`test`, `uschi`, `vpn`) immediately after; live `dig` confirmed the final state matches the pre-change records exactly. **Follow-up**: the `jens` legacy fallback (and its `.htpasswd` account) should stay only until every real device — including whichever ones are still authenticating as `jens` to update `uschi` — has been reconfigured with its own dedicated login. Once `dyndns legacy shared-key update` stops appearing in `syslog`, remove the fallback `grant` and the `jens` account can be scoped down to just its own host too. **`vpn` migrated 2026-07-26**: home FritzBox reconfigured with the new `vpn` login and confirmed live — after a WAN IP change (real connection recycle, not a test), the access log shows a genuine `Fritz!Box DDNS/1.0.3` request authenticated as `vpn` updating `vpn.dyndns.jayfield.org` to the new IP (`217.229.60.187`), and `dig` confirms the zone picked it up. First host fully off the legacy fallback. `uschi`'s device was reconfigured too but hasn't sent a real authenticated update yet (FritzBox only re-registers on WAN IP change or manual renew, neither has happened since) — not yet confirmed live. **`kack`/`test` removed 2026-07-26** instead of migrated: `kack` hadn't updated since Feb 2024 and `test` showed no real traffic in the (short) per-vhost access log history either — both looked like stale/unused entries rather than active devices, so removed outright rather than given dedicated logins. Deleted their `A`/`AAAA`/`TXT` records via `nsupdate` (using the legacy shared key), dropped their `update-policy` grants and `include` lines from `named.conf.default-zones`, moved their per-host TSIG key files and `.htpasswd` entries (`htpasswd -D`) out. Backed up to `/root/removed-configs-backup/dyndns-remove-kack-test-20260726101439/`. Validated with `named-checkconf`/`apache2ctl configtest`, applied via `rndc reload`/`systemctl reload apache2`, confirmed both names now resolve to nothing (`dig` empty) and `jens`/`uschi`/`vpn` untouched; `rndc sync` forced the on-disk zone file to reflect it immediately rather than waiting for BIND's next periodic dump. **`jens.dyndns.jayfield.org` hostname removed 2026-07-26** — same story as `kack`: stale since Feb 2024, no device actually updating it (the only `jens`-authenticated traffic ever seen was the legacy fallback updating *other* hosts, never its own). Deleted the record, dropped its dedicated `update-policy` grant and key `include` line, moved `jens.dyndns.jayfield.org.key` to `/root/removed-configs-backup/dyndns-remove-jenshost-20260726101844/`. Important distinction from the `kack`/`test` removal: this only removed the *hostname*. The `jens` **login** and the legacy shared-key fallback grant (`grant "dyndns.jayfield.org" subdomain dyndns.jayfield.org. ANY;`) were deliberately left in place, since `uschi`'s device still authenticates as `jens` through that fallback — removing the login would have broken it immediately. Re-verified after this change (direct `nsupdate()` call as `www-data`, writing `uschi`'s own current real IP back so nothing actually changed) that the `jens`→`uschi` legacy path still returns `0`. `named-checkconf`/ `rndc reload` clean throughout. **Legacy fallback removed entirely 2026-07-26** — confirmed the `jens` fallback wasn't needed for `uschi` anymore (its own dedicated login/key from the original fix is what should be used going forward), and with `jens`'s and `kack`/`test`'s own hostnames already gone, nothing was left depending on it. Removed the `grant "dyndns.jayfield.org" subdomain dyndns.jayfield.org. ANY;` fallback grant and its key `include` from `named.conf.default-zones`; retired the original shared TSIG key itself (`/etc/bind/dyndns.jayfield.org.key`, in production since March 2022) to `/root/removed-configs-backup/dyndns-remove-legacy-fallback-20260726102228/`; removed the now-dead legacy-path branch from `nsupdate.php`/`nsupdate_ipv4.php`/`nsupdate_ipv6.php` — `nsupdate()` now unconditionally rejects (`-1`) any request where the authenticated user doesn't match the requested host, no exceptions. Verified: `jens` attempting to update `uschi` now fails immediately (`ret=-1`, no BIND round-trip at all — rejected in PHP before ever building an `nsupdate` command), while `uschi` and `vpn` updating themselves still succeed (`ret=0`, confirmed via `dig` against their real IPs). Per-user auth binding for dyndns is now unconditional, closing this item out completely — no more grandfathered exceptions of any kind. The `jens` `.htpasswd` account itself was left in place (not asked to remove it, and it's now inert for DNS updates anyway since it has no hostname or grant left to use). **`uschi` migration confirmed live 2026-07-26**: after the legacy fallback was removed, a real WAN IP change (connection recycle, not a test) produced a genuine `Fritz!Box DDNS/1.0.3` request authenticated as `uschi` updating `uschi.dyndns.jayfield.org` to `92.252.63.190`, confirmed via `dig`. (One wrinkle along the way: an earlier verification test of mine — writing `uschi`'s old IP back to confirm the fix — landed moments after that device's *first* post-migration update and clobbered it back to the stale value; the device's next natural IP change overwrote it correctly, so no lasting effect, but a reminder to use non-colliding test values when a real device might update concurrently.) Both `uschi` and `vpn` are now fully migrated to dedicated per-host logins with no legacy exceptions remaining anywhere in the setup. **`vpn` re-confirmed 2026-07-26**: a second WAN IP change produced another clean authenticated update (`87.180.9.214`), this time with no test collision — TXT timestamp matches exactly. Both `uschi` and `vpn` have now each been confirmed live across a real IP change with their own dedicated login; this item is fully done, nothing further to track. **`jens` password rotated 2026-07-26**: since the account is now unused (no hostname or key left to act on) and its old password predates this whole migration, rotated it for hygiene via `htpasswd -b`. Old `.htpasswd` backed up to `/root/removed-configs-backup/dyndns-jens-rotate-20260726124040/`. Verified: new password authenticates fine (`200`), old password now gets `401`, and — as expected since the fix — even a successful `jens` login still can't perform any DNS update (`badauth`). Account kept around (not asked to remove it), but fully inert either way. - [x] **dyndns/default-vhost access logging is a no-op** — done 2026-07-25 found 2026-07-20 while investigating the command injection below. `dyndns.jayfield.org`, `jayfield.org`, `www.jayfield.org`, `mail.jayfield.org`, `web.jayfield.org`, and `cloud.jayfield.org` had no `CustomLog` directive of their own, and `access.log` (only written by `default-ssl.conf`/`000-default.conf`) had 0-8 lines — looking like no real vhost was being logged at all. Root cause (2026-07-25 re-check): the fallback assumption was wrong. There's no global `CustomLog access.log` in `apache2.conf`; instead `conf-enabled/other-vhosts-access-log.conf` enables `mod_vhost_combined`, which was already writing every vhost-without-its-own-`CustomLog`'s traffic to `/var/log/apache2/other_vhosts_access.log` (5,432 lines, confirmed real traffic incl. bot scans against `cloud.jayfield.org`). So requests were never actually un-logged — just commingled in one shared, harder-to-grep file instead of `access.log`. Fixed anyway, since a dedicated per-site log remains the better end state for incident review: added an explicit `CustomLog ${APACHE_LOG_DIR}/_access.log combined` to every `` block for `dyndns`, `jayfield.org`, `www`, `mail`, `web`, `cloud`, and `portainer` (the `:80` and `:443`/`le-ssl` blocks of each site share one log file). Configs backed up to `/root/removed-configs-backup/vhost-customlog-20260725203959/`. `/etc/logrotate.d/apache2` already globs `*.log`, so no logrotate change needed. Validated with `apache2ctl configtest`, applied via `systemctl reload apache2`, verified live with `curl` against all 7 domains and confirmed each new `_access.log` recorded the hit. - [x] **OS command injection in the dyndns update scripts** — done 2026-07-20 `/var/www/dyndns/nsupdate.php`, `nsupdate_ipv4.php`, and `nsupdate_ipv6.php` (used by both `index.php` and `nic/update.php`, the router/ddclient-facing endpoint) built a shell command string by directly interpolating unsanitized `$_GET` input (`hostname`/`myip`/ `ip`, which become `$host`/`$ip`/`$ipv4`/`$ipv6`) and passed it to PHP's `exec()`, which runs it via `/bin/sh -c` — no `escapeshellarg()`/`escapeshellcmd()` anywhere. A request like `hostname=$(id>/tmp/pwned).dyndns.jayfield.org` would have executed arbitrary shell commands as `www-data` (the domain-suffix check still passed after the `.`-split). `nic/update.php` had no application-level auth of its own at all. Mitigating factor: the whole `dyndns.jayfield.org` vhost sits behind Apache Basic Auth, so this needed valid (or leaked/brute-forced) dyndns credentials to reach — not exploitable by an anonymous visitor. Compounding factors found during the audit: PHP is `7.4.33`, EOL since Nov 2022, `disable_functions` was empty (`exec` fully available); `index.php`'s `pass == '_NO_PASS_'` check is a hardcoded magic string, not real per-user auth, so any valid dyndns credential can update *any* of the zone's 5 hostnames (`jens`, `kack`, `test`, `uschi`, `vpn`), not just its own — left as-is, out of scope for this fix; and the vhost's access logging turned out to be a no-op (shared `access.log` has 0 lines), so past exploitation couldn't be fully ruled out from logs alone — though file mtimes, `www-data` processes/crontab, and zone contents all looked unremarkable, consistent with unexploited. Fixed: all three files backed up to `/root/removed-configs-backup/dyndns-preinjectionfix-/`, then rewritten to build the nsupdate command list as a plain string written to `proc_open()`'s stdin pipe using the **array form** of the command (`array("/usr/bin/nsupdate", "-k", ...)`) — this never invokes a shell at all, so no value can be interpreted as shell syntax regardless of content. Added strict `preg_match` validation requiring `$host`/`$domain` to be shaped like a valid hostname (letters/digits/hyphens, dot-separated labels) and `filter_var(..., FILTER_VALIDATE_IP, FILTER_FLAG_IPV4/IPV6)` on the IP before use, as defense in depth on top of removing the shell. **Regression found and fixed along the way**: functional testing showed legitimate updates were *already silently failing* (`update failed: REFUSED`, `permission denied` reading the TSIG key) — turned out `www-data` was never granted read access to `/etc/bind/dyndns.jayfield.org.key` when its permissions were tightened to `640 root:bind` in the item below, so the dyndns web update feature had been broken for real users since 2026-07-19, independent of this vulnerability. Fixed with a narrowly-scoped POSIX ACL (`setfacl -m u:www-data:r /etc/bind/dyndns.jayfield.org.key`, `acl` package installed) rather than adding `www-data` to the `bind` group — the group also owns `rndc.key` (full remote BIND control), which would have been a much bigger privilege grant than dyndns needs. Verified via `sudo -u www-data php` driving the real functions directly (same code path as the web UI, without needing the Basic Auth password): two injection payloads (`$(...)` and `;`-chained) both rejected (`-1`, no `/tmp` side-effect file created), an invalid IP rejected (`-1`), and a legitimate update succeeded (`0`), confirmed live in the zone via `dig`, then cleaned up (test record deleted via `nsupdate`, temp test script removed). Also confirmed unauthenticated requests still get `401` from Apache before ever reaching the PHP, and `apache2ctl configtest` stayed clean throughout. - [x] **Public static site (`jayfield.org`/`www.jayfield.org`) was 401'ing every visitor** — done 2026-07-20 Found while auditing which services are reachable without credentials: a stray `/var/www/html/.htaccess` (`AuthType Basic`, same `AuthName "Restricted Content"`/`AuthUserFile /etc/apache2/.htpasswd` as the intentional `dyndns.jayfield.org` protection) was silently requiring login for the DocumentRoot both `jayfield.org` and `www.jayfield.org` share — `/var/www` has `AllowOverride all`, so it took effect with no vhost-level directive needed. File was dated 2025-02-18 (`index.html`/`apache.html` themselves are from 2022) — predates every other change in this doc by well over a year, almost certainly a forgotten leftover rather than intentional, and contradicts `README.md`'s own description of both as a public static site. Fixed: moved to `/root/removed-configs-backup/htaccess-var-www-html.` (recoverable, not deleted — same convention as this file's other removed-config backups). No Apache reload needed (`.htaccess` is read per-request). Verified both `jayfield.org` and `www.jayfield.org` now return `200` with real page content; `dyndns.jayfield.org`'s own (intentional, vhost-level) Basic Auth is untouched and still returns `401` without credentials. - [x] **Lock down the dyndns TSIG key file permissions** — done 2026-07-19 `/etc/bind/dyndns.jayfield.org.key` was `644` (world-readable), holding the shared secret that authenticates dynamic DNS updates for `dyndns.jayfield.org` with no IP-based `allow-update` backing it up. Fixed: `chmod 640` + `chown root:bind` (owner was already correct), matching `rndc.key`'s permissions. - [x] **Suppress BIND version disclosure** — done 2026-07-19 No `version` statement in `named.conf.options`, so `dig CH TXT version.bind @ns1.jayfield.org` leaked the exact BIND version (`9.18.39`) — a minor fingerprinting aid for attackers. Fixed: added `version "unknown";` inside the `options {}` block, validated with `named-checkconf`, and applied via `rndc reload`. Verified `dig CH TXT version.bind` now returns `"unknown"`. - [x] **Add response rate limiting (RRL)** — done 2026-07-19 No `rate-limit {}` block was configured. Since `allow-query { any; }` is required for a public authoritative zone, RRL is the standard mitigation against the server being used for spoofed-source DNS reflection/amplification against third parties. Fixed: added `rate-limit { responses-per-second 10; window 5; }` to `named.conf.options`, validated with `named-checkconf`, and applied via `rndc reload`. Started with conservative defaults (10/s, 5s window) — revisit if legitimate high-volume resolvers get throttled. - [x] **Add HSTS headers to all vhosts** — done 2026-07-19 No vhost sent a `Strict-Transport-Security` header, leaving a window for on-path SSL-stripping on a user's very first plaintext request to any domain. Fixed: added `Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"` to all 6 domain `:443` blocks (`dyndns`, `jayfield.org`, `www`, `mail`, `web`, `cloud`) — skipped `default-ssl.conf` since it's the self-signed catch-all with no real trusted hostname for a browser to pin. Validated with `apache2ctl configtest`, applied via `systemctl reload apache2`, verified live with `curl -sI` against all 6 domains. - [x] **`jayfield.org` had no `:80` vhost of its own** — done 2026-07-19 Bare `http://jayfield.org` fell through to the `dyndns` default vhost and redirected to the wrong domain. Fixed: added a `:80` block to `jayfield.org.conf` redirecting to `https://jayfield.org/`, matching the `dyndns`/`mail` pattern. Verified: `curl -sI http://jayfield.org/` → `302` with `Location: https://jayfield.org/`. - [x] **SSH: disable root login + password auth** — done 2026-07-19 `PermitRootLogin yes` and `PasswordAuthentication yes` were both still enabled (the "known deviation" flagged in `SETUP.md` §1) — every credential-stuffing bot on the internet got a real login prompt to grind against. Fixed: `PermitRootLogin no`, `PasswordAuthentication no`, `PubkeyAuthentication yes` in `/etc/ssh/sshd_config` (backed up as `sshd_config.bak.20260719225249`). The `Match Group sftp` block's own `PasswordAuthentication yes` was left as-is — separate, already-chrooted, no-shell/no-forwarding accounts, not part of this risk. Validated with `sshd -t`, applied via `systemctl reload sshd`, verified live with `sshd -T` and a fresh key-only connection before considering it done. - [x] **Portainer's `:9443` UI is unreachable in HSTS-enforcing browsers** — done 2026-07-20 `https://alpha.jayfield.org:9443` threw `MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT` in Firefox with no click-through option ("keine Ausnahme kann hinzugefügt werden"). Root cause: the `includeSubDomains` HSTS policy sent by the real `alpha.jayfield.org` vhost (`Strict-Transport-Security` header, added in the item above) pins the *hostname* to HTTPS-with-a-trusted-cert on every port, but Portainer (`README.md` §Docker services) was still serving its stock self-signed cert directly on `9443`. Not a MITM — self-inflicted by combining HSTS with an unproxied self-signed service. Fixed: added a `portainer` A record to `db.jayfield.org` (SOA serial `2022032216` → `2022032217`); recreated the `portainer` container binding the HTTPS UI to `127.0.0.1:9443:9443` instead of publishing it (old container kept renamed until the new one was verified, then removed, matching the update procedure documented in `README.md`); added an Apache vhost for `portainer.jayfield.org` (`ProxyPass`/`ProxyPassReverse` to `https://127.0.0.1:9443/`, `SSLProxyEngine on`, `SSLProxyVerify none` + `SSLProxyCheckPeerCN off`/ `SSLProxyCheckPeerName off` for the loopback hop to Portainer's self-signed cert) — same pattern as `web.jayfield.org`/ `cloud.jayfield.org` (`SETUP.md` §4/§5); issued a real cert via `certbot --apache -d portainer.jayfield.org --redirect`; added the standard HSTS header. Left the `8000` edge-agent tunnel port published as-is — no evidence it's in use. Verified: `openssl s_client`/`curl` confirm the Let's Encrypt cert (not self-signed) and `Strict-Transport-Security` header on `https://portainer.jayfield.org`, `http://` redirects 301 to `https://`, and `ss -ltnp` shows `9443` bound to `127.0.0.1` only (via `docker-proxy`), so it's no longer reachable on the public interface at all — access is now exclusively via `https://portainer.jayfield.org`. One transient wrinkle during rollout: the very first few requests through the new proxy vhost returned a stray `401` with dyndns's Basic Auth realm ("Restricted Content") — looked alarming, but `LogLevel trace8` on the vhost showed Apache correctly SNI-matching and proxying to the new container with a clean backend `200`; resolved itself within seconds (looked like a stale pooled proxy connection surviving the container recreate) and was stable across repeated checks before the debug logging was removed again. - [x] **fail2ban: `sshd` jail was evadable via low-and-slow attempts** — done 2026-07-19 Default `findtime=30m`/`maxretry=3` never triggered for `47.76.192.176`, which had been hitting `root`/`jayfield` logins roughly every 33 minutes for most of a day — just outside the 30-minute window, so failures kept aging out before reaching 3 strikes. Confirmed via `fail2ban.log` (`Found`/`Ban` counts) that no other jail showed the same per-IP timing pattern; a separate, *distributed* single-shot scan on `apache-noscript` was noted but left open (see `README.md`). Fixed: `/etc/fail2ban/jail.d/sshd-findtime.local` overrides `sshd` to `findtime=1d`, `maxretry=4`, `bantime=1d`, kept in its own drop-in rather than editing the Debian-packaged jail file. Applied via `fail2ban-client reload sshd`; `47.76.192.176` was banned on its very next attempt after the change. - [x] **fail2ban: `dovecot` jail had the same low-and-slow gap as `sshd`** — done 2026-07-25 found while auditing which IPs were hitting webmail hard enough to deserve a ban but weren't in any currently-banned list. Same default `findtime=30m`/`maxretry=3` as `sshd`'s pre-fix config. At least 4 IPs (`47.95.196.60` 167 hits, `82.157.132.42` 144, `120.48.118.132` 76, `188.164.195.116` 57, `157.245.38.26` 34) ran a coordinated IMAP password spray against generic mailbox names (`admin@`, `test@`, `support@`, `sales@jayfield.org`) starting almost simultaneously around 2026-07-21 22:40-23:38, each pacing itself at ~1 attempt/22-25min — just outside the 30-minute window, so none ever tripped 3 strikes. `120.48.118.132` was still active as late as 2026-07-25 13:32, so the campaign was ongoing, not historical. `postfix-sasl` shares the same default thresholds and mail stack but showed much lower per-IP volume in this pass — worth a follow-up check if it starts trending the same way. **Follow-up 2026-07-26**: checked `postfix-sasl` — still just scattered background noise, not a coordinated campaign. Top offender (`172.94.9.203`) logged 32 hits but all from late June/early July, none since; current activity is single/double attempts from many different IPs (no repeated per-IP low-and-slow pattern like the dovecot spray). `findtime=30m`/`maxretry=3` defaults left as-is — no fix needed unless this changes. Fixed: `/etc/fail2ban/jail.d/dovecot-findtime.local`, same values as the `sshd` fix (`findtime=1d`, `maxretry=4`, `bantime=1d`). Applied via `fail2ban-client reload dovecot`; verified live with `fail2ban-client get dovecot findtime/maxretry/bantime` and confirmed the jail's existing ban state (`47.88.56.201`, 29 total bans) survived the reload.