From e12f95878e0639a75b01d14606c459fdefcae146 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Tue, 21 Jun 2022 19:17:02 +0300 Subject: [PATCH] Softnet: an alternative to built-in NAT with better isolation (#48) * Softnet: an alternative to built-in NAT with better isolation * Softnet: increase socketpair(2) socket buffer sizes to 1 MiB * Pass VM's FD and MAC address to the Softnet * Softnet: implement graceful shutdown * Bring back the dispatchMain() and task cancellation * tart pull: check for cancellation when pulling response body * Don't dispatchMain() in withTaskCancellationHandler() * Move VNC URL opening logic into VNCWrapper.open() --- Sources/tart/Commands/Run.swift | 42 ++++++++---------- Sources/tart/OCI/Registry.swift | 2 + Sources/tart/Root.swift | 1 - Sources/tart/Softnet.swift | 77 +++++++++++++++++++++++++++++++++ Sources/tart/VM.swift | 46 ++++++++++++++++---- Sources/tart/VNCWrapper.swift | 15 ++++++- 6 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 Sources/tart/Softnet.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 64c533e..51eafb2 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -27,10 +27,12 @@ struct Run: AsyncParsableCommand { discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM.")) var vnc: Bool = false + @Flag var withSoftnet: Bool = false + @MainActor func run() async throws { let vmDir = try VMStorageLocal().open(name) - vm = try VM(vmDir: vmDir) + vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet) var vncWrapper: VNCWrapper? @@ -38,13 +40,16 @@ struct Run: AsyncParsableCommand { vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine) } - let runTask = Task { + let task = Task { do { + if let vncWrapper = vncWrapper { + await vncWrapper.open() + } + try await vm!.run(recovery) - // wait for VM to be in a final state before exit - while !(vm?.inFinalState ?? false) { - try await Task.sleep(nanoseconds: 1_000_000) + if let vncWrapper = vncWrapper { + try vncWrapper.stop() } Foundation.exit(0) @@ -58,26 +63,17 @@ struct Run: AsyncParsableCommand { Foundation.exit(1) } } - if let vncWrapper = vncWrapper { - do { - let (port, password) = try await vncWrapper.credentials() - let url = URL(string: "vnc://:\(password)@127.0.0.1:\(port)")! - print("Opening \(url)...") - if ProcessInfo.processInfo.environment["CI"] == nil { - NSWorkspace.shared.open(url) - } - } catch { - print("Failed to get an IP for screen sharing: \(error)") - } - } else if !noGraphics { - runUI() + + let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT) + sigintSrc.setEventHandler { + task.cancel() } + sigintSrc.activate() - // wait for VM to get into a final state - try await runTask.value - - if let vncWrapper = vncWrapper { - try vncWrapper.stop() + if noGraphics || vnc { + dispatchMain() + } else { + runUI() } } diff --git a/Sources/tart/OCI/Registry.swift b/Sources/tart/OCI/Registry.swift index 2f18dc4..8b088d0 100644 --- a/Sources/tart/OCI/Registry.swift +++ b/Sources/tart/OCI/Registry.swift @@ -200,6 +200,8 @@ class Registry { } for try await part in response.body { + try Task.checkCancellation() + try handler(part) } } diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 2e25576..159ee4d 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -28,7 +28,6 @@ struct Root: AsyncParsableCommand { let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT) sigintSrc.setEventHandler { task.cancel() - Darwin.exit(1) } sigintSrc.activate() diff --git a/Sources/tart/Softnet.swift b/Sources/tart/Softnet.swift new file mode 100644 index 0000000..5484831 --- /dev/null +++ b/Sources/tart/Softnet.swift @@ -0,0 +1,77 @@ +import Foundation + +enum SoftnetError: Error { + case InitializationFailed(why: String) +} + +class Softnet { + private let process = Process() + + let vmFD: Int32 + + init(vmMACAddress: String) throws { + let binaryName = "softnet" + + guard let executableURL = Self.resolveBinaryPath(binaryName) else { + throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH") + } + + let fds = UnsafeMutablePointer.allocate(capacity: MemoryLayout.stride * 2) + + let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds) + if ret != 0 { + throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)") + } + + vmFD = fds[0] + let softnetFD = fds[1] + + try setSocketBuffers(vmFD, 1 * 1024 * 1024); + try setSocketBuffers(softnetFD, 1 * 1024 * 1024); + + process.executableURL = executableURL + process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false) + } + + func run() throws { + try process.run() + } + + func stop() throws { + process.interrupt() + process.waitUntilExit() + } + + private static func resolveBinaryPath(_ name: String) -> URL? { + guard let path = ProcessInfo.processInfo.environment["PATH"] else { + return nil + } + + for pathComponent in path.split(separator: ":") { + let url = URL(fileURLWithPath: String(pathComponent)) + .appendingPathComponent(name, isDirectory: false) + + if FileManager.default.fileExists(atPath: url.path) { + return url + } + } + + return nil + } + + private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws { + var option_value = sizeBytes + let option_len = socklen_t(MemoryLayout.size) + + var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len) + if ret != 0 { + throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)") + } + + ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len) + if ret != 0 { + throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)") + } + } +} diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 25037f0..3072384 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -23,17 +23,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // VM's config var config: VMConfig - init(vmDir: VMDirectory) throws { + var softnet: Softnet? = nil + + init(vmDir: VMDirectory, withSoftnet: Bool = false) throws { let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL) name = vmDir.name config = try VMConfig.init(fromURL: vmDir.configURL) - let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config) + // Initialize the virtual machine and its configuration + if withSoftnet { + softnet = try Softnet(vmMACAddress: config.macAddress.string) + } + + let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, + softnet: softnet) virtualMachine = VZVirtualMachine(configuration: configuration) super.init() - virtualMachine.delegate = self } @@ -87,7 +94,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } } - init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16) async throws { + init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16, withSoftnet: Bool = false) async throws { let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW(); // Load the restore image and try to get the requirements @@ -120,11 +127,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { try config.save(toURL: vmDir.configURL) // Initialize the virtual machine and its configuration - let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config) + if withSoftnet { + softnet = try Softnet(vmMACAddress: config.macAddress.string) + } + + let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, + softnet: softnet) virtualMachine = VZVirtualMachine(configuration: configuration) super.init() - virtualMachine.delegate = self // Run automated installation @@ -143,6 +154,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } func run(_ recovery: Bool) async throws { + if let softnet = softnet { + try softnet.run() + } + try await virtualMachine.start(recovery) await withTaskCancellationHandler(operation: { @@ -158,9 +173,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } } } + + if let softnet = softnet { + try softnet.stop(); + } } - static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration { + static func craftConfiguration( + diskURL: URL, + auxStorage: VZMacAuxiliaryStorage, + vmConfig: VMConfig, + softnet: Softnet? = nil + ) throws -> VZVirtualMachineConfiguration { let configuration = VZVirtualMachineConfiguration() // Boot loader @@ -212,7 +236,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Networking let vio = VZVirtioNetworkDeviceConfiguration() - vio.attachment = VZNATNetworkDeviceAttachment() + + if let softnet = softnet { + let fh = FileHandle.init(fileDescriptor: softnet.vmFD) + vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh) + } else { + vio.attachment = VZNATNetworkDeviceAttachment() + } vio.macAddress = vmConfig.macAddress configuration.networkDevices = [vio] diff --git a/Sources/tart/VNCWrapper.swift b/Sources/tart/VNCWrapper.swift index 50d474c..83dd142 100644 --- a/Sources/tart/VNCWrapper.swift +++ b/Sources/tart/VNCWrapper.swift @@ -15,8 +15,19 @@ class VNCWrapper { vnc.start() } - func credentials() async throws -> (UInt16, String) { - (try await Self.waitForPort(vnc: vnc), password) + func open() async { + do { + let port = try await Self.waitForPort(vnc: vnc) + + let url = URL(string: "vnc://:\(password)@127.0.0.1:\(port)")! + print("Opening \(url)...") + + if ProcessInfo.processInfo.environment["CI"] == nil { + NSWorkspace.shared.open(url) + } + } catch { + print("Failed to retrieve a VNC server port: \(error)") + } } func stop() throws {