pbr-1.2.3: stop deleting live nft sets on reload - #155
Conversation
Review of mossdef-org#155 asked for a more defensive kill: check the binary exists, or use 'killall -q' so a missing dnsmasq is not an error. That turned out to be already solved -- resolver.kill() has done exactly this since before the branch, with the quieter 'killall -q -s HUP dnsmasq', and had no callers at all. flush_cache() duplicated dead code. Rename resolver.kill to resolver.flush_cache, keeping its command form and taking the explanatory comment from the version being dropped. One function instead of two, and the defensive flags come for free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — one of these found something real and is fixed in 050520e. Taking them in turn. 3. Good catch, and it led somewhere better than the suggestion. 050520e renames 1. The producer is Switching to 2. Logging failed The concern is fair: a delete that fails leaves an orphan behind silently. Two things make it a poor fit for this PR. There is no generic logger to hang it on. It is also not a regression: the 4. Logging when Already covered. |
4025b1f to
5c86227
Compare
A reload tore down the nft sets while dnsmasq was still pointing at them.
start_service's default branch called
nft.cleanup('main_table', 'rt_tables', 'main_chains', 'sets')
which deletes every live pbr_* set, but everything rebuilt afterwards is only
appended to nft_lines and does not reach the kernel until the 'fw4 -q reload' at
the end of nft_file.apply('main'). dnsmasq keeps answering across that gap with
its nftset= directives loaded, so every reply landing in it logged
dnsmasq: nftset inet fw4 pbr_wan_4_dst_ip_cfg09fff4 Error: No such file or directory
and those addresses were never added to the set, leaving the host unrouted until
a fresh lookup. Measured at ~0.85s on a 4-interface router and reported at ~2s on
a 10-interface one.
Drop 'main_chains' and 'sets' from that call, as stop() has always done: fw4
rebuilds the table from 30-pbr.nft, so a set still declared by the ruleset is
simply left in place and is never missing. apply('main') runs 'fw4 -q reload' on
its failure branch too, so a ruleset that fails to validate or install now leaves
the previous generation intact instead of a torn-down router.
fw4 flushes the table's rules but keeps set objects, so it does not reap sets
whose policy is gone -- previously cleanup('sets') was the only thing that did.
Add a cleanup('orphan_sets') action that diffs the live sets against those the
new ruleset declares (plus any owned by the netifd file) and deletes only the
difference. It runs after apply('main'), never before: by then the new ruleset is
live, so an orphan has no rule referencing it and deletes cleanly instead of
failing EBUSY, while sets still in use are never touched.
Preserving the sets of merely disabled policies was considered, so that
re-enabling one resumes with its addresses already resolved, and rejected: uci
recycles auto-generated section names, so a deleted policy's uid can be handed to
another one, which would generate the identical set name and inherit the previous
policy's addresses.
Because sets now survive a reload, a re-created one comes back empty while
dnsmasq may still answer from cache without ever writing to it, leaving the
policy dead until the record's TTL expires. Add resolver.flush_cache(), a
SIGHUP that drops the cache without restarting the daemon or losing a query, on
the branch where the config was unchanged; the restart branch already clears it.
Behavior changes worth noting:
- Set contents now persist across reloads, so a domain whose address changes
keeps the stale one until nft_set_timeout / nft_set_gc_interval ages it out.
Unset, entries do not expire.
- The same persistence spans stop/start, since stop() never reaped sets and
start no longer wipes them.
- Every reload that does not already restart dnsmasq now flushes its cache, so
the first lookup per domain afterwards goes upstream.
Verified on a DL-WRX36. Sampling 'nft list sets inet fw4 | grep -c pbr_' for 30s
across a reload went from 84 of 2925 samples with no pbr sets present to 0 of
2882. A set's element and its counter survive a reload unchanged. Disabling a
policy removes its set and rule, deleting it leaves the count at baseline, and
every live pbr_* set is declared by the current 30-pbr.nft. Five reloads under a
continuous nslookup loop with cachesize 0 produced no nftset errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of mossdef-org#155 asked for a more defensive kill: check the binary exists, or use 'killall -q' so a missing dnsmasq is not an error. That turned out to be already solved -- resolver.kill() has done exactly this since before the branch, with the quieter 'killall -q -s HUP dnsmasq', and had no callers at all. flush_cache() duplicated dead code. Rename resolver.kill to resolver.flush_cache, keeping its command form and taking the explanatory comment from the version being dropped. One function instead of two, and the defensive flags come for free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guards added in mossdef-org#156 assert that a start flushes exactly the four pbr_-prefixed chains. This branch removes that flush -- fw4 rebuilds the whole table from 30-pbr.nft, and tearing the chains down first left them empty for the length of the rebuild -- so both tests encoded behaviour that is now deliberately gone. They passed on each branch alone and failed on the merge. 03_nft_rules/05 is renamed from 05_main_chains_flush_prefix to 05_main_chains_no_live_teardown and reframed around the invariant that survives: a start issues no 'nft flush chain' at all, and the ruleset swap is what installs the rebuilt chains. The bare-chain assertion from bd042da (mossdef-org#143) is kept -- if live teardown is ever reinstated it must still never name one of fw4's own chains -- as are the no-table-flush and no-chain-delete checks. 01_validation/04 swaps its 'four chains flushed' check for the reload that replaced it, keeping the same shape: one command that leaves no other trace (fw4 -q reload), one absence (no chain flush), and the ip rule deletes. Tests only, no production change. 51/51. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regression guard for the fix in this branch. start_service() used to call
cleanup(..., 'main_chains', 'sets'), and the 'sets' case deleted every live
pbr_* set. Everything rebuilt afterwards only reaches the kernel with the
'fw4 -q reload' at the end of nft_file.apply('main'), so the sets named by
dnsmasq's nftset= directives were missing for the whole rebuild -- ~0.85s
measured on a four-interface router -- and dnsmasq logged 'Error: No such
file or directory' for every reply that landed in the gap.
Asserts the ordering directly over a real start_service(): no 'nft delete
set' is issued before the reload, no set the new ruleset declares is deleted
at any point, and the one orphan in the mocked live table -- left by a policy
that no longer exists -- is reaped, alone.
The live sets come from a test-local 'nft list table' mock, since the shared
one carries no pbr_ sets and get_nft_sets() would return empty, leaving the
whole path unexercised. One of them is a set the default config re-declares,
asserted explicitly so 'declared_set_kept' cannot pass vacuously.
The deletes go through nft_call() -> sh.run() -> system() and leave no trace
in /var/run/pbr.nft, so this needs the recorder from mossdef-org#156.
Verified against the pre-fix behaviour -- restoring 'main_chains' and 'sets'
to the cleanup call and dropping the orphan reap fails 3 of the 6 checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks. Went through all six — none need a change to this PR. Details below. 1. CHANGELOG / UPGRADE note There is no CHANGELOG file in the repo; user-facing notes go in the 2. QA test reproducing the original symptom Already covered, both automatically and manually.
The manual procedure is in the PR body's Verification section: 3. Documenting Legitimate, but not this repo. Neither option is documented here — 4. SIGHUP is well defined and stable in dnsmasq: it clears the cache and re-reads Two things reduce the risk further. The command form is not new — 5. Noting the disabled-policy decision Already recorded in three places: the commit message of 8f2eedf, the PR description above, and a comment in the 6. Smoke test for uid recycling Covered by the mechanism |
|
Co-pilot comment: |
Lockstep release bump with pbr, which ships the nft set reload window fix (mossdef-org/pbr#155) and the mocklib system() recorder with its regression guards (mossdef-org/pbr#156). No luci-app-pbr changes in this cycle and compat is unchanged at 36, so neither package warns about a version mismatch; the bump keeps the two release numbers aligned. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Lockstep release bump with luci-app-pbr. Covers the nft set reload window fix (#155) and the mocklib system() recorder with its three regression guards (#156, tests only, not shipped). Compat is unchanged at 36 -- no message catalog change in this cycle -- so neither package warns about a version mismatch, but they are released together as usual. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#158) nft set names are keyed on the policy's uid, not on its contents, so editing a policy's dest_addr reuses the same set. Since #155 stopped deleting live sets on reload, that set survives the rebuild holding every address the *old* domain list resolved to, and the policy goes on routing a domain that is no longer configured anywhere -- not in /etc/config/pbr, not in /var/run/pbr.dnsmasq, not in the ruleset. Only the addresses in the set the rule matches on. Nothing logs it. cleanup('orphan_sets') does not catch this: the set is still declared by the new ruleset, so it is kept. That is why disabling a policy did clear its set -- a disabled policy declares nothing, so the set is reaped as an orphan -- while editing one did not. Nothing in the package ever emptied a live set; nftset.flush() existed for exactly this and had no callers. Without nft_set_timeout / nft_set_gc_interval, which are unset by default, those entries are permanent until reboot. resolver.store_hash() now also snapshots which domains fed which set, parsed out of the dnsmasq file before configure() truncates it. It already read that file for the md5, so the previous generation was available and simply not kept. After nft_file.apply('main') the new resolver.flush_changed() re-parses the regenerated file and empties only the sets whose domain list actually differs. Deliberately narrow, on both sides: - Flush, never delete. The set object never goes away, so dnsmasq's nftset= target stays resolvable and no reply lands in a gap. That gap is what #155 closed and it is not being reopened. Flushing a set referenced by live rules is always permitted; only deleting one is refused with EBUSY. - A live nft command after apply('main'), not a line in 30-pbr.nft. fw4 re-executes that file on every reload, including reloads pbr did not trigger, so a 'flush set' baked into it would empty the sets continuously. - Only sets present in both generations with a differing domain list. Flushing everything on every reload would undo #155 the other way: a domain a client still holds cached is never re-queried, so it would silently drop out of policy routing. A set with no previous generation to compare against is left alone for the same reason. - Before resolver.restart(), so addresses re-resolved after the restart are not wiped by a flush that follows. The comparison is a sorted, de-duplicated 'domain,domain' string per set name, so a rewrite that merges or orders the file differently without changing which domains feed a set still compares equal. 'service pbr reload' arrives as 'on_reload' and resolves to the same default branch as 'on_start', so it does run this and is proven inert when nothing changed. 'on_interface_reload' takes its own branch, which reloads routing only and never calls configure() or nft_file.apply(), so it cannot reach the flush at all. nftset.flush() now delegates to a new nftset.flush_name(), which flushes by set name, rather than repeating the command. tests/03_nft_rules/09_flush_changed_domain_sets seeds a previous generation and covers the three cases across three policies: a changed domain list flushes both families, an identical one is left alone, and a set with no previous generation is untouched. It also asserts the set is flushed rather than deleted. Verified as a real guard -- with the flush_changed() call removed it fails on the two changed-set assertions rather than passing vacuously. tests/03_nft_rules/10_reload_keeps_unchanged_sets pins the two paths that must never empty anything. Its interface-reload half needed a fixture: get_mark_nft_chains() runs 'nft list table inet fw4 2>/dev/null' and the only fixture in the tree was for '2>&1', so is_service_running_nft() was always false and the reload was silently downgraded to 'on_start' -- testing the wrong branch while still passing. The test now also asserts nft_file.apply('main') never ran on that path, so it cannot go vacuous again. Verified on the router by egc. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A downstream user reported this in a reload log:
landing between
Installing fw4 nft file [✓]andForwarding enabled [✓]. It is not a dnsmasq or config fault — pbr deletes the sets out from under a running dnsmasq.The window
start_service's default branch called:'sets'deletes every livepbr_*set. Everything rebuilt afterwards is only appended tonft_lines, and does not reach the kernel until thefw4 -q reloadat the end ofnft_file.apply('main'). dnsmasq keeps answering across that whole gap with itsnftset=directives loaded, so any reply landing in it errors — and those addresses are never added to the set, so the host is not policy-routed until a fresh, non-cached lookup.Measured at ~0.85s on a 4-interface router; the reporter's 10-interface box showed ~2s.
The fix
Drop
'main_chains'and'sets', leavingcleanup('main_table', 'rt_tables')— which is whatstop()has always done.fw4rebuilds the table from30-pbr.nft, so a set the ruleset still declares is simply left alone and is never missing.A side benefit:
apply('main')runsfw4 -q reloadon its failure branch too, so a ruleset that fails to validate or install now leaves the previous generation intact rather than a torn-down router.Reaping orphans
fw4flushes the table's rules but keeps set objects, so it does not remove sets whose policy is gone —cleanup('sets')was the only thing that did. Confirmed live: disabling a policy left its set in the kernel with the rule gone and the set absent from30-pbr.nft.New
cleanup('orphan_sets')diffs the live sets against those the new ruleset declares (plus any owned by the netifd file) and deletes only the difference. It runs afterapply('main'), never before — by then the new ruleset is live, so an orphan has no rule referencing it and deletes cleanly instead of failingEBUSY, while sets still in use are never touched at all.Preserving the sets of merely disabled policies was considered, so re-enabling one would resume instantly with its addresses already resolved. Rejected: uci recycles auto-generated section names, so a deleted policy's uid can be handed to another policy, which would generate the identical set name and silently inherit the previous policy's addresses.
Cache flush
Because sets now survive a reload, a re-created one comes back empty while dnsmasq may still answer from cache without ever writing to it — leaving the policy dead until the record's TTL expires.
resolver.flush_cache()sends SIGHUP, dropping the cache without restarting the daemon or losing a query. It runs only on the branch where the config was unchanged; the restart branch already clears the cache.Behavior changes
nft_set_timeout/nft_set_gc_intervalages it out. Unset, entries do not expire. Previously every reload wiped them.stop/start, sincestop()never reaped sets andstartno longer wipes them.Verification
On a DL-WRX36, sampling
nft list sets inet fw4 | grep -c pbr_for 30s across a reload:pbr_*set is declared by the current30-pbr.nft.nslookuploop withcachesize 0produced zeronftseterrors.restart,stop/start, and anifdown/ifupof a policy's target interface are all clean.Tests
tests/03_nft_rules/08_set_reload_windowguards this directly, over a realstart_service(): nonft delete setis issued before thefw4 -q reload, no set the new ruleset declares is deleted at any point, and the single orphan is reaped alone. Verified the way #156's guards were — restoring'main_chains'and'sets'to the cleanup call and dropping the orphan reap fails 3 of its 6 checks.It carries its own
nft list tablemock, because the shared one has nopbr_sets andget_nft_sets()would otherwise return empty, leaving the path unexercised. One of the mocked live sets is one the default config re-declares, asserted explicitly sodeclared_set_keptcannot pass vacuously. This needs thesystem()recorder from #156.Two guards from #156 also had to be updated, since they asserted the chain flush this PR removes:
05_main_chains_flush_prefixis renamed to05_main_chains_no_live_teardownand reframed around "a start issues nonft flush chainat all", keeping its bare-chain assertion in case live teardown is ever reinstated, and04_mocklib_system_recorderswaps its flush check for the reload that replaced it. Both branches were green alone and failed on the merge — a semantic conflict, not a textual one.Suite is 52/52.