Repository navigation
Description
Task
| slice | size | difficulty | model | who | estimated lines |
|---|---|---|---|---|---|
| Escape into the VM reaches only the declared union | M | needs care | strongest | an agent | 620 (320 code, 300 tests) |
One builder in the switch crate shapes every TCP refusal, and each caller's output is compared against it, not against itself: the native relay and the in-VM gate leg, each driven on its own path, answer the same frame with the byte-identical reset, and the stack peer's pre-screen reset matches it with leg_mac as its source; on a VM-backed host a connection to a box's unpublished port is refused within 5 s on the VM lanes, the in-guest relay resetting a SYN the host gate passed through; a foreign source and a denied egress rule still drop silently; and every refusal is one rate-limited line in the one format on whichever leg refused it, carrying the window's real count, bounded per source so one flooding box starves no sibling's refusals.
Covers:
- NET-014
Layers:
- switch:
- crates/switch/src/refusal.rs (new): feature-free and bytes only; classifies a frame by source address, L4 protocol and whether it is a SYN or a first packet; builds the reset for a refused TCP segment under RFC 793 section 3.4 (a SYN is answered with sequence 0, acknowledgement its sequence plus its data length plus one and RST|ACK set; a segment carrying ACK is answered with sequence equal to its acknowledgement and RST alone); the bounded, rate-limited refusal emitter: its limiter map is fixed-size with LRU eviction, a refusal from a source no row holds collapses into one bucket keyed by rule alone with the source in the line, and its bound is per source (a per-source partition or quota, never one shared channel a single box can fill), so a flooder loses only its own refusals and a sibling's are untouched; the one audit-line format naming rule, address, port or name and reason, carrying the real count of refusals for that source in the window instead of a line per reset; no constant or builder without a production caller
- crates/switch/src/lib.rs: declares the module
cargo nextest run -p switch refusal_rst_carries_rfc_seq_ackcargo nextest run -p switch refusal_rst_acks_a_syn_carrying_datacargo nextest run -p switch refusal_emitter_is_bounded_and_rate_limitedcargo nextest run -p switch refusal_limiter_key_space_is_boundedcargo nextest run -p switch refusal_audit_line_names_rule_address_port_and_reasoncargo nextest run -p switch refusal_line_carries_the_window_count
- daemon:
- crates/minimald/src/net/switch.rs: the native relay leg and the in-VM gate leg both call the shared classifier, builder, emitter and line format; the daemon's own reset builder and refusal line are deleted and its reset writer keeps only the transport framing; the revoked-port arm keeps its log line whether or not a reset is built; the in-VM equality test drives the gate leg's own refusal path (refuse_tcp_segment on the gate leg) and compares the bytes it writes to the shared builder's; the verdict call into the sessions crate is unchanged
- crates/switch/src/bep_host.rs: the stack peer's pre-screen refusals (no row, foreign MAC, share spent) build their reset through the shared builder with leg_mac as the source MAC, never the incoming frame's destination MAC, and emit through the shared emitter and line format under the same per-source bound, the fourth caller; smoltcp's own unlistened-port RST and ICMP replies are active rejects, one per probe, and stay outside the emitter
cargo nextest run -p minimald unpublished_port_connection_refusedcargo nextest run -p minimald native_relay_refusal_matches_the_shared_buildercargo nextest run -p minimald in_vm_gate_refusal_matches_the_shared_buildercargo nextest run -p minimald revoked_port_refusal_is_loggedcargo nextest run -p switch --features stack-peer stack_peer_refusals_use_the_shared_emittercargo nextest run -p switch --features stack-peer stack_peer_reset_source_is_leg_mac
- e2e:
- scripts/session-e2e.sh: adds the case unpublished_port_refused_on_vm_host, run on the VM lanes: on a VM-backed host, a peer box connects to a box's unpublished port and gets connection refused within 5 s, not a timeout, and, where a published name reaches the box from the host, the host connects through it to an unpublished port with the same result, so the host gate's pass-through is shown delivering the SYN to the relay that resets it
./scripts/session-e2e.sh unpublished_port_refused_on_vm_host
Waits on:
- T43 — Own the proxy's switch address from a host-side stack peer of the switch #1652
- T63 — Gate the switch's publish verbs on the host and hand the node its ports #1708
- T66 — Register a box on the host and hand it its addresses #1711
- T21 — Bind declared ports before the name and refuse unpublished ports instantly #1657
Design ruling (architecture review of the egress-gate placement; block PASS-with-conditions)
-
Keep two legs over the one verdict in the sessions crate; share only the wire-level pieces in the switch crate, feature-free (bytes only, no smoltcp). What stays with each owner: the transport (vsock shuttle or in-process tap relay), the control-request parser and switch request gate (VM host daemon only), withdrawal reporting, and the table source (host rows or the daemon's own placement).
-
Amended by the architecture review of PR 1858: the host gate takes no refusal layer. It passes ingress untouched and holds no inside-port table (host-side forwarders bind only ingress-admitted ports, design §7.1), so on a VM-backed host NET-014 is met by the in-guest relay's reset, which the VM-lane e2e case shows receiving the SYN the host gate passed through. A second copy of the publish table in the host row would be two sources of truth for no new guarantee. A denied egress rule and a foreign source stay silent drops (a drop is not a reset; the design's rule 0 and rule 1, and NET-062). Resetting a non-SYN segment at an unpublished port is standard CLOSED-state behaviour (RFC 793 section 3.4, RFC 9293 section 3.5.2); the reply goes only to the frame's source. The revocation reset of a held connection stays the owner leg's state.
-
Conditions carried in the block: the three callers' resets are asserted byte-equal to the shared builder (native relay and in-VM gate as separate tests, each driving its own leg's path); the limiter's key space is bounded (fixed-size LRU; unknown-source refusals collapse into a rule-keyed bucket); the emitter's bound is per source, never one shared channel.
-
Guidance: a global per-window cap on resets is not required once the per-source bound holds (reply equals sender, so no third-party reflection); a per-source cap on resets written per window is the CPU bound if one is wanted. The stack peer (
crates/switch/src/bep_host.rs, T43) is the fourth caller for its pre-screen refusals, which take the same per-source bound; smoltcp's own unlistened-port RST and ICMP replies are active rejects, one per probe, and stay outside the emitter. The ICMP port-unreachable builder is out of this task: v1 ingress forwarders are TCP-only, and no task yet owns the design's UDP rejects (the quic443 block, steered UDP/443 at the BEP). -
The switch's own addresses in the shared verdict (NET-062 sub-clause, design §5.3) moved to T79, #1874: this task's layers do not include
crates/sessionsand carried no test for it.
Plan block
### T70 — Shape every egress refusal from one builder in the switch crate
slice: S9
kind: task
covers: NET-014
size: M
est: 320 code, 300 tests
executor: agent
difficulty: needs care
model: strongest
diagnostics: the bundle's VM host daemon log tail and the daemon log tail carry each refusal in the one line format (rule, address, port or name, reason), so a refusal shaped on either leg reads the same in a bundle
observability: one rate-limited warn log line per refusal class per source from whichever leg refused, in the shared format, carrying the real count of refusals for that source in the window (N refusals read N); no line per reset
layers:
- switch:
- crates/switch/src/refusal.rs (new): feature-free and bytes only; classifies a frame by source address, L4 protocol and whether it is a SYN or a first packet; builds the reset for a refused TCP segment under RFC 793 section 3.4 (a SYN is answered with sequence 0, acknowledgement its sequence plus its data length plus one and RST|ACK set; a segment carrying ACK is answered with sequence equal to its acknowledgement and RST alone); the bounded, rate-limited refusal emitter: its limiter map is fixed-size with LRU eviction, a refusal from a source no row holds collapses into one bucket keyed by rule alone with the source in the line, and its bound is per source (a per-source partition or quota, never one shared channel a single box can fill), so a flooder loses only its own refusals and a sibling's are untouched; the one audit-line format naming rule, address, port or name and reason, carrying the real count of refusals for that source in the window instead of a line per reset; no constant or builder without a production caller
- crates/switch/src/lib.rs: declares the module
test: cargo nextest run -p switch refusal_rst_carries_rfc_seq_ack
test: cargo nextest run -p switch refusal_rst_acks_a_syn_carrying_data
test: cargo nextest run -p switch refusal_emitter_is_bounded_and_rate_limited
test: cargo nextest run -p switch refusal_limiter_key_space_is_bounded
test: cargo nextest run -p switch refusal_audit_line_names_rule_address_port_and_reason
test: cargo nextest run -p switch refusal_line_carries_the_window_count
- daemon:
- crates/minimald/src/net/switch.rs: the native relay leg and the in-VM gate leg both call the shared classifier, builder, emitter and line format; the daemon's own reset builder and refusal line are deleted and its reset writer keeps only the transport framing; the revoked-port arm keeps its log line whether or not a reset is built; the in-VM equality test drives the gate leg's own refusal path (refuse_tcp_segment on the gate leg) and compares the bytes it writes to the shared builder's; the verdict call into the sessions crate is unchanged
- crates/switch/src/bep_host.rs: the stack peer's pre-screen refusals (no row, foreign MAC, share spent) build their reset through the shared builder with leg_mac as the source MAC, never the incoming frame's destination MAC, and emit through the shared emitter and line format under the same per-source bound, the fourth caller; smoltcp's own unlistened-port RST and ICMP replies are active rejects, one per probe, and stay outside the emitter
test: cargo nextest run -p minimald unpublished_port_connection_refused
test: cargo nextest run -p minimald native_relay_refusal_matches_the_shared_builder
test: cargo nextest run -p minimald in_vm_gate_refusal_matches_the_shared_builder
test: cargo nextest run -p minimald revoked_port_refusal_is_logged
test: cargo nextest run -p switch --features stack-peer stack_peer_refusals_use_the_shared_emitter
test: cargo nextest run -p switch --features stack-peer stack_peer_reset_source_is_leg_mac
- e2e:
- scripts/session-e2e.sh: adds the case unpublished_port_refused_on_vm_host, run on the VM lanes: on a VM-backed host, a peer box connects to a box's unpublished port and gets connection refused within 5 s, not a timeout, and, where a published name reaches the box from the host, the host connects through it to an unpublished port with the same result, so the host gate's pass-through is shown delivering the SYN to the relay that resets it
test: ./scripts/session-e2e.sh unpublished_port_refused_on_vm_host
produces:
- crates/switch/src/refusal.rs
consumes:
- crates/minvmd/src/net/egress_gate.rs
- crates/minimald/src/net/switch.rs
- crates/switch/src/bep_host.rs
- crates/sessions/src/core/egress.rs
proof: One builder in the switch crate shapes every TCP refusal, and each caller's output is compared against it, not against itself: the native relay and the in-VM gate leg, each driven on its own path, answer the same frame with the byte-identical reset, and the stack peer's pre-screen reset matches it with leg_mac as its source; on a VM-backed host a connection to a box's unpublished port is refused within 5 s on the VM lanes, the in-guest relay resetting a SYN the host gate passed through; a foreign source and a denied egress rule still drop silently; and every refusal is one rate-limited line in the one format on whichever leg refused it, carrying the window's real count, bounded per source so one flooding box starves no sibling's refusals.
depends on:
- blocked by #1652
- blocked by #1708
- blocked by #1711
- blocked by #1657
Activity
minimal-foundry commentedon Oct 3, 2026
Done. #1858 merged.
branch
agent/1800-shape-every-egress-refusal-from-one-builder-in-t· epic #1437 · PR #1858Updated 2026-10-28T06:17:32.453Z.