Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Sources/tart/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ struct Run: AsyncParsableCommand {
""", valueName: "comma-separated CIDRs"))
var netSoftnetBlock: String?

@Option(help: ArgumentHelp("Connected Unix stream socket file descriptor to use for the Softnet control channel (e.g. --net-softnet-control-fd=3)", discussion: """
This option enables the Softnet control channel on an inherited Unix stream socket. It can be used to dynamically replace Softnet allow and block lists while the VM is running.

The file descriptor must be greater than 2. Implies --net-softnet.
""", valueName: "file descriptor"))
var netSoftnetControlFd: Int32?

@Option(help: ArgumentHelp("Comma-separated list of TCP ports to expose (e.g. --net-softnet-expose 2222:22,8080:80)", discussion: """
Options are comma-separated and are as follows:

Expand Down Expand Up @@ -313,7 +320,7 @@ struct Run: AsyncParsableCommand {
}

// Automatically enable --net-softnet when any of its related options are specified
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil {
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil || netSoftnetControlFd != nil {
netSoftnet = true
}

Expand Down Expand Up @@ -681,13 +688,13 @@ struct Run: AsyncParsableCommand {
if netSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)

return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments, controlFD: netSoftnetControlFd)
}

if netHost {
let config = try VMConfig.init(fromURL: vmDir.configURL)

return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
}

if netBridged.count > 0 {
Expand Down
53 changes: 52 additions & 1 deletion Sources/tart/Network/Softnet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ class Softnet: Network {

let vmFD: Int32

init(vmMACAddress: String, extraArguments: [String] = []) throws {
init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws {
var controlFileHandle: FileHandle?

if let controlFD = controlFD {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use an owning FileHandle with closeOnDealloc: true here?

That would remove the manual closeControlFD()/deinit bookkeeping while still allowing run() to close the parent’s copy after launching Softnet.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4a018ce. The control socket now uses an owning FileHandle(closeOnDealloc: true); run() explicitly closes the parent copy after launch, and the raw-FD/deinit bookkeeping is gone. Standard descriptors are rejected before ownership is taken, with regression coverage to ensure stderr remains open. Full Swift test suite and SwiftFormat lint pass.

guard controlFD > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
}

controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
try Self.validateControlFD(controlFD)
}

let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)

let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
Expand All @@ -33,6 +44,44 @@ class Softnet: Network {
process.executableURL = try Self.softnetExecutableURL()
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)

if let controlFileHandle = controlFileHandle {
process.arguments! += ["--control-fd", String(STDOUT_FILENO)]
process.standardOutput = controlFileHandle
}
}

static func validateControlFD(_ fd: Int32) throws {
guard fd > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
}

var socketType: Int32 = 0
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
let details = Errno(rawValue: CInt(errno))
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
}

guard socketType == SOCK_STREAM else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
}

var peerAddress = sockaddr_storage()
var peerAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
let result = withUnsafeMutablePointer(to: &peerAddress) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getpeername(fd, $0, &peerAddressLength)
}
}
guard result == 0 else {
let details = Errno(rawValue: CInt(errno))
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
}

guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
}
}

static func softnetExecutableURL() throws -> URL {
Expand All @@ -46,6 +95,8 @@ class Softnet: Network {
}

func run(_ sema: AsyncSemaphore) throws {
defer { try? (process.standardOutput as? FileHandle)?.close() }

try process.run()

monitorTask = Task {
Expand Down
183 changes: 183 additions & 0 deletions Tests/TartTests/SoftnetControlFDTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import XCTest
@testable import tart

import Semaphore

final class SoftnetControlFDTests: XCTestCase {
func testConnectedUnixStreamSocketIsAccepted() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
defer {
close(fds[0])
close(fds[1])
}

XCTAssertNoThrow(try Softnet.validateControlFD(fds[0]))
}

func testUnixDatagramSocketIsRejected() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
defer {
close(fds[0])
close(fds[1])
}

XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
}

func testUnconnectedUnixStreamSocketIsRejected() throws {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
XCTAssertGreaterThan(fd, STDERR_FILENO)
defer { close(fd) }

XCTAssertThrowsError(try Softnet.validateControlFD(fd))
}

func testPipeIsRejected() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(pipe(&fds), 0)
defer {
close(fds[0])
close(fds[1])
}

XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
}

func testStandardDescriptorsAreRejected() throws {
XCTAssertThrowsError(try Softnet.validateControlFD(STDIN_FILENO))
XCTAssertThrowsError(try Softnet.validateControlFD(STDOUT_FILENO))
XCTAssertThrowsError(try Softnet.validateControlFD(STDERR_FILENO))
}

func testStandardDescriptorsRemainOpenWhenInitializationFails() throws {
for fd in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
let flags = fcntl(fd, F_GETFD)
XCTAssertNotEqual(flags, -1)

XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fd))
XCTAssertEqual(fcntl(fd, F_GETFD), flags)
}
}

func testControlChannelIsPassedToSoftnetAndVMFDRemainsDatagram() async throws {
let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
defer { try? FileManager.default.removeItem(at: temporaryDirectory) }

let executable = temporaryDirectory.appendingPathComponent("softnet")
let script = """
#!/usr/bin/env python3
import socket
import sys

assert sys.argv[1:] == ["--vm-fd", "0", "--vm-mac-address", "02:00:00:00:00:01", "--control-fd", "1"]
vm = socket.socket(fileno=0)
control = socket.socket(fileno=1)
assert vm.family == socket.AF_UNIX and vm.type == socket.SOCK_DGRAM
assert control.family == socket.AF_UNIX and control.type == socket.SOCK_STREAM
assert control.recv(4096) == b"softnet.policy.set\\n"
control.sendall(b"ok\\n")
"""
try script.write(to: executable, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)

let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
setenv("PATH", "\(temporaryDirectory.path):\(previousPath)", 1)
defer { setenv("PATH", previousPath, 1) }

var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
defer { close(fds[1]) }

var timeout = timeval(tv_sec: 5, tv_usec: 0)
XCTAssertEqual(setsockopt(fds[1], SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size)), 0)

let semaphore = AsyncSemaphore(value: 0)
let softnet = try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0])
try softnet.run(semaphore)

XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
XCTAssertEqual(errno, EBADF)

let request = Array("softnet.policy.set\n".utf8)
XCTAssertEqual(request.withUnsafeBytes { send(fds[1], $0.baseAddress, $0.count, 0) }, request.count)

var response = [UInt8](repeating: 0, count: 128)
let received = recv(fds[1], &response, response.count, 0)
XCTAssertGreaterThan(received, 0)
XCTAssertEqual(String(decoding: response.prefix(Int(max(received, 0))), as: UTF8.self), "ok\n")

await semaphore.wait()
}

func testControlFDIsClosedWhenSoftnetInitializationFails() throws {
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
setenv("PATH", "/this/path/does/not/exist", 1)
defer { setenv("PATH", previousPath, 1) }

var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
defer { close(fds[1]) }

XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
XCTAssertEqual(errno, EBADF)
}

func testControlFDIsClosedWhenSoftnetValidationFails() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
defer { close(fds[1]) }

XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
XCTAssertEqual(errno, EBADF)
}

func testControlFDImpliesSoftnet() throws {
let temporaryHome = try createTemporaryTartHome()
defer { try? FileManager.default.removeItem(at: temporaryHome) }
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
setenv("TART_HOME", temporaryHome.path, 1)
defer { restoreEnvironment("TART_HOME", value: previousHome) }

let command = try Run.parse(["vm", "--net-softnet-control-fd", "3"])

XCTAssertTrue(command.netSoftnet)
XCTAssertEqual(command.netSoftnetControlFd, 3)
}

func testControlFDIsRejectedWithHostNetworking() throws {
let temporaryHome = try createTemporaryTartHome()
defer { try? FileManager.default.removeItem(at: temporaryHome) }
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
setenv("TART_HOME", temporaryHome.path, 1)
defer { restoreEnvironment("TART_HOME", value: previousHome) }

XCTAssertThrowsError(
try Run.parse(["vm", "--net-host", "--net-softnet-control-fd", "3"])
)
}

private func createTemporaryTartHome() throws -> URL {
let temporaryHome = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
let vm = temporaryHome.appendingPathComponent("vms/vm")
try FileManager.default.createDirectory(at: vm, withIntermediateDirectories: true)

for name in ["config.json", "disk.img", "nvram.bin"] {
XCTAssertTrue(FileManager.default.createFile(atPath: vm.appendingPathComponent(name).path, contents: nil))
}

return temporaryHome
}

private func restoreEnvironment(_ name: String, value: String?) {
if let value = value {
setenv(name, value, 1)
} else {
unsetenv(name)
}
}
}