Skip to content

pbr-1.2.3: stop deleting live nft sets on reload - #155

Merged
egc112 merged 4 commits into
mossdef-org:1.2.3from
egc112:pbr-1.2.3-nft-reload-set-window
Aug 17, 2026
Merged

pbr-1.2.3: stop deleting live nft sets on reload#155
egc112 merged 4 commits into
mossdef-org:1.2.3from
egc112:pbr-1.2.3-nft-reload-set-window

Conversation

@egc112

@egc112 egc112 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

A downstream user reported this in a reload log:

daemon.err dnsmasq[1]: nftset inet fw4 pbr_wan_4_dst_ip_cfg09fff4 Error: No such file or directory

landing between Installing fw4 nft file [✓] and Forwarding 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:

nft.cleanup('main_table', 'rt_tables', 'main_chains', 'sets');

'sets' deletes every live pbr_* set. 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 whole gap with its nftset= 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', leaving cleanup('main_table', 'rt_tables') — which is what stop() has always done. fw4 rebuilds the table from 30-pbr.nft, so a set the ruleset still declares is simply left alone and is never missing.

A side benefit: 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 rather than a torn-down router.

Reaping orphans

fw4 flushes 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 from 30-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 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 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

  • Set contents now persist across reloads. 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. Previously every reload wiped them.
  • 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.

Verification

On a DL-WRX36, sampling nft list sets inet fw4 | grep -c pbr_ for 30s across a reload:

samples with no pbr sets
before 84 of 2925
after 0 of 2882
  • A set's element and its counter survive a reload unchanged, proving the object is never destroyed and recreated.
  • Disabling a policy removes its set and rule; deleting it returns the set count to baseline; every live pbr_* set is declared by the current 30-pbr.nft.
  • Five reloads under a continuous nslookup loop with cachesize 0 produced zero nftset errors.
  • restart, stop/start, and an ifdown/ifup of a policy's target interface are all clean.

Tests

tests/03_nft_rules/08_set_reload_window guards this directly, over a real start_service(): no nft delete set is issued before the fw4 -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 table mock, because the shared one has no pbr_ sets and get_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 so declared_set_kept cannot pass vacuously. This needs the system() 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_prefix is renamed to 05_main_chains_no_live_teardown and reframed around "a start issues no nft flush chain at all", keeping its bare-chain assertion in case live teardown is ever reinstated, and 04_mocklib_system_recorder swaps 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.

egc112 added a commit to egc112/pbr that referenced this pull request Aug 17, 2026
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>
@egc112

egc112 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — one of these found something real and is fixed in 050520e. Taking them in turn.

3. killall -HUP dnsmasq portability — fixed, and it was worse than reported

Good catch, and it led somewhere better than the suggestion. resolver.kill() has existed in nft.uc since before this branch, does exactly what flush_cache() did, already uses the quieter killall -q -s HUP dnsmasq, and has no callers anywhere in files/ or tests/. So the new function was duplicating dead code.

050520e renames resolver.kill to resolver.flush_cache, keeping its command form and carrying over the explanatory comment. One function instead of two, and the defensive -q comes for free. Net −15 lines.

1. split(live, ' ') robustness — no change

The producer is get_nft_sets(), sixty lines up in the same file. It pushes only non-empty matched names into results and returns join(' ', results), so the separator is always exactly one space and there are no empty elements. The reaper also breaks out before the loop when live is falsy (if (!live) break;), and the loop body already skips empties with if (!s || keep[s]) continue;.

Switching to split(trim(live), /\s+/) would guard against a format this function cannot produce. If we did want that hardening it should apply to the pre-existing case 'sets' too, which splits the same value the same way — a separate cleanup, not this fix.

2. Logging failed nft_call('delete', ...) — legitimate, but declining here

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. output.logger_debug() gates on cfg.debug_performance, which is the wrong flag for this, and it is the only debug-level facility that exists. Doing it properly means a new message code in state.errors, and in this project a message-catalog addition has to be mirrored across five compat locations — disproportionate for a rare non-fatal path, on a bugfix PR.

It is also not a regression: the cleanup('sets') call this replaces ignored nft_call's return in exactly the same way, and so does every other cleanup() case. Happy to do it as a follow-up if you want the whole cleanup() family to report failures consistently — that seems the right shape for it.

4. Logging when apply('main') fails — no change

Already covered. nft_file.apply('main')'s failure branch calls output.failn(), which prints Installing fw4 nft file [✗], and pushes { code: 'errorNftMainFileInstall', info: <temp file> } into state.errors, which surfaces in the error summary. An operator sees both the failed step and the reason. That the previous generation stays in place is noted in the code comment at the call site.

@egc112
egc112 force-pushed the pbr-1.2.3-nft-reload-set-window branch from 4025b1f to 5c86227 Compare August 17, 2026 13:24
egc112 and others added 4 commits August 17, 2026 15:29
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>
@egc112

egc112 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

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 PKG_RELEASE bump PR, which is this project's convention and is already queued for this change. The three behaviour changes — sets persisting across reloads, stop/start no longer wiping them, and the SIGHUP cache flush on unchanged reloads — will be carried there rather than duplicated here.

2. QA test reproducing the original symptom

Already covered, both automatically and manually.

tests/03_nft_rules/08_set_reload_window (f248c87) asserts the ordering directly over a real start_service(): no nft delete set is issued before the fw4 -q reload, no set the new ruleset declares is deleted at any point, and the one orphan is reaped alone. It was verified the way the guards in #156 were — restoring 'main_chains' and 'sets' to the cleanup call and dropping the orphan reap fails 3 of its 6 checks.

The manual procedure is in the PR body's Verification section: cachesize '0', a continuous nslookup loop, five consecutive reloads, zero nftset errors. That is the reporter's exact symptom, and it came back clean.

3. Documenting nft_set_timeout / nft_set_gc_interval

Legitimate, but not this repo. Neither option is documented here — README.md is an 18-line stub pointing at https://docs.mossdef.org/pbr/, where the option reference actually lives. The persistence interaction belongs in that reference, alongside the options themselves.

4. resolver.flush_cache() and SIGHUP semantics

SIGHUP is well defined and stable in dnsmasq: it clears the cache and re-reads /etc/hosts and resolv.conf, and explicitly does not re-read the configuration file. That is exactly why the config-changed path still needs a full restart and only the unchanged path uses the signal.

Two things reduce the risk further. The command form is not new — resolver.kill() has used killall -q -s HUP dnsmasq since before this branch; this PR renames that function rather than introducing a call. And -q keeps it quiet when dnsmasq is not running. On multi-instance setups killall signals every instance, which is the desired behaviour, since pbr's nftset= lines can be spread across them.

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 orphan_sets case in nft.uc — the last being where a future maintainer would actually encounter the question.

6. Smoke test for uid recycling

Covered by the mechanism 08 already pins. Inheritance is only possible if a stale set outlives its policy, and orphan_reaped together with only_orphan_reaped assert that it is deleted on the same reload the policy disappears — leaving nothing for a recycled uid to inherit. A test that literally hands a recycled uid to a new policy would need two runs with a mutated config between them, and would end up asserting the same property less directly.

@egc112

egc112 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Co-pilot comment:

Merge readiness and risk assessment

    Overall: Approve to merge once the operational items below are addressed (they are not code blockers).

@egc112
egc112 merged commit 67a3adf into mossdef-org:1.2.3 Aug 17, 2026
3 checks passed
egc112 added a commit to mossdef-org/luci-app-pbr that referenced this pull request Aug 17, 2026
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>
egc112 added a commit that referenced this pull request Aug 17, 2026
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>
egc112 added a commit that referenced this pull request Aug 18, 2026
…#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>
@egc112
egc112 deleted the pbr-1.2.3-nft-reload-set-window branch August 18, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant