diff --git a/Sources/Containerization/DNSConfiguration.swift b/Sources/Containerization/DNSConfiguration.swift index e87ed27d8..1e4e86c82 100644 --- a/Sources/Containerization/DNSConfiguration.swift +++ b/Sources/Containerization/DNSConfiguration.swift @@ -23,9 +23,14 @@ public struct DNS: Sendable { /// The set of default nameservers to use if none are provided /// in the constructor. public static let defaultNameservers = ["1.1.1.1"] + // Redirect-target ports must be unprivileged (>= 1024). + private static let minimumRedirectPort: UInt16 = 1024 /// The nameservers a container should use. public var nameservers: [String] + /// Optional nftables DNS redirect targets: each (IPv4 address, port) pair + /// consumed by the guest alongside the corresponding nameserver. + public var redirectTargets: [(ip: String, port: UInt16)] /// The DNS domain to use. public var domain: String? /// The DNS search domains to use. @@ -35,11 +40,13 @@ public struct DNS: Sendable { public init( nameservers: [String] = defaultNameservers, + redirectTargets: [(ip: String, port: UInt16)] = [], domain: String? = nil, searchDomains: [String] = [], options: [String] = [] ) { self.nameservers = nameservers + self.redirectTargets = redirectTargets self.domain = domain self.searchDomains = searchDomains self.options = options @@ -47,11 +54,13 @@ public struct DNS: Sendable { /// Validates the DNS configuration. /// - /// Ensures that all nameserver entries are valid IPv4 or IPv6 addresses. + /// Ensures that all nameserver entries are valid IPv4 or IPv6 addresses, + /// each redirect target is a valid IPv4 address with a port >= 1024. /// Arbitrary hostnames are not permitted as nameservers. /// /// - Throws: ``ContainerizationError`` with code `.invalidArgument` if - /// any nameserver is not a valid IP address. + /// any nameserver is not a valid IP address or any redirect target is + /// not a valid `ipv4:port` (>= 1024). public func validate() throws { for nameserver in nameservers { let isValidIPv4 = (try? IPv4Address(nameserver)) != nil @@ -63,6 +72,20 @@ public struct DNS: Sendable { ) } } + for (ip, port) in redirectTargets { + guard (try? IPv4Address(ip)) != nil else { + throw ContainerizationError( + .invalidArgument, + message: "DNS redirect target '\(ip):\(port)' is not a valid IPv4 address" + ) + } + guard port >= Self.minimumRedirectPort else { + throw ContainerizationError( + .invalidArgument, + message: "DNS redirect target '\(ip):\(port)' specifies port \(port), which must be >= 1024" + ) + } + } } } @@ -70,6 +93,10 @@ extension DNS { public var resolvConf: String { var text = "" + if !redirectTargets.isEmpty { + text += "# DNS traffic may be redirected by an nftables rule; verify with: nft list ruleset\n" + } + if !nameservers.isEmpty { text += nameservers.map { "nameserver \($0)" }.joined(separator: "\n") + "\n" } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index e866f8c05..e59549845 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -1376,6 +1376,8 @@ public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequ public var options: [String] = [] + public var redirectTargets: [Com_Apple_Containerization_Sandbox_V3_DnsRedirectTarget] = [] + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -1383,6 +1385,20 @@ public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequ fileprivate var _domain: String? = nil } +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_DnsRedirectTarget: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var ip: String = String() + + public var port: UInt32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -3866,7 +3882,7 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRes nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ConfigureDnsRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}location\0\u{1}nameservers\0\u{1}domain\0\u{1}searchDomains\0\u{1}options\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}location\0\u{1}nameservers\0\u{1}domain\0\u{1}searchDomains\0\u{1}options\0\u{1}redirectTargets\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3879,6 +3895,7 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: case 3: try { try decoder.decodeSingularStringField(value: &self._domain) }() case 4: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() case 5: try { try decoder.decodeRepeatedStringField(value: &self.options) }() + case 6: try { try decoder.decodeRepeatedMessageField(value: &self.redirectTargets) }() default: break } } @@ -3904,6 +3921,9 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: if !self.options.isEmpty { try visitor.visitRepeatedStringField(value: self.options, fieldNumber: 5) } + if !self.redirectTargets.isEmpty { + try visitor.visitRepeatedMessageField(value: self.redirectTargets, fieldNumber: 6) + } try unknownFields.traverse(visitor: &visitor) } @@ -3913,6 +3933,42 @@ nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: if lhs._domain != rhs._domain {return false} if lhs.searchDomains != rhs.searchDomains {return false} if lhs.options != rhs.options {return false} + if lhs.redirectTargets != rhs.redirectTargets {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_DnsRedirectTarget: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".DnsRedirectTarget" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}ip\0\u{1}port\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.ip) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.port) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.ip.isEmpty { + try visitor.visitSingularStringField(value: self.ip, fieldNumber: 1) + } + if self.port != 0 { + try visitor.visitSingularUInt32Field(value: self.port, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_DnsRedirectTarget, rhs: Com_Apple_Containerization_Sandbox_V3_DnsRedirectTarget) -> Bool { + if lhs.ip != rhs.ip {return false} + if lhs.port != rhs.port {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 100584a92..3a5a10a04 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -364,6 +364,12 @@ message ConfigureDnsRequest { optional string domain = 3; repeated string searchDomains = 4; repeated string options = 5; + repeated DnsRedirectTarget redirectTargets = 6; +} + +message DnsRedirectTarget { + string ip = 1; + uint32 port = 2; } message ConfigureDnsResponse {} diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 7fded4432..385c870ad 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -468,6 +468,12 @@ extension Vminitd { .with { $0.location = location $0.nameservers = config.nameservers + $0.redirectTargets = config.redirectTargets.map { entry in + .with { + $0.ip = entry.ip + $0.port = UInt32(entry.port) + } + } if let domain = config.domain { $0.domain = domain } diff --git a/Sources/ContainerizationNetlink/NetlinkSocket.swift b/Sources/ContainerizationNetlink/NetlinkSocket.swift index 7851ce80b..5c086475c 100644 --- a/Sources/ContainerizationNetlink/NetlinkSocket.swift +++ b/Sources/ContainerizationNetlink/NetlinkSocket.swift @@ -72,9 +72,11 @@ public class DefaultNetlinkSocket: NetlinkSocket { public let pid: UInt32 /// Creates a new instance. - public init() throws { + /// - Parameter socketProtocol: The netlink protocol to use (default + /// `NetlinkProtocol.NETLINK_ROUTE`). + public init(socketProtocol: Int32 = NetlinkProtocol.NETLINK_ROUTE) throws { pid = UInt32(getpid()) - sockfd = osSocket(Int32(AddressFamily.AF_NETLINK), SocketType.SOCK_RAW, NetlinkProtocol.NETLINK_ROUTE) + sockfd = osSocket(Int32(AddressFamily.AF_NETLINK), SocketType.SOCK_RAW, socketProtocol) guard sockfd >= 0 else { throw NetlinkSocketError.socketFailure(rc: errno) } @@ -128,7 +130,7 @@ public class DefaultNetlinkSocket: NetlinkSocket { public class DefaultNetlinkSocket: NetlinkSocket { public var pid: UInt32 { 0 } - public init() throws {} + public init(socketProtocol: Int32 = NetlinkProtocol.NETLINK_ROUTE) throws {} public func send(buf: UnsafeRawPointer!, len: Int, flags: Int32) throws -> Int { throw NetlinkSocketError.notImplemented diff --git a/Sources/ContainerizationNetlink/NfTablesSession.swift b/Sources/ContainerizationNetlink/NfTablesSession.swift new file mode 100644 index 000000000..c3f7604d4 --- /dev/null +++ b/Sources/ContainerizationNetlink/NfTablesSession.swift @@ -0,0 +1,409 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Logging + +/// One nftables mutation in a batch, applied by ``NfTablesSession``. +internal enum NfTablesMessage { + case addTable(family: UInt8, name: String) + case addChain(family: UInt8, table: String, chain: String, options: ChainOptions) + case addDnatRule(family: UInt8, table: String, chain: String, rule: DNATRule) + + /// The netfilter family for the message's `nfgenmsg`. + var family: UInt8 { + switch self { + case .addTable(let family, _), .addChain(let family, _, _, _), + .addDnatRule(let family, _, _, _): + return family + } + } + + var type: UInt16 { + let raw: UInt16 + switch self { + case .addTable: raw = NfTablesMessageType.NFT_MSG_NEWTABLE + case .addChain: raw = NfTablesMessageType.NFT_MSG_NEWCHAIN + case .addDnatRule: raw = NfTablesMessageType.NFT_MSG_NEWRULE + } + return (NfNetlinkSubsystem.NFNL_SUBSYS_NFTABLES << 8) | raw + } + + /// The netlink message flags, matching the native `nft` CLI. + var flags: UInt16 { + var flags = NetlinkFlags.NLM_F_REQUEST + switch self { + case .addTable: + break + case .addChain: + flags |= NetlinkFlags.NLM_F_CREATE + case .addDnatRule: + flags |= NetlinkFlags.NLM_F_CREATE | NetlinkFlags.NLM_F_APPEND + } + return flags + } + + static func totalSize(attributes: [NfTablesAttribute]) -> Int { + NetlinkMessageHeader.size + NfNetlinkGenMessage.size + NfTablesAttribute.renderSize(attributes) + } + + /// Writes this message into `buffer` at `offset`, returning the new offset. + func writeMessage( + seq: UInt32, pid: UInt32, prefix: String, attributes: [NfTablesAttribute], _ buffer: inout [UInt8], offset: Int + ) throws -> Int { + var offset = offset + + let header = NetlinkMessageHeader( + len: UInt32(Self.totalSize(attributes: attributes)), type: type, flags: flags, seq: seq, pid: pid) + offset = try header.appendBuffer(&buffer, offset: offset) + + let nfgen = NfNetlinkGenMessage(family: family) + offset = try nfgen.appendBuffer(&buffer, offset: offset) + + offset = try NfTablesAttribute.renderInto(attributes, &buffer, offset: offset) + return offset + } + + func attributes(prefix: String) -> [NfTablesAttribute] { + switch self { + case .addTable(_, let name): + return [ + .string(TableAttributeType.NAME, prefix + name), + .bigEndian(TableAttributeType.FLAGS, UInt32(0)), + ] + case .addChain(_, let table, let chain, let options): + return [ + .string(ChainAttributeType.TABLE, prefix + table), + .string(ChainAttributeType.NAME, prefix + chain), + ] + options.makeAttributes() + case .addDnatRule(_, let table, let chain, let rule): + return [ + .string(RuleAttributeType.TABLE, prefix + table), + .string(RuleAttributeType.CHAIN, prefix + chain), + .nested( + RuleAttributeType.EXPRESSIONS, + rule.makeExpressions()), + ] + } + } +} +public struct ChainOptions { + /// The nftables chain type (`NFTA_CHAIN_TYPE`), e.g. `"nat"`. + public var type: String + /// The netfilter hook number (e.g. `NF_INET_LOCAL_OUT`). + public var hook: UInt8 + /// The hook priority (e.g. NAT_DST = -100). + public var priority: Int32 + /// The verdict applied to packets matching no rule (`NetfilterVerdict`); + /// `nil` omits `NFTA_CHAIN_POLICY`, which defaults to NF_ACCEPT. + public var policy: UInt32? + + /// Creates base chain attributes. + public init( + type: String, hook: UInt8, priority: Int32, + policy: UInt32? = nil + ) { + self.type = type + self.hook = hook + self.priority = priority + self.policy = policy + } + + internal func makeAttributes() -> [NfTablesAttribute] { + var attrs: [NfTablesAttribute] = [] + if let policy { + attrs.append(.bigEndian(ChainAttributeType.POLICY, policy)) + } + attrs += [ + .string(ChainAttributeType.TYPE, type), + .nested( + ChainAttributeType.HOOK, + [ + .bigEndian(HookAttributeType.HOOKNUM, UInt32(hook)), + .bigEndian(HookAttributeType.PRIORITY, priority), + ]), + ] + return attrs + } +} + +/// A single DNAT rule to add via ``NfTablesSession.addDnatToOutput``. +public struct DNATRule { + /// The IPv4 destination address to match. + public var matchDaddr: IPv4Address + /// The destination port to match. + public var matchDport: UInt16 + /// The L4 protocol to match. + public var matchProto: UInt8 + /// The IPv4 address the DNAT rule rewrites destinations to. + public var dnatAddr: IPv4Address + /// The port the DNAT rule rewrites destinations to. + public var dnatPort: UInt16 + + /// Creates a DNAT rule. + public init( + matchDaddr: IPv4Address, matchDport: UInt16, matchProto: UInt8, + dnatAddr: IPv4Address, dnatPort: UInt16 + ) { + self.matchDaddr = matchDaddr + self.matchDport = matchDport + self.matchProto = matchProto + self.dnatAddr = dnatAddr + self.dnatPort = dnatPort + } + + internal func makeExpressions() -> [NfTablesAttribute] { + var expressions: [NfTablesAttribute] = [ + .listElement( + name: "payload", + body: [ + .bigEndian(PayloadAttributeType.DREG, NfTablesRegister.NFT_REG_1), + .bigEndian(PayloadAttributeType.BASE, NfTablesPayloadBase.NFT_PAYLOAD_NETWORK_HEADER), + .bigEndian(PayloadAttributeType.OFFSET, UInt32(16)), + .bigEndian(PayloadAttributeType.LEN, UInt32(4)), + ]), + .listElement( + name: "cmp", + body: [ + .bigEndian(CompareAttributeType.SREG, NfTablesRegister.NFT_REG_1), + .bigEndian(CompareAttributeType.OP, NfTablesCompareOp.NFT_CMP_EQ), + .data(CompareAttributeType.DATA, self.matchDaddr.bytes), + ]), + .listElement( + name: "meta", + body: [ + .bigEndian(MetaAttributeType.KEY, NfTablesMetaKey.NFT_META_L4PROTO), + .bigEndian(MetaAttributeType.DREG, NfTablesRegister.NFT_REG_1), + ]), + .listElement( + name: "cmp", + body: [ + .bigEndian(CompareAttributeType.SREG, NfTablesRegister.NFT_REG_1), + .bigEndian(CompareAttributeType.OP, NfTablesCompareOp.NFT_CMP_EQ), + .data(CompareAttributeType.DATA, [self.matchProto]), + ]), + .listElement( + name: "payload", + body: [ + .bigEndian(PayloadAttributeType.DREG, NfTablesRegister.NFT_REG_1), + .bigEndian(PayloadAttributeType.BASE, NfTablesPayloadBase.NFT_PAYLOAD_TRANSPORT_HEADER), + .bigEndian(PayloadAttributeType.OFFSET, UInt32(2)), + .bigEndian(PayloadAttributeType.LEN, UInt32(2)), + ]), + .listElement( + name: "cmp", + body: [ + .bigEndian(CompareAttributeType.SREG, NfTablesRegister.NFT_REG_1), + .bigEndian(CompareAttributeType.OP, NfTablesCompareOp.NFT_CMP_EQ), + .data(CompareAttributeType.DATA, self.matchDport), + ]), + .listElement( + name: "immediate", + body: [ + .bigEndian(ImmediateAttributeType.DREG, NfTablesRegister.NFT_REG_1), + .data(ImmediateAttributeType.DATA, self.dnatAddr.bytes), + ]), + .listElement( + name: "immediate", + body: [ + .bigEndian(ImmediateAttributeType.DREG, NfTablesRegister.NFT_REG_2), + .data(ImmediateAttributeType.DATA, self.dnatPort), + ]), + .listElement( + name: "nat", + body: [ + .bigEndian(NatAttributeType.TYPE, NfTablesNatType.NFT_NAT_DNAT), + .bigEndian(NatAttributeType.FAMILY, UInt32(NetfilterFamily.NFPROTO_IPV4)), + .bigEndian(NatAttributeType.REG_ADDR_MIN, NfTablesRegister.NFT_REG_1), + .bigEndian(NatAttributeType.REG_PROTO_MIN, NfTablesRegister.NFT_REG_2), + .bigEndian(NatAttributeType.FLAGS, NetfilterNatRange.NF_NAT_RANGE_PROTO_SPECIFIED), + ]), + ] + + return expressions + } +} + +/// Facilitates nftables netfilter messages over a `NETLINK_NETFILTER` +/// netlink socket, encoded exactly as the native `nft` CLI emits them +/// (big-endian scalar payloads). Mutations apply atomically as one batch. +public struct NfTablesSession { + private static let receiveBufferSize = 65536 + + /// Sequence/batch entry: one `nlmsghdr` + `nfgenmsg`. + private static let batchMessageSize = NetlinkMessageHeader.size + NfNetlinkGenMessage.size + + /// Prefix applied to every nftables object name. + private static let nftNamePrefix = "containerization-" + + private let socket: any NetlinkSocket + private let log: Logger + + /// Creates a new `NfTablesSession`. + /// - Parameters: + /// - socket: The `NetlinkSocket`, opened on `NETLINK_NETFILTER`. + /// - log: The logger to use. Defaults to a netfilter-scoped logger. + public init(socket: any NetlinkSocket, log: Logger? = nil) { + self.socket = socket + self.log = log ?? Logger(label: "com.apple.containerization.netfilter") + } + + /// Errors that may occur during netlink interaction. + public enum Error: Swift.Error, CustomStringConvertible, Equatable { + case unexpectedOffset(offset: Int, size: Int) + + /// The description of the errors. + public var description: String { + switch self { + case .unexpectedOffset(let offset, let size): + return "unexpected buffer state, offset = \(offset), size = \(size)" + } + } + } + + /// Adds an nftables table. The `name` is namespaced at serialization with + /// the session's `containerization-` prefix. + public func addTable(family: UInt8, name: String) throws { + try sendBatch([.addTable(family: family, name: name)]) + } + + /// Adds an nftables base chain. The `table` and `chain` names are + /// namespaced at serialization with the session's `containerization-` prefix. + public func addChain(family: UInt8, table: String, chain: String, options: ChainOptions) throws { + try sendBatch([.addChain(family: family, table: table, chain: chain, options: options)]) + } + + /// Adds a DNAT output rule set: one atomic batch with the + /// `containerization-nat` table + `containerization-output` base chain and + /// one DNAT rule per entry. On any failure nothing is applied. + public func addDnatToOutput(rules: [DNATRule]) throws { + try sendBatch(try Self.buildDnatMessages(rules)) + } + + /// Sends one atomic batch and waits for its ack. On success the kernel + /// replies with one zero `NLMSG_ERROR` for BATCH_END; on failure it queues + /// one non-zero `NLMSG_ERROR` per failed message and applies nothing. The + /// first error decides; the rest stay queued, so a fresh socket is used + /// for each batch. + func sendBatch(_ messages: [NfTablesMessage]) throws { + let bytes = try Self.buildBatch(messages, pid: socket.pid) + try sendRequest(bytes) + try receiveResponse() + } + + private func sendRequest(_ bytes: [UInt8]) throws { + log.trace("SEND-LENGTH: \(bytes.count)") + log.trace("SEND-DUMP: \(bytes.hexEncodedString())") + let sent = try socket.send(buf: bytes.withUnsafeBytes { $0.baseAddress }, len: bytes.count, flags: 0) + if sent != bytes.count { + log.warning("sent length \(sent) not equal to packet length \(bytes.count)") + } + } + + private func receiveResponse() throws { + var buffer = [UInt8](repeating: 0, count: Self.receiveBufferSize) + let size = try socket.recv(buf: &buffer, len: Self.receiveBufferSize, flags: 0) + log.trace("RECV-LENGTH: \(size)") + log.trace("RECV-DUMP: \(buffer[0.. 0 else { + break + } + offset += Int(header.len) + } + } + + static func buildDnatMessages(_ rules: [DNATRule]) throws -> [NfTablesMessage] { + let table = "nat" + let chain = "output" + var messages: [NfTablesMessage] = [ + .addTable(family: NetfilterFamily.NFPROTO_IPV4, name: table), + .addChain( + family: NetfilterFamily.NFPROTO_IPV4, + table: table, + chain: chain, + options: ChainOptions( + type: "nat", + hook: NetfilterHook.NF_INET_LOCAL_OUT, + priority: NetfilterHookPriority.NF_IP_PRI_NAT_DST)), + ] + messages += rules.map { + .addDnatRule( + family: NetfilterFamily.NFPROTO_IPV4, + table: table, + chain: chain, + rule: $0) + } + return messages + } + + static func buildBatch(_ messages: [NfTablesMessage], pid: UInt32) throws -> [UInt8] { + let attributesByMessage = messages.map { $0.attributes(prefix: Self.nftNamePrefix) } + var total = Self.batchMessageSize + for attributes in attributesByMessage { + total += NfTablesMessage.totalSize(attributes: attributes) + } + total += Self.batchMessageSize + + var buffer = [UInt8](repeating: 0, count: total) + var offset = try Self.writeBatchMessage(type: NfNetlinkBatchMessage.NFNL_MSG_BATCH_BEGIN, seq: 0, pid: pid, &buffer, offset: 0) + for (index, attributes) in attributesByMessage.enumerated() { + offset = try messages[index].writeMessage( + seq: UInt32(index + 1), pid: pid, prefix: Self.nftNamePrefix, attributes: attributes, &buffer, offset: offset) + } + // The batch's one ACK rides on END (messages carry none); see sendBatch. + offset = try Self.writeBatchMessage( + type: NfNetlinkBatchMessage.NFNL_MSG_BATCH_END, seq: UInt32(messages.count + 1), pid: pid, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK, &buffer, offset: offset) + guard offset == total else { + throw NfTablesSession.Error.unexpectedOffset(offset: offset, size: total) + } + return buffer + } + + private static func writeBatchMessage( + type: UInt16, seq: UInt32, pid: UInt32, flags: UInt16 = NetlinkFlags.NLM_F_REQUEST, + _ buffer: inout [UInt8], offset: Int + ) throws -> Int { + var offset = offset + + let header = NetlinkMessageHeader( + len: UInt32(Self.batchMessageSize), type: type, flags: flags, seq: seq, pid: pid) + offset = try header.appendBuffer(&buffer, offset: offset) + + let nfgen = NfNetlinkGenMessage( + family: NetfilterFamily.NFPROTO_UNSPEC, version: NfNetlinkVersion.NFNETLINK_V0, resID: NfNetlinkSubsystem.NFNL_SUBSYS_NFTABLES) + offset = try nfgen.appendBuffer(&buffer, offset: offset) + return offset + } +} diff --git a/Sources/ContainerizationNetlink/NfTablesTypes.swift b/Sources/ContainerizationNetlink/NfTablesTypes.swift new file mode 100644 index 000000000..5d70abf41 --- /dev/null +++ b/Sources/ContainerizationNetlink/NfTablesTypes.swift @@ -0,0 +1,346 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras + +struct NetfilterFamily { + static let NFPROTO_UNSPEC: UInt8 = 0 + static let NFPROTO_IPV4: UInt8 = 2 +} + +/// Linux `IPPROTO_*` constants used to match the L4 protocol in nftables rules. +public struct IPProtocol { + public static let IPPROTO_TCP: UInt8 = 6 + public static let IPPROTO_UDP: UInt8 = 17 +} + +struct NetfilterVerdict { + static let NF_DROP: UInt32 = 0 + static let NF_ACCEPT: UInt32 = 1 +} + +struct NetfilterHook { + static let NF_INET_PRE_ROUTING: UInt8 = 0 + static let NF_INET_LOCAL_IN: UInt8 = 1 + static let NF_INET_FORWARD: UInt8 = 2 + static let NF_INET_LOCAL_OUT: UInt8 = 3 + static let NF_INET_POST_ROUTING: UInt8 = 4 +} + +struct NetfilterHookPriority { + static let NF_IP_PRI_NAT_DST: Int32 = -100 +} + +struct NetfilterNatRange { + static let NF_NAT_RANGE_PROTO_SPECIFIED: UInt32 = 2 +} + +struct NfNetlinkVersion { + static let NFNETLINK_V0: UInt8 = 0 +} + +struct NfNetlinkSubsystem { + static let NFNL_SUBSYS_NFTABLES: UInt16 = 10 +} + +struct NfNetlinkBatchMessage { + static let NFNL_MSG_BATCH_BEGIN: UInt16 = 0x10 + static let NFNL_MSG_BATCH_END: UInt16 = 0x11 +} + +struct NfTablesMessageType { + static let NFT_MSG_NEWTABLE: UInt16 = 0 + static let NFT_MSG_NEWCHAIN: UInt16 = 3 + static let NFT_MSG_NEWRULE: UInt16 = 6 +} + +struct NfTablesRegister { + static let NFT_REG_1: UInt32 = 1 + static let NFT_REG_2: UInt32 = 2 +} + +struct NfTablesCompareOp { + static let NFT_CMP_EQ: UInt32 = 0 +} + +struct NfTablesPayloadBase { + static let NFT_PAYLOAD_NETWORK_HEADER: UInt32 = 1 + static let NFT_PAYLOAD_TRANSPORT_HEADER: UInt32 = 2 +} + +struct NfTablesMetaKey { + static let NFT_META_L4PROTO: UInt32 = 16 +} + +struct NfTablesNatType { + static let NFT_NAT_SNAT: UInt32 = 0 + static let NFT_NAT_DNAT: UInt32 = 1 +} + +/// The `nlattr` header for nf_tables attributes, a concrete +/// ``NetlinkAttribute`` distinct from the route-family `RTAttribute`. +struct NfTablesAttributeHeader: NetlinkAttribute, Equatable { + var len: UInt16 + var type: UInt16 + + init(len: UInt16 = 0, type: UInt16 = 0) { + self.len = len + self.type = type + } +} + +/// A single nf_tables attribute to serialize, built by its constructors +/// (`string`, `bytes`, `data`, `bigEndian`, `nested`). `len`/`type` are fixed +/// at construction; `render(_:)` writes the whole tree into one buffer. +struct NfTablesAttribute: Equatable { + /// The payload of an attribute: leaf bytes (big-endian scalar, bytes, or + /// NUL-terminated string) or the children of a nested attribute, mutually + /// exclusive by construction. + enum Payload: Equatable { + /// Pre-encoded wire bytes (see the attribute factories). + case raw([UInt8]) + case nested([NfTablesAttribute]) + + /// On-wire payload size. + var size: Int { + switch self { + case .raw(let bytes): bytes.count + case .nested(let children): children.reduce(0) { $0 + $1.paddedLen } + } + } + + /// Writes the payload at `offset`. The caller's buffer is pre-zeroed, + /// so NLA padding needs no explicit write. + func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + switch self { + case .raw(let bytes): + guard let after = buffer.copyIn(buffer: bytes, offset: offset) else { + throw BindError.sendMarshalFailure(type: "NfTablesAttribute", field: "payload") + } + return after + case .nested(let children): + var offset = offset + for child in children { + offset = try child.appendBuffer(&buffer, offset: offset) + } + return offset + } + } + } + + let len: UInt16 + let type: UInt16 + let payload: Payload + + private init(len: UInt16, type: UInt16, payload: Payload) { + self.len = len + self.type = type + self.payload = payload + } + + /// A big-endian scalar attribute (ports, registers, hook priorities). + static func bigEndian(_ type: UInt16, _ value: T) -> NfTablesAttribute { + let payload = Payload.raw(withUnsafeBytes(of: value.bigEndian) { Array($0) }) + return NfTablesAttribute(len: UInt16(NfTablesAttributeHeader.size + payload.size), type: type, payload: payload) + } + + static func string(_ type: UInt16, _ value: String) -> NfTablesAttribute { + let payload = Payload.raw(Array(value.utf8) + [0]) // NUL-terminated, like kernel strings + return NfTablesAttribute(len: UInt16(NfTablesAttributeHeader.size + payload.size), type: type, payload: payload) + } + + static func bytes(_ type: UInt16, _ value: [UInt8]) -> NfTablesAttribute { + let payload = Payload.raw(value) + return NfTablesAttribute(len: UInt16(NfTablesAttributeHeader.size + payload.size), type: type, payload: payload) + } + + static func data(_ type: UInt16, _ bytes: [UInt8]) -> NfTablesAttribute { + nested(type, [.bytes(DataAttributeType.VALUE, bytes)]) + } + + static func data(_ type: UInt16, _ value: T) -> NfTablesAttribute { + nested(type, [.bigEndian(DataAttributeType.VALUE, value)]) + } + + /// One rule expression: `NFTA_LIST_ELEM { NAME, DATA { ... } }`. + static func listElement(name: String, body: [NfTablesAttribute]) -> NfTablesAttribute { + nested( + ListAttributeType.ELEM, + [ + .string(ExpressionAttributeType.NAME, name), + .nested(ExpressionAttributeType.DATA, body), + ]) + } + + /// A nested attribute; its length is the NLA-4-aligned sum of its children's. + static func nested(_ type: UInt16, _ children: [NfTablesAttribute]) -> NfTablesAttribute { + let payload = Payload.nested(children) + return NfTablesAttribute( + len: UInt16(NfTablesAttributeHeader.size + payload.size), type: type | NetlinkAttributeFlags.NLA_F_NESTED, payload: payload) + } + + /// The NLA-4-aligned on-wire size of this attribute. + var paddedLen: Int { Int(((len + 3) >> 2) << 2) } + + /// Writes this attribute at `offset`. The caller's buffer is pre-zeroed, + /// so trailing padding needs no explicit write. + func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let padded = paddedLen + guard offset + padded <= buffer.count else { + throw BindError.sendMarshalFailure(type: "NfTablesAttribute", field: "payload") + } + let header = NfTablesAttributeHeader(len: len, type: type) + let start = offset + var offset = try header.appendBuffer(&buffer, offset: offset) + offset = try payload.appendBuffer(&buffer, offset: offset) + return start + padded + } + + /// Total padded length of `attrs`. + static func renderSize(_ attrs: [NfTablesAttribute]) -> Int { + attrs.reduce(0) { $0 + $1.paddedLen } + } + + static func renderInto(_ attrs: [NfTablesAttribute], _ buffer: inout [UInt8], offset: Int) throws -> Int { + var offset = offset + for attr in attrs { + offset = try attr.appendBuffer(&buffer, offset: offset) + } + return offset + } +} + +/// The 4-byte `nfgenmsg` header preceding every nfnetlink payload. Its 16-bit +/// `res_id` is big-endian, unlike the little-endian `nlmsghdr`/`nlattr`. +struct NfNetlinkGenMessage: Bindable, Equatable { + static let size = 4 + + var family: UInt8 + var version: UInt8 + var resID: UInt16 + + init(family: UInt8 = NetfilterFamily.NFPROTO_UNSPEC, version: UInt8 = NfNetlinkVersion.NFNETLINK_V0, resID: UInt16 = 0) { + self.family = family + self.version = version + self.resID = resID + } + + func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + guard let offset = buffer.copyIn(as: UInt8.self, value: family, offset: offset) else { + throw BindError.sendMarshalFailure(type: "NfNetlinkGenMessage", field: "family") + } + guard let offset = buffer.copyIn(as: UInt8.self, value: version, offset: offset) else { + throw BindError.sendMarshalFailure(type: "NfNetlinkGenMessage", field: "version") + } + guard let offset = buffer.copyIn(as: UInt16.self, value: resID.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "NfNetlinkGenMessage", field: "res_id") + } + return offset + } + + mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "NfNetlinkGenMessage", field: "family") + } + family = value + + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "NfNetlinkGenMessage", field: "version") + } + version = value + + guard let (offset, value) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "NfNetlinkGenMessage", field: "res_id") + } + resID = value.bigEndian + + return offset + } +} + +struct TableAttributeType { + static let NAME: UInt16 = 1 + static let FLAGS: UInt16 = 2 +} + +struct ChainAttributeType { + static let TABLE: UInt16 = 1 + static let NAME: UInt16 = 3 + static let HOOK: UInt16 = 4 + static let POLICY: UInt16 = 5 + static let TYPE: UInt16 = 7 +} + +struct HookAttributeType { + static let HOOKNUM: UInt16 = 1 + static let PRIORITY: UInt16 = 2 + static let DEV: UInt16 = 3 + static let DEVS: UInt16 = 4 +} + +struct RuleAttributeType { + static let TABLE: UInt16 = 1 + static let CHAIN: UInt16 = 2 + static let EXPRESSIONS: UInt16 = 4 +} + +struct ListAttributeType { + static let ELEM: UInt16 = 1 +} + +struct ExpressionAttributeType { + static let NAME: UInt16 = 1 + static let DATA: UInt16 = 2 +} + +struct PayloadAttributeType { + static let DREG: UInt16 = 1 + static let BASE: UInt16 = 2 + static let OFFSET: UInt16 = 3 + static let LEN: UInt16 = 4 +} + +struct CompareAttributeType { + static let SREG: UInt16 = 1 + static let OP: UInt16 = 2 + static let DATA: UInt16 = 3 +} + +struct MetaAttributeType { + static let DREG: UInt16 = 1 + static let KEY: UInt16 = 2 + static let SREG: UInt16 = 3 +} + +struct ImmediateAttributeType { + static let DREG: UInt16 = 1 + static let DATA: UInt16 = 2 +} + +struct NatAttributeType { + static let TYPE: UInt16 = 1 + static let FAMILY: UInt16 = 2 + static let REG_ADDR_MIN: UInt16 = 3 + static let REG_ADDR_MAX: UInt16 = 4 + static let REG_PROTO_MIN: UInt16 = 5 + static let REG_PROTO_MAX: UInt16 = 6 + static let FLAGS: UInt16 = 7 +} + +struct DataAttributeType { + static let VALUE: UInt16 = 1 + static let VERDICT: UInt16 = 2 +} diff --git a/Sources/ContainerizationNetlink/Types.swift b/Sources/ContainerizationNetlink/Types.swift index 81d62e2c9..19d7ea0ed 100644 --- a/Sources/ContainerizationNetlink/Types.swift +++ b/Sources/ContainerizationNetlink/Types.swift @@ -32,8 +32,9 @@ struct ArpHardware { static let ARPHRD_ETHER: UInt16 = 1 } -struct NetlinkProtocol { - static let NETLINK_ROUTE: Int32 = 0 +public struct NetlinkProtocol { + public static let NETLINK_ROUTE: Int32 = 0 + public static let NETLINK_NETFILTER: Int32 = 12 } struct NetlinkType { @@ -69,6 +70,10 @@ struct NetlinkFlags { static let NLM_F_APPEND: UInt16 = 0x800 } +struct NetlinkAttributeFlags { + static let NLA_F_NESTED: UInt16 = 0x8000 +} + struct NetlinkScope { static let RT_SCOPE_UNIVERSE: UInt8 = 0 } @@ -541,25 +546,22 @@ struct RouteInfo: Bindable, Equatable { } } -/// A route information. -public struct RTAttribute: Bindable, Equatable { - package static let size = 4 +package protocol NetlinkAttribute: Bindable { + var len: UInt16 { get set } + var type: UInt16 { get set } +} - public var len: UInt16 - public var type: UInt16 - public var paddedLen: Int { Int(((len + 3) >> 2) << 2) } +extension NetlinkAttribute { + package static var size: Int { 4 } - init(len: UInt16 = 0, type: UInt16 = 0) { - self.len = len - self.type = type - } + package var paddedLen: Int { Int(((len + 3) >> 2) << 2) } package func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { guard let offset = buffer.copyIn(as: UInt16.self, value: len, offset: offset) else { - throw BindError.sendMarshalFailure(type: "RTAttribute", field: "len") + throw BindError.sendMarshalFailure(type: String(describing: Self.self), field: "len") } guard let offset = buffer.copyIn(as: UInt16.self, value: type, offset: offset) else { - throw BindError.sendMarshalFailure(type: "RTAttribute", field: "type") + throw BindError.sendMarshalFailure(type: String(describing: Self.self), field: "type") } return offset @@ -567,12 +569,12 @@ public struct RTAttribute: Bindable, Equatable { package mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { guard let (offset, value) = buffer.copyOut(as: UInt16.self, offset: offset) else { - throw BindError.recvMarshalFailure(type: "RTAttribute", field: "len") + throw BindError.recvMarshalFailure(type: String(describing: Self.self), field: "len") } len = value guard let (offset, value) = buffer.copyOut(as: UInt16.self, offset: offset) else { - throw BindError.recvMarshalFailure(type: "RTAttribute", field: "type") + throw BindError.recvMarshalFailure(type: String(describing: Self.self), field: "type") } type = value @@ -580,6 +582,17 @@ public struct RTAttribute: Bindable, Equatable { } } +/// A route information. +public struct RTAttribute: NetlinkAttribute, Equatable { + public var len: UInt16 + public var type: UInt16 + + init(len: UInt16 = 0, type: UInt16 = 0) { + self.len = len + self.type = type + } +} + /// A route information with data. public struct RTAttributeData { public let attribute: RTAttribute diff --git a/Tests/ContainerizationNetlinkTests/NfTablesSessionTest.swift b/Tests/ContainerizationNetlinkTests/NfTablesSessionTest.swift new file mode 100644 index 000000000..95b7e57da --- /dev/null +++ b/Tests/ContainerizationNetlinkTests/NfTablesSessionTest.swift @@ -0,0 +1,356 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Testing + +@testable import ContainerizationNetlink + +struct NfTablesSessionTest { + /// Ground-truth bytes of the native `nft` CLI's combined batch for the + /// default redirect ruleset, byte-identical except the `NLM_F_ACK` on + /// BATCH_END (the sole deviation from the capture); the end-to-end gate + /// for layout, endianness, seq, flags. + private let referenceFullBatchEndAckHex = + // BATCH_BEGIN nlh (20 B): len=20 type=0x10 flags=REQUEST seq=0; nfgen res_id=0x000a (NFTABLES) + "140000001000010000000000000000000000000a" + // NEWTABLE nlmsghdr (16 B): len=56 type=0x0a00 flags=REQUEST seq=1; nfgen family=NFPROTO_IPV4 (2) — add table "containerization-nat" + + "38000000000a010001000000000000000200000019000100636f6e7461696e6572697a6174696f6e2d6e6174000000000800020000000000" + // NEWCHAIN nlmsghdr (16 B): len=104 type=0x0a03 flags=REQUEST|CREATE seq=2; nfgen family=NFPROTO_IPV4 (2) — add base chain "containerization-output" + + "68000000030a010402000000000000000200000019000100636f6e7461696e6572697a6174696f6e2d6e6174000000001c000300636f6e7461696e6572697a6174696f6e2d6f757470757400080007006e61740014000480080001000000000308000200ffffff9c" + // NEWRULE nlmsghdr (16 B): len=496 type=0x0a06 flags=REQUEST|CREATE|APPEND seq=3; nfgen family=NFPROTO_IPV4 (2) — add rule: UDP 192.0.2.2:53 → 198.51.100.100:3053 + + "f0010000060a010c03000000000000000200000019000100636f6e7461696e6572697a6174696f6e2d6e6174000000001c000200636f6e7461696e6572697a6174696f6e2d6f757470757400a4010480340001800c0001007061796c6f6164002400028008000100000000010800020000000001080003000000001008000400000000042c00018008000100636d700020000280080001000000000108000200000000000c00038008000100c000020224000180090001006d6574610000000014000280080002000000001008000100000000012c00018008000100636d700020000280080001000000000108000200000000000c0003800500010011000000340001800c0001007061796c6f6164002400028008000100000000010800020000000002080003000000000208000400000000022c00018008000100636d700020000280080001000000000108000200000000000c00038006000100003500002c0001800e000100696d6d6564696174650000001800028008000100000000010c00028008000100c63364642c0001800e000100696d6d6564696174650000001800028008000100000000020c000280060001000bed000038000180080001006e6174002c00028008000100000000010800020000000002080003000000000108000500000000020800070000000002" + // NEWRULE nlmsghdr (16 B): len=496 type=0x0a06 flags=REQUEST|CREATE|APPEND seq=4; nfgen family=NFPROTO_IPV4 (2) — add rule: TCP 192.0.2.2:53 → 198.51.100.100:3053 + + "f0010000060a010c04000000000000000200000019000100636f6e7461696e6572697a6174696f6e2d6e6174000000001c000200636f6e7461696e6572697a6174696f6e2d6f757470757400a4010480340001800c0001007061796c6f6164002400028008000100000000010800020000000001080003000000001008000400000000042c00018008000100636d700020000280080001000000000108000200000000000c00038008000100c000020224000180090001006d6574610000000014000280080002000000001008000100000000012c00018008000100636d700020000280080001000000000108000200000000000c0003800500010006000000340001800c0001007061796c6f6164002400028008000100000000010800020000000002080003000000000208000400000000022c00018008000100636d700020000280080001000000000108000200000000000c00038006000100003500002c0001800e000100696d6d6564696174650000001800028008000100000000010c00028008000100c63364642c0001800e000100696d6d6564696174650000001800028008000100000000020c000280060001000bed000038000180080001006e6174002c00028008000100000000010800020000000002080003000000000108000500000000020800070000000002" + // BATCH_END nlh (20 B): len=20 type=0x11 flags=REQUEST|ACK seq=5; nfgen res_id=0x000a (NFTABLES) + + "140000001100050005000000000000000000000a" + + private var mockSocket: MockNetlinkSocket! + + /// The combined batch must byte-for-byte match the ground-truth bytes + /// (two rules, UDP + TCP, no counter, with the NLM_F_ACK deviation and + /// without the table userdata attr). + @Test func buildBatchMatchesEndAckReference() throws { + let rules = [ + DNATRule( + matchDaddr: try IPv4Address("192.0.2.2"), matchDport: 53, matchProto: 17, + dnatAddr: try IPv4Address("198.51.100.100"), dnatPort: 3053), + DNATRule( + matchDaddr: try IPv4Address("192.0.2.2"), matchDport: 53, matchProto: 6, + dnatAddr: try IPv4Address("198.51.100.100"), dnatPort: 3053), + ] + + let bytes = try NfTablesSession.buildBatch(try NfTablesSession.buildDnatMessages(rules), pid: 0) + + #expect(bytes.count == 1192) + #expect(bytes == [UInt8](hex: referenceFullBatchEndAckHex)) + } + + /// `NfTablesAttribute` serialization matches the ground-truth bytes: the + /// table attrs (`NAME` "nat" + `FLAGS` BE32 0) and the chain hook nest. + @Test func renderNfTablesAttributeMatchesReference() throws { + let tableAttrs: [NfTablesAttribute] = [ + .string(TableAttributeType.NAME, "nat"), + .bigEndian(TableAttributeType.FLAGS, UInt32(0)), + ] + var tableBuffer = [UInt8](repeating: 0, count: NfTablesAttribute.renderSize(tableAttrs)) + _ = try NfTablesAttribute.renderInto(tableAttrs, &tableBuffer, offset: 0) + #expect( + tableBuffer + == [UInt8]( + hex: + "080001006e617400" // attr hdr (len=8 type=NFTA_TABLE_NAME) + "nat\0" + + "0800020000000000" // attr hdr (len=8 type=NFTA_TABLE_FLAGS) + BE32 0 + ) + ) + + // The hook nest: len=20, type=HOOK|NLA_F_NESTED; children rendered + // inside (HOOKNUM BE32 3, PRIORITY BE32 -100). + let hookAttrs: [NfTablesAttribute] = [ + .nested( + ChainAttributeType.HOOK, + [ + .bigEndian(HookAttributeType.HOOKNUM, UInt32(3)), + NfTablesAttribute.bigEndian(HookAttributeType.PRIORITY, Int32(-100)), + ]) + ] + var hookBuffer = [UInt8](repeating: 0, count: NfTablesAttribute.renderSize(hookAttrs)) + _ = try NfTablesAttribute.renderInto(hookAttrs, &hookBuffer, offset: 0) + #expect( + hookBuffer + == [UInt8]( + hex: + "14000480" // attr hdr (len=20 type=NFTA_CHAIN_HOOK|NLA_F_NESTED) + + "0800010000000003" // NFTA_HOOK_HOOKNUM — BE32 3 (NF_INET_LOCAL_OUT) + + "08000200ffffff9c" // NFTA_HOOK_PRIORITY — BE32 -100 (NF_IP_PRI_NAT_DST) + ) + ) + } + + /// A single message is emitted as a `[BATCH_BEGIN, message, BATCH_END]` + /// mini-batch; the bare name `nat` is namespaced to + /// `containerization-nat`, and the table message carries no `NLM_F_CREATE`. + @Test func buildSingleTableMiniBatch() throws { + let tableMessage = NfTablesMessage.addTable(family: 2, name: "nat") + + let bytes = try NfTablesSession.buildBatch([tableMessage], pid: 0) + + let expected = [UInt8]( + hex: + "14000000" // nlh.len: 20 + + "10000100" // nlh.type: NFNL_MSG_BATCH_BEGIN (0x10); flags: NLM_F_REQUEST + + "00000000" // nlh.seq: 0 + + "00000000" // nlh.pid: 0 + + "0000000a" // nfgenmsg: family=UNSPEC version=V0 res_id=0x000a (NFTABLES) + + "38000000" // nlmsghdr.len: 56 + + "000a0100" // nlmsghdr.type: NFNL_SUBSYS_NFTABLES << 8|NFT_MSG_NEWTABLE (0x0a00); flags: REQUEST + + "01000000" // nlmsghdr.seq: 1 + + "00000000" // nlmsghdr.pid: 0 + + "02000000" // nfgenmsg: family=NFPROTO_IPV4 (2) + + "19000100636f6e7461696e6572697a6174696f6e2d6e617400000000" // NFTA_TABLE_NAME: "containerization-nat" (bare `nat` namespaced) + + "0800020000000000" // NFTA_TABLE_FLAGS: BE32 0 + + "14000000" // nlh.len: 20 + + "11000500" // nlh.type: NFNL_MSG_BATCH_END (0x11); flags: REQUEST|ACK + + "02000000" // nlh.seq: 2 + + "00000000" // nlh.pid: 0 + + "0000000a" // nfgenmsg: family=UNSPEC version=V0 res_id=0x000a (NFTABLES) + ) + #expect(bytes.count == 96) + #expect(bytes == expected) + } + + /// `addChain` with an explicit non-default policy emits `NFTA_CHAIN_POLICY` + /// (drop = 0) between the chain name and its hook type. + @Test func buildSingleChainWithDropPolicy() throws { + let chainMessage = NfTablesMessage.addChain( + family: 2, table: "nat", chain: "output", + options: ChainOptions( + type: "nat", + hook: NetfilterHook.NF_INET_LOCAL_OUT, + priority: NetfilterHookPriority.NF_IP_PRI_NAT_DST, + policy: NetfilterVerdict.NF_DROP)) + + let bytes = try NfTablesSession.buildBatch([chainMessage], pid: 0) + + let expected = [UInt8]( + hex: + "140000001000010000000000000000000000000a" // BATCH_BEGIN nlh (20 B): len=20 type=0x10 flags=REQUEST seq=0; nfgen res_id=0x000a (NFTABLES) + + "70000000030a01040100000000000000" // NEWCHAIN nlmsghdr (16 B): len=112 type=0x0a03 flags=REQUEST|CREATE seq=1 + + "02000000" // nfgenmsg (4 B): family=NFPROTO_IPV4 (2) + + "19000100636f6e7461696e6572697a6174696f6e2d6e617400000000" // NFTA_CHAIN_TABLE: "containerization-nat" + + "1c000300636f6e7461696e6572697a6174696f6e2d6f757470757400" // NFTA_CHAIN_NAME: "containerization-output" + + "0800050000000000" // NFTA_CHAIN_POLICY: BE32 0 (NF_DROP) + + "080007006e617400" // NFTA_CHAIN_TYPE: "nat" + + "14000480" // NFTA_CHAIN_HOOK: len=20 type=HOOK|NLA_F_NESTED + + "0800010000000003" // NFTA_HOOK_HOOKNUM: BE32 3 (NF_INET_LOCAL_OUT) + + "08000200ffffff9c" // NFTA_HOOK_PRIORITY: BE32 -100 (NF_IP_PRI_NAT_DST) + + "140000001100050002000000000000000000000a" // BATCH_END nlh (20 B): len=20 type=0x11 flags=REQUEST|ACK seq=2; nfgen res_id=0x000a + ) + #expect(bytes.count == 152) + #expect(bytes == expected) + } + + /// A single UDP DNAT rule (`192.0.2.2:53` → `198.51.100.100:3053`) + /// is encoded into the expected nftables expression list. + @Test func buildSingleRule() throws { + let ruleMessage = NfTablesMessage.addDnatRule( + family: 2, table: "nat", chain: "output", + rule: DNATRule( + matchDaddr: try IPv4Address("192.0.2.2"), matchDport: 53, + matchProto: IPProtocol.IPPROTO_UDP, + dnatAddr: try IPv4Address("198.51.100.100"), dnatPort: 3053)) + + let bytes = try NfTablesSession.buildBatch([ruleMessage], pid: 0) + + let expected = [UInt8]( + hex: + "140000001000010000000000000000000000000a" // BATCH_BEGIN nlh (20 B): len=20 type=0x10 flags=REQUEST seq=0; nfgen res_id=0x000a (NFTABLES) + + "f0010000060a010c0100000000000000" // NEWRULE nlmsghdr (16 B): len=496 type=0x0a06 flags=REQUEST|CREATE|APPEND seq=1 + + "02000000" // nfgenmsg (4 B): family=NFPROTO_IPV4 (2) + + "19000100636f6e7461696e6572697a6174696f6e2d6e617400000000" // NFTA_RULE_TABLE: "containerization-nat" + + "1c000200636f6e7461696e6572697a6174696f6e2d6f757470757400" // NFTA_RULE_CHAIN: "containerization-output" + + "a4010480" // NFTA_RULE_EXPRESSIONS: len=420, NESTED (9 list elems) + // expr 1 — payload: load the IPv4 daddr at byte 16 into the network header, 4 B → REG1 + + "34000180" // LIST_ELEM: len=52 type=NFTA_LIST_ELEM|NESTED + + "0c0001007061796c6f616400" // NFTA_EXPR_NAME: "payload" + + "24000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_PAYLOAD_DREG: BE32 REG1 + + "0800020000000001" // NFTA_PAYLOAD_BASE: BE32 NFT_PAYLOAD_NETWORK_HEADER (1) + + "0800030000000010" // NFTA_PAYLOAD_OFFSET: BE32 16 + + "0800040000000004" // NFTA_PAYLOAD_LEN: BE32 4 + // expr 2 — cmp: daddr == 192.0.2.2 + + "2c000180" // LIST_ELEM: len=44 type=NFTA_LIST_ELEM|NESTED + + "08000100636d7000" // NFTA_EXPR_NAME: "cmp" + + "20000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_CMP_SREG: BE32 REG1 + + "0800020000000000" // NFTA_CMP_OP: BE32 NFT_CMP_EQ (0) + + "0c000380" // NFTA_CMP_DATA (NESTED) + + "08000100c0000202" // NFTA_DATA_VALUE: BE32 192.0.2.2 + // expr 3 — meta: load the L4 protocol → REG1 + + "24000180" // LIST_ELEM: len=36 type=NFTA_LIST_ELEM|NESTED + + "090001006d65746100000000" // NFTA_EXPR_NAME: "meta" + + "14000280" // NFTA_EXPR_DATA (NESTED) + + "0800020000000010" // NFTA_META_KEY: BE32 NFT_META_L4PROTO (16) + + "0800010000000001" // NFTA_META_DREG: BE32 REG1 + // expr 4 — cmp: l4proto == 17 (UDP) + + "2c000180" // LIST_ELEM: len=44 type=NFTA_LIST_ELEM|NESTED + + "08000100636d7000" // NFTA_EXPR_NAME: "cmp" + + "20000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_CMP_SREG: BE32 REG1 + + "0800020000000000" // NFTA_CMP_OP: BE32 NFT_CMP_EQ (0) + + "0c000380" // NFTA_CMP_DATA (NESTED) + + "0500010011000000" // NFTA_DATA_VALUE: 1 B = 0x11 (17) + // expr 5 — payload: load the TCP/UDP dport at byte 2 into the transport header, 2 B → REG1 + + "34000180" // LIST_ELEM: len=52 type=NFTA_LIST_ELEM|NESTED + + "0c0001007061796c6f616400" // NFTA_EXPR_NAME: "payload" + + "24000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_PAYLOAD_DREG: BE32 REG1 + + "0800020000000002" // NFTA_PAYLOAD_BASE: BE32 NFT_PAYLOAD_TRANSPORT_HEADER (2) + + "0800030000000002" // NFTA_PAYLOAD_OFFSET: BE32 2 + + "0800040000000002" // NFTA_PAYLOAD_LEN: BE32 2 + // expr 6 — cmp: dport == 53 + + "2c000180" // LIST_ELEM: len=44 type=NFTA_LIST_ELEM|NESTED + + "08000100636d7000" // NFTA_EXPR_NAME: "cmp" + + "20000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_CMP_SREG: BE32 REG1 + + "0800020000000000" // NFTA_CMP_OP: BE32 NFT_CMP_EQ (0) + + "0c000380" // NFTA_CMP_DATA (NESTED) + + "0600010000350000" // NFTA_DATA_VALUE: BE16 53 + // expr 7 — immediate: dnat ip → REG1 + + "2c000180" // LIST_ELEM: len=44 type=NFTA_LIST_ELEM|NESTED + + "0e000100696d6d656469617465000000" // NFTA_EXPR_NAME: "immediate" (14 B attr + pad) + + "18000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_IMMEDIATE_DREG: BE32 REG1 + + "0c000280" // NFTA_IMMEDIATE_DATA (NESTED) + + "08000100c6336464" // NFTA_DATA_VALUE: BE32 198.51.100.100 + // expr 8 — immediate: dnat port → REG2 + + "2c000180" // LIST_ELEM: len=44 type=NFTA_LIST_ELEM|NESTED + + "0e000100696d6d656469617465000000" // NFTA_EXPR_NAME: "immediate" (14 B attr + pad) + + "18000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000002" // NFTA_IMMEDIATE_DREG: BE32 REG2 + + "0c000280" // NFTA_IMMEDIATE_DATA (NESTED) + + "060001000bed0000" // NFTA_DATA_VALUE: BE16 3053 + // expr 9 — nat: DNAT REG1:REG2 (ip:port), PROTO_SPECIFIED + + "38000180" // LIST_ELEM: len=56 type=NFTA_LIST_ELEM|NESTED + + "080001006e617400" // NFTA_EXPR_NAME: "nat" + + "2c000280" // NFTA_EXPR_DATA (NESTED) + + "0800010000000001" // NFTA_NAT_TYPE: BE32 NFT_NAT_DNAT (1) + + "0800020000000002" // NFTA_NAT_FAMILY: BE32 NFPROTO_IPV4 (2) + + "0800030000000001" // NFTA_NAT_REG_ADDR_MIN: BE32 REG1 + + "0800050000000002" // NFTA_NAT_REG_PROTO_MIN: BE32 REG2 + + "0800070000000002" // NFTA_NAT_FLAGS: BE32 NF_NAT_RANGE_PROTO_SPECIFIED (2) + + "140000001100050002000000000000000000000a" // BATCH_END nlh (20 B): len=20 type=0x11 flags=REQUEST|ACK seq=2; nfgen res_id=0x000a + ) + #expect(bytes.count == 536) + #expect(bytes == expected) + } + + /// A zero `NLMSG_ERROR` reply acknowledges a combined batch commit. + @Test func batchSucceedsOnZeroAck() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.responses.append(ackOrErrorReply(error: 0, seq: 3)) + + let session = NfTablesSession(socket: mockSocket) + let messages: [NfTablesMessage] = [ + .addTable(family: 2, name: "nat"), + .addChain( + family: 2, table: "nat", chain: "output", + options: ChainOptions(type: "nat", hook: 3, priority: -100, policy: 1)), + ] + + try session.sendBatch(messages) + + #expect(mockSocket.requests.count == 1) + } + + /// A non-zero `NLMSG_ERROR` reply makes the combined batch throw the + /// kernel's error. + @Test func batchThrowsOnError() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.responses.append(ackOrErrorReply(error: -2, seq: 2)) + + let session = NfTablesSession(socket: mockSocket) + let messages: [NfTablesMessage] = [ + .addTable(family: 2, name: "nat"), + .addChain( + family: 2, table: "not_nat", chain: "output", + options: ChainOptions(type: "nat", hook: 3, priority: -100, policy: 1)), + ] + + #expect(throws: NetlinkDataError.responseError(rc: -2)) { + try session.sendBatch(messages) + } + } + + /// `addDnatToOutput` sends the whole batch as exactly one atomic send + /// and requires a zero-ACK before returning. + @Test func addDnatToOutputSendsSingleAtomicBatch() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.responses.append(ackOrErrorReply(error: 0, seq: 4)) + + let session = NfTablesSession(socket: mockSocket) + let rule = DNATRule( + matchDaddr: try IPv4Address("198.51.100.100"), matchDport: 53, matchProto: 17, + dnatAddr: try IPv4Address("192.168.64.1"), dnatPort: 3053) + try session.addDnatToOutput(rules: [rule]) + + #expect(mockSocket.requests.count == 1) + let expected = try NfTablesSession.buildBatch(NfTablesSession.buildDnatMessages([rule]), pid: 0) + #expect(mockSocket.requests[0] == expected) + } + + /// A non-zero `NLMSG_ERROR` reply makes `addDnatToOutput` throw the kernel's error. + @Test func addDnatToOutputThrowsOnError() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.responses.append(ackOrErrorReply(error: -2, seq: 3)) + + let session = NfTablesSession(socket: mockSocket) + let rule = DNATRule( + matchDaddr: try IPv4Address("198.51.100.100"), matchDport: 53, matchProto: 17, + dnatAddr: try IPv4Address("192.168.64.1"), dnatPort: 3053) + + #expect(throws: NetlinkDataError.responseError(rc: -2)) { + try session.addDnatToOutput(rules: [rule]) + } + #expect(mockSocket.requests.count == 1) + } + + private func containsSubsequence(_ bytes: [UInt8], _ needle: [UInt8]) -> Bool { + guard needle.count <= bytes.count else { + return false + } + if needle.isEmpty { + return true + } + return (0...(bytes.count - needle.count)).contains { start in + bytes[start..<(start + needle.count)].elementsEqual(needle) + } + } + + /// Builds a capped `NLMSG_ERROR` reply carrying the given error code. + private func ackOrErrorReply(error: Int32, seq: UInt32 = 0) -> [UInt8] { + let errorBytes = withUnsafeBytes(of: error.littleEndian) { Array($0) } + let seqBytes = withUnsafeBytes(of: seq.littleEndian) { Array($0) } + return [ + 0x24, 0x00, 0x00, 0x00, // len = 36 + 0x02, 0x00, 0x00, 0x01, // NLMSG_ERROR, NLM_F_CAPPED + ] + + seqBytes + + [0x00, 0x00, 0x00, 0x08] // pid = 8 + + errorBytes + + [UInt8](repeating: 0, count: 16) // echoed nlmsghdr (dummy) + } +} diff --git a/Tests/ContainerizationTests/DNSTests.swift b/Tests/ContainerizationTests/DNSTests.swift index fe866b977..129e3a3ba 100644 --- a/Tests/ContainerizationTests/DNSTests.swift +++ b/Tests/ContainerizationTests/DNSTests.swift @@ -52,6 +52,16 @@ struct DNSTests { #expect(dns.resolvConf == expected) } + @Test func dnsResolvConfAddsRedirectComment() { + let dns = DNS( + nameservers: ["8.8.8.8"], + redirectTargets: [(ip: "192.168.64.1", port: 3053)] + ) + + let expected = "# DNS traffic may be redirected by an nftables rule; verify with: nft list ruleset\nnameserver 8.8.8.8\n" + #expect(dns.resolvConf == expected) + } + @Test func dnsValidateAcceptsValidIPv4Nameservers() throws { let dns = DNS(nameservers: ["8.8.8.8", "1.1.1.1"]) #expect(throws: Never.self) { try dns.validate() } @@ -81,4 +91,43 @@ struct DNSTests { let dns = DNS(nameservers: ["not-an-ip"]) #expect(throws: (any Error).self) { try dns.validate() } } + + // MARK: - redirectTargets + + @Test func dnsValidateAcceptsValidRedirectTargets() throws { + let dns = DNS( + nameservers: ["8.8.8.8", "1.1.1.1"], + redirectTargets: [(ip: "192.168.64.1", port: 3053), (ip: "192.168.64.2", port: 5353)]) + #expect(throws: Never.self) { try dns.validate() } + } + + @Test func dnsValidateAcceptsMaxPort() throws { + let dns = DNS(nameservers: ["8.8.8.8"], redirectTargets: [(ip: "192.168.64.1", port: 65535)]) + #expect(throws: Never.self) { try dns.validate() } + } + + @Test func dnsValidateRejectsRedirectTargetPort53() { + let dns = DNS(nameservers: ["8.8.8.8"], redirectTargets: [(ip: "192.168.64.1", port: 53)]) + #expect(throws: (any Error).self) { try dns.validate() } + } + + @Test func dnsValidateRejectsRedirectTargetPort1023() { + let dns = DNS(nameservers: ["8.8.8.8"], redirectTargets: [(ip: "192.168.64.1", port: 1023)]) + #expect(throws: (any Error).self) { try dns.validate() } + } + + @Test func dnsValidateRejectsRedirectTargetPort0() { + let dns = DNS(nameservers: ["8.8.8.8"], redirectTargets: [(ip: "192.168.64.1", port: 0)]) + #expect(throws: (any Error).self) { try dns.validate() } + } + + @Test func dnsValidateRejectsRedirectTargetNonIPv4() { + let dns = DNS(nameservers: ["8.8.8.8"], redirectTargets: [(ip: "dns.example.com", port: 3053)]) + #expect(throws: (any Error).self) { try dns.validate() } + } + + @Test func dnsValidateRejectsRedirectTargetIPv6() { + let dns = DNS(nameservers: ["8.8.8.8"], redirectTargets: [(ip: "2001:db8::1", port: 3053)]) + #expect(throws: (any Error).self) { try dns.validate() } + } } diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 0b443f4d8..da65631b2 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -1537,6 +1537,11 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ return .init() } + // DNS well-known port (53). + private static let nameserverPort: UInt16 = 53 + // Redirect-target ports must be unprivileged (>= 1024). + private static let minimumRedirectPort: UInt32 = 1024 + public func configureDns( request: Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest, context: GRPCCore.ServerContext @@ -1553,11 +1558,72 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ ]) do { + // A non-empty target set must match the nameservers one-to-one. + if !request.redirectTargets.isEmpty { + guard request.nameservers.count == request.redirectTargets.count else { + throw ContainerizationError( + .invalidArgument, + message: "DNS redirect target count (\(request.redirectTargets.count)) must match nameserver count (\(request.nameservers.count))") + } + } + + // Redirect rules must apply before writing: a netlink failure throws. + let redirects = + if request.redirectTargets.isEmpty && !request.nameservers.isEmpty { + // Nameservers without targets: redirect the default nameserver to + // the default target, one TCP and one UDP rule. + [ + DNATRule( + matchDaddr: try IPv4Address([198, 51, 100, 100]), matchDport: 53, + matchProto: IPProtocol.IPPROTO_UDP, + dnatAddr: try IPv4Address([192, 168, 64, 1]), dnatPort: 3053), + DNATRule( + matchDaddr: try IPv4Address([198, 51, 100, 100]), matchDport: 53, + matchProto: IPProtocol.IPPROTO_TCP, + dnatAddr: try IPv4Address([192, 168, 64, 1]), dnatPort: 3053), + ] + } else if request.redirectTargets.isEmpty { + [] as [DNATRule] + } else { + try request.nameservers.enumerated().flatMap { index, nameserver in + // Wire ports arrive as `uint32` while the host validates `UInt16`; + // IPv6 endpoints are skipped — the rules are IPv4-only. + let target = request.redirectTargets[index] + guard target.port <= UInt16.max, target.port >= Self.minimumRedirectPort else { + throw ContainerizationError( + .invalidArgument, + message: "DNS redirect target '\(target.ip):\(target.port)' has invalid port") + } + guard let match = try? IPv4Address(nameserver), + let dnat = try? IPv4Address(target.ip) + else { + return [] as [DNATRule] + } + // Each target gets two rules in the same batch: one UDP and one TCP. + return [ + DNATRule( + matchDaddr: match, matchDport: Self.nameserverPort, + matchProto: IPProtocol.IPPROTO_UDP, + dnatAddr: dnat, dnatPort: UInt16(target.port)), + DNATRule( + matchDaddr: match, matchDport: Self.nameserverPort, + matchProto: IPProtocol.IPPROTO_TCP, + dnatAddr: dnat, dnatPort: UInt16(target.port)), + ] + } + } + + if !redirects.isEmpty { + let socket = try DefaultNetlinkSocket(socketProtocol: NetlinkProtocol.NETLINK_NETFILTER) + try NfTablesSession(socket: socket, log: log).addDnatToOutput(rules: redirects) + } + let etc = URL(fileURLWithPath: request.location).appendingPathComponent("etc") try FileManager.default.createDirectory(atPath: etc.path, withIntermediateDirectories: true) let resolvConf = etc.appendingPathComponent("resolv.conf") let config = DNS( nameservers: request.nameservers, + redirectTargets: request.redirectTargets.map { (ip: $0.ip, port: UInt16($0.port)) }, domain: domain, searchDomains: request.searchDomains, options: request.options @@ -1566,16 +1632,26 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ log.debug("writing to path \(resolvConf.path) \(text)") try text.write(toFile: resolvConf.path, atomically: true, encoding: .utf8) log.debug("wrote resolver configuration", metadata: ["path": "\(resolvConf.path)"]) + + return .init() + } catch let err as ContainerizationError { + log.error( + "configureDns", + metadata: [ + "error": "\(err)" + ]) + throw err.toRPCError(operation: "configureDns: failed to configure DNS") } catch { log.error( "configureDns", metadata: [ "error": "\(error)" ]) - throw RPCError(code: .internalError, message: "configureDns", cause: error) + if error is RPCError { + throw error + } + throw RPCError(code: .internalError, message: "configureDns: \(error)", cause: error) } - - return .init() } public func configureHosts(