-`685dcba` — do not restore legacy ipset snapshots over active sets;
-`3a4c9b2` — release `0.11-alt3`.
## Implementation details
### Durable ban state and Redis reconnect
Earlier versions relied on a one-time Redis Pub/Sub subscription. A dropped
TCP connection or Redis restart could leave the process alive while it no
longer received ban or unban events.
The switcher now uses the Redis Stream `eterban:commands` and the durable
consumer group `eterban-switcher` instead of relying on a transient
subscription:
1. Producers write commands with `XADD`; an entry contains `command`, `ip`
and, when available, `by`.
2. On startup the switcher creates the group with `MKSTREAM` if it does not
exist and reads its own pending entries before waiting for new entries.
3. A command is acknowledged with `XACK` only after its handler completed
successfully. This prevents a restart from silently discarding an
unprocessed command.
4. Idle pending entries are reclaimed with `XAUTOCLAIM` at most once per
minute. The previous per-iteration call was a busy-loop risk.
5. Redis connection failures are caught, logged, and followed by repeated
reconnection attempts. The client uses a 5-second connect timeout, a
10-second socket timeout, TCP keepalive and Redis health checks.
6. Redis-py 8 with RESP3 can represent an empty Stream response as
`{stream: [[]]}`. The switcher normalizes both RESP2 and RESP3 replies
before deciding whether entries exist; otherwise it would continuously
read pending ID `0` and never request new messages with `>`.
Active ban addresses are additionally persisted in the Redis set
`eterban:active_bans`. At switcher start they are restored to temporary ipsets
and atomically swapped into place. This preserves bans over a service restart
and supports recovery after ipset state has been lost.
### Firewall and user-facing redirects
The two ban flows are deliberately separate:
| Flow | Match | Destination | User-facing page |
| --- | --- | --- | --- |
| External client | source address belongs to a ban ipset on a WAN interface | `ban_server:81` | public page with the self-unban flow |
| Internal client | destination address belongs to a ban ipset on `internal_interface` | `ban_server:82` | internal diagnostic page showing the original destination |
The public nginx vhost listens on port 81. The internal Python service listens
on port 82 and obtains the original destination from the socket metadata.
All external IPv4 and IPv6 DNAT rules now explicitly specify `-p tcp`.
Without this, iptables rejects a rule containing a destination port. The
corresponding removal rules use the same match fields, preventing stale rules.
The IPv4 and IPv6 `FORWARD` exceptions include port 81 so redirected public
HTTP traffic is not rejected after DNAT.
### ipset migration compatibility
Old releases can leave ipset snapshot files in `/usr/share/eterban/`. The
compatibility restore is now strictly conditional: a snapshot is imported only
when the corresponding ipset does not already exist. Production already had
live ipsets referenced by firewall rules, so attempting to recreate them made
`ipset restore` fail and caused the initial `alt2` switcher start to fail.
The `alt3` fix retains the live sets, rebuilds the current state from Redis,
and avoids removing kernel-referenced ipsets. This was verified in production
with 3327 restored ban entries.
### Service and package behaviour
- Systemd units use `Restart=always` plus baseline hardening: