a73x

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()
}