diff --git a/Sources/tart/ARP/ARPCache.swift b/Sources/tart/ARP/ARPCache.swift new file mode 100644 index 00000000..fbe764ef --- /dev/null +++ b/Sources/tart/ARP/ARPCache.swift @@ -0,0 +1,116 @@ +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/ARP/MACAddress.swift b/Sources/tart/ARP/MACAddress.swift new file mode 100644 index 00000000..c5c507a7 --- /dev/null +++ b/Sources/tart/ARP/MACAddress.swift @@ -0,0 +1,21 @@ +import Foundation + +struct MACAddress: Equatable, CustomStringConvertible { + var mac: [UInt8] = Array(repeating: 0, count: 6) + + init?(fromString: String) { + let components = fromString.components(separatedBy: ":") + + if components.count != 6 { + return nil + } + + for (index, component) in components.enumerated() { + mac[index] = UInt8(component, radix: 16)! + } + } + + var description: String { + return 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/Commands/IP.swift b/Sources/tart/Commands/IP.swift new file mode 100644 index 00000000..31fa364f --- /dev/null +++ b/Sources/tart/Commands/IP.swift @@ -0,0 +1,36 @@ +import ArgumentParser +import Foundation +import SystemConfiguration + +struct IP: ParsableCommand { + static var configuration = CommandConfiguration(abstract: "Get VM's IP address") + + @Argument(help: "VM name") + var name: String + + func run() throws { + Task { + do { + let vmDir = try VMStorage().read(name) + let vmConfig = try VMConfig.init(fromURL: vmDir.configURL) + let vmMacAddress = MACAddress(fromString: vmConfig.macAddress.string)! + + guard let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) else { + print("no IP address found, is your VM running?") + + Foundation.exit(1) + } + + print(ip) + + Foundation.exit(0) + } catch { + print(error) + + Foundation.exit(1) + } + } + + dispatchMain() + } +} diff --git a/Sources/tart/Commands/Root.swift b/Sources/tart/Commands/Root.swift index 897614b5..41f034b4 100644 --- a/Sources/tart/Commands/Root.swift +++ b/Sources/tart/Commands/Root.swift @@ -3,5 +3,5 @@ import ArgumentParser struct Root: ParsableCommand { static var configuration = CommandConfiguration( commandName: "tart", - subcommands: [Create.self, Run.self, List.self, Delete.self]) + subcommands: [Create.self, Run.self, List.self, IP.self, Delete.self]) } diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index bb50cc80..dab053aa 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -25,7 +25,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { auxStorage: auxStorage, hardwareModel: vmConfig.hardwareModel, cpuCount: vmConfig.cpuCount, - memorySize: vmConfig.memorySize + memorySize: vmConfig.memorySize, + macAddress: vmConfig.macAddress ) self.virtualMachine = VZVirtualMachine(configuration: configuration) @@ -80,7 +81,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { auxStorage: auxStorage, hardwareModel: requirements.hardwareModel, cpuCount: self.vmConfig.cpuCount, - memorySize: self.vmConfig.memorySize + memorySize: self.vmConfig.memorySize, + macAddress: self.vmConfig.macAddress ) self.virtualMachine = VZVirtualMachine(configuration: configuration) @@ -116,7 +118,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { auxStorage: VZMacAuxiliaryStorage, hardwareModel: VZMacHardwareModel, cpuCount: Int, - memorySize: UInt64 + memorySize: UInt64, + macAddress: VZMACAddress ) throws -> VZVirtualMachineConfiguration { let configuration = VZVirtualMachineConfiguration() @@ -153,6 +156,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Networking let vio = VZVirtioNetworkDeviceConfiguration() vio.attachment = VZNATNetworkDeviceAttachment() + vio.macAddress = macAddress configuration.networkDevices = [vio] // Storage diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index a1cef275..cc7f08d0 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -6,6 +6,7 @@ enum CodingKeys: String, CodingKey { case hardwareModel case cpuCount case memorySize + case macAddress } struct VMConfig: Encodable, Decodable { @@ -14,12 +15,20 @@ struct VMConfig: Encodable, Decodable { var hardwareModel: VZMacHardwareModel var cpuCount: Int var memorySize: UInt64 + var macAddress: VZMACAddress - init(ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), hardwareModel: VZMacHardwareModel, cpuCount: Int, memorySize: UInt64) { + init( + ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), + hardwareModel: VZMacHardwareModel, + cpuCount: Int, + memorySize: UInt64, + macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered() + ) { self.ecid = ecid self.hardwareModel = hardwareModel self.cpuCount = cpuCount self.memorySize = memorySize + self.macAddress = macAddress } init(fromURL: URL) throws { @@ -63,6 +72,15 @@ struct VMConfig: Encodable, Decodable { self.cpuCount = try container.decode(Int.self, forKey: .cpuCount) self.memorySize = try container.decode(UInt64.self, forKey: .memorySize) + + let encodedMacAddress = try container.decode(String.self, forKey: .macAddress) + guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else { + throw DecodingError.dataCorruptedError( + forKey: .hardwareModel, + in: container, + debugDescription: "failed to initialize VZMacAddress using the provided value") + } + self.macAddress = macAddress } func encode(to encoder: Encoder) throws { @@ -73,5 +91,6 @@ struct VMConfig: Encodable, Decodable { try container.encode(self.hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) try container.encode(self.cpuCount, forKey: .cpuCount) try container.encode(self.memorySize, forKey: .memorySize) + try container.encode(self.macAddress.string, forKey: .macAddress) } } diff --git a/tart.xcodeproj/project.pbxproj b/tart.xcodeproj/project.pbxproj index 2f95bcba..ed913ca7 100644 --- a/tart.xcodeproj/project.pbxproj +++ b/tart.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 440478FD27B1352C0028EFB8 /* Create.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440478FC27B1352C0028EFB8 /* Create.swift */; }; 440478FF27B13D590028EFB8 /* Run.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440478FE27B13D590028EFB8 /* Run.swift */; }; 4473E1E527A94E28000850C3 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4473E1E427A94E28000850C3 /* main.swift */; }; + 4484BA6B27BF1F270043A359 /* IP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4484BA6A27BF1F270043A359 /* IP.swift */; }; + 4484BA7027BF1F6D0043A359 /* ARPCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4484BA6E27BF1F6D0043A359 /* ARPCache.swift */; }; + 4484BA7127BF1F6D0043A359 /* MACAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4484BA6F27BF1F6D0043A359 /* MACAddress.swift */; }; 44FDBB3427B4177C005A201B /* VMStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44FDBB3327B4177C005A201B /* VMStorage.swift */; }; 44FDBB4227B43E6D005A201B /* VM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44FDBB4127B43E6D005A201B /* VM.swift */; }; 44FDBB4427B4445E005A201B /* Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44FDBB4327B4445E005A201B /* Root.swift */; }; @@ -37,6 +40,9 @@ 440478FE27B13D590028EFB8 /* Run.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Run.swift; sourceTree = ""; }; 4473E1E127A94E27000850C3 /* tart */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = tart; sourceTree = BUILT_PRODUCTS_DIR; }; 4473E1E427A94E28000850C3 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 4484BA6A27BF1F270043A359 /* IP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IP.swift; sourceTree = ""; }; + 4484BA6E27BF1F6D0043A359 /* ARPCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ARPCache.swift; sourceTree = ""; }; + 4484BA6F27BF1F6D0043A359 /* MACAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MACAddress.swift; sourceTree = ""; }; 44FDBB3327B4177C005A201B /* VMStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMStorage.swift; sourceTree = ""; }; 44FDBB3927B43CCF005A201B /* tart-tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "tart-tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 44FDBB4127B43E6D005A201B /* VM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VM.swift; sourceTree = ""; }; @@ -91,9 +97,19 @@ path = Sources; sourceTree = ""; }; + 4484BA6D27BF1F6D0043A359 /* ARP */ = { + isa = PBXGroup; + children = ( + 4484BA6E27BF1F6D0043A359 /* ARPCache.swift */, + 4484BA6F27BF1F6D0043A359 /* MACAddress.swift */, + ); + path = ARP; + sourceTree = ""; + }; 44FDBB4027B43DCB005A201B /* Commands */ = { isa = PBXGroup; children = ( + 4484BA6A27BF1F270043A359 /* IP.swift */, 440478FC27B1352C0028EFB8 /* Create.swift */, 440478FE27B13D590028EFB8 /* Run.swift */, 44FDBB4327B4445E005A201B /* Root.swift */, @@ -106,6 +122,7 @@ 44FDBB4F27B6A4B6005A201B /* tart */ = { isa = PBXGroup; children = ( + 4484BA6D27BF1F6D0043A359 /* ARP */, 44FDBB4027B43DCB005A201B /* Commands */, 4473E1E427A94E28000850C3 /* main.swift */, 44FDBB3327B4177C005A201B /* VMStorage.swift */, @@ -214,10 +231,13 @@ 440478FF27B13D590028EFB8 /* Run.swift in Sources */, 4473E1E527A94E28000850C3 /* main.swift in Sources */, 44FDBB4827B45EA1005A201B /* Delete.swift in Sources */, + 4484BA6B27BF1F270043A359 /* IP.swift in Sources */, + 4484BA7027BF1F6D0043A359 /* ARPCache.swift in Sources */, 44FDBB3427B4177C005A201B /* VMStorage.swift in Sources */, 44FDBB4627B44B35005A201B /* VMConfig.swift in Sources */, 44FDBB4227B43E6D005A201B /* VM.swift in Sources */, 44FDBB4C27B69515005A201B /* VMDirectory.swift in Sources */, + 4484BA7127BF1F6D0043A359 /* MACAddress.swift in Sources */, 440478FD27B1352C0028EFB8 /* Create.swift in Sources */, 44FDBB4427B4445E005A201B /* Root.swift in Sources */, 44FDBB4A27B45F6F005A201B /* List.swift in Sources */,