Repository navigation
feat(minimald): block UDP 443 for a host_ip cohort with a blocked QUIC stance (T53) - #2553
Conversation
The installer creates and delegates boxes/quic443-blocked and keys one rule on it: udp dport 443 reject with icmpx port-unreachable. CohortQuic443 learns the leaf and moves the cohort's allow-subtree processes into it while a member blocks, and back when none does.
…stop A host_ip launch joins the cohort with its resolved quic443 stance. A stop leaves it, so the cohort sends UDP 443 again once the last blocking member is gone.
Two classifier tests pinned the output chain at one rule. They now allow the blocked leaf's reject beside the deny subtree's jump.
|
Important Review skippedBot user detected. To trigger a single review, invoke the
|
|
Reviewed: 2 findings, none blocking at Address the comments on the diff. Clobber: none seen. |
|
Round 1 of 10: the container stopped before the coder ran (the container took the job and stopped before it ran (exit 0 and no result, and the second start failed: Error: The container is not running, consider calling start())). The round is given back. |
There was a problem hiding this comment.
arch-review: CONDITIONS · 2 conditions · the full ruling
The table rule and the cohort moves conform to Networking §5.3 line 150. The rule is an active reject, and the leaf keeps the cohort address. A min box exec or injected hook still starts in the box's own leaf while the cohort is blocked, so it sends UDP 443. That breaks "blocked for the cohort iff any resident member's resolved stance is blocked". It also splits the exec from its entrypoint's cgroup (architecture line 493). C1 places injected processes in the blocked leaf and records them so they go home. C2 files issues for the two gaps the PR body names without one. The escalation asks how this mechanism fits Box Resources' one cgroup per box.
Ruled at 0f5d54c against gominimal/arch@109541a48c31 by claude-opus-5-5.
|
/revise Round 1 of #2553, on the red native lane at 0f5d54c, the arch ruling there and three review findings. No round is in flight: the runtime's own round 1 was given back when its container stopped before the coder ran. T53's layers were widened on 2026-10-25 in the plan comment and in #2534:
One or two commits, Conventional Commits subjects, body lines at most 100 characters. Generated by Claude Code |
The install lays out boxes/quic443-blocked beside deny and allow. The harness now expects it in the delegation list, drops its files before --uninstall, and asserts that both installs create it.
The host_ip cohort's UDP 443 block now never fails open: - A failed move, an unreadable leaf, a leaf that never empties or a missing blocked leaf comes back to the launch as an error. The box leaves the cohort again, and the launch is killed and refused. - A box that re-joins with a weaker stance as the only blocker lifts the block and sends every process in the blocked leaf home. - While the cohort blocks, command_in_session hands the shim the blocked leaf. The shim reports its pid from a pre_exec hook, and the pid is recorded under the box, so an exec or a hook goes home too.
|
Round 2 of 10 on a /revise note, pushed as
Verify Review threads the coder judged:
A host_ip cohort with one blocked member has every member's processes in boxes/quic443-blocked. The table rejects UDP 443 from that leaf with ICMP port unreachable. A min box exec or a hook injected while the block is on joins the same leaf. When the blocking member stops, or joins again with a weaker stance, every process goes back to its own leaf and sends UDP 443 again. A launch that cannot be placed under the block is killed and refused, so the block never fails open. Notes from the agent: Item 1: the harness also drops the leaf's files in the third drop_cgroup_files block (the uninstall_live case), so all three blocks agree. That case still exits 1 on the live leaf. Item 3: I chose the 'fail the launch' option, not 'place before the first process runs'. The launcher (box_runtime/launcher.rs) places the first process in the box's own leaf and is outside the layers. Until a placement moves there, a blocked box can send UDP 443 between its spawn and the move. Item 2: command_in_session only builds the Command. The caller spawns it, so the shim pid comes back from a pre_exec hook over a close-on-exec socket pair and is recorded by a reader thread. Known gap: if the block lifts between the build and the shim's own join, the shim can land in the blocked leaf after it was sent home. It then stays blocked (fails closed) until the next block lifts. New printed text: a refused launch prints 'session spawn: the session was stopped because QUIC (UDP to port 443) could not be blocked for it. Every session that runs with the host's address gets this block while the quic443 setting of one of them blocks QUIC. The cause: '. The missing-leaf cause names boxes/quic443-blocked and gives no remedy, because I did not prove that min finalize-install re-creates the leaf on an older install. Item 6: I did not resolve any thread, because resolution is the reviewer's decision. The verdicts are below. I ran neither just clippy-strict nor just ci. I ran cargo clippy -p…[truncated] |
|
The runtime will not revise this pull request again on its own: the diff is +1311/-16 (1327 lines), past 3x the plan's estimate of 360 lines (est: 200 code, 160 tests, as #2534 reads now), at round 2 (the last trigger was a /revise note). A person decides now. Remove |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The compare meets the two conditions from 0f5d54c. A failed placement now kills the box and fails the launch. Two paths still send UDP 443 while the cohort blocks, against Networking §5.3 line 150. A QUIC socket opened before the flip keeps its own cgroup (§4.1 line 56), so the rule never matches it (C1). An exec that takes its leaf just before the flip joins its own leaf after the move ends (C2). File an issue for each and list it under Residuals. The change can then merge.
Ruled at ecdb372 against gominimal/arch@109541a48c31 by claude-opus-5-5.
There was a problem hiding this comment.
Babysitter approval at ecdb372: every merge gate passes at this head (arch PASS_WITH_CONDITIONS at ecdb372 whose conditions are filed residuals #2558 and #2559, zero open threads, every lane aggregator green including ci-macos-success). Out-of-layer files were widened into T53's layers. Squash title feat(minimald): block UDP 443 for a host_ip cohort with a blocked QUIC stance (T53) and the squashed message pass commitlint. Queued for squash merge.
Generated by Claude Code
…(0.7.0 backport) (#2742) * fix(minimal): hold the answerer channel socket owner-only (#2297) * fix(minimal,minvmd): hold the answerer channel socket owner-only The box-zone answerer's channel socket was world-connectable: the launchd plist bound it SockPathMode 0666 and the systemd socket unit SocketMode=0666, leaving the peer-uid gate the only check on who may connect. Both units now bind it owner-only -- SockPathOwner the operator, SockPathMode 0600 on launchd; SocketUser the operator, SocketMode=0600 on systemd -- with the uid gate kept as defence in depth. An install whose socket node carries group or other bits -- one that predates the mode, or a hand that relaxed it -- now fails custody through a channel_mode fact, so the advisory re-surfaces and a step re-run re-binds the socket. The accept loop's foreign-refusal warn is rate-limited to a few lines a second, so a connect storm cannot flood the log; the refusals themselves are never limited. * fix(minimal,minimald,minvmd): name the other account on a refused channel connect A daemon whose connect the owner-only channel socket refuses now says the host's DNS service belongs to another account, not a bare Permission denied. The answerer step needs the operator's account to resolve, with no stand-in uid, and custody fails a channel socket the operator does not own. * fix(minimal,minvmd): leave another operator's DNS service alone A channel socket owned by another existing, non-root account is that operator's service: the answerer step is now unavailable with the same sentence the daemons say on a refused connect, instead of a custody failure whose step would reinstall the unit and take the service over. A root-owned socket, or one whose uid names no account, still fails custody so the step re-binds it. The minvmd tests that take the process-global foreign-refusal slots now run one at a time and start and end with every slot free, and the warn-rate test bounds the burst at two windows' worth of lines, so a slow runner that spreads the burst does not fail it. * fix(minimal): offer the step over a socket another account left behind A channel socket another account owns refuses the answerer step only when a service is installed; with none, the step's install removes the leftover socket before it loads the unit, so it is offered. --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> (cherry picked from commit 746770d) * fix(net): refuse out-of-zone names at a guest deny-subtree DNS listener (#2300) * fix(net): refuse out-of-zone names at a guest deny-subtree DNS listener A deny-all host-address box on a VM-backed guest resolved out-of-zone names at the switch gateway: the interim NET-079 carve-out admitted the gateway's port 53 whole, UDP and TCP, so a box that admits no destination could still ask a resolver beyond the machine — and DNS over TCP could retry past the verdict. The guest's deny subtree now steers its UDP-53 lookups onto a loopback listener on the answerer's port (NET-141 slice 2): deny_out admits the listener beside the reply direction, the dstnat match carries no destination address (the box asks the gateway like every other host-address box; the steer is the carve-out), and the listener itself answers REFUSED for every name outside the box zone — apex and a non-lowercase zone suffix among them — NODATA for an in-zone non-A question, and forwards the zone's A questions to the switch gateway alone. DNS over TCP is neither steered nor admitted. The guest daemon starts the listener after its classifier table loads; the daemon's own lookups are untouched (cgroup-keyed match, deny path only). The interim --gateway-resolver installer flag and every test, digest and pin that spelled the interim carve-out follow the new shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(net): bound and pin the guest DNS listener's forwards Connect each forward socket to the gateway so a datagram from any other sender is dropped, cap forwards in flight so a flood cannot exhaust the daemon's descriptors, and drop the needless to_string calls clippy rejects in the guest classifier integration test. * fix(net): refuse questionless and non-QUERY messages at the guest DNS listener decide() forwarded a message with no question, and an UPDATE or NOTIFY whose zone section named an in-zone A, because every per-question check passed vacuously. Answer the first REFUSED and the rest NOTIMP, so the forward path carries only standard lookups. Bind the listener directly so its log does not read as the box-zone answerer, pin the integration test's boot-log check to the exact example.com REFUSED line, and cite NET-079 (the deny-all case) rather than NET-141 at the lines this change added. --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 2c2f8cc) * fix(net): spawn the guest DNS tasks with tokio::spawn on release/0.7.0 crate::traced landed on main after the release branch was cut, so the cherry-pick of #2300 did not compile here. * build: declare 0.7.3 for the release/0.7.0 backports v0.7.2 is tagged at the branch head, so the version check requires the next patch release. * chore(deps): update pulldown-cmark to 0.13.5 for RUSTSEC-2026-0414 * fix(minimald): close the host classifier's mark, IPv6 and probe gaps (T105) (#2712) * fix(minimald): close the classifier's mark, IPv6 and probe gaps The install's conflict scan now treats a ct-mark write with no mask as a user of every bit, names each rule with its table and chain, and --ct-mark-mask refuses the ranges Tailscale and kube-proxy class with. The table refuses non-loopback IPv6 from the boxes subtree and counts that refusal in the same named counter as the deny chain. The per-launch probe reads with a UDP datagram, so a launch no longer waits the SYN retransmit a refused TCP connect pays. The reading at daemon start keeps its TCP leg. Each probe logs its leg and duration. * test(minimald): prove the classifier's mark, IPv6 and probe gaps live Three root-lane proofs load the real table: the install refuses a whole-mark ct writer and a mask in a well-known range, and an allow box's IPv6 connection to a peer is refused and counted. The live probe proof also checks the per-launch reading waits no retransmit. * fix(minimald): pass shellcheck and pin the new mask refusals The rerun hint's empty assignment tripped SC1007 in the shell lint gate. The installer test pinned the old remedy text and escaped with 0x0000c000, the kube-proxy range the install now refuses: it now pins the new remedy, escapes with 0x03000000, and pins the well-known range refusal. The IPv6 refusal comment names the condition that replaces it. * fix(minimald): re-apply the recorded ct-mark mask and name min finalize-install With no --ct-mark-mask, the install step adopts the mask a previous install recorded under the tree. Mask refusals now name min finalize-install --ct-mark-mask <mask>. The conflict scan joins the nft and iptables-save hits with a newline. * feat(minimal): add min finalize-install --ct-mark-mask and keep it at boot The mask record lives in cgroupfs, so a reboot empties it. The classifier step's block now passes the chosen or recorded mask to the one-time run and to the boot unit's ExecStart, so the operator's bits survive a reboot. A requested mask that differs from the loaded table's re-runs the step. * fix(minimald): refuse a CONNMARK --restore-mark that copies the whole mark The install's ct-mark scan now treats --restore-mark with no --mask, --ctmask or --nfmask, or an all-ones one, as a whole-mark writer, as it does --save-mark. A restore with a narrow mask is still no conflict. * fix(minimald): allow --restore-mark, bind probe datagrams first A CONNMARK --restore-mark copies the connection mark into the packet mark and never writes the connection mark, which is all the classifier reads. This reverts 16d9160 and pins a restore with no mask as no conflict. hold_probe_listeners now binds its datagram endpoints before it spawns any TCP drain thread, so a failed datagram bind returns before a listener serves. --------- Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com> (cherry picked from commit 354548e) * fix(minimald): adapt the host classifier gaps backport to release/0.7.0 The cherry-pick before this one keeps main's text for every line #2712 changes. Main's neighbouring code comes from the QUIC block work (#2513, #2553, #2601, #2644), the box-name placement (#2722) and the crate renames, none of which release/0.7.0 has. The conflicts were resolved onto release's code without importing that work: - classifier.rs: the four new unit tests are appended after release's last test, without the QUIC cohort tests main has above them. Their sandbox::classifier paths are sandbox2::classifier here. - install-host-classifier.sh: the table gains the refused counter. The QUIC leaf set that main declares beside it is not on release, so it stays out. The --help range (2,87p) matches release's header too. - finalize_install.rs: release has one classifier item, built by classifier_item_on_this_host, not main's classifier_items_on_this_host with the QUIC block helper item. That function takes --ct-mark-mask and fills recorded_mask and requested_mask itself. plan_for_this_host keeps release's names item placement and only passes the mask on. - #2712's edits to finalize_install_names_the_quic_block_helper are dropped: release has no QUIC block helper item or test. - install_test.sh: run_hc puts HC_PATH_PREFIX ahead of PATH, the line #2601 added on main. Without it the new --restore-mark case never reaches its stand-in iptables-save, and shellcheck flags the variable as unused. The user-facing text is main's, unchanged. --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com>
T53 — Enforce the host_ip cohort's blocked QUIC stance in the host-address ruleset
slice: S9
kind: task
covers: BEP-018
size: M
est: 200 code, 160 tests
executor: agent
difficulty: needs care
model: standard
diagnostics: the bundle's daemon log tail carries one line per cohort stance change naming the box that flipped it
observability: one info log line when the cohort flag turns on or off, with the blocking members
layers:
- ruleset:
- crates/minimald/src/net/classifier.rs:
CohortQuic443learns the path of the cohort's blocked leaf and the daemon's move of ahost_ipbox's processes into and out of it (acgroup.procswrite inside the delegated slice, which needs no CAP_NET_ADMIN and never rewrites the table); its doc stops saying the type is unenforced- scripts/install-host-classifier.sh:
render_rulesetgains one rule keyed on a new delegated leaf under the boxes directory, the cohort's blocked leaf:socket cgroupv2 level <n> "<blocked leaf>" udp dport 443 reject with icmpx port-unreachable; the install step creates that leaf with the rest of the tree, so the privileged step ships the rule and the daemon flips it by membership (widened 2026-10-25: classifier.rs only embeds this script withinclude_str!, so the table is rendered here)test: cargo nextest run -p minimald host_ip_cohort_blocked_stance_rejects_udp_443
- lifecycle:
- crates/minimald/src/box_actor/host.rs: the
host_iplaunch path callsCohortQuic443::joinwith the box's resolved stance and moves every residenthost_ipbox into the blocked leaf whenblocked()turns true; the stop path callsleaveand moves them back to their own leaves when the last blocking member goestest: cargo nextest run -p minimald host_ip_cohort_join_block_leave_drives_the_rule
produces: none
consumes: none
proof: A host_ip cohort with one blocked member sends no UDP 443 and gets ICMP port unreachable; when that member stops, the cohort sends UDP 443 again.
depends on:
- blocked by T31
Proof
The classifier table that the installer loads now has the rule
socket cgroupv2 level 3 "minimald.slice/boxes/quic443-blocked" udp dport 443 reject with icmpx port-unreachable. The install creates and delegates that leaf. When a host_ip box with a blocked quic443 stance launches, minimald moves the processes of every allow-subtree host_ip box into the leaf, so their UDP 443 gets ICMP port unreachable. When the last blocking box stops, minimald moves them back to their own leaves, and UDP 443 leaves again. The tests show this on the rendered table and on a stand-in cgroup tree. No test sent a real datagram through a loaded table on this box. This covers sockets opened after the flip; sockets opened earlier keep their own cgroup (#2558).Notes from the agent: 1) Not proved on a kernel: no test loads the table with real nft and sends UDP 443. The proof rests on the rendered rule and on the moves over a stand-in tree. 2) Gap:
min execplaces a new process in the box's own leaf (crates/minimald/src/nsenter.rs, outside the layers). A process that an exec starts while the cohort is blocked is not in the blocked leaf and can send UDP 443. Processes forked inside the box follow their parent and are covered. 3) While a box is in the blocked leaf, it is outside its own leaf, so a per-leaf memory limit on that leaf does not apply. The blocked leaf has no limit of its own. 4) Deny-subtree boxes are not moved: deny_out already rejects all of their UDP 443 (with admin-prohibited), and a move would remove that refusal. 5) The guest builds its own tree in the sandbox crate (outside the layers). nft resolves cgroup paths at load, so load_guest_table_over in classifier.rs now creates boxes/quic443-blocked before the render when boxes/ exists. Without this, the guest load fails. 6) A host installed before this change has no blocked leaf. The daemon then logs a warning that says re-running the classifier install creates the leaf, and moves nothing.--checknow reports the missing leaf as a problem. 7) A member leaves the cohort only in stop_running. A host that a …[truncated]Tests
cargo nextest run -p minimald host_ip_cohort_blocked_stance_rejects_udp_443cargo nextest run -p minimald host_ip_cohort_join_block_leave_drives_the_ruleVerify
min task run agent-verifyexited 101 under rustc 1.98.1, which the repository does not pin (1.97.0 in rust-toolchain.toml); the PR is not a draft on that alone — CI, which honours the pin, is the gate.Residuals
quic443-blockedleaf are outside their own cgroup, so a per-leaf limit does not apply while the cohort is blocked; the mechanism must be reconciled with Box Resources' one cgroup per box (arch C2 and escalation at 0f5d54c; PR note 3).quic443-blockedrule never matches them and their QUIC flows continue (arch C1 at ecdb372; §4.1 line 56, §5.3 line 150). The Proof covers only sockets opened after the flip.injection_leafreleasing the cohort lock and the shim joining its leaf (arch C2 at ecdb372).Layer exception
T53's layers were widened on 2026-10-25 in the plan comment and in #2534:
scripts/install-host-classifier.sh(the host-address table is rendered there, not inclassifier.rs) andcrates/minimald/src/box_runtime.rs(arch C1:command_in_sessionmust hand the shim the blocked leaf while the cohort is blocked). Both are inside the task now.Closes #2534
Generated by Claude Code