From b585ee1a962b97e58e15f5d775951594766bf8c4 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Tue, 21 Jul 2026 11:53:12 -0400 Subject: [PATCH 1/4] 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 9788db11..a13bdb64 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 dda828a5..92e54f18 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 00000000..af11a4ac --- /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) + } + } +} From 842320ac5cc01d6d59f84d846952b43e3ee53bfc Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Tue, 21 Jul 2026 13:33:27 -0400 Subject: [PATCH 2/4] Update Softnet control test for policy set --- Tests/TartTests/SoftnetControlFDTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/TartTests/SoftnetControlFDTests.swift b/Tests/TartTests/SoftnetControlFDTests.swift index af11a4ac..13d18885 100644 --- a/Tests/TartTests/SoftnetControlFDTests.swift +++ b/Tests/TartTests/SoftnetControlFDTests.swift @@ -67,8 +67,8 @@ final class SoftnetControlFDTests: XCTestCase { 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") + 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) @@ -91,13 +91,13 @@ final class SoftnetControlFDTests: XCTestCase { XCTAssertEqual(fcntl(fds[0], F_GETFD), -1) XCTAssertEqual(errno, EBADF) - let request = Array("policy.replace\n".utf8) + 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), "policy.replaced\n") + XCTAssertEqual(String(decoding: response.prefix(Int(max(received, 0))), as: UTF8.self), "ok\n") await semaphore.wait() } From 311eec95f4372ca4d152f4c1b207fd38870a3589 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Tue, 21 Jul 2026 17:10:36 -0400 Subject: [PATCH 3/4] Reject Softnet control FD with host networking --- Sources/tart/Commands/Run.swift | 4 ++-- Tests/TartTests/SoftnetControlFDTests.swift | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index a13bdb64..ad22bf18 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -227,7 +227,7 @@ struct Run: AsyncParsableCommand { @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. + The file descriptor must be greater than 2. Implies --net-softnet. """, valueName: "file descriptor")) var netSoftnetControlFd: Int32? @@ -320,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 || (netSoftnetControlFd != nil && !netHost) { + if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil || netSoftnetControlFd != nil { netSoftnet = true } diff --git a/Tests/TartTests/SoftnetControlFDTests.swift b/Tests/TartTests/SoftnetControlFDTests.swift index 13d18885..59696bce 100644 --- a/Tests/TartTests/SoftnetControlFDTests.swift +++ b/Tests/TartTests/SoftnetControlFDTests.swift @@ -139,18 +139,16 @@ final class SoftnetControlFDTests: XCTestCase { XCTAssertEqual(command.netSoftnetControlFd, 3) } - func testControlFDWorksWithHostNetworking() throws { + 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) } - let command = try Run.parse(["vm", "--net-host", "--net-softnet-control-fd", "3"]) - - XCTAssertTrue(command.netHost) - XCTAssertFalse(command.netSoftnet) - XCTAssertEqual(command.netSoftnetControlFd, 3) + XCTAssertThrowsError( + try Run.parse(["vm", "--net-host", "--net-softnet-control-fd", "3"]) + ) } private func createTemporaryTartHome() throws -> URL { From 4a018ce4bb2ab442f449300e4bdef679b9bf1cb9 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Tue, 21 Jul 2026 17:12:27 -0400 Subject: [PATCH 4/4] Use an owning Softnet control file handle --- Sources/tart/Network/Softnet.swift | 32 +++++++-------------- Tests/TartTests/SoftnetControlFDTests.swift | 10 +++++++ 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Sources/tart/Network/Softnet.swift b/Sources/tart/Network/Softnet.swift index 92e54f18..41595146 100644 --- a/Sources/tart/Network/Softnet.swift +++ b/Sources/tart/Network/Softnet.swift @@ -13,21 +13,20 @@ 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] = [], controlFD: Int32? = nil) throws { + var controlFileHandle: FileHandle? + if let controlFD = controlFD { - do { - try Self.validateControlFD(controlFD) - } catch { - close(controlFD) - throw error + guard controlFD > STDERR_FILENO else { + throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2") } - } - self.controlFD = controlFD + controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true) + try Self.validateControlFD(controlFD) + } let fds = UnsafeMutablePointer.allocate(capacity: MemoryLayout.stride * 2) @@ -46,16 +45,12 @@ class Softnet: Network { process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false) - if let controlFD = controlFD { + if let controlFileHandle = controlFileHandle { process.arguments! += ["--control-fd", String(STDOUT_FILENO)] - process.standardOutput = FileHandle(fileDescriptor: controlFD, closeOnDealloc: false) + process.standardOutput = controlFileHandle } } - 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") @@ -100,7 +95,7 @@ class Softnet: Network { } func run(_ sema: AsyncSemaphore) throws { - defer { closeControlFD() } + defer { try? (process.standardOutput as? FileHandle)?.close() } try process.run() @@ -116,13 +111,6 @@ 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 index 59696bce..25bd6dff 100644 --- a/Tests/TartTests/SoftnetControlFDTests.swift +++ b/Tests/TartTests/SoftnetControlFDTests.swift @@ -51,6 +51,16 @@ final class SoftnetControlFDTests: XCTestCase { 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)