Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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: ":")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user