From e4ac2275b93e0ddbe8e36e023f92036bf5166eeb Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Thu, 18 Aug 2022 22:25:02 +0400 Subject: [PATCH] tart ip: show a warning when DHCP lease and ARP cache entries mismatch (#186) --- Sources/tart/Commands/IP.swift | 16 ++- .../tart/MACAddressResolver/ARPCache.swift | 119 ++++++++++++++++++ .../tart/MACAddressResolver/MACAddress.swift | 2 +- Sources/tart/VNC/ScreenSharingVNC.swift | 3 +- 4 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 Sources/tart/MACAddressResolver/ARPCache.swift diff --git a/Sources/tart/Commands/IP.swift b/Sources/tart/Commands/IP.swift index aaa9abe..89ba76c 100644 --- a/Sources/tart/Commands/IP.swift +++ b/Sources/tart/Commands/IP.swift @@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand { do { let vmDir = try VMStorageLocal().open(name) let vmConfig = try VMConfig.init(fromURL: vmDir.configURL) + let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)! - guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else { + guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else { print("no IP address found, is your VM running?") Foundation.exit(1) } - print(ip) + if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP { + fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: " + + "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to" + + " https://github.com/cirruslabs/tart/issues/172\n", stderr) + } + + print(ipViaDHCP) Foundation.exit(0) } catch { @@ -33,12 +40,11 @@ struct IP: AsyncParsableCommand { } } - static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? { + static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? { let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)! - let vmMacAddress = MACAddress(fromString: config.macAddress.string)! repeat { - if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) { + if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) { return ip } diff --git a/Sources/tart/MACAddressResolver/ARPCache.swift b/Sources/tart/MACAddressResolver/ARPCache.swift new file mode 100644 index 0000000..f8e02d9 --- /dev/null +++ b/Sources/tart/MACAddressResolver/ARPCache.swift @@ -0,0 +1,119 @@ +import Foundation +import Network +import Virtualization + +struct ARPCommandFailedError: Error, CustomStringConvertible { + var terminationReason: Process.TerminationReason + var terminationStatus: Int32 + + var description: String { + var reason: String + + switch terminationReason { + case .exit: + reason = "exit code \(terminationStatus)" + case .uncaughtSignal: + reason = "uncaught signal" + default: + reason = "unknown reason" + } + + return "arp command failed: \(reason)" + } +} + +struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible { + var explanation: String + + var description: String { + "arp command yielded invalid output: \(explanation)" + } +} + +struct ARPCacheInternalError: Error, CustomStringConvertible { + var explanation: String + + var description: String { + "ARPCache internal error: \(explanation)" + } +} + +struct ARPCache { + static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? { + let process = Process.init() + process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp") + process.arguments = ["-an"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + process.standardInput = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + if !(process.terminationReason == .exit && process.terminationStatus == 0) { + throw ARPCommandFailedError( + terminationReason: process.terminationReason, + terminationStatus: process.terminationStatus) + } + + guard let rawLines = try pipe.fileHandleForReading.readToEnd() else { + throw ARPCommandYieldedInvalidOutputError(explanation: "empty output") + } + let lines = String(decoding: rawLines, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: "\n") + + // Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html + let regex = try NSRegularExpression(pattern: #"^.* \((?.*)\) at (?.*) on (?.*) .*$"#) + + for line in lines { + let nsLineRange = NSRange(line.startIndex.. String { + let nsRange = self.range(withName: name) + + if nsRange.location == NSNotFound { + throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)") + } + + guard let range = Range.init(nsRange, in: string) else { + throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range") + } + + return String(string[range]) + } +} diff --git a/Sources/tart/MACAddressResolver/MACAddress.swift b/Sources/tart/MACAddressResolver/MACAddress.swift index d22a687..f2ad265 100644 --- a/Sources/tart/MACAddressResolver/MACAddress.swift +++ b/Sources/tart/MACAddressResolver/MACAddress.swift @@ -16,6 +16,6 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible { } var description: String { - return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]) + String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]) } } diff --git a/Sources/tart/VNC/ScreenSharingVNC.swift b/Sources/tart/VNC/ScreenSharingVNC.swift index 982295f..11b78ff 100644 --- a/Sources/tart/VNC/ScreenSharingVNC.swift +++ b/Sources/tart/VNC/ScreenSharingVNC.swift @@ -10,7 +10,8 @@ class ScreenSharingVNC: VNC { } func waitForURL() async throws -> URL { - let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60) + let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)! + let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60) if let ip = ip { return URL(string: "vnc://\(ip)")!