Repository navigation
fix(minimald): route vm host-address box dns through the node's dns layer - #2012
Conversation
Every host-address box on a VM-backed guest shares the guest's root namespace, so its lookups left through the daemon's own relay, which carried no DNS gate: they reached the gateway resolver without passing the layer an own-address box's lookups pass. The daemon's relay now carries a node-scoped DNS gate that answers AAAA, HTTPS and SVCB with NODATA (NET-136) and forwards every other lookup, so the namespace's lookups reach upstream only through the node's DNS layer (NET-003).
A deny-all host-address box on a VM-backed guest had no resolver: the guest's table carved nothing out and its plan named no nameserver. The installer gains --gateway-resolver ADDR, which admits ADDR on port 53 over UDP and TCP in deny_out and renders no dstnat chain. The guest renders with the switch gateway, and every host-address box's plan names the gateway, deny-all or not and decided or not: one path, through the node's DNS layer (NET-003, NET-079). host_ip_box_resolves_through_node_dns_layer now drives the daemon's relay as the guest builds it, so it fails when that relay carries no DNS layer. guest_deny_all_box_resolves_through_answerer_once_decided is restored over the new resolver and ties it to the rendered carve-out. A new test pins that a deciding guest refuses an allow-list host-address declaration under the NET-079 interim.
guest_deny_all_resolver_reachable replaces guest_deny_all_dns_to_gateway_refused. A deny-all host-address box resolves host.min.internal through the gateway, gets NODATA for AAAA from the node's DNS layer on the daemon's relay, passes the table on TCP 53, and is refused when it connects to what it resolved. The native digest renders with the guest's --gateway-resolver parameter.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (7)
Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 0 remain after this review. 📝 WalkthroughWalkthroughVM-host guests now use the switch gateway DNS layer, including deny-all boxes. The daemon relay handles selected DNS queries locally and forwards other lookups. Guest classifier rules admit gateway DNS on UDP and TCP port 53 without DNS destination translation. ChangesGuest gateway DNS
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant Guest
participant GuestClassifier
participant NodeDnsLayer
participant Switch
Guest->>GuestClassifier: Send DNS query to gateway
GuestClassifier->>NodeDnsLayer: Admit gateway DNS traffic
alt Query matches node DNS interception rules
NodeDnsLayer->>Guest: Return synthesized DNS reply
else Query is not intercepted
NodeDnsLayer->>Switch: Forward DNS query
end
Suggested reviewers: Merge Risk: ⚪ Minimal · up to This change routes DNS for host-address boxes on VM hosts through the node's DNS layer. No concrete defect was identified. The Linux VM test lane remains the final proof before merge. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
A rabbit checks the gateway gate, Comment |
|
Approved: no findings at Nothing to do. Clobber: none: |
|
ARCH verdict on the head, KVM proof, approval. |
Summary
On a VM-backed host, every host-address box now resolves through the node's DNS layer, deny-all boxes included, over one code path. Two pieces make that true:
Before this change, a deny-all host-address box on a VM host got an empty resolver and its port 53 to the gateway was refused. Every other host-address box's lookups left through a relay that carried no DNS layer at all, so they reached gvproxy's resolver without passing anything Minimal owns.
Requirements closed
host.min.internal(to the host alias) and reaches nothing.NET-066 is moot on this path while the NET-079 interim stands; see below.
Design
What "the node's DNS layer" is inside the guest. For an own-address box, the layer is two things. First, the in-guest relay's DNS gate (
net/dns_gate.rs), which intercepts the box's lookups to the gateway resolver and answers AAAA, HTTPS and SVCB itself (NET-136); it also pins names for NET-066. Second, the zone the switch's resolver answers at the gateway:min.internal, with the host row and sibling rows. Non-matching names go upstream from there. A host-address box on a VM host shares the guest's root namespace. Its frames leave through the daemon's own relay,guest::bring_up_root_egress, which carried no gate. Its lookups therefore reached the gateway resolver without passing the layer an own-address box's lookups pass. The nameserver address was the same, but the path was different.Routing.
switch::attach_node_to_switch_vsockandswitch::spawn_node_relayattach the guest's root tap the way every daemon relay is attached: ungated, with the NET-084 lease check. The relay also carries a node-scopedDnsGate(node_dns_layer), and its egress leg consults that gate for UDP to the gateway resolver on port 53. The gate has no session policy, so it declares no names and pins nothing. It answers AAAA, HTTPS and SVCB with NODATA and forwards every other lookup on, exactly as a box relay's gate does.guest::bring_up_root_egressnow attaches through it, so nothing from the guest's namespace reaches the resolver except through the layer. A frame cannot tell the daemon's lookups from a host-address box's, because both carry the guest's address. The layer therefore also serves the daemon's own lookups. That costs nothing: the guest runs with IPv6 disabled (NET-082), so an AAAA answer was of no use to it.Plan.
HostIpAddressNetwork::planon a VM host gives every host-address box the gateway as its nameserver, whatever its verdict and whatever the launch's decision. The deny-all special case that returned an empty resolver is gone. That is the "one code path" the ruling asks for.Carve-out.
scripts/install-host-classifier.shreplaces--no-resolver-carve-outwith--gateway-resolver ADDR. That mode renders two rules indeny_out,ip daddr ADDR udp dport 53 acceptandip daddr ADDR tcp dport 53 accept, and no dstnat chain, because the box already asks the address the rule admits. It records no answerer carve-out. The guest's boot passesDEFAULT_SUBNET.dns_server()through a newGuestRender::gateway_resolverfield and logs one line naming the carve-out. The rule names the address and the port together, because the gateway is also the switch's control surface: the deny-all probe to the gateway on port 443 stays refused. The native render (answerer on loopback, plus dstnat) does not change. The host-side gate already excepts the resolver's port, on both protocols, from its switch-control refusal.Why this shape. Two options were on the table: the daemon relay gates the box's DNS, or the answer comes from the registry. The relay option reuses the gate and the zone every other box on the node already resolves through. It needs no new resolver in the guest, and the guest has none since the zone answerer moved host-side. A frame-level layer cannot apply a per-box name rule to host-address boxes, because they all leave as the guest's one address. The ruling leaves per-box refusal out of scope (NET-141), so this fix does not attempt it.
Tests
minimaldhost_ip_box_resolves_through_node_dns_layer(corrected). The old test asserted only that the plan's nameserver is the gateway and equals an own-address box's nameserver. That equality held while the host-address box's lookups bypassed the layer entirely, because an address says nothing about the path. The test now also drives the daemon's relay as the guest builds it (spawn_node_relay, through the harness'sspawn_daemon_relay), from the guest's own address to the plan's nameserver:host.min.internalcomes back NODATA from the relay and never reaches the switch.On the old relay the AAAA lookup is forwarded to the switch and the test fails.
minimaldguest_deny_all_box_resolves_through_answerer_once_decided(restored name, new body). A deny-all box in a guest that decides per box resolves through the gateway. That nameserver is the address the guest's own rendered table admits on port 53 over UDP and TCP, so the plan and the table state one fact. An undecided launch, an unread decision and a box that is not deny-all all get the same resolver.minimaldguest_deny_all_renders_gateway_resolver_carve_out(replacesguest_deny_all_renders_no_resolver_carve_out). The guest's render admits exactly three things indeny_out: the reply direction, the gateway's UDP 53 and the gateway's TCP 53. It has no dstnat chain, no rule moves a lookup anywhere, and port 53 is matched nowhere else.minimaldclassifier render tests (guest_table_is_the_installers_rendered_ruleset, the digest test,guest_installer_runs_with_cleared_env_and_argv_params,carve_out_targets_live_answerer_bind) now render with the guest's--gateway-resolverparameters.minvmdguest_deny_all_resolver_reachable(KVM, restored; replacesguest_deny_all_dns_to_gateway_refused). A deny-all host-address box on a guest whose table is loaded:host.min.internalto the host alias through its own resolv.conf;session_id=nodeline for it, which is whyminimald::net::dns_gate=debugjoins the guest log filter;minvmdguest_loaded_ruleset_matches_native_print_rulesetrenders the native digest with--gateway-resolver.minimaldallow_list_host_address_declaration_refused_on_vm_host(new). Under the NET-079 interim, a guest that decides per box refuses an allow-list host-address declaration (allow_dns_hostsplus its address rules), naming each rule. The same declaration is not refused on a guest that decides nothing, nor for an own-address box. This replaces the allow-list leg the original scope asked for; see the next section. The create and launch paths are already covered byrpc::tests::per_box_host_refuses_unenforceable_host_ip_declarationandsession_host::tests::launch_refuses_unenforceable_host_ip_declaration_after_host_gains_per_box.NET-066: moot while the NET-079 interim stands
No allow-list leg is added. On a VM-backed host the guest decides per box once its table loads. Under the NET-079 interim refusal (spec PR #1916, which matches the shipped code), a host that decides per box refuses any host-address box whose declaration carries
allow_dns_hosts, at create and at launch (classifier::unenforceable_rules,refuses_unenforceable_declaration), because the guest's table enforces deny-all or nothing. No allow-list host-address box runs on such a host, so NET-066 has no box to apply to there. It is recorded as moot while the interim stands, not measured. The new unit test above pins the refusal.Residuals
The carve-out is interim: a later change replaces this PR's
--gateway-resolvercarve-out with a deny-subtree listener in the guest. That change dstnats deny-subtree UDP 53 to a guest-loopback listener that refuses names outside the zone, and drops TCP 53. This PR's routing, plan change and node DNS layer stay.NET-066 for allow-list host-address boxes on a VM host is moot while the NET-079 interim stands; lifting that refusal will need per-box admission inside the guest.
Name refusal: the node's DNS layer still forwards names it does not hold, for host-address boxes as for every box. A deny-all box can therefore still carry data out in a queried name. This is the same gap own-address boxes on a VM host have. It is tracked separately with the refusal work for NET-141 (draft spec PR docs(specs): refuse names a box's allow list does not match before forwarding upstream #2009), not here.
Resolvers an allow-all box chooses: an allow-all host-address box can reach any resolver as ordinary egress. This is not a bypass that adds reach: the box can already reach any destination. A deny-all box can't, because
deny_outadmits only the gateway on port 53. An allow-list host-address declaration is refused on a VM host under the NET-079 interim.TCP lookups: the node layer intercepts UDP only, as every box relay's gate does. TCP lookups to the gateway go to the resolver unchanged. The deny-all carve-out admits TCP 53 because a truncated answer is retried over TCP. Intercepting TCP lookups is tracked with the same refusal work.
Daemon lookups: the layer also answers the daemon's own AAAA, HTTPS and SVCB lookups with NODATA, because a frame cannot tell them apart from a box's. The guest has no IPv6, so nothing is lost.
Validation
On macOS arm64. minimald goes through
cross(musl, in Docker):CROSS_CONTAINER_OPTS="--env HOME=/tmp" cross test -p minimald --target aarch64-unknown-linux-musl --locked(full suite):test result: FAILED. 808 passed; 16 failed; 1 ignored. Every changed and new test passed in that run. 13 of the failures (net::switch::tests::{allowed_egress_succeeds, egress_drop_logged_rate_limited, native_relay_drops_flows_to_switch_addresses, refused_resets_are_budgeted_per_source, relay_rejects_non_lease_source, revoked_port_refusal_is_logged, the_shared_quota_spends_per_source_and_says_the_windows_count, unpublished_port_connection_refused},hooks::tests::a_n*(2),nsenter::tests::*(3)) reproduce identically on the base commit, with this branch'scrates/minimaldreverted:test result: FAILED. 44 passed; 13 failedon both. They are pre-existing in this container, not caused by this change. The other 3 (connection::tests::direct_tcpip_served_in_release,rpc::tests::name_surface_reported_when_both_deployed,net::switch::tests::a_laned_box_frame_to_the_proxy_listener_passes) failed only in the parallel full run and passed when rerun serially.cross test -p minimald ... --lib -- net::classifier::tests:: net::provider::tests:: net::dns_gate::tests:: ...:test result: ok. 69 passed; 0 failed.spawn_node_relay,host_ip_box_resolves_through_node_dns_layerfails (test result: FAILED. 0 passed; 1 failed). The corrected test fails on the old behaviour.cross clippy -p minimald --target aarch64-unknown-linux-musl --locked --all-targets -- -D warnings: clean.just fix,just clippy(macOS scope,-p minvmd -p sessions --all-targets, which compilesguest_classifier_integration.rs): clean.just test: 1003 tests run, 1003 passed, 19 skipped (the ignored VM tests).What CI proves
The KVM lane (
just test-vmon Linux,MINVMD_E2E=1) is the final proof. It runsguest_deny_all_resolver_reachableagainst a real guest: the loaded table, the daemon's relay carrying the node layer, and the gvproxy resolver. It also runsguest_deny_all_probe_refused, which shows the carve-out admits port 53 and nothing else at the gateway, andguest_loaded_ruleset_matches_native_print_rulesetwith the new render parameters. None of these can run on this Mac. They are compiled here, not run.Summary by CodeRabbit
New Features
Bug Fixes