diff --git a/Sources/tart/ARP/ARPCache.swift b/Sources/tart/ARP/ARPCache.swift deleted file mode 100644 index f691540..0000000 --- a/Sources/tart/ARP/ARPCache.swift +++ /dev/null @@ -1,119 +0,0 @@ -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/Commands/IP.swift b/Sources/tart/Commands/IP.swift index 905c049..aaa9abe 100644 --- a/Sources/tart/Commands/IP.swift +++ b/Sources/tart/Commands/IP.swift @@ -38,7 +38,7 @@ struct IP: AsyncParsableCommand { let vmMacAddress = MACAddress(fromString: config.macAddress.string)! repeat { - if let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) { + if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) { return ip } diff --git a/Sources/tart/MACAddressResolver/Lease.swift b/Sources/tart/MACAddressResolver/Lease.swift new file mode 100644 index 0000000..4ddef91 --- /dev/null +++ b/Sources/tart/MACAddressResolver/Lease.swift @@ -0,0 +1,32 @@ +import Network + +struct Lease { + var mac: MACAddress + var ip: IPv4Address + + init?(fromRawLease: [String : String]) { + // Retrieve the required fields + guard let hwAddress = fromRawLease["hw_address"] else { return nil } + guard let ipAddress = fromRawLease["ip_address"] else { return nil } + + // Parse MAC address + let hwAddressSplits = hwAddress.split(separator: ",") + if hwAddressSplits.count != 2 { + return nil + } + if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER { + return nil + } + guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else { + return nil + } + + // Parse IP address + guard let ip = IPv4Address(ipAddress) else { + return nil + } + + self.ip = ip + self.mac = mac + } +} diff --git a/Sources/tart/MACAddressResolver/Leases.swift b/Sources/tart/MACAddressResolver/Leases.swift new file mode 100644 index 0000000..1ae5cfc --- /dev/null +++ b/Sources/tart/MACAddressResolver/Leases.swift @@ -0,0 +1,106 @@ +import Foundation +import Network + +enum LeasesError: Error { + case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int) + case Truncated(name: String = "truncated DHCPD leases file") + + var description: String { + switch self { + + case .UnexpectedFormat(name: let name, message: let message, line: let line): + return "\(name) on line \(line): \(message)" + case .Truncated(name: let name): + return "\(name)" + } + } +} + +class Leases { + private let leases: [MACAddress : Lease] + + convenience init() throws { + try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases")) + } + + convenience init(_ fromURL: URL) throws { + let fileContents = try String(contentsOf: fromURL, encoding: .utf8) + + try self.init(fileContents) + } + + init(_ fromString: String) throws { + var leases: [MACAddress : Lease] = Dictionary() + + for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) { + leases[lease.mac] = lease + } + + self.leases = leases + } + + /// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases. + /// + /// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391 + private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] { + var rawLeases: [[String : String]] = Array() + + enum State { + case Nowhere + case Start + case Body + case End + } + var state = State.Nowhere + + var currentRawLease: [String : String] = Dictionary() + + for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) { + if line == "{" { + // Handle lease block start + if state != .Nowhere && state != .End { + throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber) + } + + state = .Start + } else if line == "}" { + // Handle lease block end + if state != .Body { + throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber) + } + + rawLeases.append(currentRawLease) + currentRawLease = Dictionary() + + state = .End + } else { + // Handle lease block contents + let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"}) + + if lineWithoutTabs.isEmpty { + continue + } + + let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1) + if splits.count != 2 { + throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber) + } + let (key, value) = (String(splits[0]), String(splits[1])) + + currentRawLease[key] = value + + state = .Body + } + } + + if state == .Start || state == .Body { + throw LeasesError.Truncated() + } + + return rawLeases + } + + func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? { + leases[macAddress]?.ip + } +} diff --git a/Sources/tart/ARP/MACAddress.swift b/Sources/tart/MACAddressResolver/MACAddress.swift similarity index 87% rename from Sources/tart/ARP/MACAddress.swift rename to Sources/tart/MACAddressResolver/MACAddress.swift index e41017c..d22a687 100644 --- a/Sources/tart/ARP/MACAddress.swift +++ b/Sources/tart/MACAddressResolver/MACAddress.swift @@ -1,6 +1,6 @@ import Foundation -struct MACAddress: Equatable, CustomStringConvertible { +struct MACAddress: Equatable, Hashable, CustomStringConvertible { var mac: [UInt8] = Array(repeating: 0, count: 6) init?(fromString: String) { diff --git a/Tests/TartTests/MACAddressResolverTests.swift b/Tests/TartTests/MACAddressResolverTests.swift new file mode 100644 index 0000000..9fab7b0 --- /dev/null +++ b/Tests/TartTests/MACAddressResolverTests.swift @@ -0,0 +1,35 @@ +import XCTest +import Network +@testable import tart + +final class MACAddressResolverTests: XCTestCase { + func testSingleEntry() throws { + let leases = try Leases(""" + { + ip_address=1.2.3.4 + hw_address=1,00:11:22:33:44:55 + } + """) + + XCTAssertEqual(IPv4Address("1.2.3.4"), + try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!)) + } + + func testMultipleEntries() throws { + let leases = try Leases(""" + { + ip_address=1.2.3.4 + hw_address=1,00:11:22:33:44:55 + } + { + ip_address=5.6.7.8 + hw_address=1,AA:BB:CC:DD:EE:FF + } + """) + + XCTAssertEqual(IPv4Address("1.2.3.4"), + try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!)) + XCTAssertEqual(IPv4Address("5.6.7.8"), + try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!)) + } +}