From b585ee1a962b97e58e15f5d775951594766bf8c4 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Tue, 21 Jul 2026 11:53:12 -0400 Subject: [PATCH] Pass Softnet policy control FD through Tart --- Sources/tart/Commands/Run.swift | 13 +- Sources/tart/Network/Softnet.swift | 65 +++++++- Tests/TartTests/SoftnetControlFDTests.swift | 175 ++++++++++++++++++++ 3 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 Tests/TartTests/SoftnetControlFDTests.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 9788db1..a13bdb6 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -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 unless --net-host is specified. + """, 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: @@ -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 && !netHost) { netSoftnet = true } @@ -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 { diff --git a/Sources/tart/Network/Softnet.swift b/Sources/tart/Network/Softnet.swift index dda828a..92e54f1 100644 --- a/Sources/tart/Network/Softnet.swift +++ b/Sources/tart/Network/Softnet.swift @@ -13,10 +13,22 @@ class Softnet: Network { private let process = Process() private var monitorTask: Task? = nil private let monitorTaskFinished = ManagedAtomic(false) + private var controlFD: Int32? let vmFD: Int32 - init(vmMACAddress: String, extraArguments: [String] = []) throws { + init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws { + if let controlFD = controlFD { + do { + try Self.validateControlFD(controlFD) + } catch { + close(controlFD) + throw error + } + } + + self.controlFD = controlFD + let fds = UnsafeMutablePointer.allocate(capacity: MemoryLayout.stride * 2) let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds) @@ -33,6 +45,48 @@ 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 controlFD = controlFD { + process.arguments! += ["--control-fd", String(STDOUT_FILENO)] + process.standardOutput = FileHandle(fileDescriptor: controlFD, closeOnDealloc: false) + } + } + + deinit { + closeControlFD() + } + + 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.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.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 { @@ -46,6 +100,8 @@ class Softnet: Network { } func run(_ sema: AsyncSemaphore) throws { + defer { closeControlFD() } + try process.run() monitorTask = Task { @@ -60,6 +116,13 @@ class Softnet: Network { } } + private func closeControlFD() { + if let controlFD = controlFD { + close(controlFD) + self.controlFD = nil + } + } + func stop() async throws { if monitorTaskFinished.load(ordering: .sequentiallyConsistent) { // Consume the monitor task's value to ensure the task has finished diff --git a/Tests/TartTests/SoftnetControlFDTests.swift b/Tests/TartTests/SoftnetControlFDTests.swift new file mode 100644 index 0000000..af11a4a --- /dev/null +++ b/Tests/TartTests/SoftnetControlFDTests.swift @@ -0,0 +1,175 @@ +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 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"policy.replace\\n" + control.sendall(b"policy.replaced\\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.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("policy.replace\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), "policy.replaced\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 testControlFDWorksWithHostNetworking() 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-host", "--net-softnet-control-fd", "3"]) + + XCTAssertTrue(command.netHost) + XCTAssertFalse(command.netSoftnet) + XCTAssertEqual(command.netSoftnetControlFd, 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) + } + } +}