a73x

3f10d17c

feat(agent): a Mac reads the subnet vmnet gave it

a73x   2026-08-06 09:12

Commit message
feat(agent): a Mac reads the subnet vmnet gave it

A macOS host reports the subnet its guests are actually on, so the fleet records
where they are rather than the allocation it handed out.

Not from vmnet's own configuration, which states the guest network outright in
/Library/Preferences/SystemConfiguration/com.apple.vmnet.plist and is root-only.
The agent does not run as root on macOS and should not start: the guest network
belongs to Virtualization.framework and the entitlement to vfkit, so that one
file would be the only reason it ever needed privileges.

The same two facts are available unprivileged from elsewhere. macOS's lease
database is world-readable and holds addresses vmnet actually handed out; the
interface list needs no privileges and carries each network's own mask. The
guest subnet is the host network containing a leased address — an address inside
it, and a prefix length that is read rather than assumed, which is what reading
a lease alone could not give.

It matches on containment, not on an interface name. bridge100 is not
contractual — a second vmnet network is bridge101 — and a Mac carries bridges
that have nothing to do with guests, Thunderbolt's among them. A network holding
an address vmnet leased to a guest is the guest network, whatever it is called.

A host with no guests reports nothing, and that is the honest answer rather than
a failure: vmnet's bridge exists only while a guest is attached, and nothing has
leased an address. The subnet arrives with the first guest and every report
after it, which is precisely the late answer polling was chosen to allow.

The agent logs its guest subnet when it changes, and warns when it goes away. An
empty answer is correct on the wire — the fleet keeps its existing record — and
that correctness is what makes a broken reader invisible: a host that cannot see
its own network looks exactly like one that could.

docs/assumptions.md
Old New
@@ -238,12 +238,51 @@ Mac-local control plane: `assigned_ip = 192.168.64.10`, an address no fleet
238 allocation contains, accepted and stored. The Linux gate cannot show this — its 238 allocation contains, accepted and stored. The Linux gate cannot show this — its
239 guests sit inside their own host's `bridge_cidr` and satisfy the old guard too. 239 guests sit inside their own host's `bridge_cidr` and satisfy the old guard too.
240 240
241 What remains untrue is the host row itself. That same run reports the Mac's 241 **Inverted** 2026-08-04, which is what the entry above was asking for: the
242 `bridge_cidr` as 10.102.1.0/24 while its guests live on 192.168.64.x: the fleet 242 subnet a host's guests are on is now reported BY the host, in every
243 still allocates a subnet to a host whose OS already owns one. The address no 243 `ActualStateReport`, and the fleet records what it is told. Proven on the same
244 longer dies of it, but the row asserts something false, and a host whose OS owns 244 M1 — its row read 10.102.1.0/24, an allocation from a pool it never used, and
245 addressing should be telling the fleet which subnet its guests are on rather 245 now reads 192.168.64.0/24, which is where its guests actually are.
246 than being told. 246
247 The transition is worth keeping, because it is the empty-means-no-answer rule
248 made visible rather than asserted. With a guest running: `guest subnet
249 cidr=192.168.64.0/24`. Twenty seconds after it was reaped, vmnet's bridge gone
250 with it: `guest subnet unknown — the fleet keeps this host's existing record`.
251 The row still reads 192.168.64.0/24. A host that cannot see its own network
252 leaves the fleet's memory alone rather than erasing it.
253
254 ### A Mac can read the configuration of its own guest network
255
256 vmnet states the guest network outright in
257 `/Library/Preferences/SystemConfiguration/com.apple.vmnet.plist` —
258 `Shared_Net_Address` and `Shared_Net_Mask`, an address and a netmask, exactly
259 what a host needs to report its subnet. Underpinned reading it there.
260
261 **DISPROVEN** 2026-08-04: that file is root-only, and the agent does not run as
262 root on macOS. It has no reason to — the guest network belongs to
263 Virtualization.framework and the entitlement to vfkit — so reading one config
264 file would have been the only thing it ever needed privileges for, which is a
265 bad trade. Two implementations died on this. The first asked `defaults`, which
266 resolves domains through cfprefsd rather than opening the path it is given, and
267 answered "The domain/default pair does not exist" for a file `plutil` reads
268 happily. The second asked `plutil`, which opens the file and is refused.
269
270 What replaced it uses nothing privileged: macOS's lease database is
271 world-readable and holds addresses vmnet has actually handed out, and the
272 interface list carries each network's own mask. The guest subnet is the host
273 network containing a leased address — an address inside it, and a prefix length
274 read rather than assumed, which is the half a lease alone could never supply.
275
276 The cost is that a Mac with no guests running reports no subnet, because vmnet's
277 bridge exists only while a guest is attached to it. That is honest, and the poll
278 design makes it free: the answer arrives with the first guest.
279
280 **The thing that made this expensive to find** was that all three readers fail
281 IDENTICALLY and correctly. An unreadable configuration reports no subnet, the
282 fleet keeps its existing record, and nothing anywhere says so — a wrong command
283 and a Mac with no network configured are indistinguishable. The agent now logs
284 its guest subnet when it changes and warns when it goes away, which is what
285 turned the third attempt into a single run rather than a fourth guess.
247 286
248 ### Apple Silicon runs arm64 guests only 287 ### Apple Silicon runs arm64 guests only
249 288
internal/agent/run/wire_darwin.go
Old New
@@ -44,8 +44,9 @@ func newPlatform(_ context.Context, cfg Config, st *state.Store) (platform, erro
44 pumps := serialpump.NewManager(vfkit.NewConsoleSource(prov.SocketPath), st.SerialLogPath) 44 pumps := serialpump.NewManager(vfkit.NewConsoleSource(prov.SocketPath), st.SerialLogPath)
45 prov.Pumps = pumps 45 prov.Pumps = pumps
46 46
47 // Reading vmnet's subnet lands in a later commit; until then this host has 47 // Asked once per report. Two file reads and an interface walk — no
48 // no answer, which by the empty-means-no-answer rule leaves the fleet's 48 // subprocess, no privileges, and nothing worth caching over a ten-second
49 // record alone rather than erasing it. 49 // poll, where a cache would reintroduce exactly the staleness polling exists
50 return platform{Prov: prov, Pumps: pumps, GuestCIDR: func() string { return "" }}, nil 50 // to avoid.
51 return platform{Prov: prov, Pumps: pumps, GuestCIDR: vfkit.GuestCIDR}, nil
51 } 52 }
internal/agent/syncclient/client.go
Old New
@@ -345,6 +345,12 @@ func (c *Client) session(ctx context.Context) error {
345 sessCtx, cancel := context.WithCancel(ctx) 345 sessCtx, cancel := context.WithCancel(ctx)
346 defer cancel() 346 defer cancel()
347 347
348 // Remembered across ticks so the subnet is logged when it CHANGES rather
349 // than every ten seconds. A host that cannot say which network its guests
350 // are on is otherwise indistinguishable from one that can: the empty answer
351 // is correct on the wire — the fleet keeps its existing record — and so is
352 // silent everywhere an operator might look.
353 lastCIDR := unreported
348 step := func() error { 354 step := func() error {
349 mu.Lock() 355 mu.Lock()
350 snap := latest 356 snap := latest
@@ -361,6 +367,7 @@ func (c *Client) session(ctx context.Context) error {
361 // leaves the fleet's record alone. 367 // leaves the fleet's record alone.
362 if c.GuestCIDR != nil { 368 if c.GuestCIDR != nil {
363 rep.GuestCidr = c.GuestCIDR() 369 rep.GuestCidr = c.GuestCIDR()
370 logGuestCIDR(rep.GuestCidr, &lastCIDR)
364 } 371 }
365 return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}}) 372 return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}})
366 } 373 }
@@ -570,6 +577,27 @@ func (c *Client) dial(ip string) (net.Conn, error) {
570 return net.Dial("tcp", net.JoinHostPort(ip, "22")) 577 return net.Dial("tcp", net.JoinHostPort(ip, "22"))
571 } 578 }
572 579
580 // unreported distinguishes "no report has been sent yet" from a report that
581 // carried no subnet, so the first answer is always logged — including the first
582 // empty one.
583 const unreported = "\x00never reported"
584
585 // logGuestCIDR reports a change in this host's guest subnet, once, at the tick
586 // it changes. An answer that goes away is a WARNING: the fleet will keep
587 // answering from its existing record, which is right, and quietly wrong-looking
588 // if the host's network has genuinely moved.
589 func logGuestCIDR(cidr string, last *string) {
590 if cidr == *last {
591 return
592 }
593 *last = cidr
594 if cidr == "" {
595 slog.Warn("guest subnet unknown — the fleet keeps this host's existing record")
596 return
597 }
598 slog.Info("guest subnet", "cidr", cidr)
599 }
600
573 // classifyErr distinguishes a permanent auth rejection from a transient error so 601 // classifyErr distinguishes a permanent auth rejection from a transient error so
574 // Run() can log loudly and avoid a tight reconnect loop on a dead credential. 602 // Run() can log loudly and avoid a tight reconnect loop on a dead credential.
575 func classifyErr(err error) error { 603 func classifyErr(err error) error {
internal/agent/syncclient/client_test.go
Old New
@@ -442,3 +442,33 @@ func TestMaybeUpgradeSkipsOwnVersionAndSingleFlights(t *testing.T) {
442 } 442 }
443 close(block) 443 close(block)
444 } 444 }
445
446 // TestLogGuestCIDROnlyOnChange pins the observability that was missing while
447 // two implementations of the macOS reader silently reported nothing. An empty
448 // answer is CORRECT on the wire — the fleet keeps its existing record — which
449 // is exactly why it has to be visible somewhere: a host that cannot see its own
450 // network otherwise looks identical to one that can.
451 func TestLogGuestCIDROnlyOnChange(t *testing.T) {
452 last := unreported
453
454 // The first answer always logs, including the first empty one.
455 logGuestCIDR("", &last)
456 if last != "" {
457 t.Fatalf("last = %q, want the empty answer recorded", last)
458 }
459 // A repeat does not: this runs every tick, forever.
460 logGuestCIDR("", &last)
461 if last != "" {
462 t.Fatalf("last = %q, want unchanged", last)
463 }
464 // An answer arriving is a change.
465 logGuestCIDR("192.168.64.0/24", &last)
466 if last != "192.168.64.0/24" {
467 t.Fatalf("last = %q, want the new subnet", last)
468 }
469 // And an answer going away is a change too — the interesting one.
470 logGuestCIDR("", &last)
471 if last != "" {
472 t.Fatalf("last = %q, want the loss recorded", last)
473 }
474 }
internal/agent/vfkit/guestnet.go
Old New
@@ -0,0 +1,125 @@
1 package vfkit
2
3 import (
4 "net"
5 "net/netip"
6 "os"
7 "strings"
8 )
9
10 // GuestCIDR reports the subnet this Mac's guests are on: the network of
11 // whichever host interface contains an address vmnet has actually handed out.
12 // It returns "" when it cannot say, which by the empty-means-no-answer rule
13 // leaves the fleet's record alone and retries on the next report.
14 //
15 // Both halves are things an unprivileged agent can see, which is the whole
16 // reason for this shape. The obvious source — vmnet's own configuration at
17 // /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist, which states
18 // the address and netmask outright — is ROOT-ONLY, and the agent deliberately
19 // does not run as root on macOS: the guest network belongs to
20 // Virtualization.framework and the entitlement to vfkit, so there is nothing
21 // else it would need privileges for. Reading one config file is a poor reason
22 // to take them. (`defaults` cannot read that file either, for an unrelated
23 // reason: it resolves domains through cfprefsd rather than opening the path.)
24 //
25 // The lease database is world-readable and the interface list needs no
26 // privileges at all, and between them they give exactly what the plist would
27 // have: an address inside the guest network, and a netmask to size it with.
28 // Neither is guessed — in particular the prefix length is the interface's own,
29 // so nothing here assumes a /24 the way deriving from a lease alone would.
30 //
31 // A host with no guests reports nothing. vmnet's bridge exists only while a
32 // guest is attached to it, and the lease database is empty until one has asked
33 // for an address, so a Mac that has never run a guest has no guest network to
34 // describe. That is honest rather than a failure, and it costs nothing: the
35 // answer arrives with the first guest, and on every report after it.
36 func GuestCIDR() string {
37 return guestCIDR(readFile(defaultLeasesPath), hostNetworks())
38 }
39
40 // guestCIDR is GuestCIDR with both readings injected, so the pairing is
41 // testable on a machine that has neither of them.
42 func guestCIDR(leases string, networks []netip.Prefix) string {
43 for _, addr := range leasedAddresses(leases) {
44 for _, pfx := range networks {
45 if pfx.Contains(addr) {
46 return pfx.String()
47 }
48 }
49 }
50 return ""
51 }
52
53 // hostNetworks returns every IPv4 network configured on this host's interfaces,
54 // each masked to its own prefix length. Addresses are flattened across
55 // interfaces deliberately: which interface holds the guest network is not
56 // something this needs to know, and the name vmnet uses is not contractual —
57 // bridge100 today, bridge101 for a second vmnet network, while bridge0 is
58 // Thunderbolt's and holds no IPv4 address at all. A network containing an
59 // address vmnet leased to a guest IS the guest network, whatever it is called.
60 func hostNetworks() []netip.Prefix {
61 ifaces, err := net.Interfaces()
62 if err != nil {
63 return nil
64 }
65 var out []netip.Prefix
66 for _, iface := range ifaces {
67 addrs, err := iface.Addrs()
68 if err != nil {
69 continue
70 }
71 for _, a := range addrs {
72 if pfx, ok := prefixOf(a); ok {
73 out = append(out, pfx)
74 }
75 }
76 }
77 return out
78 }
79
80 // prefixOf reduces one interface address to the IPv4 network it sits on.
81 func prefixOf(a net.Addr) (netip.Prefix, bool) {
82 ipnet, ok := a.(*net.IPNet)
83 if !ok || ipnet.IP.To4() == nil {
84 return netip.Prefix{}, false
85 }
86 ones, bits := ipnet.Mask.Size()
87 // A non-contiguous mask reports (0, 0), and a /0 on a host interface is not
88 // a network anything is meaningfully "on".
89 if bits != 32 || ones == 0 {
90 return netip.Prefix{}, false
91 }
92 host, ok := netip.AddrFromSlice(ipnet.IP.To4())
93 if !ok {
94 return netip.Prefix{}, false
95 }
96 return netip.PrefixFrom(host, ones).Masked(), true
97 }
98
99 // readFile returns path's contents, or "" when it cannot be read.
100 func readFile(path string) string {
101 raw, err := os.ReadFile(path)
102 if err != nil {
103 return ""
104 }
105 return string(raw)
106 }
107
108 // leasedAddresses returns every address in the lease database. Any one of them
109 // locates the guest network — they are all on it — so the caller takes the
110 // first that lands inside a live interface. Stale entries from destroyed VMs
111 // are useful here rather than a problem: they still name the subnet, and one
112 // that no longer matches any interface is simply skipped.
113 func leasedAddresses(leases string) []netip.Addr {
114 var out []netip.Addr
115 for _, line := range strings.Split(leases, "\n") {
116 key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
117 if !ok || key != "ip_address" {
118 continue
119 }
120 if a, err := netip.ParseAddr(parseIP(value)); err == nil && a.Is4() {
121 out = append(out, a)
122 }
123 }
124 return out
125 }
internal/agent/vfkit/guestnet_test.go
Old New
@@ -0,0 +1,167 @@
1 package vfkit
2
3 import (
4 "net"
5 "net/netip"
6 "testing"
7 )
8
9 // realLeases is the shape of macOS's lease database, taken from an M1 running
10 // eitri guests: brace-delimited stanzas, unpadded MAC octets behind an ARP
11 // hardware-type prefix, expiry in hex.
12 const realLeases = `{
13 name=proof-1
14 ip_address=192.168.64.10
15 hw_address=1,52:54:0:9d:c1:4
16 identifier=1,52:54:0:9d:c1:4
17 lease=0x68b0a1f2
18 }
19 {
20 name=proof-2
21 ip_address=192.168.64.11
22 hw_address=1,52:54:0:3a:7b:2
23 lease=0x68b0a2c9
24 }
25 `
26
27 // prefixes is shorthand for the host networks a reading of the interface list
28 // would produce.
29 func prefixes(t *testing.T, ss ...string) []netip.Prefix {
30 t.Helper()
31 var out []netip.Prefix
32 for _, s := range ss {
33 p, err := netip.ParsePrefix(s)
34 if err != nil {
35 t.Fatalf("bad test prefix %q: %v", s, err)
36 }
37 out = append(out, p)
38 }
39 return out
40 }
41
42 // TestGuestCIDRPairsALeaseWithItsInterface pins the whole idea: an address
43 // vmnet actually handed out, matched against the networks this host has
44 // configured, gives the guest subnet AND its prefix length without guessing
45 // either.
46 func TestGuestCIDRPairsALeaseWithItsInterface(t *testing.T) {
47 // What a Mac running a guest looks like: loopback, the uplink, Thunderbolt's
48 // bridge (no IPv4, so absent here), and vmnet's.
49 host := prefixes(t, "127.0.0.0/8", "192.168.1.0/24", "192.168.64.0/24")
50 if got := guestCIDR(realLeases, host); got != "192.168.64.0/24" {
51 t.Errorf("guestCIDR() = %q, want 192.168.64.0/24", got)
52 }
53 }
54
55 // TestGuestCIDRTakesThePrefixFromTheInterface pins that the prefix length is
56 // the interface's own and never assumed. Deriving from a lease alone would
57 // force a guess at /24, and vmnet's network is configurable.
58 func TestGuestCIDRTakesThePrefixFromTheInterface(t *testing.T) {
59 for _, tc := range []struct{ name, network, want string }{
60 {"the usual /24", "192.168.64.0/24", "192.168.64.0/24"},
61 {"a wider network", "192.168.0.0/16", "192.168.0.0/16"},
62 {"a narrower one", "192.168.64.0/26", "192.168.64.0/26"},
63 } {
64 t.Run(tc.name, func(t *testing.T) {
65 if got := guestCIDR(realLeases, prefixes(t, tc.network)); got != tc.want {
66 t.Errorf("guestCIDR() = %q, want %q", got, tc.want)
67 }
68 })
69 }
70 }
71
72 // TestGuestCIDRSaysNothingRatherThanGuessing pins the empty-means-no-answer
73 // rule at its source. Each of these leaves the fleet's record alone and retries
74 // on the next report, rather than reporting something invented.
75 func TestGuestCIDRSaysNothingRatherThanGuessing(t *testing.T) {
76 host := prefixes(t, "127.0.0.0/8", "192.168.1.0/24", "192.168.64.0/24")
77
78 t.Run("no lease database", func(t *testing.T) {
79 // A Mac that has never run a guest: nothing has asked for an address, so
80 // there is no guest network to describe yet.
81 if got := guestCIDR("", host); got != "" {
82 t.Errorf("guestCIDR() = %q, want empty", got)
83 }
84 })
85
86 t.Run("no interface holds the leased network", func(t *testing.T) {
87 // vmnet's bridge exists only while a guest is attached, so a lease left
88 // over from a destroyed VM matches nothing. Skipped, not guessed at.
89 if got := guestCIDR(realLeases, prefixes(t, "127.0.0.0/8", "192.168.1.0/24")); got != "" {
90 t.Errorf("guestCIDR() = %q, want empty", got)
91 }
92 })
93
94 t.Run("no host networks at all", func(t *testing.T) {
95 if got := guestCIDR(realLeases, nil); got != "" {
96 t.Errorf("guestCIDR() = %q, want empty", got)
97 }
98 })
99
100 t.Run("leases with no usable address", func(t *testing.T) {
101 if got := guestCIDR("{\n\tname=x\n\thw_address=1,52:54:0:1:2:3\n}\n", host); got != "" {
102 t.Errorf("guestCIDR() = %q, want empty", got)
103 }
104 })
105 }
106
107 // TestGuestCIDRSkipsStaleLeasesForLiveOnes pins that a lease database
108 // outliving its VMs is an asset rather than a hazard: an entry matching no live
109 // interface is skipped, and a later one that does match is used.
110 func TestGuestCIDRSkipsStaleLeasesForLiveOnes(t *testing.T) {
111 leases := "{\n\tip_address=10.9.9.9\n}\n" + realLeases
112 if got := guestCIDR(leases, prefixes(t, "192.168.64.0/24")); got != "192.168.64.0/24" {
113 t.Errorf("guestCIDR() = %q, want the live network", got)
114 }
115 }
116
117 // TestPrefixOfRejectsWhatIsNotANetwork pins the reading of one interface
118 // address, including shapes a host really carries.
119 func TestPrefixOfRejectsWhatIsNotANetwork(t *testing.T) {
120 ipnet := func(ip string, mask net.IPMask) net.Addr {
121 return &net.IPNet{IP: net.ParseIP(ip), Mask: mask}
122 }
123 for _, tc := range []struct {
124 name string
125 addr net.Addr
126 want string // "" = rejected
127 }{
128 {"a normal /24", ipnet("192.168.64.1", net.CIDRMask(24, 32)), "192.168.64.0/24"},
129 {"masked to its network", ipnet("192.168.64.200", net.CIDRMask(24, 32)), "192.168.64.0/24"},
130 {"IPv6", ipnet("fd00::1", net.CIDRMask(64, 128)), ""},
131 {"a /0", ipnet("10.0.0.1", net.CIDRMask(0, 32)), ""},
132 {"non-contiguous mask", ipnet("10.0.0.1", net.IPMask{255, 0, 255, 0}), ""},
133 {"not an IPNet at all", &net.TCPAddr{IP: net.ParseIP("10.0.0.1")}, ""},
134 } {
135 t.Run(tc.name, func(t *testing.T) {
136 pfx, got := prefixOf(tc.addr)
137 if tc.want == "" {
138 if got {
139 t.Errorf("prefixOf() accepted %v as %s", tc.addr, pfx)
140 }
141 return
142 }
143 if !got || pfx.String() != tc.want {
144 t.Errorf("prefixOf() = %s, %v; want %s", pfx, got, tc.want)
145 }
146 })
147 }
148 }
149
150 // TestHostNetworksReadsThisMachine is a smoke test against the real interface
151 // list. It cannot assert a subnet — it runs on whatever CI is — but it proves
152 // the reading needs no privileges and returns something well-formed, which is
153 // the property that sent the previous two implementations back.
154 func TestHostNetworksReadsThisMachine(t *testing.T) {
155 nets := hostNetworks()
156 if len(nets) == 0 {
157 t.Skip("no IPv4 interfaces on this machine")
158 }
159 for _, p := range nets {
160 if !p.Addr().Is4() {
161 t.Errorf("hostNetworks returned a non-IPv4 prefix: %s", p)
162 }
163 if p != p.Masked() {
164 t.Errorf("hostNetworks returned an unmasked prefix: %s", p)
165 }
166 }
167 }