8000 fix(minimald): route vm host-address box dns through the node's dns layer by norrietaylor · Pull Request #2012 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

fix(minimald): route vm host-address box dns through the node's dns layer - #2012

Merged
norrietaylor merged 4 commits into
mainfrom
fix/vm-host-address-dns-through-node-layer
Oct 7, 2026
Merged

norrietaylor merged 4 commits into
mainfrom
fix/vm-host-address-dns-through-node-layer

Conversation

@norrietaylor
@norrietaylor norrietaylor commented Oct 6, 2026 •
Copy link
Copy Markdown
Member

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:

  • The daemon's own relay now carries the node's DNS layer. That relay is the guest's root egress, the tap every host-address box shares.
  • The guest's classifier table renders the resolver carve-out for the deny subtree: the gateway on port 53, over UDP and TCP.

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

  • NET-003, VM sub-bullet: WHERE the host is VM-backed, WHILE a box is a host-address box, its lookups resolve through the node's DNS layer and never through the host's own resolver.
  • NET-003, main line, for a deny-all host-address box: it resolves host.min.internal (to the host alias) and reaches nothing.
  • Design section 4.1, the deny-all resolver carve-out (spec 18 design reasoning, "the one carve-out from a deny-all verdict is the address and port of the resolver Minimal owns for the box"), for slice S11 (S11 — Host-address boxes under deny-all #1606), on VM hosts.

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_vsock and switch::spawn_node_relay attach 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-scoped DnsGate (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_egress now 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::plan on 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.sh replaces --no-resolver-carve-out with --gateway-resolver ADDR. That mode renders two rules in deny_out, ip daddr ADDR udp dport 53 accept and ip 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 passes DEFAULT_SUBNET.dns_server() through a new GuestRender::gateway_resolver field 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

  • minimald host_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's spawn_daemon_relay), from the guest's own address to the plan's nameserver:

    • An AAAA lookup for host.min.internal comes back NODATA from the relay and never reaches the switch.
    • The A lookup that follows is the first frame the switch sees.

    On the old relay the AAAA lookup is forwarded to the switch and the test fails.

  • minimald guest_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.

  • minimald guest_deny_all_renders_gateway_resolver_carve_out (replaces guest_deny_all_renders_no_resolver_carve_out). The guest's render admits exactly three things in deny_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.

  • minimald classifier 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-resolver parameters.

  • minvmd guest_deny_all_resolver_reachable (KVM, restored; replaces guest_deny_all_dns_to_gateway_refused). A deny-all host-address box on a guest whose table is loaded:

    • resolves host.min.internal to the host alias through its own resolv.conf;
    • gets NODATA for a raw AAAA query to the gateway, and the guest console carries the node layer's own session_id=node line for it, which is why minimald::net::dns_gate=debug joins the guest log filter;
    • passes the table on a TCP connect to the gateway's port 53;
    • is refused, with an errno in the reject set, when it connects to the address it resolved.
  • minvmd guest_loaded_ruleset_matches_native_print_ruleset renders the native digest with --gateway-resolver.

  • minimald allow_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_hosts plus 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 by rpc::tests::per_box_host_refuses_unenforceable_host_ip_declaration and session_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-resolver carve-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_out admits 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's crates/minimald reverted: test result: FAILED. 44 passed; 13 failed on 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.
  • Targeted, on the final head: cross test -p minimald ... --lib -- net::classifier::tests:: net::provider::tests:: net::dns_gate::tests:: ...: test result: ok. 69 passed; 0 failed.
  • Mutation check: with the node layer unwired from spawn_node_relay, host_ip_box_resolves_through_node_dns_layer fails (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 compiles guest_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-vm on Linux, MINVMD_E2E=1) is the final proof. It runs guest_deny_all_resolver_reachable against a real guest: the loaded table, the daemon's relay carrying the node layer, and the gvproxy resolver. It also runs guest_deny_all_probe_refused, which shows the carve-out admits port 53 and nothing else at the gateway, and guest_loaded_ruleset_matches_native_print_ruleset with the new render parameters. None of these can run on this Mac. They are compiled here, not run.

Summary by CodeRabbit

  • New Features

    • Guest host-address environments now use the switch gateway’s DNS service, including when access is denied or not yet decided. DNS queries to the gateway are allowed over UDP and TCP, while other denied traffic remains blocked.
    • The gateway DNS service can return local responses for selected record types and forward other lookups.
    • The host-classifier installer now supports selecting a gateway DNS resolver.
  • Bug Fixes

    • Deny-all guest environments can resolve host names without gaining access to the resolved addresses.

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.
@coderabbitai
coderabbitai Bot commented Oct 6, 2026 •
Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: f839b4a0-b213-4214-92e4-4ca12859ebb0
📥 Commits

Reviewing files that changed from the base of the PR and between 5ec89e9 and 038acf3.

📒 Files selected for processing (7)
  • crates/minimald/src/guest.rs 8000
  • crates/minimald/src/main.rs
  • crates/minimald/src/net/classifier.rs
  • crates/minimald/src/net/provider.rs
  • crates/minimald/src/net/switch.rs
  • crates/minvmd/tests/guest_classifier_integration.rs
  • scripts/install-host-classifier.sh

Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

VM-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.

Changes

Guest gateway DNS

Layer / File(s) Summary
Add the node DNS layer to the daemon relay
crates/minimald/src/net/switch.rs, crates/minimald/src/guest.rs
A node-specific relay carries a DNS layer that handles selected queries locally and forwards other lookups. Root egress uses this relay.
Configure the gateway resolver
crates/minimald/src/main.rs, crates/minimald/src/net/classifier.rs, crates/minimald/src/net/provider.rs, scripts/install-host-classifier.sh
Guest render and VM-host resolver planning use the switch gateway DNS address. Classifier and installer rules admit that address on UDP and TCP port 53 without DNS destination translation.
Validate resolver planning and guest rules
crates/minimald/src/net/classifier.rs, crates/minimald/src/net/provider.rs, crates/minvmd/tests/guest_classifier_integration.rs
Tests check resolver selection, gateway DNS admission, local AAAA responses, forwarding of other lookups, and refusal when connecting to a resolved address.

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
Loading

Suggested reviewers: evanspearman

Merge Risk: ⚪ Minimal · up to 038ac

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)
Check name Status Explanation
Title check ✅ Passed The title is concise, follows the repository’s Conventional Commit format, and clearly states the main change: routing VM host-address box DNS through the node’s DNS layer.
Description check ✅ Passed The description includes a detailed summary and extensive testing and validation evidence. It does not include the template’s Checklist section, but the required information is otherwise mostly comple…
Docstring Coverage ✅ Passed Docstring coverage is 97.06% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 7 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

A rabbit checks the gateway gate,
DNS hops in, then learns its fate.
Some answers bloom beside the tap,
Other queries take the switch-side map.
Deny-all guests can ask once more,
Then find their paths are barred as before.

Comment @coderabbitai help to get the list of available commands.

@norrietaylor
norrietaylor marked this pull request as ready for review October 6, 2026 15:33
@norrietaylor
norrietaylor requested a review from a team as a code owner October 6, 2026 15:33
@minimal-foundry
minimal-foundry Bot commented Oct 6, 2026 •
Copy link
Copy Markdown
Contributor

Approved: no findings at 038acf38098a.

Nothing to do.

Clobber: none: git log on switch.rs/provider.rs shows the two head commits are this PR's own; the removed deny-all branch was introduced by this same PR's predecessor and its removal is the point of the change, not a revert.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Oct 6, 2026
@norrietaylor
norrietaylor enabled auto-merge October 6, 2026 23:44
@norrietaylor
norrietaylor disabled auto-merge October 7, 2026 01:45
@norrietaylor
Copy link
Copy Markdown
Member Author

ARCH verdict on the head, KVM proof, approval.

@norrietaylor
norrietaylor merged commit 80f93a9 into main Oct 7, 2026
34 checks passed
@norrietaylor
norrietaylor deleted the fix/vm-host-address-dns-through-node-layer branch October 7, 2026 01:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:approved minimal-review approved the current head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0