This commit is contained in:
Fedor Korotkov 2026-02-28 12:04:03 +01:00 committed by GitHub
commit 8c8737144c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 230 additions and 1 deletions

View File

@ -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 {

View File

@ -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<Int32>.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<sockaddr_storage>.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))
}
}

View File

@ -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
)
}
}

View File

@ -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
)
}
}

View File

@ -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 <VM NAME>)`, 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 <FD> <VM NAME>` 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`: