mirror of https://github.com/cirruslabs/tart.git
Add FD-based network mode
This commit is contained in:
parent
37b8219579
commit
15754bcc8d
|
|
@ -243,6 +243,13 @@ struct Run: AsyncParsableCommand {
|
||||||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||||
var netHost: Bool = false
|
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\")",
|
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")",
|
||||||
discussion: """
|
discussion: """
|
||||||
Options are comma-separated and are as follows:
|
Options are comma-separated and are as follows:
|
||||||
|
|
@ -295,14 +302,19 @@ struct Run: AsyncParsableCommand {
|
||||||
netSoftnet = true
|
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
|
// Check that no more than one network option is specified
|
||||||
var netFlags = 0
|
var netFlags = 0
|
||||||
if netBridged.count > 0 { netFlags += 1 }
|
if netBridged.count > 0 { netFlags += 1 }
|
||||||
if netSoftnet { netFlags += 1 }
|
if netSoftnet { netFlags += 1 }
|
||||||
if netHost { netFlags += 1 }
|
if netHost { netFlags += 1 }
|
||||||
|
if netFd != nil { netFlags += 1 }
|
||||||
|
|
||||||
if 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 {
|
if graphics && noGraphics {
|
||||||
|
|
@ -620,6 +632,10 @@ struct Run: AsyncParsableCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||||
|
if let netFd = netFd {
|
||||||
|
return try NetworkFD(fd: netFd)
|
||||||
|
}
|
||||||
|
|
||||||
var softnetExtraArguments: [String] = []
|
var softnetExtraArguments: [String] = []
|
||||||
|
|
||||||
if let netSoftnetAllow = netSoftnetAllow {
|
if let netSoftnetAllow = netSoftnetAllow {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
docs/faq.md
14
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 <VM NAME>)`, then the network isolation
|
[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.
|
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
|
## Changing the default NAT subnet
|
||||||
|
|
||||||
To change the default network to `192.168.77.1`:
|
To change the default network to `192.168.77.1`:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue