From fc5bba22b2cd241a0b19dc322d9d56cc8269683d Mon Sep 17 00:00:00 2001 From: Hector Zhao Date: Sat, 29 Aug 2026 19:46:06 +0800 Subject: [PATCH 1/3] ContainerizationNetlink: add minimal nftables netlink support Implements a deliberately minimal, incomplete subset of the nftables netlink interface, sufficient for a single use case: redirecting outbound port 53 traffic to an alternate ip:port, to accommodate DNS servers that cannot bind to port 53. --- .../NetlinkSocket.swift | 8 +- .../NfTablesSession.swift | 409 ++++++++++++++++++ .../NfTablesTypes.swift | 346 +++++++++++++++ Sources/ContainerizationNetlink/Types.swift | 45 +- .../NfTablesSessionTest.swift | 356 +++++++++++++++ 5 files changed, 1145 insertions(+), 19 deletions(-) create mode 100644 Sources/ContainerizationNetlink/NfTablesSession.swift create mode 100644 Sources/ContainerizationNetlink/NfTablesTypes.swift create mode 100644 Tests/ContainerizationNetlinkTests/NfTablesSessionTest.swift 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) + } +} From 7d93abf38e8ee177c6cb6e40ed57b9e2df03e20a Mon Sep 17 00:00:00 2001 From: Hector Zhao Date: Sat, 29 Aug 2026 19:46:18 +0800 Subject: [PATCH 2/3] dns: propagate port-aware redirect targets Adds redirect (ip, port) pairs to DNSConfiguration and propagates them through the SandboxContext proto to vminitd, which installs nftables rules DNATing outbound port 53 traffic to each target. Completes the nftables subset for DNS servers that cannot bind to port 53. --- .../Containerization/DNSConfiguration.swift | 31 ++++++++- .../SandboxContext/SandboxContext.pb.swift | 58 +++++++++++++++- .../SandboxContext/SandboxContext.proto | 6 ++ Sources/Containerization/Vminitd.swift | 6 ++ Tests/ContainerizationTests/DNSTests.swift | 49 +++++++++++++ vminitd/Sources/VminitdCore/Server+GRPC.swift | 69 ++++++++++++++++++- 6 files changed, 213 insertions(+), 6 deletions(-) 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/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..2595ea678 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,59 @@ 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 { + [] 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 +1619,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( From c84464678e2ad7fc1f12fd06150cf6139e1ffe4d Mon Sep 17 00:00:00 2001 From: Hector Zhao Date: Tue, 1 Sep 2026 14:47:40 +0800 Subject: [PATCH 3/3] NOT TO BE MERGED, default DNAT rules for empty redirectTargets. So that one could test with `--init-image` w/o any update to the container. --- vminitd/Sources/VminitdCore/Server+GRPC.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 2595ea678..da65631b2 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -1569,7 +1569,20 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ // Redirect rules must apply before writing: a netlink failure throws. let redirects = - if request.redirectTargets.isEmpty { + 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