firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync
Post-DHCP-incident hardening per HARDEN.md.
- apply guard: refuse (ConflictError, `force` overrides) when a
network-managed interface would end up in no zone; absent
`interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
+ advisory in /api/status/pending; zones.js banner + interfaces-picker
last-zone confirm
- target drift (Option A): absent or default-normalizing target is
unmanaged: not diffed, never re-set by apply; create_zone runs
--new-zone first and sets non-default targets only; importer omits
the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
({timestamp, default_zone, zones, config}); drop the empty post-apply
skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
(get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
867 passing
This commit is contained in:
+38
-4
@@ -681,14 +681,48 @@ def main() -> None:
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
def _teardown_exception_handler(_loop, context) -> None:
|
||||
# Swallow teardown noise ("Task was destroyed but it is pending",
|
||||
# in-flight task exceptions on SIGTERM) instead of the default
|
||||
# logging-error tracebacks.
|
||||
logger.debug("Suppressed teardown exception: %s", context)
|
||||
|
||||
async def _shutdown() -> None:
|
||||
"""Graceful shutdown: cancel poller, close runner, teardown."""
|
||||
"""Graceful shutdown: stop accepting, drain in-flight work, teardown.
|
||||
|
||||
Bounded grace periods + a suppressed exception handler during the
|
||||
teardown window avoid the "Task was destroyed but it is pending" and
|
||||
logging-error tracebacks that otherwise appear on SIGTERM.
|
||||
"""
|
||||
logger.info("Shutting down daemon...")
|
||||
_stop_polling()
|
||||
# Suppress the default exception handler during teardown so that
|
||||
# cancelling in-flight tasks does not spew tracebacks on SIGTERM.
|
||||
prev_handler = loop.exception_handler
|
||||
loop.set_exception_handler(_teardown_exception_handler)
|
||||
try:
|
||||
await asyncio.wait_for(runner.cleanup(), timeout=5)
|
||||
except TimeoutError:
|
||||
logger.warning("Runner cleanup timed out, abandoning")
|
||||
# Stop accepting new connections (also waits for open sockets,
|
||||
# bounded so a stuck WebSocket can't hang shutdown).
|
||||
try:
|
||||
await asyncio.wait_for(runner.cleanup(), timeout=5)
|
||||
except TimeoutError:
|
||||
logger.warning("Runner cleanup timed out, abandoning")
|
||||
# Give in-flight request/WS tasks a bounded grace period to
|
||||
# finish; cancel anything still pending so they are not
|
||||
# "destroyed but pending" when the loop closes.
|
||||
pending = [
|
||||
t
|
||||
for t in asyncio.all_tasks()
|
||||
if t is not asyncio.current_task() and not t.done()
|
||||
]
|
||||
if pending:
|
||||
_, still_pending = await asyncio.wait(pending, timeout=3)
|
||||
for t in still_pending:
|
||||
t.cancel()
|
||||
if still_pending:
|
||||
await asyncio.wait(still_pending, timeout=1)
|
||||
finally:
|
||||
loop.set_exception_handler(prev_handler)
|
||||
if Path(socket_path).exists():
|
||||
os.unlink(socket_path)
|
||||
logger.info("vacuum-walld stopped")
|
||||
|
||||
Reference in New Issue
Block a user