Skip to content

macOS: guests are assigned a 10.100.0.0/16 address that vz never NATs, so they have zero egress #358

Description

@wyatt-halluminate

Summary

On macOS (vz), instances come up with an IP and a default route, the server logs
allocated network, and everything looks healthy — but the guest cannot reach anything,
including its own gateway. Guests are configured with an address from
network.subnet_cidr (default 10.100.0.0/16), while Virtualization.framework's NAT
bridge is bridge100 at 192.168.64.1/24. The 10.100.0.0/16 subnet does not exist
anywhere on the host, so all guest traffic is blackholed.

This makes a default macOS install unusable for any workload that touches the network
(apt/apk, image pulls from inside a VM, outbound API calls).

Environment

  • hypeman-api v0.3.0 (installed via curl -fsSL https://get.hypeman.sh | bash)
  • hypeman CLI v0.16.1
  • macOS 26.2 (build 25C56), Apple Silicon (arm64)
  • Hypervisor: vz (Virtualization.framework)

Reproduction

$ hypeman run --name demo nginx:alpine
$ hypeman exec demo -- /bin/sh -c 'ip -4 -o addr show eth0; ip route; ping -c2 -W2 10.100.0.1'
10.100.225.140/16
default via 10.100.0.1 dev eth0
2 packets transmitted, 0 packets received, 100% packet loss     # cannot reach its own gateway

Meanwhile on the host:

$ ifconfig bridge100 | grep inet
        inet 192.168.64.1 netmask 0xffffff00 broadcast 192.168.64.255
$ ifconfig | grep "inet 10.100"                                  # nothing

Confirming the subnet is the whole problem — reconfiguring the guest by hand onto the
real vmnet subnet restores full connectivity:

$ hypeman exec demo -- /bin/sh -c '
    ip addr del 10.100.225.140/16 dev eth0
    ip route del default via 10.100.0.1 dev eth0
    ip addr add 192.168.64.200/24 dev eth0
    ip route add default via 192.168.64.1 dev eth0
    echo "nameserver 192.168.64.1" > /etc/resolv.conf
    wget -qO- -T10 https://registry-1.docker.io/v2/'
wget: server returned error: HTTP/1.1 401 Unauthorized          # TCP+TLS egress works

(Note ICMP to the internet stays blocked — vmnet NAT doesn't forward it — so egress has
to be tested with TCP rather than ping.)

Root cause

There are two sources of truth for the default network, and on macOS they disagree. The
wrong one wins.

  1. lib/network/manager.go:120-128Initialize() seeds the cached default network
    from m.config.Network.SubnetCIDR, which defaults to "10.100.0.0/16" at
    cmd/api/config/config.go:329. This value is not platform-conditional.

  2. lib/network/bridge_darwin.go:63-71queryNetworkState() returns the correct
    macOS values (192.168.64.1, 192.168.64.0/24).

  3. lib/network/allocate.go:424-433getOrInitDefaultNetwork() returns the cache
    first:

    func (m *manager) getOrInitDefaultNetwork(ctx context.Context) (*Network, error) {
        if network := m.cachedDefaultNetwork(); network != nil {
            return network, nil          // <- always populated by Initialize() from config
        }
        network, err := m.getDefaultNetwork(ctx)   // <- only path that reads queryNetworkState()
        ...

    Because Initialize() always populates the cache, the darwin-aware branch is
    effectively unreachable.

  4. lib/network/allocate.go:203 allocates the guest IP from network.Subnet and
    :230/:239 set Gateway: network.Gateway, which are then pushed to the guest agent
    (ReconfigureNetworkRequest). So the guest is statically configured onto 10.100.x.x.

This also contradicts the intended design stated in the code and docs:

  • lib/network/bridge_darwin.go:65"The actual IP will be assigned by
    Virtualization.framework's DHCP"
  • lib/network/README.md"macOS | NAT | Virtualization.framework built-in NAT
    (192.168.64.0/24)"

Expected

On macOS the guest should either take a DHCP lease from vz's NAT (as
bridge_darwin.go:65 intends), or be statically configured from the darwin
queryNetworkState() values — not from the Linux-shaped subnet_cidr default.

Suggested fix

Make the darwin backend the authority for its own addressing rather than layering a
Linux-shaped config default over it. Options, roughly in order of preference:

  1. Have Initialize() seed the cache from queryNetworkState() when the platform
    backend supplies concrete values, falling back to subnet_cidr only when it doesn't.
    This removes the second source of truth entirely.
  2. Failing that, make the subnet_cidr default platform-conditional
    (192.168.64.0/24 on darwin).

A regression test on the existing macOS arm64 CI runner asserting that a booted guest can
complete a TCP connection off-box would catch this class of bug — the current darwin tests
cover fork speed, Rosetta, and reflink copy, but nothing asserts guest egress.

Workaround

Reconfigure the guest onto 192.168.64.0/24 as shown above. It survives
standby/restore.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions