//===----------------------------------------------------------------------===// // 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. //===----------------------------------------------------------------------===// #if os(Linux) import ContainerizationError import ContainerizationExtras import ContainerizationNetlink import Foundation import Logging #if canImport(Musl) import Musl #elseif canImport(Glibc) import Glibc #endif /// Linux-only host plumbing for a container bridge network. /// /// `create()` is idempotent: it brings the bridge to a known state (created /// if absent, configured if already present), records what it changed in /// `/run/containerization/bridge-.state`, and `delete()` reverses /// only what was recorded. /// /// **NAT is opt-in.** With the default (`enableNAT: false`) `create()` only /// brings up the bridge link and assigns the gateway IP — it does NOT touch /// `ip_forward`, does NOT program iptables, and does NOT pick an egress /// interface. Containers attached to the bridge can talk to each other and /// to the host, but not to the outside world. Pass `enableNAT: true` to /// also enable IPv4 forwarding and program a scoped MASQUERADE/FORWARD /// pair (`-i -o `); the bridge becomes a NAT exit and the /// host now routes guest traffic. /// /// Concurrent `create()`/`delete()` calls (e.g. from two `cctl run` /// processes) serialize via `flock(LOCK_EX)` on /// `/run/containerization/bridge-.lock`. /// /// Requires root (or `CAP_NET_ADMIN` plus, when NAT is enabled, the ability /// to write `/proc/sys/...` and invoke `iptables`). public struct BridgeManager: Sendable { public let name: String public let subnet: CIDRv4 public let gateway: IPv4Address public let mtu: UInt32 public let egressInterface: String? public let enableNAT: Bool private let log: Logger /// - Parameters: /// - name: bridge interface name (e.g. `cz0`). /// - subnet: subnet to assign on the bridge. /// - gateway: host-side IP on the bridge. Defaults to `subnet.gateway` /// (= `subnet.lower + 1`). /// - mtu: bridge MTU. Default 1500. /// - egressInterface: explicit egress iface for MASQUERADE. nil = /// auto-detect via `/proc/net/route` at `create()` time. Only used /// when `enableNAT` is true. /// - enableNAT: when true, program iptables MASQUERADE+FORWARD and /// enable `net.ipv4.ip_forward`. Default false — the bridge is /// created without external connectivity, leaving host firewall /// policy untouched. /// - logger: optional logger. Defaults to a `bridge`-labeled logger. public init( name: String, subnet: CIDRv4, gateway: IPv4Address? = nil, mtu: UInt32 = 1500, egressInterface: String? = nil, enableNAT: Bool = false, logger: Logger? = nil ) { self.name = Self.validateInterfaceName(name) self.subnet = subnet self.gateway = gateway ?? subnet.gateway self.mtu = mtu self.egressInterface = egressInterface.map(Self.validateInterfaceName) self.enableNAT = enableNAT self.log = logger ?? Logger(label: "com.apple.containerization.bridge") } /// Reject obviously-bogus interface names before they hit netlink or /// `iptables`. This is a defense-in-depth check; the kernel and /// `iptables` themselves will also reject pathological inputs, but doing /// it here surfaces the error in a callable Swift API rather than as a /// netlink rc or iptables exit. Asserts (rather than throws) — these /// constraints are static, so a violation is a programming error. private static func validateInterfaceName(_ name: String) -> String { // IFNAMSIZ on Linux is 16 (15 usable + NUL). iptables itself caps // at 15. Names with `/`, whitespace, or NUL are kernel-rejected. precondition(!name.isEmpty, "interface name must be non-empty") precondition(name.utf8.count <= 15, "interface name '\(name)' exceeds IFNAMSIZ-1 (15)") precondition( !name.contains(where: { $0.isWhitespace || $0 == "/" || $0 == "\0" || $0 == ":" }), "interface name '\(name)' contains invalid characters" ) return name } /// Idempotent create. public func create() throws { try Self.ensureStateDirectory() let lock = try FileLock(path: Self.lockPath(for: name)) try lock.withExclusive { try createLocked() } } /// Idempotent delete. No-op when the bridge does not exist. public func delete() throws { try Self.ensureStateDirectory() let lock = try FileLock(path: Self.lockPath(for: name)) try lock.withExclusive { try deleteLocked() } } private func createLocked() throws { let session = NetlinkSession(socket: try DefaultNetlinkSocket(), log: log) let stateURL = URL(fileURLWithPath: Self.statePath(for: name)) // Preserve `prevIpForward` across re-runs: a second NAT-enabled // create() call would otherwise read the value the FIRST run left // behind ("1") and clobber the original prior state, so delete() // couldn't restore. let priorState: BridgeState? = (try? Data(contentsOf: stateURL)) .flatMap { try? BridgeState.decode($0) } // 1. Bridge link. do { try session.linkAddBridge(name: name) log.info("created bridge \(name)") } catch { // EEXIST is fine; treat any error as "maybe it exists" and probe. // `linkGet` throws ENODEV when the iface is absent (rather than // returning an empty array), so coalesce both shapes to "absent". let existing = (try? session.linkGet(interface: name)) ?? [] if existing.isEmpty { throw ContainerizationError( .internalError, message: "linkAddBridge \(name) failed and bridge does not exist: \(error)" ) } log.debug("bridge \(name) already exists") } // 2. Address (gateway/prefix) on the bridge. let cidr = try CIDRv4(gateway, prefix: subnet.prefix) do { try session.addressAdd(interface: name, ipv4Address: cidr) } catch { // EEXIST tolerated; netlink layer doesn't expose errno cleanly, // so log and continue. linkSet/up below will fail visibly if the // bridge state is actually broken. log.debug("addressAdd \(cidr) on \(name) returned \(error) (likely already set)") } // 3. Up + MTU. try session.linkSet(interface: name, up: true, mtu: mtu) // NAT is opt-in but sticky: once enabled by a previous create(), // subsequent create() calls without --enable-nat leave the existing // rules and ip_forward state in place. Otherwise `cctl run` // (defaults to NAT off) called after `cctl bridge create // --enable-nat` would silently disable the NAT the user explicitly // turned on. delete() always reverses whatever the state file // records. let effectiveNAT = enableNAT || (priorState?.natEnabled ?? false) guard effectiveNAT else { let state = BridgeState(natEnabled: false) try state.encode().write(to: stateURL) log.info("bridge \(name) ready (subnet \(subnet), NAT disabled)") return } // 4. ip_forward: read what's currently on the host, decide what to // record. If we already have a NAT-enabled state file from a prior // create(), keep its `prevIpForward` (it's the *original* prior // value); otherwise record what we just read. let currentIpForward = (try? Self.readSysctl("net/ipv4/ip_forward")) ?? "0" let prevIpForward = (priorState?.natEnabled == true ? priorState?.prevIpForward : nil) ?? currentIpForward if currentIpForward != "1" { try Self.writeSysctl("net/ipv4/ip_forward", value: "1") } // 5. Egress iface — explicit override or auto-detect. let egress: String if let explicit = egressInterface { egress = explicit } else if let detected = HostDefaultRoute.currentEgress() { egress = detected } else { throw ContainerizationError( .invalidArgument, message: "no default route on host; pass egressInterface explicitly" ) } // 6. Record state BEFORE iptables. If a later iptables -A fails, // delete() still has authority to clean up partial rules; if we // deferred the write until after, a mid-failure would orphan rules // with no record. let state = BridgeState( natEnabled: true, prevIpForward: prevIpForward, egressInterface: egress ) try state.encode().write(to: stateURL) // 7. iptables rules — idempotent. The FORWARD rule is scoped to // `-i -o ` so the host doesn't become an // unrestricted router for guest traffic across every host iface // (e.g. a VPN or a sibling bridge). try IptablesRules.ensure( table: "nat", args: [ "POSTROUTING", "-s", subnet.description, "!", "-o", name, "-j", "MASQUERADE", ]) try IptablesRules.ensure(args: [ "FORWARD", "-i", name, "-o", egress, "-j", "ACCEPT", ]) try IptablesRules.ensure(args: [ "FORWARD", "-i", egress, "-o", name, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT", ]) log.info("bridge \(name) ready (subnet \(subnet), egress \(egress), NAT enabled)") } private func deleteLocked() throws { let stateURL = URL(fileURLWithPath: Self.statePath(for: name)) let state: BridgeState? = (try? Data(contentsOf: stateURL)) .flatMap { try? BridgeState.decode($0) } // 1. iptables — only if a prior create() with NAT enabled left state // we own. The rules are keyed off subnet, bridge name, and the // recorded egress iface, so removal is precise even when the // host has rules from other tools. if let state, state.natEnabled, let egress = state.egressInterface { log.debug("removing iptables rules for bridge \(name) (egress \(egress))") IptablesRules.remove( table: "nat", args: [ "POSTROUTING", "-s", subnet.description, "!", "-o", name, "-j", "MASQUERADE", ]) IptablesRules.remove(args: [ "FORWARD", "-i", name, "-o", egress, "-j", "ACCEPT", ]) IptablesRules.remove(args: [ "FORWARD", "-i", egress, "-o", name, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT", ]) } // 2. Bridge link. let session = NetlinkSession(socket: try DefaultNetlinkSocket(), log: log) // Refuse to delete anything that isn't actually a bridge — the // kernel exposes `/sys/class/net//bridge` only for links of // kind=bridge, so its presence is an authoritative kind check // without parsing IFLA_LINKINFO. This guards against `cctl bridge // delete --name eth0` (or docker0, etc.) taking down host links. let sysfsBridge = "/sys/class/net/\(name)/bridge" let isBridge = FileManager.default.fileExists(atPath: sysfsBridge) let exists = !((try? session.linkGet(interface: name)) ?? []).isEmpty if exists && !isBridge { throw ContainerizationError( .invalidArgument, message: "refusing to delete \(name): exists but is not a bridge interface" ) } do { try session.linkDel(name: name) log.info("deleted bridge \(name)") } catch { // ENODEV-like: nothing to do. log.debug("linkDel \(name) returned \(error) (likely already absent)") } // 3. Restore ip_forward only if this bridge's create()-with-NAT set // it from 0 AND no other containerization bridge still has NAT // enabled. ip_forward is a single global sysctl shared by every // bridge, so it must be reference-counted against the on-disk state // files rather than blindly reset — otherwise tearing down one NAT // bridge would disable forwarding for a sibling that still needs it. // // (Torn down in the order that removes the original flipper first — // or two NAT bridges torn down concurrently — may leave ip_forward=1 // after the last bridge is gone. That's the safe direction: // forwarding with no bridge or iptables rules attached is inert, and // a reboot clears it. Erroneously forcing it to 0 under a live NAT // bridge is the failure this guards against.) let otherNAT = Self.otherNATEnabledBridgesExist(excluding: name) if state?.natEnabled == true, state?.prevIpForward == "0", !otherNAT { try? Self.writeSysctl("net/ipv4/ip_forward", value: "0") } // 4. Remove state file. try? FileManager.default.removeItem(at: stateURL) } // MARK: - Paths / sysctl helpers private static let stateDir = "/run/containerization" private static func statePath(for name: String) -> String { "\(stateDir)/bridge-\(name).state" } private static func lockPath(for name: String) -> String { "\(stateDir)/bridge-\(name).lock" } private static func ensureStateDirectory() throws { try FileManager.default.createDirectory( atPath: stateDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o755] ) } private static func readSysctl(_ path: String) throws -> String { let url = URL(fileURLWithPath: "/proc/sys/\(path)") return try String(contentsOf: url, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines) } private static func writeSysctl(_ path: String, value: String) throws { let url = URL(fileURLWithPath: "/proc/sys/\(path)") try Data((value + "\n").utf8).write(to: url) } /// Whether any *other* containerization bridge still has NAT enabled, /// determined by scanning the `bridge-*.state` files under `stateDir`. /// Used by `delete()` to reference-count the shared global `ip_forward` /// sysctl so tearing down one NAT bridge doesn't disable forwarding for /// its siblings. `excluding` is this bridge's name — its own (still /// present) state file is skipped since `delete()` removes it afterward. private static func otherNATEnabledBridgesExist(excluding name: String) -> Bool { let selfFile = "bridge-\(name).state" let entries = (try? FileManager.default.contentsOfDirectory(atPath: stateDir)) ?? [] for entry in entries { guard entry.hasPrefix("bridge-"), entry.hasSuffix(".state"), entry != selfFile else { continue } let url = URL(fileURLWithPath: "\(stateDir)/\(entry)") guard let data = try? Data(contentsOf: url), let state = try? BridgeState.decode(data) else { continue } if state.natEnabled { return true } } return false } } /// `flock(2)` wrapper. Held for the duration of a closure. struct FileLock { let fd: Int32 init(path: String) throws { let f = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0o600) guard f >= 0 else { throw ContainerizationError( .internalError, message: "open \(path) failed: errno=\(errno)" ) } self.fd = f } func withExclusive(_ body: () throws -> T) throws -> T { guard flock(fd, LOCK_EX) == 0 else { close(fd) throw ContainerizationError( .internalError, message: "flock LOCK_EX failed: errno=\(errno)" ) } defer { _ = flock(fd, LOCK_UN) close(fd) } return try body() } } #endif