From 5c1c6bd315cb743f0fd4d2b3c7dfe7518c9a3aca Mon Sep 17 00:00:00 2001 From: edi-oai Date: Mon, 28 Sep 2026 16:49:06 +0100 Subject: [PATCH] tart ip: read /etc/bootptab in addition to /var/db/dhcpd_leases (#1355) --- Sources/tart/Commands/IP.swift | 5 +- .../tart/MACAddressResolver/Bootptab.swift | 65 +++++++++++++++++++ .../tart/MACAddressResolver/MACAddress.swift | 6 +- Tests/TartTests/BootptabTests.swift | 40 ++++++++++++ docs/faq.md | 2 +- 5 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 Sources/tart/MACAddressResolver/Bootptab.swift create mode 100644 Tests/TartTests/BootptabTests.swift diff --git a/Sources/tart/Commands/IP.swift b/Sources/tart/Commands/IP.swift index 4550a55..c9bb7e3 100644 --- a/Sources/tart/Commands/IP.swift +++ b/Sources/tart/Commands/IP.swift @@ -20,7 +20,7 @@ struct IP: AsyncParsableCommand { @Option(help: ArgumentHelp("Strategy for resolving IP address", discussion: """ - By default, Tart is using a "dhcp" resolver which parses the DHCP lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs are not using the bridged networking.\n + By default, Tart is using a "dhcp" resolver which parses the DHCP reservation file, then the lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs not using the bridged networking.\n Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM. """)) @@ -60,6 +60,9 @@ struct IP: AsyncParsableCommand { return ip } case .dhcp: + if let bootptab = try Bootptab(), let ip = try bootptab.ResolveMACAddress(macAddress: vmMACAddress) { + return ip + } if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) { return ip } diff --git a/Sources/tart/MACAddressResolver/Bootptab.swift b/Sources/tart/MACAddressResolver/Bootptab.swift new file mode 100644 index 0000000..ddeea13 --- /dev/null +++ b/Sources/tart/MACAddressResolver/Bootptab.swift @@ -0,0 +1,65 @@ +import Foundation +import Network + +struct Bootptab { + private var reservations: [MACAddress: Swift.Set] = [:] + + init?(_ fromURL: URL = URL(fileURLWithPath: "/etc/bootptab")) throws { + let contents: String + + do { + contents = try String(contentsOf: fromURL, encoding: .utf8) + } catch { + if error.isFileNotFound() { + return nil + } + + throw error + } + + for line in contents.split(whereSeparator: \.isNewline) { + let fields = line.split(whereSeparator: \.isWhitespace) + + // Skip lines that don't look like reservation fields + guard fields.count >= 4 else { + continue + } + + // Assign reservation fields + let hardwareType = fields[1] + let hardwareAddress = fields[2] + let ipAddress = fields[3] + + // Skip non-Ethernet reservations + guard hardwareType == "1" else { + continue + } + + // Skip malformed MAC addresses + guard let mac = MACAddress(fromString: String(hardwareAddress)) else { + continue + } + + // Skip malformed IPv4 addresses + guard let ip = IPv4Address(String(ipAddress)) else { + continue + } + + reservations[mac, default: []].insert(ip) + } + } + + func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? { + guard let addresses = reservations[macAddress] else { + return nil + } + + if addresses.count > 1 { + let addresses = addresses.map { $0.debugDescription }.sorted().joined(separator: ", ") + + throw RuntimeError.Generic("multiple DHCP reservations in /etc/bootptab for \(macAddress): \(addresses)") + } + + return addresses.first + } +} diff --git a/Sources/tart/MACAddressResolver/MACAddress.swift b/Sources/tart/MACAddressResolver/MACAddress.swift index f2ad265..d8dd964 100644 --- a/Sources/tart/MACAddressResolver/MACAddress.swift +++ b/Sources/tart/MACAddressResolver/MACAddress.swift @@ -11,7 +11,11 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible { } for (index, component) in components.enumerated() { - mac[index] = UInt8(component, radix: 16)! + guard let byte = UInt8(component, radix: 16) else { + return nil + } + + mac[index] = byte } } diff --git a/Tests/TartTests/BootptabTests.swift b/Tests/TartTests/BootptabTests.swift new file mode 100644 index 0000000..bcf8dfa --- /dev/null +++ b/Tests/TartTests/BootptabTests.swift @@ -0,0 +1,40 @@ +import XCTest +import Network +@testable import tart + +final class BootptabTests: XCTestCase { + func testResolveMACAddress() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + // A missing file produces no Bootptab + XCTAssertNil(try Bootptab(url)) + + // Write reservations with duplicates, conflicts, and a malformed MAC address + let contents = """ + # DHCP reservations + % + client1 1 02:ab:00:01:02:03 192.168.64.2 + client2 1 02:ab:00:01:02:03 192.168.64.2 + client3 1 02:ab:00:01:02:04 192.168.64.3 + client4 1 02:ab:00:01:02:04 192.168.65.3 + malformed 1 02:gg:00:01:02:03 192.168.64.9 + """ + try contents.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + // Parse the reservation file + let bootptab = try XCTUnwrap(Bootptab(url)) + + // Identical reservations resolve to one address + XCTAssertEqual(try bootptab.ResolveMACAddress( + macAddress: MACAddress(fromString: "02:ab:00:01:02:03")!), IPv4Address("192.168.64.2")) + + // An unknown MAC address has no reservation + XCTAssertNil(try bootptab.ResolveMACAddress( + macAddress: MACAddress(fromString: "02:ab:00:01:02:05")!)) + + // Conflicting reservations produce an error + XCTAssertThrowsError(try bootptab.ResolveMACAddress( + macAddress: MACAddress(fromString: "02:ab:00:01:02:04")!)) + } +} diff --git a/docs/faq.md b/docs/faq.md index 9c692f8..9f516ef 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -132,7 +132,7 @@ And no worries, this file will be re-created on the next `tart run`. Due to the limitations of the macOS built-in DHCP server, `tart ip` is unable to correctly report the IP addresses for VMs using DHCP client identifiers that are not based on VMs link-layer addresses (MAC addresses). -By default, when [no `--resolver=arp` is specified](#resolving-the-vms-ip-when-using-bridged-networking), `tart ip` reads the `/var/db/dhcpd_leases` file and tries to find the freshest entry that matches the VM's MAC address (based on the `hw_address` field). +By default, when [no `--resolver=arp` is specified](#resolving-the-vms-ip-when-using-bridged-networking), `tart ip` first checks the `/etc/bootptab` file for a matching DHCP reservation. Otherwise, it reads the `/var/db/dhcpd_leases` file and tries to find the freshest entry that matches the VM's MAC address (based on the `hw_address` field). However, things starts to break when the VM uses a [DUID-EN](https://metebalci.com/blog/a-note-on-dhcpv6-duid-and-prefix-delegation#duid-types) identifier, for example. One of the notorious examples of this being Ubuntu, using this type of identifier by default on latest versions. This results in the `/var/db/dhcpd_leases` entry for Ubuntu appearing as follows: