fix(config): write blocked_ips.map atomically; close review gaps
Follow-up to 9d16151 (take the rollback backup BEFORE writing the new config).
Review findings, none of them blockers, plus two corrections to the record.
blocked_ips.map was still written with a plain open(path,'w'). `haproxy -c`
LOADS that file - hap_listener.tpl matches on
map_ip(/etc/haproxy/blocked_ips.map,0) - and a half-written final line is a
FATAL config error, not one dropped entry. Verified against HAProxy 2.8: a
truncated "198.51.10" gives "is not a valid IPv4 or IPv6 address at line 2 of
file ..." and the whole configuration is rejected. That is precisely the
failure shape the backup-ordering fix exists to prevent, on a different file,
reachable from all five /api/blocked-ips routes. It now goes through the same
write_config_atomically() as haproxy.cfg and coraza-spoe.cfg. (A truncation
that happens to land on a line boundary is not fatal - it silently drops
blocks - which is its own reason to write the file atomically.)
The previous commit message claimed the fast path adds "no `haproxy -c`
latency to customer-facing API calls". That was measured on an idle box and is
false in this fleet's normal pattern: update_blocked_ips_map() is called from
those five routes OUTSIDE generate_config(), so the live map drifts from its
backup and the NEXT config change misses create_backup()'s fast path. Measured
`haproxy -c` runs per domain add: 1 steady state, 2 after an IP block. This
fleet blocks IPs automatically, so the 2x recurred on the customer-facing call
indefinitely. update_blocked_ips_map() now promotes the map it just wrote to
its backup, restoring the steady state - guarded twice: nothing is promoted
unless a config backup set already exists (never fabricate a rollback target),
and not unless the map parses as IPs/CIDRs, so promotion cannot leave a
"rollback target" HAProxy would refuse to load. generate_config() passes
promote_backup=False: it took the snapshot moments earlier and the map is part
of the not-yet-validated change, so refreshing the backup there would be the
original bug again. The remaining 2 is the first generation after this upgrade
(coraza-spoe.cfg has no backup yet); that is once per host, by construction.
Two claims in 9d16151's message are wrong and are corrected here rather than by
rewriting a pushed commit:
* "12 of the 17 fail against the previous code" - it is 14 of 17 (6 failures +
8 errors). 12 was measured before two fast-path tests were added and never
re-measured.
* "a missing haproxy binary is not read as a bad config" - true of
create_backup() only. validate_haproxy_config() collapses both 'invalid' and
'unavailable' to False, so in the reload path a missing validator still
triggers a full rollback labelled "Config validation failed". The behaviour
is right (without a working validator we cannot claim the new config is
safe, and the reload path is where guessing wrong takes the edge down); the
sentence was broader than the code. Now documented on the function.
Tests: 26 (was 17), all green; 22 fail against main. New coverage closes the
review's mutation survivors:
* the "Refusing to regenerate config" guard, previously entirely uncovered;
* the invalid/unavailable split, previously zero coverage - both the verdict
and the consequence create_backup() draws from it;
* the byte-compare loop, with a same-size-different-content config, which is
the exact case the "not filecmp.cmp" rationale exists for. Building that
fixture found a bug in the test itself: sizing the drifted config with
len(str) instead of bytes made it pass for the wrong reason, because the
rendered config contains non-ASCII.
* test_failed_write_leaves_the_previous_file_intact was vacuous: its bare
assertRaises(Exception) swallowed the AttributeError from
write_config_atomically not existing, so it passed against main and would
have kept passing if the function were deleted. Narrowed to TypeError -
proven by deleting the function and watching it go red.
* test_backup_set_covers_every_file_generate_config_writes restated the three
files it expected, so it could never have noticed a fourth. It now derives
the set - observed on disk for the branches the fixture can execute, read
out of generate_config()'s source for the env-gated one that writes a
hardcoded /etc/haproxy path - and requires anything unbacked-up to be on a
documented exclusion list (suspended_domains.list, cluster-secret, each with
its reason). Proven by adding a fourth written file and watching it fail.
Every change above was mutation-proved: 11 mutations, 0 survivors, each
reddening only the tests that cover it. Two review items were confirmed
untestable in-process and are deliberately skipped: the fsync (M11) and a
log-line-only mutation (M15).
Left alone deliberately, all pre-existing on main and unchanged here: the
outer `except Exception` in reload_haproxy_safely() does not roll back (narrow
window, now commented at the site); stale .tmp files after SIGKILL are never
swept (verified inert - nothing globs /etc/haproxy, and the only
directory-wide load is `crt /etc/haproxy/certs`, which nothing here writes to);
a partial backup-copy failure can leave a mixed-vintage backup set (very
narrow, and generate_config() correctly refuses to write).
VERSION stays 2026.08.1. NOTE: fix/cert-write-safety, which is stacked on this
branch, carries the same 2026.08.1. If both land on main as separate commits,
CI pushes :2026.08.1 twice with different content. Either that branch moves to
2026.08.2 or the two land as a single merge - not decided here.
No template, QUIC or HTTP/3 changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+111
-8
@@ -1853,8 +1853,12 @@ def generate_config():
|
||||
)
|
||||
config_parts.append(default_headers)
|
||||
|
||||
# Update blocked IPs map file first
|
||||
update_blocked_ips_map()
|
||||
# Update blocked IPs map file first. promote_backup=False: the rollback
|
||||
# snapshot was taken a few lines above and this map is part of the
|
||||
# not-yet-validated change, so refreshing the backup copy here would
|
||||
# overwrite the bytes rollback needs - the same class of bug as backing
|
||||
# up after the write.
|
||||
update_blocked_ips_map(promote_backup=False)
|
||||
|
||||
# Add Listener Block
|
||||
listener_block = template_env.get_template('hap_listener.tpl').render(
|
||||
@@ -2287,7 +2291,19 @@ def validate_config_file(config_path):
|
||||
|
||||
|
||||
def validate_haproxy_config():
|
||||
"""Validate the live HAProxy configuration file. Returns (is_valid, error)."""
|
||||
"""Validate the live HAProxy configuration file. Returns (is_valid, error).
|
||||
|
||||
NOTE the invalid/unavailable distinction validate_config_file() draws does
|
||||
NOT survive here: this returns a bare bool, so a validator that could not
|
||||
run is reported the same as a rejected config and the reload path rolls
|
||||
back, logging "Config validation failed". That is deliberate - without a
|
||||
working validator we cannot claim the new config is safe, and the reload
|
||||
path is the one place where guessing wrong takes the edge down. The
|
||||
distinction is only acted on inside create_backup(), where treating
|
||||
"cannot check" as "bad" would mean refusing to keep any rollback target at
|
||||
all. (The 2026-08 commit message said flatly that "a missing haproxy binary
|
||||
is not read as a bad config"; that is true of create_backup() only.)
|
||||
"""
|
||||
status, message = validate_config_file(HAPROXY_CONFIG_PATH)
|
||||
if status == 'valid':
|
||||
logger.info("HAProxy configuration validation passed")
|
||||
@@ -2399,12 +2415,89 @@ def reload_haproxy_safely(backup_status=None):
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
# KNOWN GAP (pre-existing, unchanged by the 2026-08 backup-ordering
|
||||
# fix): this outer handler does NOT roll back. The window is narrow -
|
||||
# everything after the validation gate has its own handler - but an
|
||||
# exception raised between the gate and those handlers leaves the new
|
||||
# config on disk. Left as-is deliberately; rolling back from here would
|
||||
# also undo changes that had in fact loaded.
|
||||
error_msg = f"Critical error in reload process: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
def update_blocked_ips_map():
|
||||
"""Update the blocked IPs map file from database"""
|
||||
def _blocked_ips_map_is_wellformed(path):
|
||||
"""True if every key in a blocked-IPs map is one HAProxy will parse.
|
||||
|
||||
map_ip() rejects the ENTIRE configuration if a single key is not an IP or
|
||||
CIDR ("'198.51.10' is not a valid IPv4 or IPv6 address at line 2 of file
|
||||
..."), so this is the property that decides whether the file is safe to
|
||||
record as a rollback target. Pure Python over a small file - deliberately
|
||||
not another `haproxy -c`, which is the cost this check exists to avoid.
|
||||
"""
|
||||
try:
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
ipaddress.ip_network(line.split()[0], strict=False)
|
||||
except (OSError, ValueError, IndexError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _promote_blocked_ips_map_to_backup():
|
||||
"""Keep blocked_ips.map.backup in step with the map just written.
|
||||
|
||||
The /api/blocked-ips routes call update_blocked_ips_map() OUTSIDE
|
||||
generate_config(): the map write IS the whole change there, published
|
||||
against a config HAProxy is already running. Without promoting it, the live
|
||||
map drifts from its backup, create_backup() misses its fast path on the
|
||||
NEXT config change, and an edge that blocks IPs automatically (this fleet
|
||||
does) pays an extra `haproxy -c` on the following customer-facing domain
|
||||
add - permanently, since every block re-opens the drift.
|
||||
|
||||
Guarded twice:
|
||||
* a config backup set must already exist - never fabricate a rollback
|
||||
target out of nothing (see restore_backup()); and
|
||||
* the map must be well-formed, so promoting it cannot leave a "rollback
|
||||
target" HAProxy refuses to load. That would be this module's own bug
|
||||
on a different file.
|
||||
When either fails we leave the older backup map alone and the next
|
||||
create_backup() takes the slow, `haproxy -c`-validated path - i.e. the
|
||||
behaviour before this function existed. Nothing here can lose data.
|
||||
|
||||
Note the map is promoted after the write rather than after the caller's
|
||||
reload: the routes only warn on a failed reload and carry on, so there is
|
||||
no reload result to gate on. A well-formed map that HAProxy has not loaded
|
||||
yet is still a loadable rollback target, which is the guarantee that
|
||||
matters.
|
||||
"""
|
||||
if not os.path.exists(HAPROXY_BACKUP_PATH):
|
||||
return False
|
||||
if not _blocked_ips_map_is_wellformed(BLOCKED_IPS_MAP_PATH):
|
||||
logger.warning(
|
||||
f"Not recording {BLOCKED_IPS_MAP_PATH} as known-good: it contains "
|
||||
"an entry HAProxy cannot parse, which would make the whole "
|
||||
"configuration invalid. Keeping the previous backup map."
|
||||
)
|
||||
return False
|
||||
try:
|
||||
shutil.copy2(BLOCKED_IPS_MAP_PATH, BLOCKED_IPS_MAP_BACKUP_PATH)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not update the backup blocked IPs map: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_blocked_ips_map(promote_backup=True):
|
||||
"""Update the blocked IPs map file from database.
|
||||
|
||||
promote_backup=False is for callers that are in the middle of an
|
||||
unvalidated configuration change - generate_config() has already taken the
|
||||
rollback snapshot by the time it gets here, so refreshing the backup map
|
||||
would overwrite the very bytes rollback needs.
|
||||
"""
|
||||
try:
|
||||
with sqlite3.connect(DB_FILE) as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -2415,9 +2508,19 @@ def update_blocked_ips_map():
|
||||
# For IP blocking, we use: <ip_or_cidr> 1
|
||||
# This allows map_ip() to work with both single IPs and CIDR ranges
|
||||
os.makedirs(os.path.dirname(BLOCKED_IPS_MAP_PATH), exist_ok=True)
|
||||
with open(BLOCKED_IPS_MAP_PATH, 'w') as f:
|
||||
for ip in blocked_ips:
|
||||
f.write(f"{ip} 1\n")
|
||||
# Atomically, for the same reason haproxy.cfg is: `haproxy -c` LOADS
|
||||
# this file (hap_listener.tpl matches on
|
||||
# map_ip(/etc/haproxy/blocked_ips.map,0)), and a half-written final
|
||||
# line is a FATAL config error rather than one dropped entry - verified
|
||||
# against HAProxy 2.8. A truncated map is therefore exactly the failure
|
||||
# this module's backup ordering exists to prevent, on a different file.
|
||||
write_config_atomically(
|
||||
BLOCKED_IPS_MAP_PATH,
|
||||
''.join(f"{ip} 1\n" for ip in blocked_ips)
|
||||
)
|
||||
|
||||
if promote_backup:
|
||||
_promote_blocked_ips_map_to_backup()
|
||||
|
||||
logger.info(f"Updated blocked IPs map file with {len(blocked_ips)} IPs")
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user