import Foundation /// One entry from macOS's `/var/db/dhcpd_leases`. /// /// The Virtualization NAT attachment hands guests addresses from the host's /// built-in `bootpd`, which records each lease in that file. There is no API for /// this, so parsing the file keyed by the guest's MAC is how we learn a VM's IP. public struct DHCPLease: Sendable, Equatable { /// The guest's advertised hostname (`name=` in the lease block). Often the /// guest's local hostname, sometimes absent. public let name: String? /// The leased IPv4 address, e.g. `192.168.64.7`. public let ipAddress: String /// The hardware address, **normalized**: lowercase, colon-separated, each /// octet zero-padded to two hex digits, with the `1,` type prefix stripped. public let hwAddress: String /// Lease expiry, parsed from the `lease=` hex epoch, when present. public let leaseExpiry: Date? public init(name: String?, ipAddress: String, hwAddress: String, leaseExpiry: Date?) { self.name = name self.ipAddress = ipAddress self.hwAddress = hwAddress self.leaseExpiry = leaseExpiry } } /// Parser for `/var/db/dhcpd_leases`. /// /// ## File format /// /// A sequence of brace-delimited blocks of `key=value` lines: /// /// ``` /// { /// name=macos-guest /// ip_address=192.168.64.7 /// hw_address=1,aa:bb:c:dd:ee:ff /// identifier=1,aa:bb:c:dd:ee:ff /// lease=0x67a1b2c3 /// } /// ``` /// /// Two details bite: /// /// 1. `hw_address` carries a leading hardware-type prefix (`1,` for Ethernet) /// that is not part of the MAC. /// 2. Octets are **not zero-padded** — `aa:bb:c:dd:ee:ff` is the same address /// that `VZMACAddress.string` renders as `aa:bb:0c:dd:ee:ff`. Comparing raw /// strings silently fails to match; both sides must be normalized. /// /// Blocks accumulate: a MAC can appear more than once as leases are renewed or /// reissued, so lookups take the **newest** lease (latest `leaseExpiry`, falling /// back to last-in-file when expiry is missing). /// /// - Note: macOS's DHCP lease time is 24 hours. That is exactly why clones must /// reuse a small set of **persistent per-slot MACs** rather than randomizing a /// MAC per VM: a randomized fleet would fill this file with day-long stale /// leases and exhaust the NAT subnet. public enum DHCPLeaseParser { /// The canonical path of the lease database. public static let defaultPath = "/var/db/dhcpd_leases" /// Parses the whole file. /// /// Malformed blocks are skipped rather than throwing — the file is written /// by another process and may be observed mid-write. /// /// - Parameter text: The file's contents. /// - Returns: Leases in file order. public static func parse(_ text: String) -> [DHCPLease] { var leases: [DHCPLease] = [] var fields: [String: String] = [:] var inBlock = false for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { let line = rawLine.trimmingCharacters(in: .whitespaces) if line.isEmpty { continue } if line.hasPrefix("{") { // A `{` while already inside a block means the previous one was // truncated (the file is written by bootpd and can be observed // mid-write). Drop it and start over rather than merging. inBlock = true fields = [:] continue } if line.hasPrefix("}") { if inBlock, let lease = makeLease(from: fields) { leases.append(lease) } inBlock = false fields = [:] continue } guard inBlock, let separator = line.firstIndex(of: "=") else { continue } let key = line[line.startIndex.. DHCPLease? { guard let ip = fields["ip_address"], !ip.isEmpty, let rawMAC = fields["hw_address"] ?? fields["identifier"], let mac = normalizeMAC(rawMAC) else { return nil } let name = fields["name"].flatMap { $0.isEmpty ? nil : $0 } return DHCPLease( name: name, ipAddress: ip, hwAddress: mac, leaseExpiry: fields["lease"].flatMap(parseLeaseTime) ) } /// Parses a `lease=` value. `bootpd` writes a hex epoch (`0x66b2c0de`), but /// a plain decimal epoch has been observed too, so both are accepted. private static func parseLeaseTime(_ raw: String) -> Date? { let text = raw.trimmingCharacters(in: .whitespaces).lowercased() guard !text.isEmpty else { return nil } let seconds: UInt64? if text.hasPrefix("0x") { seconds = UInt64(text.dropFirst(2), radix: 16) } else { seconds = UInt64(text, radix: 10) } guard let seconds else { return nil } return Date(timeIntervalSince1970: TimeInterval(seconds)) } /// Reads and parses the lease database from disk. /// /// - Parameter path: Defaults to ``defaultPath``. /// - Returns: Leases, or `[]` when the file does not exist yet (no guest has /// ever leased an address). public static func parseFile(at path: String = DHCPLeaseParser.defaultPath) -> [DHCPLease] { guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return [] } return parse(text) } /// Finds the current IP for a MAC. /// /// Both `mac` and each lease's `hwAddress` are normalized before comparison. /// /// - Parameters: /// - mac: The guest's MAC, in any common rendering. /// - leases: Leases from ``parse(_:)``. /// - Returns: The newest matching lease's IP, or `nil`. public static func ipAddress(forMAC mac: String, in leases: [DHCPLease]) -> String? { lease(forMAC: mac, in: leases)?.ipAddress } /// Finds the newest lease for a MAC. /// /// Callers that must distinguish a *fresh* lease from the 24 h-old one the /// slot's previous guest left behind need the whole record, not just its /// address — see ``isNewer(_:than:)``. /// /// - Parameters: /// - mac: The guest's MAC, in any common rendering. /// - leases: Leases from ``parse(_:)``. /// - Returns: The newest matching lease, or `nil`. public static func lease(forMAC mac: String, in leases: [DHCPLease]) -> DHCPLease? { guard let wanted = normalizeMAC(mac) else { return nil } var best: DHCPLease? for lease in leases where lease.hwAddress == wanted { guard let current = best else { best = lease continue } // Newest expiry wins; a missing expiry sorts oldest. `>=` means that // among equally-dated (or equally-undated) duplicates the last block // in the file wins, which is the one bootpd wrote most recently. let candidate = lease.leaseExpiry ?? .distantPast let incumbent = current.leaseExpiry ?? .distantPast if candidate >= incumbent { best = lease } } return best } /// Whether `candidate` is a lease `bootpd` wrote *after* `previous`. /// /// Slot MACs are persistent and macOS leases live 24 h, so a MAC almost /// always still has its previous guest's entry when the next clone boots. /// A caller that accepted the first entry it saw would hand out a stale /// address and then spend the whole boot timeout SSHing at nothing. /// /// `bootpd` rewrites the block — bumping `lease=` — whenever it hands the /// address out again, so a strictly later expiry means a new lease. A /// changed address means the same thing. With no `previous` (first boot on /// this MAC) anything counts as new. /// /// - Parameters: /// - candidate: The lease just read from the file. /// - previous: The lease observed before the guest was started. /// - Returns: `true` when `candidate` may be used. public static func isNewer(_ candidate: DHCPLease, than previous: DHCPLease?) -> Bool { guard let previous else { return true } if candidate.ipAddress != previous.ipAddress { return true } guard let previousExpiry = previous.leaseExpiry else { return true } guard let candidateExpiry = candidate.leaseExpiry else { return false } return candidateExpiry > previousExpiry } /// Normalizes a MAC to lowercase, colon-separated, zero-padded octets. /// /// Accepts an optional `,` prefix (as written by `bootpd`), and /// tolerates `-` separators. /// /// - Parameter raw: For example `1,aa:bb:c:dd:ee:ff` or `AA-BB-0C-DD-EE-FF`. /// - Returns: For example `aa:bb:0c:dd:ee:ff`, or `nil` if unparseable. public static func normalizeMAC(_ raw: String) -> String? { var text = raw.trimmingCharacters(in: .whitespaces) // `bootpd` prefixes the hardware type: `1,` for Ethernet. if let comma = text.lastIndex(of: ",") { text = String(text[text.index(after: comma)...]) } text = text.replacingOccurrences(of: "-", with: ":") let octets = text.split(separator: ":", omittingEmptySubsequences: false) guard octets.count == 6 else { return nil } var normalized: [String] = [] normalized.reserveCapacity(6) for octet in octets { guard (1...2).contains(octet.count), octet.allSatisfy(\.isHexDigit) else { return nil } normalized.append(String(repeating: "0", count: 2 - octet.count) + octet.lowercased()) } return normalized.joined(separator: ":") } }