8000 fix(net): answer a macOS box's ports at the address its name resolves to (0.7.0 backport) by norrietaylor · Pull Request #2740 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

fix(net): answer a macOS box's ports at the address its name resolves to (0.7.0 backport) - #2740

Closed
norrietaylor wants to merge 9 commits into
release/0.7.xfrom
fix/net-macos-box-address-0.7.0
Closed

norrietaylor wants to merge 9 commits into
release/0.7.xfrom
fix/net-macos-box-address-0.7.0

Conversation

@norrietaylor
Copy link
Copy Markdown
Member

Backports #2713 (task T98, #2687) to release/0.7.0, together with #2332, which it builds on.

On macOS, a box's name resolves to its own loopback address (127.0.64.N), but the guest walked that range once at VM start and fell back to publishing every port on 127.0.0.1. So curl http://<box>.min.internal:<port> and curl 127.0.64.N:<port> failed while 127.0.0.1:<port> worked. With this change a box's ports answer at the address its name resolves to: the range is walked again at box start, so a range made bindable after the VM started (min finalize-install) is picked up. A host that cannot bind the range hands the box the loopback interim and says so.

Stacked on #2678; merge that first. #2678 carries the 0.7.3 version bump and the pulldown-cmark advisory fix. Until it merges, this diff also shows #2678's commits.

Commits

Squash-merge the three. All tests from both PRs are kept, with no assertion dropped. User-facing text is main's.

Conflict with #2738: both add the same ADDRESS_IN_USE_BODY constant to net/policy.rs. Whichever lands second needs a one-hunk resolution: keep one copy. Clippy is clean on the merged tree.

Verification (Linux, Lima)

  • just clippy and just fmt-check are clean.
  • New tests pass: session_start_rewalks_range_after_finalize_install, each_box_gets_own_loopback_address and the policy probe tests (minimald); f 10BC8 ix(net): hand boxes the loopback interim when the host cannot bind the box range #2332's answerer and control tests (minvmd).
  • minvmd: 542 passed. minimald: 999 passed, 1 failed, the VM-only cgroup EACCES failure session_host::tests::a_hook_launch_does_not_advise_unenforced_placement.
  • Linux routes all of 127.0.0.0/8, so the macOS behaviour itself is not observable here. The unit tests simulate an unbindable range with stand-in probes. The real proof is the new e2e case fresh_install_range_lands_on_macos, which runs on the macOS lane.

gominimal-aw-bot Bot and others added 8 commits October 27, 2026 20:37
* 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)
…er (#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)
crate::traced landed on main after the release branch was cut, so the
cherry-pick of #2300 did not compile here.
v0.7.2 is tagged at the branch head, so the version check requires the
next patch release.
…he box range (#2332)

* fix(minvmd): hand boxes the interim when the host cannot bind the range

Allocation drew an address out of the reserved 127.0.64.0/24 without
measuring whether the host can bind it. Linux routes the whole of
127.0.0.0/8, but macOS answers only the addresses lo0 carries, so on a
host without the range aliases the hand was dead: the zone answered an
address at which nothing listens, while the box actually publishes at
127.0.0.1. A publish may not claim an address the host cannot bind, so
allocation now probes the freshly resolved address with a TcpListener
bind to port 0 and hands the 127.0.0.1 interim instead when the bind
refuses; the address the guest then publishes is the one the zone
answers, and every part agrees. A host that gains the aliases mid-way
hands range addresses out on the next allocation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(minvmd): keep a box's address across the bind probe

A box created at the 127.0.0.1 interim now keeps it on resume: the
resume does not ask the answerer, which records nothing for an interim
box and would hand a range address once the host binds the range. A
box's own held or quarantined address comes back without the probe, so
its address never moves under it.

The probe reads only EADDRNOTAVAIL as an unaliased range and logs a
warning with the error. Any other bind error refuses the allocation.
Unit tests take a probe that always binds, so hosts without the lo0
aliases still hand distinct range addresses in the service-path tests.

* fix(minvmd): read the box range through the one whole-range probe

The address book now hands a fresh box address only when switch::loopback::probe reads the
whole box range present, so a partial alias set gets the 127.0.0.1 interim, as min list
advises finalize-install. The per-address bind copy is gone. A probe refused for any reason
other than an absent address still refuses the allocation.

The probe is a field of AddressBook: the live probe in production, a present range under
test, so the service-path tests hand range addresses on a Mac without the lo0 aliases. The
warning that the range is absent is logged once per episode, with the probe's summary.

A control test proves a box created at the interim resumes at 127.0.0.1 after the host gains
the range while a new box gets a range address, and the book test now asserts the range
address only for a new box.

* fix(minvmd): keep a range box's address on resume after a restart

A restarted answerer holds no record of a dormant box. Where the host
could not bind the box range, a resume of a range box was handed the
127.0.0.1 interim and refused as LoopbackMoved.

The resume now sends the address the box was created at. The book hands
it back without the range probe and records it, while no other box holds
it or released it inside its quarantine. An answerer that predates the
field ignores it.

* fix(minvmd): build #2348's dormant-creation test against allocate's keep

The merged test calls RegisteredTables::allocate without the keep
argument this branch added. Pass None there, and say at the fresh
registration's call site why it keeps no address.

---------

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>
Co-authored-by: Norrie Taylor <91171431+norrietaylor@users.noreply.github.com>
(cherry picked from commit 379c2f0)
…olves to (T98) (#2713)

* fix(minvmd): hand the interim when the host cannot bind the range

Bring in the work of #2332 from its merge-queue commit. Allocation
reads the whole box range through switch::loopback::probe and hands
the 127.0.0.1 interim when the host cannot bind it, so the zone never
answers an address that nothing listens at. A resume keeps the range
or interim address the box was created at across an answerer restart.

* fix(minimald): re-walk the box range at each session start on a VM

A microVM daemon walked the host's loopback once, at its start. After
`min finalize-install` the host binds the range, but the daemon kept
the absent verdict, so new boxes stood at the 127.0.0.1 interim until
the VM restarted.

The create handler on a VM-backed daemon now asks the forwarder's
walk, not the guest's own lo, so the reply's interim flag is the
host's verdict. While the landed verdict is not present, each session
start walks again. A present landing moves every box at the interim
onto its range address through land_range_verdict's present arm.
Walks run one at a time on the shared probe port.

* test(minimald): two boxes created after the range lands hold their own

The boxes in each_box_gets_own_loopback_address are now created after a
session-start walk lands the range present over an absent daemon-start
verdict. The test checks that each box's forwards bind at its own range
address.

* test(e2e): assert a macOS box answers at its range address

On the macOS lane the fresh-install case runs the script that
min finalize-install runs on a running VM. It then asserts that a new
own-IP box answers at <name>.min.internal:8080 and at its 127.0.64.N
address, and that min ls names that address.

* fix(minimald): bind-probe the range at every session start

The range can go away under a running VM. A present verdict no longer
skips the session-start walk, so a present-to-absent walk lands the
absent arm and moves the boxes in the range onto the 127.0.0.1 interim.

* docs(specs): publish NET-040 ports at the address the box name resolves to

* fix(minimald): count a held probe port as a bound range address

The session-start range walk binds port 21064 on each range address.
A box forward or a host_ip mirror on that port made the forwarder refuse
the round, and the walk read the whole range absent. Every box then left
its session-stable address.

post_json_within now gives the forwarder's in-use refusal the AddrInUse
kind. The walk counts such a round as bound and sends no unexpose for it.

* fix(minimald): keep a present range over a walk with no answer

A session-start walk that times out or cannot reach the forwarder is a
fault of the channel, not an absent range. Over a landed present verdict
it now lands nothing, so running boxes keep their range addresses. Only
a refusal the forwarder answered lands the absent arm.

The absent landing also moves the node's own address off the range onto
the 127.0.0.1 interim, so host-address names stop answering an address
the host cannot bind.

* fix(minimald): release a leftover range probe forward before reading it

The forwarder answers 'already running' from its own forward table, so a
probe forward that an earlier walk left bound read as a bound address on
every later walk. The walk now marks each address before its expose and
clears the mark once nothing of its own is held there. At a marked
address an in-use answer releases the leftover and asks again.

---------

Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com>
Co-authored-by: Norrie Taylor <91171431+norrietaylor@users.noreply.github.com>
(cherry picked from commit 64d3358)
The two cherry-picks before this one keep main's text. Main renamed and
added code that release/0.7.0 does not have, so this commit adapts it:

- The box_manager module is sessions.rs here. The cherry-pick placed
  #2713's box_manager.rs and box_manager/handle.rs hunks in sessions.rs:
  RangeWalk, RangeWalkSource and RangeWalkAnswer, the range_walk cell on
  ManagerHandle and WeakManagerHandle, session_start_range_walk and
  install_range_walk_standin. BoxManager::init is Manager::init, and the
  create handler's answer type is crate::sessions::RangeWalkAnswer.
- The rpc tests live inline in rpc.rs here, so
  session_start_rewalks_range_after_finalize_install and its
  spawn_holding_forwarder joined that module, reformatted for its indent.
- crate::traced is not on release. The stand-in forwarders in the policy
  and rpc tests spawn with tokio::spawn.
- The boxes crate is sessions here, so the answerer imports
  sessions::core::zone_answer.
- ADDRESS_IN_USE_BODY came with #2352's held-port refusal, which release
  lacks. post_json_within reads it to give a held listener the AddrInUse
  kind, so this adds the constant alone, with main's text, beside
  ALREADY_RUNNING_BODY.
- #2332 also passed None to RegisteredTables::allocate in the dormant
  creation tests that #2348 added on main. Release has neither those tests
  nor the dormant rows, so the cherry-pick kept the release's tests there.

Every test both pull requests add is kept, with no assertion dropped. The
NET-040 spec line applies to release's spec unchanged and is kept. The
behaviour is #2332's and #2713's: a box's ports answer at the address its
name resolves to.
@norrietaylor
norrietaylor requested a review from a team as a code owner October 29, 2026 03:05
@coderabbitai
coderabbitai Bot commented Oct 29, 2026 •
Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 049b1f78-525a-4dbb-89b7-9cbdb2b1ff41

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@minimal-foundry
minimal-foundry Bot commented Oct 29, 2026 •
Copy link
Copy Markdown
Contributor

Approved: no findings at e16328f41274.

Nothing to do.

Clobber: none seen.

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

Panel: 3 of 3 personas ran (correctness, security, supply-chain).

…s-box-address-0.7.0

# Conflicts:
#	scripts/install-host-classifier.sh
norrietaylor pushed a commit that referenced this pull request Nov 4, 2026
…he box range (#2332)

Prerequisite for T98. Allocation drew an address out of the reserved 127.0.64.0/24 without
measuring whether the host can bind it. Linux routes all of 127.0.0.0/8, but macOS answers
only the addresses lo0 carries, so on a host without the range aliases the zone answered
an address nothing listens at. The address book now hands a fresh box address only when
switch::loopback::probe reads the whole range present, and hands the 127.0.0.1 interim
otherwise. A probe refused for any reason but an absent address refuses the allocation.
A box keeps its address across the probe: an interim box resumes at the interim, and a
resume sends the address it was created at, so a restarted answerer hands it back.

Backport of #2332, folded from #2740.

Adapted to the line: the answerer imports sessions::core::zone_answer. Dropped: the edit to
main's dormant-creation tests, since the line has neither those tests nor dormant rows.

(cherry picked from commit 379c2f0)
norrietaylor pushed a commit that referenced this pull request Nov 4, 2026
…olves to (#2713)

T98. A microVM daemon walked the host's loopback once, at its start, so after min
finalize-install new boxes stood at the 127.0.0.1 interim until the VM restarted. The
create handler on a VM-backed daemon now asks the forwarder's walk, not the guest's own
lo, and every session start walks again, so a present landing moves boxes at the interim
onto their range addresses and a present-to-absent walk moves them back. Walks run one at
a time on the shared probe port. A held probe port counts as bound, a leftover probe
forward is released before it is read, and a walk that gets no answer keeps a present
verdict. The absent landing also moves the node's own address to the interim. The NET-040
spec line and the macOS e2e case come with the change.

Backport of #2713, folded from #2740.

Adapted to the line:
- box_manager.rs and box_manager/handle.rs hunks land in sessions.rs: RangeWalk,
  RangeWalkSource and RangeWalkAnswer, the range_walk cell on ManagerHandle and
  WeakManagerHandle, session_start_range_walk and install_range_walk_standin.
- rpc/tests.rs hunks land in the inline tests in rpc.rs.
- crate::traced::spawn becomes tokio::spawn in the stand-in forwarders.
- ADDRESS_IN_USE_BODY is shared with #2352: one copy stays in net/policy.rs.

(cherry picked from commit 64d3358)
8000
norrietaylor pushed a commit that referenced this pull request Nov 5, 2026
…he box range (#2332)

Prerequisite for T98. Allocation drew an address out of the reserved 127.0.64.0/24 without
measuring whether the host can bind it. Linux routes all of 127.0.0.0/8, but macOS answers
only the addresses lo0 carries, so on a host without the range aliases the zone answered
an address nothing listens at. The address book now hands a fresh box address only when
switch::loopback::probe reads the whole range present, and hands the 127.0.0.1 interim
otherwise. A probe refused for any reason but an absent address refuses the allocation.
A box keeps its address across the probe: an interim box resumes at the interim, and a
resume sends the address it was created at, so a restarted answerer hands it back.

Backport of #2332, folded from #2740.

Adapted to the line: the answerer imports sessions::core::zone_answer. Dropped: the edit to
main's dormant-creation tests, since the line has neither those tests nor dormant rows.

(cherry picked from commit 379c2f0)
norrietaylor pushed a commit that referenced this pull request Nov 5, 2026
…olves to (#2713)

T98. A microVM daemon walked the host's loopback once, at its start, so after min
finalize-install new boxes stood at the 127.0.0.1 interim until the VM restarted. The
create handler on a VM-backed daemon now asks the forwarder's walk, not the guest's own
lo, and every session start walks again, so a present landing moves boxes at the interim
onto their range addresses and a present-to-absent walk moves them back. Walks run one at
a time on the shared probe port. A held probe port counts as bound, a leftover probe
forward is released before it is read, and a walk that gets no answer keeps a present
verdict. The absent landing also moves the node's own address to the interim. The NET-040
spec line and the macOS e2e case come with the change.

Backport of #2713, folded from #2740.

Adapted to the line:
- box_manager.rs and box_manager/handle.rs hunks land in sessions.rs: RangeWalk,
  RangeWalkSource and RangeWalkAnswer, the range_walk cell on ManagerHandle and
  WeakManagerHandle, session_start_range_walk and install_range_walk_standin.
- rpc/tests.rs hunks land in the inline tests in rpc.rs.
- crate::traced::spawn becomes tokio::spawn in the stand-in forwarders.
- ADDRESS_IN_USE_BODY is shared with #2352: one copy stays in net/policy.rs.

(cherry picked from commit 64d3358)
@norrietaylor
Copy link
Copy Markdown
Member Author

Folded into #3019, which landed on release/0.7.x as ac7491e84.

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