Skip to content

snapshot run --publish lets a clone choose where its published ports listen - #1031

Merged
ejc3 merged 1 commit into
mainfrom
snapshot-run-publish
Oct 1, 2026
Merged

ejc3 merged 1 commit into
mainfrom
snapshot-run-publish

Conversation

@ejc3

@ejc3 ejc3 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Stacked on: routed-publish-host-address (PR #1030).

A clone took its port mappings from the snapshot and nothing else. With routed networking honouring a mapping's host address (#1030), one snapshot could not be restored both as the VM that owns the host's ports (--publish '[::]:80:80') and as an ordinary clone on its per-VM address.

What changes

  • fcvm snapshot run --publish takes the grammar of podman run --publish. Given, the clone uses exactly those mappings. Absent, it inherits the snapshot's, host address included.
  • Each mapping's guest port and protocol must be one the snapshot published. Anything else is refused before a VM id, state file or network exists, with the snapshot's guest ports in the error. So are two mappings that claim one host address and port.
  • The clone's state records the mappings it uses, and a disk-only clone sets its network up from the same list.
  • Bridged setup refuses two mappings with the same host port and protocol, whatever their HOSTIPs. It rescopes every mapping to the VM's veth address, where the two would be one socket: the first DNAT rule took all the traffic and the second guest port was unreachable, with no error. podman run and snapshot run --publish both reach the refusal.
  • podman run restoring from its snapshot cache passes its own --publish, so the clone listens where that run asked.

Contract and impact

Production code on the restore path (snapshot run, and podman run on a cache hit). Without --publish a clone behaves as before. No snapshot key includes the flag. A bridged VM given two mappings on one host port now fails at network setup with an error that names both, where it used to start with one of them unreachable.

Minimum evidence

Unit tests with a red each, the unit suite and lint, and test_clone_port_forward_routed on a VM with a red.

Not in this PR

Evidence

snapshot run --publish lets a clone choose where its published ports listen

Red. Each test failed with the behaviour it pins put back and the test kept
(no flag on snapshot run, the clone's mappings always the snapshot's, RunArgs
written from the snapshot's mappings and not the clone's, no publish on the
cache restore, no check for one host socket claimed twice), and passes on this
commit:

  cli::args::tests::snapshot_run_publish_takes_the_podman_run_grammar
    CLI should parse: UnknownArgument "--publish"
  commands::snapshot::tests::a_clone_inherits_the_snapshots_mappings_or_uses_exactly_the_requested_ones
    left: the snapshot's three mappings, where the three requested ones were expected
  commands::snapshot::tests::a_clone_cannot_publish_a_guest_port_the_snapshot_did_not
    must be refused: the snapshot's mappings came back instead of an error
    must be refused: [8080 to guest 80, 8080 to guest 443] (one host socket twice)
  commands::snapshot::tests::run_args_from_metadata_carries_boot_plan_fields
    left: [] right: ["[::]:80:80/tcp", "127.0.0.1:5300:53/udp"]
  commands::podman::tests::a_cache_restore_publishes_where_this_run_asked
    left: [] where the run's two specs were expected
  network::types::tests::two_mappings_cannot_claim_one_host_socket
    one host socket is claimed twice: ()

Red for the bridged refusal, with only its two tests added (run with
FCVM_DATA_DIR on a scratch directory, because the unfixed setup() takes
bridged-subnet.lock before it fails for a user who is not root):
  make _test-unit FILTER="-p fcvm --lib -E 'test(/^network::bridged::tests::/)'"
  Summary [   5.023s] 10 tests run: 8 passed, 2 failed, 726 skipped
  network::bridged::tests::two_host_addresses_on_one_host_port_are_refused_before_any_setup
  network::bridged::tests::a_host_address_and_none_on_one_host_port_are_refused_before_any_setup
    both: reserving per-VM network names: creating network namespace fcvm-vm-one-po: failed to create namespace
The third test is a control (another host port, or another protocol on the
same port, is not refused). Its red is the check with the host port dropped
from its comparison:
  network::bridged::tests::mappings_on_different_host_ports_or_protocols_are_not_refused
    ["8080:80", "8081:80"]: port mappings 8080:80/tcp and 8081:80/tcp claim the same host port

Red at the VM level, with clone_port_mappings returning the snapshot's
mappings whatever was requested (as root, set up as in the block below):
  make _test-root FILTER="-E 'test(/test_clone_port_forward_routed/)'"
  Summary [ 176.378s] 1 test run: 0 passed, 1 failed, 1716 skipped
  test_clone_port_forward_routed
    Access via 127.221.87.200:25520: FAIL (curl: (7) Failed to connect to 127.221.87.200 port 25520: Connection refused)
    Error: snapshot run with an unpublished guest port did not exit within 60 s

Green on this commit:
  make test-unit
  Summary [  71.296s] 1371 tests run: 1371 passed (1 slow), 0 skipped
  make lint  rc 0
  make _test-root FILTER=--no-run  rc 0 (compiles the root-only tests, runs none)

As root on the build host, at the top of this stack with the commit that reads
--ipv6-prefix from FCVM_IPV6_PREFIX picked on top (the host's own addresses are
not a routable /64, so the routed tests take its delegated subnet from that
variable):
  make test-root FILTER="-E 'test(/test_port_forward_routed|test_clone_port_forward_routed|test_forward_localhost_routed/)'"
  Summary [  58.010s] 3 tests run: 3 passed, 1719 skipped
  PASS [   9.0s] fcvm::test_forward_localhost test_forward_localhost_routed
  PASS [  11.5s] fcvm::test_port_forward test_port_forward_routed
  PASS [  37.5s] fcvm::test_snapshot_clone test_clone_port_forward_routed
test_clone_port_forward_routed restores a second clone with --publish on a
127/8 address of its own, checks that it answers there and that its state
records that mapping and no loopback address, and checks that --publish
18081:81 is refused. The refused run is bounded at 60 s and dies with the
test, so a clone that started instead would fail the test, not hang it.

Summary by CodeRabbit

  • New Features

    • Snapshot clones can use --publish to replace inherited port mappings, provided each guest port and protocol was published by the snapshot. Without an override, mappings are inherited.
    • Clone state and JSON output reflect the mappings used. Bridged clones listen on their virtual network address, regardless of HOSTIP.
  • Bug Fixes

    • Clones are refused when requested guest ports are unavailable or host port-and-protocol combinations conflict, including bridged mappings with different host IPs. Validation occurs before network resources are created.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: a5c5e70a-85aa-4035-941c-0dd51aff21f0

📥 Commits

Reviewing files that changed from the base of the PR and between 7ac66e9 and 9ad4b7e.

📒 Files selected for processing (8)
  • DESIGN.md
  • README.md
  • src/cli/args.rs
  • src/commands/podman/mod.rs
  • src/commands/snapshot.rs
  • src/network/bridged.rs
  • src/network/types.rs
  • tests/test_snapshot_clone.rs

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


📝 Walkthrough

Walkthrough

Snapshot clones now accept optional publish mappings. The selected mappings are validated and carried through clone setup, state, and reboot paths. Bridged setup also rejects mappings that share a host port and protocol, regardless of host IP.

Changes

Snapshot clone port mappings

Layer / File(s) Summary
Publish input and restore arguments
src/cli/args.rs, src/commands/podman/mod.rs, README.md
Snapshot run arguments accept repeated --publish values. Podman snapshot-cache restore paths preserve the current run’s publish mappings.
Host socket conflict checks
src/network/types.rs, src/network/bridged.rs, README.md
Port mappings can be checked for duplicate host sockets. Bridged setup rejects mappings with the same host port and protocol, regardless of host IP.
Selected mappings in clone paths
src/commands/snapshot.rs, DESIGN.md, README.md, tests/test_snapshot_clone.rs
Clones inherit snapshot mappings unless overrides are supplied. Requested mappings are checked against published guest ports and protocols, then used in clone boot arguments, state, disk-only clones, and reboot plans. Tests cover overrides and rejected guest ports.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant SnapshotClone
  participant BridgedNetwork
  participant VMState
  CLI->>SnapshotClone: Pass --publish mappings
  SnapshotClone->>SnapshotClone: Validate guest ports and protocols
  SnapshotClone->>BridgedNetwork: Set up selected mappings
  BridgedNetwork->>BridgedNetwork: Check host port and protocol conflicts
  SnapshotClone->>VMState: Record selected mappings
Loading

Merge Risk: ⚪ Minimal · up to 9ad4b

The change adds clone publish overrides while preserving inherited mappings by default. No actionable merge-blocking issue is established; normal checks remain appropriate, including bridged VM validation.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 9ad4b

Clones can deliberately expose previously published services on different host addresses, including addresses reachable off-host. Guest-port restrictions and consistent restore propagation constrain that capability. No introduced security defect was established, but deployment-specific exposure and failure recovery were not demonstrated at runtime.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — An explicit routed wildcard override can make a snapshot-approved guest service reachable through all applicable host addresses rather than only a per-VM loopback address. Effective external reachability also depends on host networking and firewall policy, which were not supplied. The demonstrated control surface is host publication of approved guest services, not authority to add unpublished guest services.

Trust Boundaries and Controls

  • observed — Caller-supplied publish strings are parsed and checked against snapshot guest-port/protocol metadata before clone allocation. Literal duplicate host sockets are rejected there; bridged networking adds stricter effective-socket validation before creating network resources. Host addresses are not restricted to the snapshot’s original addresses, which is the intended new authority.

Resilience and Maintainability Implications

  • inferred — Literal-address duplicate validation does not cover every routed wildcard overlap, so some conflicts can fail during binding rather than before allocation. This is not established as an introduced security defect: existing cold-run callers accept the same mapping forms, binding failure closes previously bound listeners before relays start, and restore setup invokes cleanup.

Hardening Proposals

  • proposed — Consider routed-backend preflight validation of effective wildcard and dual-stack socket overlap, including collisions between an explicit address and a resolved default loopback address. This would improve early failure containment; it is not a finding of unintended exposure.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding snapshot run --publish so clones can select where published ports listen.
Docstring Coverage ✅ Passed Docstring coverage is 80.95% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 6 files. (2 skipped: 2 …
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

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-01T13:38:32.482242Z 9ad4b7e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93388d5afc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/network/types.rs Outdated
…listen

A clone took its port mappings from the snapshot and nothing else, so one
snapshot could not be restored both as the VM that owns the host's ports
(--publish [::]:80:80 with routed networking) and as an ordinary clone on its
per-VM address.

fcvm snapshot run takes --publish in the grammar of podman run. When given,
the clone uses exactly those mappings; when absent it inherits the snapshot's,
host address included. Each mapping's guest port and protocol must be one the
snapshot published, because a guest restored from memory set its published
ports up when it booted (fc-agent's DNAT to loopback) and the restore does not
run that again. The rule is the same for a disk-only clone and for UDP, which
would not need it. Anything else is refused before a VM id, state file, or
network exists, with the snapshot's guest ports in the error, and so are two
mappings that claim one host address and port
(PortMapping::require_distinct_host_sockets). The choice is clone_port_mappings
in src/commands/snapshot.rs.

The clone's state records the mappings it uses, and a disk-only clone sets its
network up from the same list, written back as --publish specs with their host
address (PortMapping's Display). No snapshot key includes the flag. A bridged
clone listens on its veth address whatever HOSTIP says, as podman run does. A
snapshot taken of a clone records that clone's mappings.

Bridged setup replaces every mapping's host address with the VM's veth address,
so two mappings that differ only in HOSTIP became two DNAT rules for one
destination: the first took all the traffic and the second guest port was
unreachable, with no error. BridgedNetwork::setup now refuses two mappings
with the same host port and protocol before it creates anything, next to its
refusal of an IPv6 HOSTIP, and names both mappings as they were given. podman
run and snapshot run --publish both reach it. The search for the first two
mappings on one socket is PortMapping::first_on_one_socket, which both checks
use, and README.md states the rule beside the other bridged HOSTIP rules.

podman run restoring from its snapshot cache builds its SnapshotRunArgs in one
place (snapshot_restore_args) and passes its own --publish, so the clone
listens where that run asked.

Red. Each test failed with the behaviour it pins put back and the test kept
(no flag on snapshot run, the clone's mappings always the snapshot's, RunArgs
written from the snapshot's mappings and not the clone's, no publish on the
cache restore, no check for one host socket claimed twice), and passes on this
commit:

  cli::args::tests::snapshot_run_publish_takes_the_podman_run_grammar
    CLI should parse: UnknownArgument "--publish"
  commands::snapshot::tests::a_clone_inherits_the_snapshots_mappings_or_uses_exactly_the_requested_ones
    left: the snapshot's three mappings, where the three requested ones were expected
  commands::snapshot::tests::a_clone_cannot_publish_a_guest_port_the_snapshot_did_not
    must be refused: the snapshot's mappings came back instead of an error
    must be refused: [8080 to guest 80, 8080 to guest 443] (one host socket twice)
  commands::snapshot::tests::run_args_from_metadata_carries_boot_plan_fields
    left: [] right: ["[::]:80:80/tcp", "127.0.0.1:5300:53/udp"]
  commands::podman::tests::a_cache_restore_publishes_where_this_run_asked
    left: [] where the run's two specs were expected
  network::types::tests::two_mappings_cannot_claim_one_host_socket
    one host socket is claimed twice: ()

Red for the bridged refusal, with only its two tests added (run with
FCVM_DATA_DIR on a scratch directory, because the unfixed setup() takes
bridged-subnet.lock before it fails for a user who is not root):
  make _test-unit FILTER="-p fcvm --lib -E 'test(/^network::bridged::tests::/)'"
  Summary [   5.023s] 10 tests run: 8 passed, 2 failed, 726 skipped
  network::bridged::tests::two_host_addresses_on_one_host_port_are_refused_before_any_setup
  network::bridged::tests::a_host_address_and_none_on_one_host_port_are_refused_before_any_setup
    both: reserving per-VM network names: creating network namespace fcvm-vm-one-po: failed to create namespace
The third test is a control (another host port, or another protocol on the
same port, is not refused). Its red is the check with the host port dropped
from its comparison:
  network::bridged::tests::mappings_on_different_host_ports_or_protocols_are_not_refused
    ["8080:80", "8081:80"]: port mappings 8080:80/tcp and 8081:80/tcp claim the same host port

Red at the VM level, with clone_port_mappings returning the snapshot's
mappings whatever was requested (as root, set up as in the block below):
  make _test-root FILTER="-E 'test(/test_clone_port_forward_routed/)'"
  Summary [ 176.378s] 1 test run: 0 passed, 1 failed, 1716 skipped
  test_clone_port_forward_routed
    Access via 127.221.87.200:25520: FAIL (curl: (7) Failed to connect to 127.221.87.200 port 25520: Connection refused)
    Error: snapshot run with an unpublished guest port did not exit within 60 s

Green on this commit:
  make test-unit
  Summary [  71.296s] 1371 tests run: 1371 passed (1 slow), 0 skipped
  make lint  rc 0
  make _test-root FILTER=--no-run  rc 0 (compiles the root-only tests, runs none)

As root on the build host, at the top of this stack with the commit that reads
--ipv6-prefix from FCVM_IPV6_PREFIX picked on top (the host's own addresses are
not a routable /64, so the routed tests take its delegated subnet from that
variable):
  make test-root FILTER="-E 'test(/test_port_forward_routed|test_clone_port_forward_routed|test_forward_localhost_routed/)'"
  Summary [  58.010s] 3 tests run: 3 passed, 1719 skipped
  PASS [   9.0s] fcvm::test_forward_localhost test_forward_localhost_routed
  PASS [  11.5s] fcvm::test_port_forward test_port_forward_routed
  PASS [  37.5s] fcvm::test_snapshot_clone test_clone_port_forward_routed
test_clone_port_forward_routed restores a second clone with --publish on a
127/8 address of its own, checks that it answers there and that its state
records that mapping and no loopback address, and checks that --publish
18081:81 is refused. The refused run is bounded at 60 s and dies with the
test, so a clone that started instead would fail the test, not hang it.
@ejc3
ejc3 force-pushed the routed-publish-host-address branch from 4472878 to 482c48c Compare October 1, 2026 13:31
@ejc3
ejc3 force-pushed the snapshot-run-publish branch from 93388d5 to 9ad4b7e Compare October 1, 2026 13:31
@ejc3

ejc3 commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ad4b7ee6b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/network/types.rs

@ejc3 ejc3 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

RED-VERIFIED: network::bridged::tests::two_host_addresses_on_one_host_port_are_refused_before_any_setup

This answers the finding of the Codex review of 93388d5, that two host addresses on one host port pass the duplicate check and collapse onto one veth address under bridged networking. The reply on its thread has the output without the fix: with that test and a_host_address_and_none_on_one_host_port_are_refused_before_any_setup added and nothing else, 10 tests run: 8 passed, 2 failed. In 9ad4b7e BridgedNetwork::setup refuses such a pair before it creates anything.

@ejc3 ejc3 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

DISAGREE: the finding of the Codex review of 9ad4b7e, a wildcard address beside a specific one on the same port, does not block this PR. The refusal this PR promises is for two mappings on one host address and port, and it holds. Which pairs of different addresses collide depends on the network mode, and a run with such a pair fails at the second bind with the address in the error and no listener left behind (a_bind_failure_names_the_address_and_leaves_no_listener_behind). One mode-aware rule for podman run, snapshot run, and bridged setup, with its test matrix, is tracked in #1039. The reply on the thread says the same.

@ejc3 ejc3 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

NOT-A-DEFECT: CodeRabbit's summary of 9ad4b7e reports no actionable comment, minimal merge risk, and no retained concern. Its one hardening proposal, a preflight for wildcard and dual-stack overlap in routed networking, is the rule tracked in #1039.

@ejc3
ejc3 merged commit 2b90ee1 into main Oct 1, 2026
14 checks passed
@ejc3
ejc3 deleted the snapshot-run-publish branch October 1, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant