Files
wehub-resource-sync 680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

400 lines
17 KiB
Swift

//===----------------------------------------------------------------------===//
// 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-<name>.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 <bridge> -o <egress>`); 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-<name>.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 <bridge> -o <egress>` 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/<iface>/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<T>(_ 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