diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 2b19aa5..0cdcf31 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -243,6 +243,13 @@ struct Run: AsyncParsableCommand { @Flag(help: ArgumentHelp("Restrict network access to the host-only network")) var netHost: Bool = false + @Option(help: ArgumentHelp("Use externally managed connected datagram socket file descriptor for VM networking (e.g. --net-fd=3)", discussion: """ + This option allows integrating Tart with externally launched networking helpers. + + The provided file descriptor must reference a connected datagram socket. + """, valueName: "fd", visibility: .hidden)) + var netFd: Int32? + @Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")", discussion: """ Options are comma-separated and are as follows: @@ -295,14 +302,19 @@ struct Run: AsyncParsableCommand { netSoftnet = true } + if let netFd = netFd, netFd < 0 { + throw ValidationError("--net-fd must be greater than or equal to 0") + } + // Check that no more than one network option is specified var netFlags = 0 if netBridged.count > 0 { netFlags += 1 } if netSoftnet { netFlags += 1 } if netHost { netFlags += 1 } + if netFd != nil { netFlags += 1 } if netFlags > 1 { - throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive") + throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive") } if graphics && noGraphics { @@ -620,6 +632,10 @@ struct Run: AsyncParsableCommand { } func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? { + if let netFd = netFd { + return try NetworkFD(fd: netFd) + } + var softnetExtraArguments: [String] = [] if let netSoftnetAllow = netSoftnetAllow { diff --git a/Sources/tart/Network/NetworkFD.swift b/Sources/tart/Network/NetworkFD.swift new file mode 100644 index 0000000..c6344b7 --- /dev/null +++ b/Sources/tart/Network/NetworkFD.swift @@ -0,0 +1,74 @@ +import Darwin +import Foundation +import Semaphore +import Virtualization + +class NetworkFD: Network { + private let fd: Int32 + + init(fd: Int32) throws { + self.fd = fd + + try Self.validateFD(fd) + try Self.validateSocketType(fd) + try Self.validateConnected(fd) + } + + func attachments() -> [VZNetworkDeviceAttachment] { + [VZFileHandleNetworkDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd))] + } + + func run(_ sema: AsyncSemaphore) throws { + // no-op, only used for Softnet + } + + func stop() async throws { + // no-op, only used for Softnet + } + + private static func validateFD(_ fd: Int32) throws { + if fcntl(fd, F_GETFD) == -1 { + throw RuntimeError.VMConfigurationError( + "invalid --net-fd \(fd): file descriptor is not open (\(errnoDescription(errno)))" + ) + } + } + + private static func validateSocketType(_ fd: Int32) throws { + var socketType: Int32 = 0 + var optionLength = socklen_t(MemoryLayout.size) + + if getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &optionLength) == -1 { + throw RuntimeError.VMConfigurationError( + "invalid --net-fd \(fd): file descriptor must reference a socket (\(errnoDescription(errno)))" + ) + } + + if socketType != SOCK_DGRAM { + throw RuntimeError.VMConfigurationError( + "invalid --net-fd \(fd): expected SOCK_DGRAM socket, got \(socketType)" + ) + } + } + + private static func validateConnected(_ fd: Int32) throws { + var address = sockaddr_storage() + var addressLength = socklen_t(MemoryLayout.size) + + let result = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + getpeername(fd, sockaddrPointer, &addressLength) + } + } + + if result == -1 { + throw RuntimeError.VMConfigurationError( + "invalid --net-fd \(fd): socket must be connected (\(errnoDescription(errno)))" + ) + } + } + + private static func errnoDescription(_ code: CInt) -> String { + String(cString: strerror(code)) + } +} diff --git a/Tests/TartTests/NetworkFDTests.swift b/Tests/TartTests/NetworkFDTests.swift new file mode 100644 index 0000000..c26b64e --- /dev/null +++ b/Tests/TartTests/NetworkFDTests.swift @@ -0,0 +1,83 @@ +import Darwin +import Foundation +import XCTest +@testable import tart + +final class NetworkFDTests: XCTestCase { + func testAcceptsConnectedDatagramSocket() throws { + let (fdLeft, fdRight) = try makeDatagramSocketPair() + defer { + _ = close(fdLeft) + _ = close(fdRight) + } + + let network = try NetworkFD(fd: fdLeft) + + XCTAssertEqual(network.attachments().count, 1) + } + + func testRejectsClosedFileDescriptor() throws { + let (fdLeft, fdRight) = try makeDatagramSocketPair() + defer { _ = close(fdRight) } + + _ = close(fdLeft) + + XCTAssertThrowsError(try NetworkFD(fd: fdLeft)) { error in + self.assertVMConfigurationError(error, contains: "file descriptor is not open") + } + } + + func testRejectsNonSocketFileDescriptor() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + XCTAssertTrue(FileManager.default.createFile(atPath: fileURL.path, contents: Data())) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let fd = open(fileURL.path, O_RDONLY) + XCTAssertGreaterThanOrEqual(fd, 0) + defer { _ = close(fd) } + + XCTAssertThrowsError(try NetworkFD(fd: fd)) { error in + self.assertVMConfigurationError(error, contains: "must reference a socket") + } + } + + func testRejectsUnconnectedDatagramSocket() throws { + let fd = socket(AF_UNIX, SOCK_DGRAM, 0) + XCTAssertGreaterThanOrEqual(fd, 0) + defer { _ = close(fd) } + + XCTAssertThrowsError(try NetworkFD(fd: fd)) { error in + self.assertVMConfigurationError(error, contains: "socket must be connected") + } + } + + private func makeDatagramSocketPair() throws -> (Int32, Int32) { + var fds: [Int32] = [-1, -1] + let result = socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds) + + if result == -1 { + throw RuntimeError.VMConfigurationError("failed to create a datagram socketpair for tests") + } + + return (fds[0], fds[1]) + } + + private func assertVMConfigurationError( + _ error: Error, + contains expectedSubstring: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case RuntimeError.VMConfigurationError(let message) = error else { + XCTFail("Expected RuntimeError.VMConfigurationError, got \(error)", file: file, line: line) + return + } + + XCTAssertTrue( + message.contains(expectedSubstring), + "Expected message to contain \"\(expectedSubstring)\", got \"\(message)\"", + file: file, + line: line + ) + } +} diff --git a/Tests/TartTests/RunNetworkValidationTests.swift b/Tests/TartTests/RunNetworkValidationTests.swift new file mode 100644 index 0000000..0bba670 --- /dev/null +++ b/Tests/TartTests/RunNetworkValidationTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import tart + +final class RunNetworkValidationTests: XCTestCase { + func testNetFdRejectsNegativeValue() throws { + XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd=-1"])) { error in + self.assertError(error, contains: "--net-fd must be greater than or equal to 0") + } + } + + func testNetFdConflictsWithNetBridged() throws { + XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd", "3", "--net-bridged=en0"])) { error in + self.assertError(error, contains: "--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive") + } + } + + func testNetFdConflictsWithNetSoftnet() throws { + XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd", "3", "--net-softnet"])) { error in + self.assertError(error, contains: "--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive") + } + } + + func testNetFdConflictsWithNetHost() throws { + XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd", "3", "--net-host"])) { error in + self.assertError(error, contains: "--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive") + } + } + + private func assertError( + _ error: Error, + contains expectedSubstring: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + String(describing: error).contains(expectedSubstring), + "Expected error to contain \"\(expectedSubstring)\", got \"\(error)\"", + file: file, + line: line + ) + } +} diff --git a/docs/faq.md b/docs/faq.md index 3c51a90..4e9b3e4 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -77,6 +77,20 @@ Note: that accessing host is only possible with the default NAT network. If you [Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet )`, then the network isolation is stricter and it's not possible to access the host. +## Using externally managed networking (`--net-fd`) + +For advanced integrations, `tart run` can consume a pre-opened connected datagram socket via `--net-fd`. + +Unlike `--net-softnet`, Tart will not launch Softnet or configure Softnet permissions in this mode. + +External launcher is responsible for: + +* creating a connected datagram socketpair (for example, `socketpair(AF_UNIX, SOCK_DGRAM, ...)`) +* starting `softnet --vm-fd ...` (or another networking helper) with one end of that socketpair +* starting `tart run --net-fd ` with the other end inherited into Tart + +If the file descriptor is invalid, not a datagram socket, or not connected, `tart run` fails fast. + ## Changing the default NAT subnet To change the default network to `192.168.77.1`: