internal/agent/vfkit/leases.go
Ref: Size: 4.2 KiB History
package vfkit
import (
"net/netip"
"os"
"strconv"
"strings"
"github.com/a73x/eitri/internal/agent/state"
)
// Address returns the address macOS gave this VM's guest, or "" when there is
// no answer yet.
//
// This backend reads addresses; it does not assign them. Under vmnet NAT the
// host's own bootpd is the DHCP server, and it decides — there is no hook to
// reserve an address in advance that does not require editing /etc/bootptab as
// root and restarting a system daemon under the fleet's feet. So the address
// does not exist until the guest has booted and asked for one, which is why
// the seam polls Address rather than having Boot return it.
//
// The lease is keyed on the VM's deterministic MAC, so stickiness survives
// anyway: the same VM asks with the same hardware address and bootpd offers it
// the lease it already holds.
func (p *Provisioner) Address(vmID string) string {
raw, err := os.ReadFile(p.leasesPath)
if err != nil {
// No lease database yet — no guest on this host has ever asked for an
// address. "" means "no answer", which is what reconcile reads it as.
return ""
}
return leaseAddress(string(raw), state.MAC(vmID))
}
// NetworkAddress is always "" on this backend: a Mac host advertises no named
// guest networks (bridged attachment needs the restricted
// com.apple.vm.networking entitlement), so no VM placed here can have a second
// NIC to report an address for. Answering "" is not a stub — it is the whole
// truth for this platform, and the create-time refusal upstream is what keeps
// it from ever being asked a different question.
func (p *Provisioner) NetworkAddress(string) string { return "" }
// leaseAddress finds mac's address in the contents of macOS's dhcpd_leases
// file, or "" if there is none. The file is a series
// of brace-delimited stanzas of key=value lines:
//
// {
// name=ubuntu
// ip_address=192.168.64.7
// hw_address=1,52:54:0:3a:9f:c1
// identifier=1,52:54:0:3a:9f:c1
// lease=0x68a1b2c3
// }
//
// The last matching stanza wins, and the honest reason is that a second one
// should not exist. A guest asks under one hardware address, and the seed pins
// its DHCP client identifier to that same address, so bootpd has one client to
// file it under. Where a duplicate DOES arise the file gives no way to rank
// them: `lease` is the epoch the lease EXPIRES at, not the moment it was
// granted, and bootpd honours a client's requested duration — so a longer older
// grant outlives a shorter newer one and expiry is not recency. Position is no
// better; nothing bootpd documents fixes the write order, and the one database
// captured on real hardware cannot settle it, because its two stanzas were
// keyed differently (one by a DUID, one by the MAC) and only ever one of them
// was a candidate. So: take the last, and treat two candidates as the anomaly
// they are rather than pretending to arbitrate between them.
func leaseAddress(leases, mac string) string {
want := normalizeMAC(mac)
if want == "" {
return ""
}
var ip, hw, found string
for _, line := range strings.Split(leases, "\n") {
field := strings.TrimSpace(line)
switch field {
case "{":
ip, hw = "", ""
case "}":
if hw == want && ip != "" {
found = ip
}
default:
key, value, ok := strings.Cut(field, "=")
if !ok {
continue
}
switch key {
case "ip_address":
ip = parseIP(value)
case "hw_address":
hw = normalizeMAC(value)
}
}
}
return found
}
func parseIP(s string) string {
addr, err := netip.ParseAddr(strings.TrimSpace(s))
if err != nil {
return ""
}
return addr.String()
}
// normalizeMAC reduces a hardware address to one comparable form, because the
// two sides spell the same address differently: state.MAC pads every octet
// ("52:54:00:0a:…") while bootpd writes them bare and prefixes the ARP
// hardware type ("1,52:54:0:a:…").
func normalizeMAC(s string) string {
if _, rest, ok := strings.Cut(s, ","); ok {
s = rest
}
octets := strings.Split(strings.TrimSpace(s), ":")
if len(octets) != 6 {
return ""
}
var b strings.Builder
for i, o := range octets {
v, err := strconv.ParseUint(o, 16, 8)
if err != nil {
return ""
}
if i > 0 {
b.WriteByte(':')
}
b.WriteString(strconv.FormatUint(v, 16))
}
return b.String()
}