feat(network): split the workload forward chain by family and enforce the block list on every path - #989
Merged
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR restructures the inet weaver-workload-policy nftables ruleset used on the forward hook, splitting evaluation by address family and changing the hooked chain from an enforcement point (policy drop) into a classifier (policy accept) that primarily stamps meta priority for the HTB hierarchy.
Changes:
- Replaces the single flat
forwardchain with a minimal hooked chain that dispatches viameta nfprotointoforward_ipv4/forward_ipv6, moving rules into their matching family chain. - Removes conntrack state gating (
ct state ...) and the terminaldrop, eliminating the “default drop + escape hatches” structure and makingforwardclassification-only (explicitbn-restricteddrops remain). - Updates tests, goldens, CLI help text, and operator docs to reflect the new chain layout and the deliberate security posture change.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/workflows/steps/step_network_policy.go | Updates step comment to reflect that an empty registry means no persisted nft file. |
| internal/workflows/steps/step_network_nft_weaver.go | Updates rationale comment around removing stale persisted policy file on empty registry. |
| internal/network/policy/render.go | Implements family split (forward_ipv4/forward_ipv6), minimal base chain dispatch, and removes enforcement-era tiers (ct state, terminal drop, unclassified tier). |
| internal/network/policy/render_weaver.go | Updates empty-registry behavior rationale to “hygiene not blackhole guard” under policy accept. |
| internal/network/policy/render_weaver_test.go | Updates empty-registry test comments to match new semantics. |
| internal/network/policy/policy_test.go | Adds chainBody helper and new tests/assertions for family split structure, ordering, and single-stack empty-chain emission. |
| internal/network/policy/manager.go | Updates Delete doc/comments to reflect last-policy behavior (tear down table entirely). |
| internal/network/policy/manager_ops_test.go | Adjusts test comment for last-policy delete behavior under new semantics. |
| internal/network/policy/testdata/network-weaver-workload-policy.golden.nft | Regenerates golden to reflect base dispatch chain and per-family chains. |
| internal/network/policy/testdata/network-weaver-workload-policy-dualstack.golden.nft | Regenerates dual-stack golden with per-family chain separation and new policy semantics. |
| cmd/cli/commands/network/policy/delete.go | Updates CLI help text to match actual last-policy delete behavior (tear down table). |
| docs/quickstart.md | Updates operator-facing quickstart text for network policy delete to match new semantics. |
| docs/dev/traffic-shaper.md | Rewrites coexistence section to clearly distinguish input (enforcing) vs forward (classifying). |
Suppressed comments (2)
internal/network/policy/render.go:400
- renderStampRule()’s comment says that when a family has no pod CIDR, “the other family's chain still carries the deny tier”. With the current implementation, the other family chain may also still carry tier-2 reply-restore rules (which are pod-CIDR-independent). Updating the comment to reflect that only stamp classification rules are suppressed would keep the docs accurate.
// renderStampRule renders one address family's classification rule for a stamp
// policy, honoring --from-entity world (no IP-set clause) and --reply-stamp
// (compound-key egress forward rule with a ct mark write). It returns "" when
// this family has no pod CIDR, so a single-stack deployment renders only its own
// family's rules while the other family's chain still carries the deny tier.
func renderStampRule(p *Policy, f family) (string, error) {
if f.podCIDR == "" {
return "", nil
}
internal/network/policy/policy_test.go:195
- This test’s doc comment says the deny tier is “the only tier rendered for a family without a pod CIDR”, but tier 2 (reply-restore) is rendered independent of pod CIDR. The current fixture happens not to include reply-stamp policies, but the comment is inaccurate and could mislead future changes.
// TestRender_SingleStackEmitsAnEmptyChainForTheAbsentFamily covers the one input
// that produces a chain with no rules at all: stamp-only policies (no deny tier,
// which is the only tier rendered for a family without a pod CIDR) on a
// single-stack deployment. The chain must still exist, because the hooked chain's
// vmap jumps to it unconditionally and an unresolved jump fails the whole load.
func TestRender_SingleStackEmitsAnEmptyChainForTheAbsentFamily(t *testing.T) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…top enforcing on it The `inet weaver-workload-policy` forward chain was one flat list where every deny policy emitted four rules and every stamp policy emitted one per address family, so on a dual-stack host roughly half the expressions evaluated per forwarded packet were guaranteed non-matches. Reduce the hooked chain to a single `meta nfproto` dispatch into forward_ipv4 and forward_ipv6, and move every rule into the family chain it can actually match. `meta nfproto` rather than `meta protocol`: in an inet table it reads the netfilter family off the hook instead of depending on the skb's ethertype. The tier-2 reply restore matches conntrack only, so it is family-agnostic and is duplicated into both chains rather than hoisted above the dispatch. Hoisting it would end the base chain with its own `accept`, letting a quarantined peer's reply traffic escape the tier-1 deny drops that now sit below the dispatch. Flip the chain policy from drop to accept and delete both `ct state` matches, the terminal drop, and the unclassified-pod-egress tier that existed only to escape the default drop. This is a deliberate security-posture change: the table stops being an enforcement point on the forward hook and becomes purely a classifier, leaving workload isolation entirely to Cilium. The `bn-restricted` quarantine is unaffected — it drops explicitly rather than relying on the chain policy. `ct mark` set/restore is retained, since it is what classifies asymmetric backfill replies. Unclassified traffic lands in the same place it did before: the removed tier carried no `meta priority`, and neither does falling through to `policy accept`, so both end in the HTB default class. Correct the rationales that assumed the old default. The coexistence section of the traffic-shaper doc claimed weaver was the binding filter on both hooks, which is now true only on input. The empty-registry file removal was justified as a blackhole guard; the behavior is kept but it is hygiene now, not safety. The delete path's "an empty chain is applied" claim was already wrong before this change — the code tears the table down — and is fixed in the doc comment, the CLI help, and quickstart. Both goldens verified against nftables v1.1.3: they load, re-apply idempotently, and every jump resolves. Signed-off-by: Bruno Marques <bruno.marques@swirldslabs.com>
The block list was spelled once, on the input hook. That does not block a peer — it only blocks the peer from initiating. Nothing stopped this host from dialing a blocked address, and once it did, the replies were admitted by the input chain's `ct state established` accept. Forwarded, pod-bound traffic from a blocked source was never filtered at all, since the host firewall has no forward chain and the workload policy table only drops `bn-restricted`. Add two base chains to `inet weaver-host-firewall`: - `prerouting_blocklist` at priority -300 (the `raw` band, ahead of conntrack at -200), dropping blocked sources before a conntrack lookup happens. This hook also covers the forward path, so a blocked CIDR is now blocked for pod-bound traffic — the list means "blocked on this node", not "blocked from the host's own services". - `output`, `policy accept`, dropping traffic to a blocked destination. It is deliberately not an egress allowlist: enumerating legitimate outbound traffic on a Kubernetes node is large, brittle, and stranding the node is the failure mode. The input chain keeps its own copy. It is redundant with prerouting for anything arriving on a wire, but the ordering it encodes — block list ahead of the conntrack fast-path, so an added CIDR also kills open connections — is a tested property of that chain and should not become contingent on a chain registered on a different hook. Verified against nftables v1.1.3 rather than by inspection: the rendered table loads and re-applies idempotently with all three base chains registered at the expected hooks, a packet from a blocked source across a veth increments the prerouting counter while the input counter stays at zero, and no conntrack entry is created for either a blocked source or a blocked destination. Signed-off-by: Bruno Marques <bruno.marques@swirldslabs.com>
brunodam
force-pushed
the
00977-policy-forward-chain-family-split
branch
from
August 10, 2026 06:25
a066741 to
a39dd31
Compare
… topology The harness reported five peer source IPs it had never created, and then drove traffic at ports the block node was not listening on. Both failures are silent, and both surface downstream as a shaper classification bug rather than a setup bug. The peers VM has no passwordless sudo. vm.yaml installs that drop-in only on the BN VM and the golden image does not carry one, so each `sudo` in the macvlan setup blocked on a password prompt that non-interactive SSH cannot answer. The failures were swallowed twice over: `2>/dev/null || true` on the link add, and a trailing `|| echo` on the whole block, after which hosts.env was written anyway. - provision-peers.sh installs the sudoers drop-in and verifies it with `sudo -n` over a real login — the same verify-by-effect the SSH-key step already uses, for the same reason: the guest agent reports success on writes that did not persist. - provision-peers.sh also pins arp_ignore=1 / arp_announce=2 before any child exists. All six addresses share one /24, and at the default arp_ignore=0 any interface answers ARP for any local address; the upstream switch then binds the parent's management IP to a child's MAC and the peers VM drops off mid-run. Written to /etc/sysctl.d so a restart keeps it. - network:up fails hard rather than warning. An unresolved NIC, a missing iperf3, or an address that did not land now stops the run before hosts.env is written, and iperf3 is installed once instead of five times inside the loop. Separately, gen-appstate.sh hardcoded per-facility listener ports while network:bn-deploy installed the chart without splitting them. Unset, the chart binds every facility to blockNode.config.SERVER_PORT, so the block node answered on one port while the seeded statusz claimed four — the daemon then programmed ports nothing listened on into the `<policy>_ports` sets, and real traffic matched no rule and fell to the default class. - test/config/blocknode_values_multi_ports.yaml splits the facilities onto distinct ports via blockNode.ports, so the harness exercises the port half of every classification rule instead of leaving the source-address set to do all the work. It deliberately does not set ports.health: the base template pins that from DefaultBlockNodeHealthPort so the port weaver opens for bn-mgmt is the one it dials for statusz, and an operator value would win the merge and break the discovery that bootstraps every other port. - network:bn-deploy installs with that file, and network:seed-statusz reads the same file for the ports it writes into the fixtures, so the deployed chart and the seeded statusz cannot drift apart again. Verified on the UTM harness: all five macvlan source IPs come up and each one reaches the BN VM from its own address, which also settles the macvlan-over-UTM question the file flagged as its load-bearing unknown — the segment does forward the child MACs, so the five-tiny-VMs fallback is not needed. Signed-off-by: Bruno Marques <bruno.marques@swirldslabs.com>
brunodam
force-pushed
the
00977-policy-forward-chain-family-split
branch
from
August 10, 2026 07:44
a39dd31 to
06cae15
Compare
The two nft tables register on different hooks and therefore see disjoint traffic, which is why neither carries a rule for the other's ports and why no block-node service port appears in the host firewall at all. That was implicit and repeatedly caused confusion about where a given packet is dropped. Adds a traffic-to-outcome table alongside the existing hook-to-policy table in the coexistence section, and spells out the load-balancer translation mechanism that decides whether a packet reaches `input` or `forward`: a port with no service behind it is never translated, so it stays host-destined and dies on the `input` default drop, while service traffic is translated ahead of the routing decision and bypasses `input` entirely. Also records the silent consequence of the port sets being statusz-derived: an exposed port absent from them is forwarded unclassified into the HTB default class rather than erroring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Bruno Marques <bruno.marques@swirldslabs.com>
The `[ -z "$val" ] && { echo; exit 1; }` guard aborted `network:appstate` with a
bare "exit status 1" and no message, on the path where every port *was* found.
go-task runs recipes under mvdan/sh, which treats an `&&` list whose final
evaluated command returns false as a failure under `set -e` — so the guard fired
`set -e` precisely when it was not tripped. bash exempts that case, which is why
the idiom looks correct.
Replaced with an if/fi helper, and the messages now name the values file keys
(`blockNode.ports.publisher`) rather than the shell variable names, so a missing
port points at the line to fix. Dropping the `eval` loop is a side benefit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Bruno Marques <bruno.marques@swirldslabs.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The
inet weaver-workload-policyforwardchain was one flat list where every deny policy emitted four rules and every stamp policy emitted one per address family. On a dual-stack host running the canonical BN set, roughly half the expressions evaluated per forwarded packet were guaranteed non-matches.The hooked chain is now a single
meta nfprotodispatch intoforward_ipv4/forward_ipv6, with every rule moved into the family chain it can actually match. Sibling of #979, which did the same to the host firewall'sinputchain.meta nfprotorather thanmeta protocol: in aninettable it reads the netfilter protocol family straight off the hook instead of depending on the skb's ethertype, which is not guaranteed for locally generated traffic or non-Ethernet interfaces.Security-posture change — please review this deliberately
The chain policy flips from
droptoaccept, and bothct statematches, the terminaldrop, and the unclassified-pod-egress tier all go away. The table stops being an enforcement point on theforwardhook and becomes purely a traffic classifier. Workload isolation rests entirely on Cilium from here — including on a host whose Cilium datapath is degraded or not yet up, which previously still had weaver'spolicy dropas a backstop.The issue asked for this to be signed off as a posture change rather than merged as a perf tweak. Flagging it accordingly rather than burying it in the diff.
What does not change:
bn-restrictedstill quarantines, both directions and both families. It drops explicitly; it never relied on the chain policy.ct markset/restore is retained. Tier 2 is what classifies asymmetric backfill replies — dropping conntrack from the table wholesale would leave them unclassified in the HTB default class.meta priority, and neither does falling through topolicy accept, so both end in the HTB default class. That equivalence is what makes the tier dead rather than load-bearing.Why the reply restore is duplicated rather than hoisted
Tier 2 (
ct direction reply ct mark …) matches conntrack only, so it is family-agnostic and looks like it belongs in the base chain above the dispatch. It does not. It ends inaccept, which is terminal for the base chain — a quarantined peer's reply traffic would sail past the tier-1 deny drops that now sit below the dispatch.It is emitted into both family chains instead: one extra line in the document, zero extra evaluations per packet, ordering preserved exactly.
TestRender_FamilySplitpins it in both chains, and the reasoning is in the rendered comment so nobody "optimizes" it upward later.Rationales this change invalidated
Three places asserted things that stop being true, each corrected here rather than left to rot:
docs/dev/traffic-shaper.md(from docs(traffic-shaper): document coexistence with the host's existing nftables and tc stack #984) said "Both weaver chains arepolicy drop… That makes weaver the binding filter on the node." Now true oninputonly. The section is rewritten as a four-row table covering every hook weaver registers on, and says plainly thatforwardconstrains nothing.policy dropchain would kill pod startup, image pulls, DNS). Underpolicy acceptan empty chain is inert. The behavior is kept — never replaying stale classification is still right — but it is hygiene now, not safety. Same rationale appeared in four places.Deletepath's "an empty chain (policy drop, no rules) is applied" was already wrong before this PR:manager.go:726-743tears the table down entirely. Fixed in the doc comment, the CLI help, anddocs/quickstart.md(per the repo rule that CLI surface changes must update quickstart), because leaving apolicy dropclaim in operator-facing help after this change would be doubly misleading.Accepted regression
ExtractPodCIDRsrecovers the pod CIDR from inlineip saddr <CIDR>literals so a--denycreate — which never supplies--pod-cidr— can re-render stamp siblings. The removed tier was the only rule putting an inline CIDR into a deny-only document, so this sequence loses recovery:Post-change that create fails with the existing explicit "pod CIDR is required to render a --stamp policy" error, not silently, and every production caller supplies the value. Accepted rather than adding a recovery mechanism for a path that surfaces loudly.
Files changed
internal/network/policy/render.gopolicy accept+ thenfprotodispatch; newrenderFamilyChainandfamily; tier 5, bothct staterules and the terminaldropdeletedinternal/network/policy/policy_test.gochainBodyhelper; ordering assertions rescoped per chain; newTestRender_FamilySplitand single-stack empty-chain testinternal/network/policy/testdata/*.golden.nftinternal/network/policy/manager.goDeletedoc comment and inline rationaleinternal/network/policy/render_weaver.gointernal/network/policy/{render_weaver,manager_ops}_test.gointernal/workflows/steps/step_network_{nft_weaver,policy}.gocmd/cli/commands/network/policy/delete.go,docs/quickstart.mddocs/dev/traffic-shaper.mdinputenforces,forwardclassifiesinternal/network/policy/parse.gois deliberately untouched — its regexes scan the whole document rather than anchoring to chain position, so they survive the layout change.Review guide
Document order is no longer evaluation order. The family chains are defined below the hooked chain but run before it falls through, via the dispatch. Any whole-document
strings.Indexordering assertion is now meaningless — it compares positions in chains that never evaluate the same packet. Hence thechainBodyhelper (same shape as the one #979 added on the firewall side); scope every new ordering assertion to one chain body.Invariants to verify, each of which fails silently — the ruleset still loads:
accept, so a deny that sorted lower would let a restricted peer's traffic be stamped and accepted.TestRender_FamilySplitcounts the rules.Test commands
nft validation against a real kernel. Unit tests only compare strings and cannot tell you whether
nftaccepts the ruleset, so both goldens — plus the single-stack case that renders an emptyforward_ipv6— were loaded on nftables v1.1.3. All three load, re-apply idempotently, and every jump resolves:The kernel normalizes
priority 0→priority filterand printsmeta priority 0x10020back as1:20; both are nft's listing behavior, not drift.The empty-chain case is worth knowing about: stamp-only policies on a single-stack host leave the absent family with no rules at all, but the dispatch jumps to both families unconditionally and an unresolved jump fails the entire load. So the chain must still be emitted. It renders as
chain forward_ipv6 { }, andTestRender_SingleStackEmitsAnEmptyChainForTheAbsentFamilypins it.Manual UAT
Setup — one command. Builds both binaries, self-installs the provisioner on the BN VM, clones the peers VM with five distinct un-NAT'd source IPs, seeds the statusz roster from them, and deploys the block node with shaping and firewall enabled:
1. The chains exist and the dispatch resolves
An unresolved jump fails the load outright, so reaching this output is itself the check. The hooked chain must hold exactly those two lines — no
ct state, no trailingdrop:On a single-stack node the absent family must carry the deny tier and nothing else. A
ct direction replyline there means the reply-restore gate regressed:2. Resolve the two shaped devices
The six classes are split across two devices, which is the easiest thing to get wrong when reading counters:
Expect two: the
--egress-interfaceNIC (egress classes partner1:40, public1:50, reserve-egress1:60) and the block node pod's host-side veth (ingress classes publisher1:10, backfill-response1:20, reserve-ingress1:30). The ingress hierarchy is attached per-pod by the daemon, so it appears only once a block node pod is running. Looking for ingress classes on the physical NIC shows them permanently at zero and reads as a false regression.3. Traffic lands in the class it should
Read the live port sets first — they are reconciled from statusz, not literals:
Watch the counters live on the BN VM, one terminal per direction:
Create a debug container sharing the block node pod's network namespace — classification matches the pod's addresses, so traffic must originate there:
Egress classes. Start a listener on the peer holding the matching role address, then drive the flow from the pod with
--cportset to a port in that policy's set — the egress rules matchtcp sport, so the client port is what selects the class:--cportfrom$PARTNER_IPbn-partner-outbn-partner-out_ports1:40$PUBLIC_IPbn-public-outbn-public-out_ports1:50$BACKFILL_IPbn-backfillbn-backfill(compound set)1:60fwd,1:20on the reply1:60Ingress classes. Drive from the peer toward the block node's real listening ports, with the source address selecting the policy:
The block node owns those ports, so iperf3 will not complete a handshake — the byte volume is small and the check is that the counter moves on the expected class at all, not throughput.
--classnarrows the watch when a row is ambiguous:Each generator must move its own class and no other. Traffic collapsing into the default class means the stamp rules stopped matching — the most likely regression from moving rules into the family chains.
4. Quarantine still drops, both directions and both families
sudo solo-provisioner network policy add --name bn-restricted --cidr $RESTRICTED_IP/32From the peers VM,
iperf3 -c <bn-address> -B $RESTRICTED_IPmust fail outright, and the drops must be the first rules in each family chain. An already-open connection from that peer dies immediately — there is no conntrack fast-path left to shield it.5. Survives a reboot
Unit
active (exited), chain count3.Risks / rollback. The posture change is the risk; see above. No startup migration re-renders this template — the only re-render paths are
step_network_policy.goand thenetwork policyCLI verbs — so the new layout cannot leak silently into already-provisioned clusters, and old and new layouts coexist across the fleet until each node is explicitly re-rendered. That is safe because the artifact is replaced wholesale rather than patched. Rollback is a straight revert plus re-apply; there is no migration to undo.Block list on every path (
inet weaver-host-firewall)Second commit, second table.
@blocked_addrswas spelled once, on theinputhook — which does not block a peer, it blocks the peer from initiating. Two paths stayed open:outputchain, so outbound traffic to a blocked CIDR left normally — and the replies came back in under the input chain'sct state establishedaccept, because they are not a new connection. For any host-initiated connection, the block list had no effect in either direction.forwardchain, and the workload policy table only drops@bn-restricted.Two base chains close both:
-300is therawband, ahead of conntrack at-200, so a blocked source never gets a tuple lookup. That hook also covers the forward path, which is what closes the pod-bound gap.The
outputchain is deliberately not an egress allowlist and must staypolicy accept. Enumerating legitimate outbound traffic on a Kubernetes node — kubelet to the API server, etcd, DNS, NTP, image pulls from arbitrary registries, Cilium, Teleport — is large, brittle, and stranding the node is the failure mode.TestRender_BlockListReachesEveryPathasserts the chain carries block-list rules only, so it cannot drift into one.Scope change — please read
@blocked_addrsnow filters pod-bound forwarded traffic, which the host firewall has never done. The block list starts meaning "blocked on this node" rather than "blocked from the host's own services". That blurs the host/workload table split described as deliberate ininternal/network/firewall/table.go:31-37andinternal/workflows/steps/step_network_policy.go:68-73; both comments, the--blocked-cidrsflag help,docs/quickstart.md, anddocs/dev/traffic-shaper.mdare reworded to match.The
inputcopy of the drop is kept. It is redundant with prerouting for anything arriving on a wire, but the ordering it encodes — block list ahead of the conntrack fast-path, so an added CIDR also kills already-open connections — is a tested property of theinputchain and should not become contingent on a chain registered on another hook.Verified against a real kernel, not by inspection
nftables v1.1.3. The table loads, re-applies idempotently, and all three base chains register at the expected hooks (
nftnormalizes-300toraw):The drops actually fire, which is the part string tests cannot reach:
That second pair is also the evidence for the redundancy note above: prerouting takes the packet, input never sees it.
Conntrack was checked separately, because the intuition that a drop leaves a stale entry is wrong: entries are only confirmed at the end of the pipeline, so a packet dropped at
filterpriority 0 never lands one. Same UDP payload to two destinations, counters proving both traversed the chain — accepted destination left 1 entry, dropped destination left 0. The prerouting move therefore saves per-packet CPU, not table growth.Manual UAT — block list
Test harness fix (
taskfiles/network.yaml,scripts/network/provision-peers.sh)Unrelated to the nft work, but it rides along because it is what makes the UAT above runnable at all.
network:upbrought up no macvlan source IPs and noiperf3, yet still wrote ahosts.envlisting five peer addresses — so every downstream task ran against addresses that did not exist, and a classification failure looked like a shaper bug rather than a harness that never armed.The peers VM has no passwordless sudo.
vm.yamlinstalls that drop-in only on the BN VM and the golden image does not carry one, so eachsudoin the macvlan setup blocked on a password prompt non-interactive SSH cannot answer. The failures were swallowed twice:2>/dev/null || trueon the link add, and a trailing|| echoon the whole block.provision-peers.shinstalls the sudoers drop-in and verifies it withsudo -nover a real login — the same verify-by-effect the SSH-key step already uses, for the same reason.arp_ignore=1/arp_announce=2before any child exists. All six addresses share one/24, and at the defaultarp_ignore=0any interface answers ARP for any local address; the upstream switch then binds the parent's management IP to a child's MAC and the peers VM drops off mid-run.network:upfails hard rather than warning: an unresolved NIC, a missingiperf3, or an address that did not land now stops the run beforehosts.envis written.iperf3installs once instead of five times inside the loop.Verified on the UTM harness: all five macvlan source IPs come up and each reaches the BN VM from its own address. That also settles the macvlan-over-UTM question the file flagged as its load-bearing unknown — the segment does forward the child MACs, so the five-tiny-VMs fallback is not needed.
Related Issues
Sibling of #979 (
inputchain). #978 builds on the host-firewall side and should land after #979.