add_ip_to_runtime_map() and remove_ip_from_runtime_map() sent
add map #0 <ip> 1
del map #0 <ip>
to /tmp/haproxy-cli and returned True whenever socat exited 0. Neither command has ever worked, on any deployment, for the entire life of the feature — while logging Added IP x to runtime map every single time.
IP blocking as a whole was never broken: update_blocked_ips_map() rewrites /etc/haproxy/blocked_ips.map and the callers reload HAProxy, which re-reads it. That path is untouched and remains authoritative. What was broken is the no-reload fast path, plus every report that it had worked.
Defect 1 — no @1 worker prefix
/tmp/haproxy-cli is HAProxy's master CLI socket; map commands are worker commands. Captured verbatim on whp01 (HAProxy 3.0.11):
$ echo "add map #0 192.0.2.77 1" | socat stdio /tmp/haproxy-cli
Unknown command: 'add', but maybe one of the following ones is a better match:
@!<pid> : send a command to the <pid> process
@master : send a command to the master process
hard-reload : achieve a hard-reload (-st) of haproxy
reload : achieve a soft-reload (-sf) of haproxy
user : lower the level of the current CLI session to user
help [<command>] : list matching or all commands
prompt [timed] : toggle interactive mode with prompt
quit : disconnect
$ echo $?
0
socat exits 0 on the rejection, so result.returncode == 0 was true and the function returned True. Same silence #7 fixed on the show table path.
Defect 2 — #0 is not a valid map id
Ids are assigned at config-parse time and move on every config regeneration:
There is no id 0, and hardcoding any number is wrong. The map is referenced by file path — which is stable, because it is what haproxy.cfg itself names in map_ip(/etc/haproxy/blocked_ips.map,0).
Defect 3 — and this is why a body check alone is not enough
On the add path the reply is byte-for-byte identical to success (a successful mutation answers nothing). Only reading the entry back can tell them apart.
haproxy_manager.py — both functions send @1 add|del map /etc/haproxy/blocked_ips.map <ip> [1] and read the entry back with get map before returning True. runtime_map_lookup() / runtime_map_keys() are the read-back primitives. The value 1 is asserted too: haproxy.cfg matches with -m int gt 0, so a valueless entry does not block.
haproxy_cli() grows expect_empty=True for mutating commands. HAProxy answers those with nothing on success, so an empty body is the success and any non-empty body is a rejection. Deliberately stricter than _HAPROXY_CLI_ERROR_MARKERS — a marker list can only recognise rejections someone has already seen, and this catches 'add map' expects three parameters ..., which matches no marker. HaproxyCliError now carries .responses, so del map answering Key not found. (the requested end state) is told apart from a real failure without regex-matching a formatted message. Follows #7's response-body pattern; show table is untouched.
sync_blocked_ips loses clear map #0 — rejected by the master socket just as loudly and just as invisibly, which made the whole endpoint a no-op that reported a full sync. It now verifies the whole set with one show map read-back instead of counting commands that did not visibly complain, and answers 207 + runtime_map_synced: false when the runtime map does not match the database.
The four callers capture the boolean instead of discarding it and report runtime_map_updated / runtime_map_failures in the API response and the operation log. A runtime failure degrades to "enforced on the reload that already happens two lines later" — never to an unblocked IP, and never to a 500.
scripts/test-runtime-map-contract.py (offline, 26 tests, exit-code driven, same shape as #7's contract test) asserts the bytes on the wire, classifies every captured response, and scans the repo's Python string literals and shell/template code lines for #<id> map references — comments may describe the old form, code may not use it.
The #0 form is also corrected in IP_BLOCKING_API.md, MIGRATION_GUIDE.md and the comment in templates/hap_listener.tpl — where every copy of it additionally omitted the 1.
Verification (live, whp01, docker cp + SIGHUP — no recreate)
Before, running main @ e331671, in the live container:
INFO - Added IP 192.0.2.77 to runtime map
add_ip_to_runtime_map('192.0.2.77') returned: True
remove_ip_from_runtime_map('192.0.2.77') returned: True
$ @1 get map /etc/haproxy/blocked_ips.map 192.0.2.77
type=ip, case=sensitive, found=no <- nothing happened
entry_cnt=263 <- unchanged
After, same container, same socket:
add_ip_to_runtime_map returned: True runtime_map_lookup: (True, '1')
old command form -> HaproxyCliError: HAProxy rejected 'add map #0 ...' (socat exited 0 ...)
#0 map reference -> False ("... is in #0 with value None, not '1' ...")
remove_ip_from_runtime_map returned: True runtime_map_lookup: (False, None)
End to end through the API:
POST /api/blocked-ips {"runtime_map_updated":true,"status":"success"}
runtime: found=yes value="1"; map file: 1 line
POST /api/blocked-ips/sync HTTP 200 {"synced_ips":264,"total_ips":264,
"missing_ips":[],"runtime_map_synced":true}
DELETE /api/blocked-ips {"runtime_map_updated":true,"status":"success"}
runtime: found=no; map file: 0 lines
Enforcement, with the reload path excluded — the discriminator that proves the fast path itself now works. No map-file write, no reload, same haproxy worker pid throughout; the only change is the runtime map:
haproxy pids before: 29 # 26694 map file lines for the test IP: 0
1. before: HTTP 200
add_ip_to_runtime_map -> True
2. runtime-map only: HTTP 403 map file still has 0 lines; pids: 29 # 26694
remove_ip_from_runtime_map -> True
3. after: HTTP 200 pids: 29 # 26694
The test IP is 100.123.171.78, whp01's own netbird overlay address — not a customer IP, not in the is_local ranges, carries no site traffic. Documentation-range 192.0.2.0/24 was used everywhere else.
Cleanup / no residue:blocked_ips rows for every test IP = 0, no test IPs in the map file, entry_cnt back to 263.
HAProxy still serving (identical to the baseline taken before the change, and a bad config here is a silent total outage that /health would not show):
The one template change is a comment. haproxy -c on the live rendered config with it applied is clean on HAProxy 3.0.11 (validate-rendered-config.py cannot run in the dev container — its HAProxy is 2.8 and rejects stats-file, which is pre-existing and unrelated).
The new test was checked against each defect reintroduced separately: dropping @1 → 3 failures, restoring #0 → 4, removing the read-back → 2, trusting the reply body → 1.
Live container state
whp01's haproxy-manager is currently running this branch's haproxy_manager.py via docker cp + docker kill --signal=HUP (gunicorn worker reload; haproxy itself was not restarted). Original file preserved in the container at /haproxy/haproxy_manager.py.pre-runtimemap-bak (md5 e548f26…, identical to main). No recreate was done — deferred so the edge blips once after both PRs land. To revert without a recreate:
scripts/manage-blocked-ips.sh already gets both defects right for map commands (@1, map by path — its comments are where the correct form was documented all along). Its stats / blacklist / auto-blacklist / threat-score subcommands, however, read and write gpc(0)/gpc(13)/gpc(14) — the same fabricated counters #7 removed elsewhere, which no stick table in this repo stores. Out of scope for this PR; worth its own.
The reload call sites still check result.returncode rather than the response body. reload is a genuine master-socket command, so it is not affected by either defect fixed here, but the exit-status check is just as uninformative. Also deliberately left alone.
## The bug
`add_ip_to_runtime_map()` and `remove_ip_from_runtime_map()` sent
```
add map #0 <ip> 1
del map #0 <ip>
```
to `/tmp/haproxy-cli` and returned `True` whenever `socat` exited 0. **Neither command has ever worked**, on any deployment, for the entire life of the feature — while logging `Added IP x to runtime map` every single time.
IP blocking as a whole was never broken: `update_blocked_ips_map()` rewrites `/etc/haproxy/blocked_ips.map` and the callers reload HAProxy, which re-reads it. That path is untouched and remains authoritative. What was broken is the *no-reload fast path*, plus every report that it had worked.
### Defect 1 — no `@1` worker prefix
`/tmp/haproxy-cli` is HAProxy's **master** CLI socket; map commands are worker commands. Captured verbatim on whp01 (HAProxy 3.0.11):
```
$ echo "add map #0 192.0.2.77 1" | socat stdio /tmp/haproxy-cli
Unknown command: 'add', but maybe one of the following ones is a better match:
@!<pid> : send a command to the <pid> process
@master : send a command to the master process
hard-reload : achieve a hard-reload (-st) of haproxy
reload : achieve a soft-reload (-sf) of haproxy
user : lower the level of the current CLI session to user
help [<command>] : list matching or all commands
prompt [timed] : toggle interactive mode with prompt
quit : disconnect
$ echo $?
0
```
**socat exits 0 on the rejection**, so `result.returncode == 0` was true and the function returned `True`. Same silence #7 fixed on the `show table` path.
### Defect 2 — `#0` is not a valid map id
Ids are assigned at config-parse time and move on every config regeneration:
```
$ echo "@1 show map" | socat stdio /tmp/haproxy-cli
# id (file) description
10 (/etc/haproxy/trusted_ips.map) ... entry_cnt=1
37 (/etc/haproxy/blocked_ips.map) ... entry_cnt=263
```
There is no id 0, and hardcoding *any* number is wrong. The map is referenced by **file path** — which is stable, because it is what `haproxy.cfg` itself names in `map_ip(/etc/haproxy/blocked_ips.map,0)`.
### Defect 3 — and this is why a body check alone is not enough
`add map` and `del map` do **not** fail the same way:
```
$ echo "@1 del map #0 192.0.2.77" | socat stdio /tmp/haproxy-cli
Unknown map identifier. Please use #<id> or <file>.
$ echo "@1 add map #0 192.0.2.88 1" | socat stdio /tmp/haproxy-cli
<- empty. exit 0. adds nothing, anywhere.
$ echo "@1 show map" | socat stdio /tmp/haproxy-cli | grep blocked
37 (/etc/haproxy/blocked_ips.map) ... entry_cnt=263 <- unchanged
```
On the add path the reply is byte-for-byte identical to success (a successful mutation answers nothing). Only reading the entry back can tell them apart.
### The corrected form, same socket, same moment
```
$ echo "@1 add map /etc/haproxy/blocked_ips.map 192.0.2.77 1" | socat stdio /tmp/haproxy-cli
<- empty = success
$ echo "@1 get map /etc/haproxy/blocked_ips.map 192.0.2.77" | socat stdio /tmp/haproxy-cli
type=ip, case=sensitive, found=yes, idx=tree, key="192.0.2.77", value="1", type="str"
$ echo "@1 del map /etc/haproxy/blocked_ips.map 192.0.2.77" | socat stdio /tmp/haproxy-cli
$ echo "@1 get map /etc/haproxy/blocked_ips.map 192.0.2.77" | socat stdio /tmp/haproxy-cli
type=ip, case=sensitive, found=no
```
## The fix
* **`haproxy_manager.py`** — both functions send `@1 add|del map /etc/haproxy/blocked_ips.map <ip> [1]` and **read the entry back** with `get map` before returning `True`. `runtime_map_lookup()` / `runtime_map_keys()` are the read-back primitives. The value `1` is asserted too: `haproxy.cfg` matches with `-m int gt 0`, so a valueless entry does not block.
* **`haproxy_cli()`** grows `expect_empty=True` for **mutating** commands. HAProxy answers those with nothing on success, so an empty body is the success and *any* non-empty body is a rejection. Deliberately stricter than `_HAPROXY_CLI_ERROR_MARKERS` — a marker list can only recognise rejections someone has already seen, and this catches `'add map' expects three parameters ...`, which matches no marker. `HaproxyCliError` now carries `.responses`, so `del map` answering `Key not found.` (the requested end state) is told apart from a real failure without regex-matching a formatted message. Follows #7's response-body pattern; `show table` is untouched.
* **`sync_blocked_ips`** loses `clear map #0` — rejected by the master socket just as loudly and just as invisibly, which made the whole endpoint a no-op that reported a full sync. It now verifies the whole set with one `show map` read-back instead of counting commands that did not visibly complain, and answers 207 + `runtime_map_synced: false` when the runtime map does not match the database.
* **The four callers** capture the boolean instead of discarding it and report `runtime_map_updated` / `runtime_map_failures` in the API response and the operation log. A runtime failure degrades to *"enforced on the reload that already happens two lines later"* — never to an unblocked IP, and never to a 500.
* **`scripts/test-runtime-map-contract.py`** (offline, 26 tests, exit-code driven, same shape as #7's contract test) asserts the bytes on the wire, classifies every captured response, and scans the repo's Python string literals and shell/template *code* lines for `#<id>` map references — comments may describe the old form, code may not use it.
* The `#0` form is also corrected in `IP_BLOCKING_API.md`, `MIGRATION_GUIDE.md` and the comment in `templates/hap_listener.tpl` — where every copy of it additionally omitted the `1`.
## Verification (live, whp01, `docker cp` + SIGHUP — no recreate)
**Before**, running `main` @ e331671, in the live container:
```
INFO - Added IP 192.0.2.77 to runtime map
add_ip_to_runtime_map('192.0.2.77') returned: True
remove_ip_from_runtime_map('192.0.2.77') returned: True
$ @1 get map /etc/haproxy/blocked_ips.map 192.0.2.77
type=ip, case=sensitive, found=no <- nothing happened
entry_cnt=263 <- unchanged
```
**After**, same container, same socket:
```
add_ip_to_runtime_map returned: True runtime_map_lookup: (True, '1')
old command form -> HaproxyCliError: HAProxy rejected 'add map #0 ...' (socat exited 0 ...)
#0 map reference -> False ("... is in #0 with value None, not '1' ...")
remove_ip_from_runtime_map returned: True runtime_map_lookup: (False, None)
```
**End to end through the API:**
```
POST /api/blocked-ips {"runtime_map_updated":true,"status":"success"}
runtime: found=yes value="1"; map file: 1 line
POST /api/blocked-ips/sync HTTP 200 {"synced_ips":264,"total_ips":264,
"missing_ips":[],"runtime_map_synced":true}
DELETE /api/blocked-ips {"runtime_map_updated":true,"status":"success"}
runtime: found=no; map file: 0 lines
```
**Enforcement, with the reload path excluded** — the discriminator that proves the *fast path itself* now works. No map-file write, no reload, same haproxy worker pid throughout; the only change is the runtime map:
```
haproxy pids before: 29 # 26694 map file lines for the test IP: 0
1. before: HTTP 200
add_ip_to_runtime_map -> True
2. runtime-map only: HTTP 403 map file still has 0 lines; pids: 29 # 26694
remove_ip_from_runtime_map -> True
3. after: HTTP 200 pids: 29 # 26694
```
The test IP is `100.123.171.78`, whp01's own netbird overlay address — not a customer IP, not in the `is_local` ranges, carries no site traffic. Documentation-range `192.0.2.0/24` was used everywhere else.
**Cleanup / no residue:** `blocked_ips` rows for every test IP = 0, no test IPs in the map file, `entry_cnt` back to 263.
**HAProxy still serving** (identical to the baseline taken before the change, and a bad config here is a silent total outage that `/health` would not show):
```
anhonesthost.com 200 brain-jar.com 301 compassionplanet.org 200
dianeletarte.com 200 alphaoneaminos.com 200 axisxchange.com 200
container: Up (healthy) haproxy -c: warnings unchanged, no errors
```
The one template change is a comment. `haproxy -c` on the live rendered config with it applied is clean on HAProxy 3.0.11 (`validate-rendered-config.py` cannot run in the dev container — its HAProxy is 2.8 and rejects `stats-file`, which is pre-existing and unrelated).
**Offline suites, all green:** `test-runtime-map-contract.py` 26 · `test-stick-table-contract.py` 21 · `test-config-rollback.py` 26 · `test-cert-write-safety.py` 22 · `test-cert-scripts.py` 38 · `test-wpadmin-gate.py` 30 · `test-trusted-proxy-gate.py` 4 · `test-xmlrpc-rate-limit.py` 6.
The new test was checked against each defect reintroduced separately: dropping `@1` → 3 failures, restoring `#0` → 4, removing the read-back → 2, trusting the reply body → 1.
## Live container state
whp01's `haproxy-manager` is currently running this branch's `haproxy_manager.py` via `docker cp` + `docker kill --signal=HUP` (gunicorn worker reload; haproxy itself was not restarted). Original file preserved in the container at `/haproxy/haproxy_manager.py.pre-runtimemap-bak` (md5 `e548f26…`, identical to `main`). **No recreate was done** — deferred so the edge blips once after both PRs land. To revert without a recreate:
```
docker exec haproxy-manager cp /haproxy/haproxy_manager.py.pre-runtimemap-bak /haproxy/haproxy_manager.py
docker kill --signal=HUP haproxy-manager
```
`VERSION` → 2026.08.10.
## Noted, not fixed here
`scripts/manage-blocked-ips.sh` already gets both defects right for map commands (`@1`, map by path — its comments are where the correct form was documented all along). Its `stats` / `blacklist` / `auto-blacklist` / `threat-score` subcommands, however, read and write `gpc(0)`/`gpc(13)`/`gpc(14)` — the same fabricated counters #7 removed elsewhere, which no stick table in this repo stores. Out of scope for this PR; worth its own.
The `reload` call sites still check `result.returncode` rather than the response body. `reload` is a genuine master-socket command, so it is not affected by either defect fixed here, but the exit-status check is just as uninformative. Also deliberately left alone.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
add_ip_to_runtime_map() and remove_ip_from_runtime_map() sent
`add map #0 <ip> 1` / `del map #0 <ip>` to /tmp/haproxy-cli and returned True
whenever socat exited 0. Neither command has ever worked, on any deployment,
for the entire life of the feature -- while logging "Added IP x to runtime map"
every single time. Two independent defects:
* NO `@1` PREFIX. /tmp/haproxy-cli is HAProxy's MASTER CLI socket; map
commands are worker commands. Captured verbatim on whp01:
$ echo "add map #0 192.0.2.77 1" | socat stdio /tmp/haproxy-cli
Unknown command: 'add', but maybe one of the following ones is a better match:
@!<pid> : send a command to the <pid> process
...
$ echo $?
0
socat exits 0 on the rejection, so `result.returncode == 0` was true. Same
silence PR #7 fixed on the `show table` path.
* `#0` IS NOT A VALID MAP ID. Ids are assigned at config-parse time and move
on every config regeneration -- `@1 show map` on whp01 reports
blocked_ips.map as 37 and trusted_ips.map as 10. There is no id 0.
Hardcoding any number is wrong; the map is referenced by FILE PATH, which
is what haproxy.cfg itself names in map_ip(/etc/haproxy/blocked_ips.map,0).
And a third silence, which is why a response-body check alone is not enough
here: `@1 add map #0 <ip> 1` returns an EMPTY body, exit 0, and adds nothing to
any map -- while `@1 del map #0 <ip>` and `@1 show map #0` both answer
`Unknown map identifier.`. On the add path the reply is byte-for-byte identical
to success. Only reading the entry back can tell them apart.
IP blocking itself was never broken: update_blocked_ips_map() rewrites
/etc/haproxy/blocked_ips.map and the callers reload HAProxy, which re-reads it.
That path is untouched and stays authoritative. What was broken is the
no-reload fast path, plus every report that it had worked.
* haproxy_manager.py: both functions send `@1 add|del map
/etc/haproxy/blocked_ips.map <ip> [1]` and READ THE ENTRY BACK with
`get map` before returning True. runtime_map_lookup()/runtime_map_keys()
are the read-back primitives. `sync_blocked_ips` loses `clear map #0`
(which the master socket rejected just as loudly and just as invisibly) and
verifies the whole set with one `show map` instead of counting commands
that did not visibly complain; it answers 207 + `runtime_map_synced: false`
when the runtime map does not match the database.
* haproxy_cli() grows `expect_empty=True` for MUTATING commands: HAProxy
answers those with nothing on success, so an empty body is the success and
ANY non-empty body is a rejection. That is stricter than the marker list on
purpose -- markers only recognise rejections someone has already seen, and
it catches `'add map' expects three parameters ...`, which matches nothing.
HaproxyCliError carries `.responses` so `del map` answering `Key not found.`
(the requested end state) is told apart from a real failure without regex.
* The four callers capture the boolean instead of discarding it and report
`runtime_map_updated` / `runtime_map_failures` in the API response and the
operation log. A runtime failure degrades to "enforced on the reload that
already happens two lines later" -- never to an unblocked IP, never to a
500.
* scripts/test-runtime-map-contract.py (offline, 26 tests) asserts the bytes
on the wire (`@1` first, map by path, value `1`), classifies every captured
response, and scans the repo's Python string literals and shell/template
code lines for `#<id>` map references -- comments may describe the old
form, code may not use it. Verified to fail on each defect reintroduced
separately: no `@1` (3 failures), `#0` (4), no read-back (2), trust-the-
reply (1).
* The `#0` form is also corrected in IP_BLOCKING_API.md, MIGRATION_GUIDE.md
and the comment in templates/hap_listener.tpl -- where every copy of it
additionally omitted the `1`, which `-m int gt 0` needs to match.
The only template change is a comment; `haproxy -c` on the live rendered config
with it applied is clean (HAProxy 3.0.11, warnings unchanged).
Verified on whp01 against the running container (docker cp + SIGHUP, no
recreate). Before: both functions returned True and logged success while
`@1 get map` answered `found=no` and entry_cnt stayed at 263. After: the fixed
add lands with value "1" and the remove takes it out again; the old command
form is now classified as a failure; a `#0` map reference returns False via the
read-back. End to end through the API, `runtime_map_updated: true`, and
/api/blocked-ips/sync -- which used to be a no-op reporting a full sync --
reports 264/264 verified present.
The runtime path was isolated from the reload that normally follows it: with
NO map-file write and NO reload (same haproxy worker pid throughout), adding
100.123.171.78 (whp01's own netbird overlay address -- not a customer IP, not
in the is_local ranges) to the runtime map alone flipped a live site from
HTTP 200 to 403, and removing it flipped it back to 200. That is the fast path
working for the first time. All test IPs were removed afterwards: 0 rows in
blocked_ips, 0 lines in the map file, entry_cnt back to 263. Six customer
sites, the panel /health and `haproxy -c` are byte-identical to the baseline
taken before the change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp
merged commit a00431854d into main2026-08-22 18:20:07 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The bug
add_ip_to_runtime_map()andremove_ip_from_runtime_map()sentto
/tmp/haproxy-cliand returnedTruewheneversocatexited 0. Neither command has ever worked, on any deployment, for the entire life of the feature — while loggingAdded IP x to runtime mapevery single time.IP blocking as a whole was never broken:
update_blocked_ips_map()rewrites/etc/haproxy/blocked_ips.mapand the callers reload HAProxy, which re-reads it. That path is untouched and remains authoritative. What was broken is the no-reload fast path, plus every report that it had worked.Defect 1 — no
@1worker prefix/tmp/haproxy-cliis HAProxy's master CLI socket; map commands are worker commands. Captured verbatim on whp01 (HAProxy 3.0.11):socat exits 0 on the rejection, so
result.returncode == 0was true and the function returnedTrue. Same silence #7 fixed on theshow tablepath.Defect 2 —
#0is not a valid map idIds are assigned at config-parse time and move on every config regeneration:
There is no id 0, and hardcoding any number is wrong. The map is referenced by file path — which is stable, because it is what
haproxy.cfgitself names inmap_ip(/etc/haproxy/blocked_ips.map,0).Defect 3 — and this is why a body check alone is not enough
add mapanddel mapdo not fail the same way:On the add path the reply is byte-for-byte identical to success (a successful mutation answers nothing). Only reading the entry back can tell them apart.
The corrected form, same socket, same moment
The fix
haproxy_manager.py— both functions send@1 add|del map /etc/haproxy/blocked_ips.map <ip> [1]and read the entry back withget mapbefore returningTrue.runtime_map_lookup()/runtime_map_keys()are the read-back primitives. The value1is asserted too:haproxy.cfgmatches with-m int gt 0, so a valueless entry does not block.haproxy_cli()growsexpect_empty=Truefor mutating commands. HAProxy answers those with nothing on success, so an empty body is the success and any non-empty body is a rejection. Deliberately stricter than_HAPROXY_CLI_ERROR_MARKERS— a marker list can only recognise rejections someone has already seen, and this catches'add map' expects three parameters ..., which matches no marker.HaproxyCliErrornow carries.responses, sodel mapansweringKey not found.(the requested end state) is told apart from a real failure without regex-matching a formatted message. Follows #7's response-body pattern;show tableis untouched.sync_blocked_ipslosesclear map #0— rejected by the master socket just as loudly and just as invisibly, which made the whole endpoint a no-op that reported a full sync. It now verifies the whole set with oneshow mapread-back instead of counting commands that did not visibly complain, and answers 207 +runtime_map_synced: falsewhen the runtime map does not match the database.runtime_map_updated/runtime_map_failuresin the API response and the operation log. A runtime failure degrades to "enforced on the reload that already happens two lines later" — never to an unblocked IP, and never to a 500.scripts/test-runtime-map-contract.py(offline, 26 tests, exit-code driven, same shape as #7's contract test) asserts the bytes on the wire, classifies every captured response, and scans the repo's Python string literals and shell/template code lines for#<id>map references — comments may describe the old form, code may not use it.#0form is also corrected inIP_BLOCKING_API.md,MIGRATION_GUIDE.mdand the comment intemplates/hap_listener.tpl— where every copy of it additionally omitted the1.Verification (live, whp01,
docker cp+ SIGHUP — no recreate)Before, running
main@e331671, in the live container:After, same container, same socket:
End to end through the API:
Enforcement, with the reload path excluded — the discriminator that proves the fast path itself now works. No map-file write, no reload, same haproxy worker pid throughout; the only change is the runtime map:
The test IP is
100.123.171.78, whp01's own netbird overlay address — not a customer IP, not in theis_localranges, carries no site traffic. Documentation-range192.0.2.0/24was used everywhere else.Cleanup / no residue:
blocked_ipsrows for every test IP = 0, no test IPs in the map file,entry_cntback to 263.HAProxy still serving (identical to the baseline taken before the change, and a bad config here is a silent total outage that
/healthwould not show):The one template change is a comment.
haproxy -con the live rendered config with it applied is clean on HAProxy 3.0.11 (validate-rendered-config.pycannot run in the dev container — its HAProxy is 2.8 and rejectsstats-file, which is pre-existing and unrelated).Offline suites, all green:
test-runtime-map-contract.py26 ·test-stick-table-contract.py21 ·test-config-rollback.py26 ·test-cert-write-safety.py22 ·test-cert-scripts.py38 ·test-wpadmin-gate.py30 ·test-trusted-proxy-gate.py4 ·test-xmlrpc-rate-limit.py6.The new test was checked against each defect reintroduced separately: dropping
@1→ 3 failures, restoring#0→ 4, removing the read-back → 2, trusting the reply body → 1.Live container state
whp01's
haproxy-manageris currently running this branch'shaproxy_manager.pyviadocker cp+docker kill --signal=HUP(gunicorn worker reload; haproxy itself was not restarted). Original file preserved in the container at/haproxy/haproxy_manager.py.pre-runtimemap-bak(md5e548f26…, identical tomain). No recreate was done — deferred so the edge blips once after both PRs land. To revert without a recreate:VERSION→ 2026.08.10.Noted, not fixed here
scripts/manage-blocked-ips.shalready gets both defects right for map commands (@1, map by path — its comments are where the correct form was documented all along). Itsstats/blacklist/auto-blacklist/threat-scoresubcommands, however, read and writegpc(0)/gpc(13)/gpc(14)— the same fabricated counters #7 removed elsewhere, which no stick table in this repo stores. Out of scope for this PR; worth its own.The
reloadcall sites still checkresult.returncoderather than the response body.reloadis a genuine master-socket command, so it is not affected by either defect fixed here, but the exit-status check is just as uninformative. Also deliberately left alone.🤖 Generated with Claude Code