3f10d17c
feat(agent): a Mac reads the subnet vmnet gave it
a73x 2026-08-06 09:12
Commit message
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 | } | ||