BlockedIp/AllowedIp changes via settings API don't take effect until restart

Issue Description

Changes to BlockedIp/AllowedIp made through the webadmin (Settings > Security) are persisted correctly but never take effect on live connections — the in-memory cache used for accept-time IP checks is never refreshed. Removing a block doesn’t unblock the IP, and adding an allow-list entry doesn’t bypass auto-ban. Only a full process restart fixes it. I’ve traced the root cause to RegistrySet::registry_set and have a fix + regression test ready (branch linked below).

Version/Platform: Stalwart v0.16.11, Docker (stalwartlabs/stalwart:v0.16.11 image)

Root cause (traced against current main, commit 00ca5573): Server::is_ip_blocked/is_ip_allowed (crates/common/src/network/security.rs) read in-memory state that’s only populated at startup or updated by the internal fail2ban logic (Server::block_ip). Server::reload_registry (crates/common/src/cache/reload.rs) correctly refreshes that state, but it’s never called when BlockedIp/AllowedIp records are changed through the settings API (RegistrySet::registry_set in crates/jmap/src/registry/set.rs) — which is what the webadmin uses. The cache-invalidation path that is called there (CacheInvalidationBuilder in crates/common/src/cache/invalidate.rs) only special-cases Account/Domain/DkimSignature/Tenant/Role/MailingList — BlockedIp/AllowedIp fall through to a no-op. So the persisted store is correct (the webadmin list updates fine), but the in-memory state the enforcement path actually reads never gets refreshed until an unrelated full config reload or a process restart.

Fix: after a successful create/update/destroy of AllowedIp/BlockedIp in registry_set, explicitly call Server::reload_registry + broadcast the change — the same pattern already used elsewhere (ACME certificate renewal, the Action::Reload* handlers). Added a regression test in tests/src/system/security.rs that drives the settings API directly and verifies blocking/unblocking/allow-listing take effect immediately.

I’ve already written the fix and a regression test — happy to have it turned into a PR if a maintainer confirms this as a bug.

Branch: GitHub - flojon/stalwart at fix/blocked-allowed-ip-live-reload · GitHub
Diff: Comparing stalwartlabs:main...flojon:fix/blocked-allowed-ip-live-reload · stalwartlabs/stalwart · GitHub

Expected Behavior

Removing a BlockedIp entry via webadmin (Settings > Security > Blocked IPs) should immediately unblock that IP. Adding an IP to AllowedIp should immediately bypass auto-ban for that IP, per the documented behavior.

Actual Behavior

Neither remediation takes effect until the process is restarted:

  1. Removing the BlockedIp entry disappears from the webadmin list, but within seconds a fresh “Blocked IP address” log line appears for the same IP and connections keep being rejected.
  2. Adding the IP to AllowedIp has no effect — bans continue regardless.

Only a full container restart clears the problem, with no other config changes.

Note: this looks similar to issue #410 (“Auto-banned IPs not taking effect until node restarted”), but I believe it’s a distinct issue. #410’s root cause (fixed in v0.16.7) was Keep-Alive connections letting an attacker reuse an already-open connection past a freshly-created ban. We’re on v0.16.11 (post-fix) and hitting this regardless of Keep-Alive — the block/allow-list change itself never reaches the in-memory enforcement state, whether it’s a brand-new ban or a removal/allow-list addition. So this doesn’t look like a Keep-Alive/connection-reuse problem, but a settings-API cache-invalidation gap (details below).

This is the same bug reported twice before on GitHub (search “allowed-ip does not unblock the address”, GitHub issue #1383, and “Automatic ban keeps blocking IP despite being on the Allowed IP list”, GitHub issue #1922, v0.13.x) and apparently never fixed.

Reproduction Steps

  1. Trigger the auto-ban (security.ip-blocked) for an IP, e.g. via excessive port-scan-like connection attempts on the HTTP listener.
  2. Confirm the IP is blocked (connections rejected at accept time).
  3. Via webadmin, either delete the BlockedIp entry for that IP, or add an AllowedIp entry for it.
  4. Observe that the block is still enforced — the change is visible in the webadmin list but has no effect on actual connection handling.
  5. Restart the Stalwart process — the fix takes effect immediately.

Relevant Log Output

INFO Blocked IP address (security.ip-blocked) listenerId = “http”, localPort = 8080, remoteIp = , remotePort = , reason = “Excessive port scanning attempts”

This log line reappears for the same IP immediately after deleting the BlockedIp record via webadmin, even with no further scanning activity, because the fail2ban rate-limiter counter is untouched by deleting the record.

Note: this is INFO-level output — I don’t have trace-level logs captured for the original incident. Happy to reproduce with trace logging enabled if that would help confirm.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This is a well-documented bug report with root cause and fix already traced, so it’s best handled by a human maintainer rather than something I can confirm or resolve from docs. I won’t speculate on whether the fix is correct or duplicate any triage.

A maintainer will need to review the traced root cause in RegistrySet::registry_set and CacheInvalidationBuilder, and assess the proposed branch/diff for merging.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

You have to reload the settings after changing the Blocked or Allowed IP list, this is not done automatically. A restart is not required.

Thanks for confirming — that clarifies the intended behavior, but I think it exposes a bigger gap than I first framed. Two issues:

  1. The reload step isn’t automatic. RegistrySet::registry_set persists Blocked/AllowedIp changes but never calls Server::reload_registry, so a write via the API has no visible effect on live connections until something else triggers a reload.
  2. The reload mechanism itself isn’t documented anywhere public. I went looking for how to trigger it and came up empty on the docs site (checked the BlockedIp reference and the HTTP API overview — neither mentions a reload step or how to perform one). The only way I found it was by reading the source: it turns out reload is itself a JMAP call — creating an Action object of type ReloadBlockedIps (or ReloadSettings for a full reload) via x:Action/set. There’s no REST /api/reload equivalent for this the way there is for certificates.

So right now, a user who edits Blocked/AllowedIp via the API or WebAdmin has no way to know (a) that the change isn’t live, or (b) what call to make to fix that, short of reading Rust source.

Suggested fixes, in order of preference:

  1. In registry_set, after a successful write to BlockedIp/AllowedIp, call reload_registry(RegistryChange::Reload(object_type)) and broadcast it to the cluster — same pattern action_set already uses for Action::ReloadBlockedIps. It’s a small, localized change (~5-8 lines in one spot), not a big refactor.
  2. If manual reload is meant to stay separate by design, document the Action/ReloadBlockedIps JMAP call somewhere public, and have the settings write response indicate a reload is pending.
  3. At minimum, add a note to the BlockedIp/AllowedIp docs pages describing the required reload step and how to perform it.

Happy to open a PR for #1 or for the docs fix (#3) — whichever you’d rather see. Let me know.

This it working as expected but needs to be explained in the documentation.

I don’t think the new web ui has any way to reload config. Would you consider adding it?