From 180aa89cc40b7da46d6ebc1d1423bc8fe103c82e Mon Sep 17 00:00:00 2001 From: Chris George Date: Sat, 2 May 2026 15:48:17 -0700 Subject: [PATCH] feat(network): apply richer IPAM at network creation time Extends `container network create` and the `container-network-vmnet` plugin to honor six compose-spec / Docker-style network creation options that were previously silently dropped: `--gateway`, `--ip-range`, `--aux-address`, `--driver-opt`, `--ipv6`, and `--attachable`. The vmnet plugin now actually wires the user's gateway override and IPv4 sub-range constraints through to the runtime; aux-address reservations are pre-allocated in the per-network attachment allocator so the dynamic pool never hands them out. Motivation ---------- Container-Compose (and other compose-spec orchestrators) decode these fields from `docker-compose.yml` but cannot apply them today because `container network create` only accepts `--label`, `--internal`, `--subnet`, `--subnet-v6`, `--plugin`, and `--plugin-variant`. The gap was tracked downstream in CHAOS-1334 and surfaced as warn-and-skip behaviour in container-compose's `setupNetwork` path. With this PR the runtime accepts the full IPAM surface and the plugin honours each field at network start. What this PR changes -------------------- - Sources/ContainerResource/Network/NetworkConfiguration.swift: six new optional fields on the persisted `NetworkConfiguration` (`ipv4Gateway`, `ipv4Range`, `auxAddresses`, `driverOpts`, `attachable`, `enableIPv6`). All decoded with `decodeIfPresent`, encoded with `encodeIfPresent`, and validated end-to-end (gateway, range, and aux-addresses must lie inside `ipv4Subnet` when both are configured). - Sources/ContainerCommands/Network/NetworkCreate.swift: matching CLI flags (`--gateway`, `--ip-range`, `--aux-address HOSTNAME=IP` repeatable, `--driver-opt KEY=VALUE` repeatable, `--ipv6`, and `--attachable`). The CLI emits an explicit advisory on stderr when `--attachable` is requested because apple/container has no swarm attachment concept. - Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift: `registerService` now serializes the new configuration fields into argv passed to the plugin's `start` subcommand. `auxAddresses` is encoded as a single JSON argv value to keep the wire shape simple and trivially extensible; `driverOpts` is forwarded as repeated `--driver-opt KEY=VALUE` entries. - Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift: parses the new argv (`--gateway`, `--ip-range`, `--aux-addresses`, `--driver-opt`, `--ipv6`) and reconstructs a richer `NetworkConfiguration` for the vmnet variants. - Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift and AllocationOnlyVmnetNetwork.swift: the IPv4 gateway is no longer hard-coded to `subnet.lower + 1`. When the configuration carries an explicit `ipv4Gateway`, that address is passed directly to `vmnet_network_configuration_set_ipv4_subnet` and validated as in-subnet. The allocation-only variant explicitly rejects the IPv6 request paths with an actionable error pointing users at the `reserved` variant on macOS 26+. - Sources/Services/ContainerNetworkService/Server/NetworkService.swift: the per-network attachment allocator now accepts an `ipv4Range` override (so the dynamic pool spans the user-supplied sub-CIDR rather than the full subnet) and pre-reserves both a custom gateway and any in-range `auxAddresses` through `RotatingAddressAllocator`'s existing `reserve(_:)` API. Out-of-range aux entries are recorded for reference but require no allocator state because they are already outside the dynamic pool. - Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift: small `reserveHostname(hostname:address:)` helper that wraps the underlying allocator's `reserve(_:)` with the actor's hostname-to-index dictionary, so reserved entries round-trip through `lookup` / `deallocate`. Wire compatibility ------------------ All new `NetworkConfiguration` fields are optional and decoded with `decodeIfPresent`; configurations persisted by older daemons decode cleanly with `nil` defaults (covered by the `testLegacyConfigurationDecodesWithoutNewFields` regression test). Plugin argv is purely additive: a newer `NetworksService` against an older plugin will pass argv that the plugin ignores; an older `NetworksService` against a newer plugin simply omits the new flags and the plugin behaves as before. Known limitations (intentional, follow-up work) ----------------------------------------------- - `--attachable` is accepted at the CLI for compose-spec parity but is not threaded into the plugin and produces no behavioural change on apple/container, which has no swarm-mode attachment concept. The CLI surfaces this explicitly on stderr. - `--driver-opt KEY=VALUE` is parsed, persisted on the configuration, and forwarded through to the plugin process, but the vmnet plugin does not currently interpret any specific keys. Keeping the surface on day one means future plugin enhancements can interpret options (e.g. DHCP toggles) without another wire-format break. - Bare `--ipv6` (no `--subnet-v6`) takes effect on the `reserved` variant only; the `allocation-only` variant does not yet support IPv6 and now emits an actionable error pointing at the `reserved` variant on macOS 26+. - The IPv4 gateway override only takes effect when an explicit `--subnet` is also supplied; without a subnet, vmnet is the source of truth for both subnet and gateway. Verification ------------ - `swift build -c release` clean on macOS 26 / Apple silicon. - `swift test --filter ContainerResourceTests.NetworkConfigurationTest` passes 11 tests (3 pre-existing + 8 new), covering: gateway/range/ aux-address validation (positive and negative), full-shape Codable round-trip, and decoding a legacy (pre-PR) JSON configuration with none of the new fields populated. --- .../Network/NetworkCreate.swift | 68 ++++++++ .../Network/NetworkConfiguration.swift | 104 ++++++++++++ .../NetworkVmnetHelper+Start.swift | 60 ++++++- .../Server/Networks/NetworksService.swift | 34 ++++ .../Network/Server/AttachmentAllocator.swift | 8 + .../Server/DefaultNetworkService.swift | 80 +++++++++- .../Server/AllocationOnlyVmnetNetwork.swift | 8 +- .../Server/ReservedVmnetNetwork.swift | 10 +- .../NetworkConfigurationTest.swift | 148 ++++++++++++++++++ 9 files changed, 514 insertions(+), 6 deletions(-) diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index 2278fb380..3b6369aa0 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -54,6 +54,41 @@ extension Application { }) var ipv6Subnet: CIDRv6? = nil + @Option( + name: .customLong("gateway"), + help: "Set the IPv4 gateway address for the network. Defaults to the first usable host address in --subnet.", + transform: { + try IPv4Address($0) + }) + var ipv4Gateway: IPv4Address? = nil + + @Option( + name: .customLong("ip-range"), + help: "Restrict dynamic IPv4 allocation to a sub-range of --subnet, expressed as CIDR.", + transform: { + try CIDRv4($0) + }) + var ipv4Range: CIDRv4? = nil + + @Option( + name: .customLong("aux-address"), + help: "Reserve a static hostname-to-IPv4 mapping (HOSTNAME=IPV4). Repeatable.") + var auxAddresses: [String] = [] + + @Option( + name: .customLong("driver-opt"), + help: "Set a free-form network driver option (KEY=VALUE). Repeatable. Currently informational; the vmnet plugin does not interpret any keys.") + var driverOpts: [String] = [] + + @Flag( + name: .customLong("attachable"), + help: "Accepted for compose-spec parity. Currently a no-op on apple/container, which has no swarm concept.") + var attachable: Bool = false + + @Flag( + name: .customLong("ipv6"), + help: "Enable IPv6 even when no --subnet-v6 is supplied. The runtime asks vmnet to auto-allocate an IPv6 prefix.") + var enableIPv6: Bool = false @OptionGroup public var logOptions: Flags.Logging @@ -66,11 +101,44 @@ extension Application { let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels)) let parsedOptions = Utility.parseKeyValuePairs(options) let mode: NetworkMode = hostOnly ? .hostOnly : .nat + + let parsedAuxAddresses: [String: IPv4Address]? + if auxAddresses.isEmpty { + parsedAuxAddresses = nil + } else { + let raw = Utility.parseKeyValuePairs(auxAddresses) + var mapped: [String: IPv4Address] = [:] + mapped.reserveCapacity(raw.count) + for (hostname, addressText) in raw { + mapped[hostname] = try IPv4Address(addressText) + } + parsedAuxAddresses = mapped + } + + let parsedDriverOpts: [String: String]? = driverOpts.isEmpty ? nil : Utility.parseKeyValuePairs(driverOpts) + + // Compose-spec parity: report acceptance of attachable but make it explicit + // that apple/container has no swarm-style attachment concept. + if attachable { + FileHandle.standardError.write( + Data("Note: --attachable is accepted for compose-spec parity but has no behavioral effect on apple/container.\n".utf8) + ) + } + + // Either an explicit --subnet-v6 or an explicit --ipv6 enables IPv6 on the network. + let resolvedEnableIPv6: Bool? = (enableIPv6 || ipv6Subnet != nil) ? true : nil + let config = try NetworkConfiguration( name: self.name, mode: mode, ipv4Subnet: ipv4Subnet, ipv6Subnet: ipv6Subnet, + ipv4Gateway: ipv4Gateway, + ipv4Range: ipv4Range, + auxAddresses: parsedAuxAddresses, + driverOpts: parsedDriverOpts, + attachable: attachable ? true : nil, + enableIPv6: resolvedEnableIPv6, labels: parsedLabels, plugin: self.plugin, options: parsedOptions diff --git a/Sources/ContainerResource/Network/NetworkConfiguration.swift b/Sources/ContainerResource/Network/NetworkConfiguration.swift index 7678c4713..4e0c5e974 100644 --- a/Sources/ContainerResource/Network/NetworkConfiguration.swift +++ b/Sources/ContainerResource/Network/NetworkConfiguration.swift @@ -48,12 +48,52 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { /// Plugin-specific options for this network. public let options: [String: String] + /// The IPv4 gateway address for the network, if explicitly specified. + /// When `nil`, the runtime derives the gateway from `ipv4Subnet` (typically + /// the first usable host address). When set, the value must lie within + /// `ipv4Subnet`. + public let ipv4Gateway: IPv4Address? + + /// A sub-CIDR of `ipv4Subnet` from which the runtime should allocate + /// dynamic IPv4 addresses. When `nil`, the entire usable subnet is + /// available. When set, must be contained within `ipv4Subnet`. + public let ipv4Range: CIDRv4? + + /// Static hostname-to-IPv4 reservations that must not be handed out by the + /// dynamic allocator. Each address must lie within `ipv4Subnet`. Entries + /// outside `ipv4Range` (when specified) are recorded but have no allocator + /// effect because they are already outside the dynamic pool. + public let auxAddresses: [String: IPv4Address]? + + /// Free-form network driver options. Persisted on the configuration and + /// forwarded to the network plugin via repeated `--driver-opt KEY=VALUE` + /// arguments. The vmnet plugin accepts no options today; future driver + /// enhancements may interpret known keys without changing the wire format. + public let driverOpts: [String: String]? + + /// Whether to allow ad-hoc container attachments to the network. Accepted + /// for compose-spec parity but currently a no-op on apple/container, + /// which does not have a multi-host swarm concept. + public let attachable: Bool? + + /// Request IPv6 connectivity even when no explicit `ipv6Subnet` is + /// configured. When `true` and `ipv6Subnet` is `nil`, the runtime asks + /// vmnet to auto-allocate an IPv6 prefix at network start. The flag is + /// implicitly `true` whenever `ipv6Subnet` is set. + public let enableIPv6: Bool? + /// Creates a network configuration public init( name: String, mode: NetworkMode, ipv4Subnet: CIDRv4? = nil, ipv6Subnet: CIDRv6? = nil, + ipv4Gateway: IPv4Address? = nil, + ipv4Range: CIDRv4? = nil, + auxAddresses: [String: IPv4Address]? = nil, + driverOpts: [String: String]? = nil, + attachable: Bool? = nil, + enableIPv6: Bool? = nil, labels: ResourceLabels = .init(), plugin: String, options: [String: String] = [:] @@ -63,6 +103,12 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { self.mode = mode self.ipv4Subnet = ipv4Subnet self.ipv6Subnet = ipv6Subnet + self.ipv4Gateway = ipv4Gateway + self.ipv4Range = ipv4Range + self.auxAddresses = auxAddresses + self.driverOpts = driverOpts + self.attachable = attachable + self.enableIPv6 = enableIPv6 self.labels = labels self.plugin = plugin self.options = options @@ -78,6 +124,12 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { case mode case ipv4Subnet case ipv6Subnet + case ipv4Gateway + case ipv4Range + case auxAddresses + case driverOpts + case attachable + case enableIPv6 case labels case plugin case options @@ -102,6 +154,23 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { ipv4Subnet = try subnetText.map { try CIDRv4($0) } ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet) .map { try CIDRv6($0) } + ipv4Gateway = try container.decodeIfPresent(String.self, forKey: .ipv4Gateway) + .map { try IPv4Address($0) } + ipv4Range = try container.decodeIfPresent(String.self, forKey: .ipv4Range) + .map { try CIDRv4($0) } + if let rawAux = try container.decodeIfPresent([String: String].self, forKey: .auxAddresses) { + var decoded: [String: IPv4Address] = [:] + decoded.reserveCapacity(rawAux.count) + for (hostname, addressText) in rawAux { + decoded[hostname] = try IPv4Address(addressText) + } + auxAddresses = decoded + } else { + auxAddresses = nil + } + driverOpts = try container.decodeIfPresent([String: String].self, forKey: .driverOpts) + attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) + enableIPv6 = try container.decodeIfPresent(Bool.self, forKey: .enableIPv6) let decodedLabels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] labels = try .init(decodedLabels) @@ -132,6 +201,15 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { try container.encode(mode, forKey: .mode) try container.encodeIfPresent(ipv4Subnet, forKey: .ipv4Subnet) try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet) + try container.encodeIfPresent(ipv4Gateway?.description, forKey: .ipv4Gateway) + try container.encodeIfPresent(ipv4Range, forKey: .ipv4Range) + if let auxAddresses { + let encodable = auxAddresses.mapValues { $0.description } + try container.encode(encodable, forKey: .auxAddresses) + } + try container.encodeIfPresent(driverOpts, forKey: .driverOpts) + try container.encodeIfPresent(attachable, forKey: .attachable) + try container.encodeIfPresent(enableIPv6, forKey: .enableIPv6) try container.encode(labels, forKey: .labels) try container.encode(plugin, forKey: .plugin) try container.encode(options, forKey: .options) @@ -141,6 +219,32 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { guard NetworkResource.nameValid(name) else { throw ContainerizationError(.invalidArgument, message: "invalid network name: \(name)") } + if let ipv4Gateway, let ipv4Subnet { + guard ipv4Subnet.contains(ipv4Gateway) else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(ipv4Gateway) is not within IPv4 subnet \(ipv4Subnet)" + ) + } + } + if let ipv4Range, let ipv4Subnet { + guard ipv4Subnet.contains(ipv4Range.lower) && ipv4Subnet.contains(ipv4Range.upper) else { + throw ContainerizationError( + .invalidArgument, + message: "ip-range \(ipv4Range) is not contained within IPv4 subnet \(ipv4Subnet)" + ) + } + } + if let auxAddresses, let ipv4Subnet { + for (hostname, address) in auxAddresses { + guard ipv4Subnet.contains(address) else { + throw ContainerizationError( + .invalidArgument, + message: "aux-address \(hostname)=\(address) is not within IPv4 subnet \(ipv4Subnet)" + ) + } + } + } } } diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift index 7d67f1f32..f194780fc 100644 --- a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -67,6 +67,21 @@ extension NetworkVmnetHelper { return .reserved }() + @Option(name: .customLong("gateway"), help: "Explicit IPv4 gateway address (optional; default derived from subnet)") + var ipv4Gateway: String? + + @Option(name: .customLong("ip-range"), help: "Sub-CIDR of subnet from which dynamic IPv4 addresses are allocated") + var ipv4Range: String? + + @Option(name: .customLong("aux-addresses"), help: "JSON-encoded hostname-to-IPv4 reservations") + var auxAddressesJSON: String? + + @Option(name: .customLong("driver-opt"), help: "Free-form driver option (KEY=VALUE), repeatable") + var driverOpts: [String] = [] + + @Flag(name: .customLong("ipv6"), help: "Enable IPv6 even when no IPv6 subnet is supplied") + var enableIPv6 = false + var logRoot = LogRoot.path func run() async throws { @@ -82,12 +97,37 @@ extension NetworkVmnetHelper { log.info("configuring XPC server") let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) } let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) } + let ipv4Gateway = try self.ipv4Gateway.map { try IPv4Address($0) } + let ipv4Range = try self.ipv4Range.map { try CIDRv4($0) } + let auxAddresses = try Self.decodeAuxAddresses(self.auxAddressesJSON) + let parsedDriverOpts: [String: String]? + if driverOpts.isEmpty { + parsedDriverOpts = nil + } else { + var collected: [String: String] = [:] + collected.reserveCapacity(driverOpts.count) + for entry in driverOpts { + guard let separatorIndex = entry.firstIndex(of: "=") else { + throw ContainerizationError(.invalidArgument, message: "driver option '\(entry)' is missing '='") + } + let key = String(entry[.. [String: IPv4Address]? { + guard let jsonText, !jsonText.isEmpty else { return nil } + guard let data = jsonText.data(using: .utf8) else { + throw ContainerizationError(.invalidArgument, message: "aux-addresses payload is not valid UTF-8") + } + let raw = try JSONDecoder().decode([String: String].self, from: data) + var decoded: [String: IPv4Address] = [:] + decoded.reserveCapacity(raw.count) + for (hostname, addressText) in raw { + decoded[hostname] = try IPv4Address(addressText) + } + return decoded + } } } diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 7fe35fae8..de298a93a 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -174,6 +174,12 @@ public actor NetworksService { mode: configuration.mode, ipv4Subnet: configuration.ipv4Subnet, ipv6Subnet: configuration.ipv6Subnet, + ipv4Gateway: configuration.ipv4Gateway, + ipv4Range: configuration.ipv4Range, + auxAddresses: configuration.auxAddresses, + driverOpts: configuration.driverOpts, + attachable: configuration.attachable, + enableIPv6: configuration.enableIPv6, labels: configuration.labels, plugin: configuration.plugin, options: configuration.options @@ -395,6 +401,34 @@ public actor NetworksService { args += ["--variant", variant] } + if let ipv4Gateway = configuration.ipv4Gateway { + args += ["--gateway", ipv4Gateway.description] + } + + if let ipv4Range = configuration.ipv4Range { + args += ["--ip-range", ipv4Range.description] + } + + if let auxAddresses = configuration.auxAddresses, !auxAddresses.isEmpty { + // Encode as JSON so a single argv value can carry the full hostname-to-IP mapping. + let serializable = auxAddresses.mapValues { $0.description } + let payload = try JSONEncoder().encode(serializable) + guard let payloadString = String(data: payload, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to encode aux-addresses for plugin") + } + args += ["--aux-addresses", payloadString] + } + + if let driverOpts = configuration.driverOpts { + for (key, value) in driverOpts { + args += ["--driver-opt", "\(key)=\(value)"] + } + } + + if configuration.enableIPv6 == true { + args.append("--ipv6") + } + let entityPath = try store.entityPath(configuration.id) try pluginLoader.registerWithLaunchd( plugin: networkPlugin, diff --git a/Sources/Services/Network/Server/AttachmentAllocator.swift b/Sources/Services/Network/Server/AttachmentAllocator.swift index b7d3aeebb..57ba7564f 100644 --- a/Sources/Services/Network/Server/AttachmentAllocator.swift +++ b/Sources/Services/Network/Server/AttachmentAllocator.swift @@ -56,4 +56,12 @@ actor AttachmentAllocator { func lookup(hostname: String) async throws -> UInt32? { hostnames[hostname] } + + /// Pre-reserve a hostname-to-address mapping in the allocator's pool. + /// The address must be within the allocator's range; out-of-range or + /// already-allocated addresses cause the underlying allocator to throw. + func reserveHostname(hostname: String, address: UInt32) async throws { + try allocator.reserve(address) + hostnames[hostname] = address + } } diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 70d17d396..bfdaf2981 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -30,6 +30,7 @@ public actor DefaultNetworkService: NetworkService { /// Set up a network service for the specified network. public init( network: any Network, + configuration: NetworkConfiguration, log: Logger ) async throws { guard let status = await network.status else { @@ -37,12 +38,87 @@ public actor DefaultNetworkService: NetworkService { } let subnet = status.ipv4Subnet - let size = Int(subnet.upper.value - subnet.lower.value - 3) + + // Determine the allocator range. By default skip the network address, + // gateway, and broadcast (`subnet.lower + 2 ... subnet.upper - 1`). When + // an explicit `ipv4Range` was configured, use exactly that span as the + // allocator's pool — the user has taken responsibility for excluding + // the gateway and any aux reservations. + let allocatorLower: UInt32 + let allocatorSize: Int + if let ipv4Range = configuration.ipv4Range { + allocatorLower = ipv4Range.lower.value + allocatorSize = Int(ipv4Range.upper.value - ipv4Range.lower.value + 1) + } else { + allocatorLower = subnet.lower.value + 2 + allocatorSize = Int(subnet.upper.value - subnet.lower.value - 3) + } self.network = network self.log = log - self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + self.allocator = try AttachmentAllocator(lower: allocatorLower, size: allocatorSize) self.macAddresses = [:] self.allocationsBySession = [:] + + // If the configured IPv4 gateway differs from the default (`subnet.lower + 1`) + // and falls within the dynamic allocator's pool, reserve it so the runtime + // never hands the gateway out to a container. + if let configuredGateway = configuration.ipv4Gateway, configuredGateway.value != subnet.lower.value + 1 { + if configuredGateway.value >= allocatorLower + && configuredGateway.value < allocatorLower + UInt32(allocatorSize) + { + do { + try await self.allocator.reserveHostname( + hostname: "__gateway__", + address: configuredGateway.value + ) + } catch { + log.warning( + "failed to pre-reserve configured gateway address", + metadata: [ + "address": "\(configuredGateway)", + "error": "\(error)", + ]) + } + } + } + + // Pre-reserve aux addresses that fall within the allocator's range so the + // dynamic allocator never hands them out. Out-of-range aux addresses are + // recorded in logs only — they are already outside the dynamic pool. + if let auxAddresses = configuration.auxAddresses { + for (hostname, address) in auxAddresses { + let value = address.value + guard + value >= allocatorLower + && value < allocatorLower + UInt32(allocatorSize) + else { + log.info( + "aux address outside dynamic allocation range; recorded for reference only", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + ]) + continue + } + do { + try await self.allocator.reserveHostname(hostname: hostname, address: value) + log.info( + "pre-reserved aux address", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + ]) + } catch { + log.warning( + "failed to pre-reserve aux address", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + "error": "\(error)", + ]) + } + } + } } @Sendable diff --git a/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift index 131e8af28..1e3d9c209 100644 --- a/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift +++ b/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift @@ -72,7 +72,13 @@ public actor AllocationOnlyVmnetNetwork: Network { ) let ipv4Subnet = configuration.ipv4Subnet ?? Self.defaultIPv4Subnet - let gateway = IPv4Address(ipv4Subnet.lower.value + 1) + let gateway = configuration.ipv4Gateway ?? IPv4Address(ipv4Subnet.lower.value + 1) + guard ipv4Subnet.contains(gateway) else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(gateway) is not within IPv4 subnet \(ipv4Subnet)" + ) + } self._status = NetworkStatus( ipv4Subnet: ipv4Subnet, ipv4Gateway: gateway, diff --git a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift index 5b0fee6ad..23440b560 100644 --- a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift +++ b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift @@ -123,7 +123,13 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { // set the IPv4 subnet if let ipv4Subnet { - let gateway = IPv4Address(ipv4Subnet.lower.value + 1) + let gateway = configuration.ipv4Gateway ?? IPv4Address(ipv4Subnet.lower.value + 1) + guard ipv4Subnet.contains(gateway) else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(gateway) is not within IPv4 subnet \(ipv4Subnet)" + ) + } var gatewayAddr = in_addr() inet_pton(AF_INET, gateway.description, &gatewayAddr) let mask = IPv4Address(ipv4Subnet.prefix.prefixMask32) @@ -168,7 +174,7 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { let lower = IPv4Address(subnetValue & maskValue) let upper = IPv4Address(lower.value + ~maskValue) let runningSubnet = try CIDRv4(lower: lower, upper: upper) - let runningGateway = IPv4Address(runningSubnet.lower.value + 1) + let runningGateway = configuration.ipv4Gateway ?? IPv4Address(runningSubnet.lower.value + 1) var prefixAddr = in6_addr() var prefixLength = UInt8(0) diff --git a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift index e3fd1faa2..19710cd3c 100644 --- a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift +++ b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift @@ -16,6 +16,7 @@ import ContainerizationError import ContainerizationExtras +import Foundation import Testing @testable import ContainerResource @@ -82,4 +83,151 @@ struct NetworkConfigurationTest { } } + @Test func testGatewayWithinSubnet() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let gateway = try IPv4Address("10.0.0.254") + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Gateway: gateway, + plugin: "container-network-vmnet" + ) + } + + @Test func testGatewayOutsideSubnetRejected() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let gateway = try IPv4Address("10.0.1.1") + #expect { + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Gateway: gateway, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.contains("is not within IPv4 subnet")) + return true + } + } + + @Test func testIPRangeWithinSubnet() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let ipv4Range = try CIDRv4("10.0.0.128/28") + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Range: ipv4Range, + plugin: "container-network-vmnet" + ) + } + + @Test func testIPRangeOutsideSubnetRejected() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let ipv4Range = try CIDRv4("10.0.1.0/28") + #expect { + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Range: ipv4Range, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.contains("ip-range")) + return true + } + } + + @Test func testAuxAddressesWithinSubnet() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let aux: [String: IPv4Address] = [ + "db": try IPv4Address("10.0.0.10"), + "web": try IPv4Address("10.0.0.20"), + ] + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + auxAddresses: aux, + plugin: "container-network-vmnet" + ) + } + + @Test func testAuxAddressOutsideSubnetRejected() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let aux: [String: IPv4Address] = [ + "oops": try IPv4Address("172.16.0.1") + ] + #expect { + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + auxAddresses: aux, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.contains("aux-address")) + return true + } + } + + @Test func testNewFieldsRoundTripThroughCodable() throws { + let aux: [String: IPv4Address] = ["db": try IPv4Address("10.0.0.10")] + let original = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: try CIDRv4("10.0.0.0/24"), + ipv4Gateway: try IPv4Address("10.0.0.254"), + ipv4Range: try CIDRv4("10.0.0.128/28"), + auxAddresses: aux, + driverOpts: ["key": "value"], + attachable: true, + enableIPv6: true, + plugin: "container-network-vmnet" + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(NetworkConfiguration.self, from: data) + + #expect(decoded.ipv4Gateway?.description == "10.0.0.254") + #expect(decoded.ipv4Range?.description == original.ipv4Range?.description) + #expect(decoded.auxAddresses?["db"]?.description == "10.0.0.10") + #expect(decoded.driverOpts == ["key": "value"]) + #expect(decoded.attachable == true) + #expect(decoded.enableIPv6 == true) + } + + @Test func testLegacyConfigurationDecodesWithoutNewFields() throws { + // Pre-existing on-disk configurations were persisted without the new + // optional fields. Verify they decode cleanly with `nil` defaults. + let legacyJSON = """ + { + \"id\": \"legacy\", + \"mode\": \"nat\", + \"creationDate\": 0, + \"labels\": {}, + \"pluginInfo\": {\"plugin\": \"container-network-vmnet\"} + } + """ + let data = legacyJSON.data(using: .utf8)! + let decoded = try JSONDecoder().decode(NetworkConfiguration.self, from: data) + + #expect(decoded.ipv4Gateway == nil) + #expect(decoded.ipv4Range == nil) + #expect(decoded.auxAddresses == nil) + #expect(decoded.driverOpts == nil) + #expect(decoded.attachable == nil) + #expect(decoded.enableIPv6 == nil) + } + }