mirror of https://github.com/cirruslabs/tart.git
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:
parent
b9ed1a98f0
commit
cbc160a592
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue