8000 fix(minimald): refuse an activation whose declared port something else holds (0.7.0 backport) by norrietaylor · Pull Request #2738 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

fix(minimald): refuse an activation whose declared port something else holds (0.7.0 backport) - #2738

Closed
norrietaylor wants to merge 9 commits into
release/0.7.xfrom
fix/net-activation-port-held-0.7.0
Closed

norrietaylor wants to merge 9 commits into
release/0.7.xfrom
fix/net-activation-port-held-0.7.0

Conversation

@norrietaylor
Copy link
Copy Markdown
Member

Backports #2725 (task T100, #2703) to release/0.7.0, together with #2352, which it builds on. If a box's declared ingress port cannot be bound on the host at activation, because another box or a host program holds it, the activation is refused before the box is reported active. The refusal names the port and what holds it, and nothing is left behind. Before this, two boxes declaring the same port both activated and one silently lost the port.

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. The refusal text is main's, unchanged.

Verification (Linux, Lima)

  • just clippy and just fmt-check are clean.
  • The 10 new tests pass (activation_refused_when_declared_port_is_held_*, host_port_in_use::*, activate_refusal_names_port_and_holder).
  • minimald: 1006 passed, 1 failed, the VM-only cgroup EACCES failure session_host::tests::a_hook_launch_does_not_advise_unenforced_placement.
  • minimal: 546 passed, 3 failed: the resolver::tests::finalize_install_undo_removes_* tests, which try to remove root-owned files under /etc/systemd/system in the VM. They fail the same way on the unmodified fix(net): backport the deny-all DNS refusal and the owner-only answerer socket to 0.7.0 #2678 head; this PR does not touch resolver.rs.
  • minimald-rpc: 65 passed.

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.
…aken (#2352)

* fix(minimald,minimal): skip a taken host port instead of failing on it later

An own-ip session that declares --ingress <port>:<internal> on a host
port another process already holds activated silently (exit 0), and
every later exec/attach failed on the raw forwarder refusal
(HTTP 500: bind: address already in use) with no usable trace at
activation.

The daemon now reads the OS's own EADDRINUSE words in that refusal
and skips just that mapping: the box keeps the forwards it could
bind, the attach completes, and each skipped port logs a warn line
with the session's name. Every other refusal keeps the existing
rollback-and-fail.

The client learns the box's published address in the finalize reply
(report_published_address, gated like report_shared_port_collisions)
and probes each declared TCP host port at activation, printing a
warning that names the port and the way out -- the box activates
either way, never refused on it.

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

* fix(minimald,minimal): fail on a taken host port and warn at activation

Per review: a declared host port another process holds no longer skips
the forward while the name stays registered. The apply rolls back and
fails as NET-121 requires, with an error that names the taken port.
The daemon now probes the declared ports at finalize, when no activate
hook bound them, and reports the taken ones. The client prints one
warning per port, in place of its own probe.

* chore(worker-iterate): address CodeRabbit findings on PR #2352

* fix(minimald,minimal): say the next step for a taken host port and skip the probe on a vm host

The taken-port error reaches the person at every failed command, so it now names the port and
the next step (free the port, or activate again with --ingress <other>:<internal>) and drops the
forwarder's raw refusal, which the apply's warn line already logs.

On a VM-backed host the forwarder binds on the host OS, so a bind in the guest cannot find a
port the host holds and can warn falsely for a host_ip box. host_ports_in_use now takes the
control channel and reports nothing when the host unbinds the declared forwards.

The comments state the all-or-nothing rule without review history, and the CLI warning says
"activate again with --ingress" in place of "declare --ingress".

* chore(worker-iterate): address CodeRabbit findings on PR #2352

* fix(minimald,minimal): fail finalize on a taken declared host port

The finalize probe now runs right after the name registration, before
the activate-hook launch and the Active write. A declared TCP host port
that another process holds fails the finalize with the named-port error.
The host_ports_in_use reply field, its request flag and the CLI warning
are removed, because a held port no longer reaches a successful reply.

* test(minimald): pin the finalize failure on a taken host port

---------

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>
(cherry picked from commit 9437ba3)
…e holds (T100) (#2725)

* feat(minimald): refuse activation on a declared port held elsewhere (T100)

Finalize refuses a declared ingress host port that a program outside
Minimal holds, or that another box holds at the 127.0.0.1 fallback
once a landed range verdict keeps the box there. The refusal is a
typed PortInUseRefusal that names the port and its holder, and the
daemon logs one warn line with the session, port, address and holder.

* feat(minimal): print the port refusal when activation is refused (T100)

min session activate ends with the daemon's refusal text alone when a
declared host port is already in use: the port, its holder and the
--ingress remedy, in place of the RPC context around it.

* fix(minimald-rpc): name an unknown port holder as another program

No box in the daemon's hostname registry holding the port does not prove
the holder is outside Minimal: the daemon's own proxy and a co-resident
node's box also arrive as the host holder. Arch-review condition C1.

* fix(minimald): bind declared ports at publish on a VM-backed host

On a VM-backed host the daemon cannot probe the host's ports, so a box
went Active with a held declared port and failed only at its first
attach. Finalize now binds the declared forwards toward the handed
switch address before the Active write. A held port fails with the same
refusal and holder text as the native probe. The first attach takes the
host's "already bound" answer as the box's own forward.

A box that finalizes at the 127.0.0.1 fallback under a pending range
verdict still yields a port another box holds there. A watcher now
waits for the verdict to land, and an absent landing reports the held
port, the address and the holder in the daemon log.

---------

Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com>

(cherry picked from commit 703bb7f)
…ease/0.7.0

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 finalize code lives in session.rs, not box_actor/setup.rs. The
  refusal block, warn_port_refusal and watch_fallback_landing moved there,
  and registry_name is the session module's own helper, not
  super::helpers::registry_name.
- boxes::NetworkMode, boxes::IngressPolicy and boxes::IpProto are
  sessions::NetworkMode, sessions::IngressPolicy and sessions::IpProto.
  switch::BoxGate is switch::SessionGate.
- crate::traced is not on release. The fallback watcher spawns with
  tokio::spawn under the same root span, and the fake forwarder in the
  policy tests spawns with tokio::spawn.
- OwnAddressReporter::registry came with NET-129's dynamic-hold tracking
  on main. The attach path's holder lookup needs only the accessor, so
  this adds the accessor alone.
- DestroySessionRequest has no only_if_unfinalized field on release, and
  the activate cleanup there sends a plain destroy. The refusal tests
  assert that the refused box is listed and not Active, then destroy it
  as the release cleanup does, and still assert that no record is left.

The user-facing refusal text is main's, unchanged.
@norrietaylor
norrietaylor requested a review from a team as a code owner October 29, 2026 01:02
@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: 90e59301-6217-4ba0-8ce9-d389d2fe5b0b

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 19553b164ba2.

Nothing to do.

Clobber: not checked in depth. If that was not intentional, restore the earlier change.

@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 — advisory review

Verdict COMMENT — No findings.

Panel: 2 of 3 personas ran (correctness, security); did not complete: supply-chain.

…vation-port-held-0.7.0

# Conflicts:
#	scripts/install-host-classifier.sh
norrietaylor pushed a commit that referenced this pull request Nov 4, 2026
…e holds (#2725)

T100. Finalize refuses a declared ingress host port that a program outside Minimal holds,
or that another box holds at the 127.0.0.1 fallback once a landed range verdict keeps the
box there. The typed PortInUseRefusal names the port and its holder, the daemon logs one
warn line, and min session activate ends with that refusal text alone. An unknown holder
is named as another program. On a VM-backed host, finalize binds the declared forwards
toward the handed switch address before the Active write, and the first attach takes the
host's "already bound" answer as the box's own forward. A box that finalizes at the
fallback under a pending verdict gets a watcher that reports an absent landing.

Backport of #2725, folded from #2738.

Adapted to the line:
- The finalize code lives in session.rs, not box_actor/setup.rs, and registry_name is the
  session module's own helper. In the CLI, activate_refusal applies to activate_session.
- boxes:: becomes sessions:: and switch::BoxGate becomes switch::SessionGate.
- The fallback watcher spawns with tokio::spawn under the same root span.
- OwnAddressReporter::registry comes without NET-129's dynamic-hold tracking. The attach
  path's holder lookup needs only the accessor.
- The line's DestroySessionRequest has no only_if_unfinalized, so the refusal tests assert
  the refused box is listed and not Active, destroy it plainly, and assert no record stays.

(cherry picked from commit 703bb7f)
norrietaylor pushed a commit that referenced this pull request Nov 5, 2026
…aken (#2352)

Prerequisite for T100. A declared TCP host port that another process holds at the box's
published address fails the finalize before the Active write (NET-121), with an error that
names the port and the next step. The daemon probes on a native host. On a VM-backed host
the forwarder binds on the host OS, so the probe is skipped and the spawn's apply reports
the port. A forwarder's EADDRINUSE refusal fails the apply the same way.

Backport of #2352, folded from #2738.

Adapted to the line: main's box_actor/setup.rs hunk lands in session.rs, boxes::IpProto and
boxes::NetworkMode become sessions::, and the fake forwarder spawns with tokio::spawn. The
split from T100 carries those renames here, so this commit builds alone.

(cherry picked from commit 9437ba3)
norrietaylor pushed a commit that referenced this pull request Nov 5, 2026
…e holds (#2725)

T100. Finalize refuses a declared ingress host port that a program outside Minimal holds,
or that another box holds at the 127.0.0.1 fallback once a landed range verdict keeps the
box there. The typed PortInUseRefusal names the port and its holder, the daemon logs one
warn line, and min session activate ends with that refusal text alone. An unknown holder
is named as another program. On a VM-backed host, finalize binds the declared forwards
toward the handed switch address before the Active write, and the first attach takes the
host's "already bound" answer as the box's own forward. A box that finalizes at the
fallback under a pending verdict gets a watcher that reports an absent landing.

Backport of #2725, folded from #2738.

Adapted to the line:
- The finalize code lives in session.rs, not box_actor/setup.rs, and registry_name is the
  session module's own helper. In the CLI, activate_refusal applies to activate_session.
- boxes:: becomes sessions:: and switch::BoxGate becomes switch::SessionGate.
- The fallback watcher spawns with tokio::spawn under the same root span.
- OwnAddressReporter::registry comes without NET-129's dynamic-hold tracking. The attach
  path's holder lookup needs only the accessor.
- The line's DestroySessionRequest has no only_if_unfinalized, so the refusal tests assert
  the refused box is listed and not Active, destroy it plainly, and assert no record stays.

(cherry picked from commit 703bb7f)
@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