tart ip: read /etc/bootptab in addition to /var/db/dhcpd_leases (#1355)

This commit is contained in:
edi-oai
2026-09-28 16:49:06 +01:00
committed by GitHub
parent 27d3e2c5da
commit 5c1c6bd315
5 changed files with 115 additions and 3 deletions
+4 -1
View File
@@ -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
}
@@ -0,0 +1,65 @@
import Foundation
import Network
struct Bootptab {
private var reservations: [MACAddress: Swift.Set<IPv4Address>] = [:]
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
}
}
@@ -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
}
}
+40
View File
@@ -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")!))
}
}
+1 -1
View File
@@ -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: