247 lines
10 KiB
Swift
247 lines
10 KiB
Swift
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..<separator].trimmingCharacters(in: .whitespaces).lowercased()
|
|
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
|
|
if key.isEmpty { continue }
|
|
fields[key] = value
|
|
}
|
|
|
|
return leases
|
|
}
|
|
|
|
/// Builds a lease from one block's `key=value` pairs, or `nil` when the block
|
|
/// lacks the two fields that make it useful (an address and a MAC we can
|
|
/// normalize). Never throws: a half-written block is simply not a lease.
|
|
private static func makeLease(from fields: [String: String]) -> 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 `<type>,` 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: ":")
|
|
}
|
|
}
|