Pass Softnet policy control FD through Tart (#1287)

* Pass Softnet policy control FD through Tart

* Update Softnet control test for policy set
This commit is contained in:
Fedor Kororkov 2026-07-21 17:23:08 -04:00 committed by GitHub
parent b9ed1a98f0
commit cbc160a592
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 245 additions and 4 deletions

View File

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

View File

@ -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 {
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)
@ -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 {
@ -46,6 +95,8 @@ class Softnet: Network {
}
func run(_ sema: AsyncSemaphore) throws {
defer { try? (process.standardOutput as? FileHandle)?.close() }
try process.run()
monitorTask = Task {

View File

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