Files
container/Sources/Services/Network/Server/DefaultNetworkService.swift
T
J Logan b3eb70307a Add DNS aliases and subtree aliases for container network attachments (#2390)
- Closes #457.
- A container can now declare additional DNS names for
  a network attachment with a repeatable `alias` property
  on `--network`, including subtree aliases that match
  every name below a domain.
- With the default domain `test`, this container answers
  to `gateway.test` by its name, to `api.test` by the exact
  alias, and to `web.gateway.test`, `a.b.gateway.test`
  and every other name below `gateway.test` by the
  subtree alias. The motivating case is a gateway container
  in front of a local Kubernetes cluster that answers for
  `*.gateway.test`, with an ingress controller routing on
  the `Host` header.
- An alias is an exact name, or a name with a leading
  dot that matches every name below it. A name without
  a dot gets the default domain, as container names do.
  A subtree alias must be below a name the same
  attachment owns, and exact names are unique across
  all containers, so a container cannot claim another
  container's namespace. The rules for one attachment
  are checked when a container is created, again when
  a stored container starts, and again by the network
  helper when it registers the aliases. See
  `docs/architecture/dns.md` for the design, the
  invariants and the security considerations.
2026-10-14 10:11:00 -07:00

186 lines
7.4 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerNetworkClient
import ContainerResource
import ContainerXPC
import ContainerizationError
import ContainerizationExtras
import DNSServer
import Logging
public actor DefaultNetworkService: NetworkService {
private let network: any Network
private let log: Logger
private var allocator: AttachmentAllocator
private var macAddresses: [UInt32: MACAddress]
private var allocationsBySession: [XPCServerSession: [(hostname: String, index: UInt32)]]
/// Set up a network service for the specified network.
public init(
network: any Network,
log: Logger
) async throws {
guard let status = await network.status else {
throw ContainerizationError(.invalidState, message: "network \(network.id) must be running")
}
let subnet = status.ipv4Subnet
let size = 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.macAddresses = [:]
self.allocationsBySession = [:]
}
@Sendable
public func status() async throws -> NetworkStatus {
guard let status = await network.status else {
throw ContainerizationError(.invalidState, message: "network \(network.id) is not running")
}
return status
}
@Sendable
public func allocate(
hostname: String,
macAddress: MACAddress?,
aliases: [String],
session: XPCServerSession
) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
log.debug("enter", metadata: ["func": "\(#function)"])
defer { log.debug("exit", metadata: ["func": "\(#function)"]) }
guard let status = await network.status else {
throw ContainerizationError(.invalidState, message: "network \(network.id) must be running")
}
let macAddress = macAddress ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000)
// The aliases come over XPC, so check them against the same rules as the API server does,
// instead of trusting that the caller did.
let rules: [DNSMatchRule]
do {
let name = try? DNSName(AttachmentOptions.canonicalHostname(hostname))
rules = try AliasPolicy.validate(aliases: aliases, hostname: name, network: network.id)
} catch {
throw ContainerizationError(.invalidArgument, message: "invalid aliases for \(hostname): \(error)")
}
let index = try await allocator.allocate(hostname: hostname, aliases: rules)
let ipv6Address = try status.ipv6Subnet
.map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) }
let ip = IPv4Address(index)
let attachment = Attachment(
network: network.id,
hostname: hostname,
ipv4Address: try CIDRv4(ip, prefix: status.ipv4Subnet.prefix),
ipv4Gateway: status.ipv4Gateway,
ipv6Address: ipv6Address,
macAddress: macAddress,
variant: network.variant
)
log.info(
"allocated attachment",
metadata: [
"hostname": "\(hostname)",
"ipv4Address": "\(attachment.ipv4Address)",
"ipv4Gateway": "\(attachment.ipv4Gateway)",
"ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")",
"macAddress": "\(attachment.macAddress?.description ?? "unspecified")",
])
var additionalData: XPCMessage?
try network.withAdditionalData {
additionalData = $0
}
macAddresses[index] = macAddress
let isNewSession = allocationsBySession[session] == nil
allocationsBySession[session, default: []].append((hostname: hostname, index: index))
if isNewSession {
await session.onDisconnect { [weak self] in
await self?.releaseSession(session)
}
}
return (attachment: attachment, additionalData: additionalData)
}
private func releaseSession(_ session: XPCServerSession) async {
guard let allocations = allocationsBySession.removeValue(forKey: session) else {
return
}
for allocation in allocations {
_ = try? await allocator.deallocate(hostname: allocation.hostname)
macAddresses.removeValue(forKey: allocation.index)
}
log.info("released session", metadata: ["allocations": "\(allocations.count)"])
}
@Sendable
public func lookup(name: String) async throws -> NetworkMatch? {
log.debug("enter", metadata: ["func": "\(#function)"])
defer { log.debug("exit", metadata: ["func": "\(#function)"]) }
guard let status = await network.status else {
throw ContainerizationError(.invalidState, message: "network \(network.id) must be running")
}
guard let match = try await allocator.match(name: name) else {
return nil
}
// Invariant: hostname -> index if and only if index -> MAC address
guard let macAddress = macAddresses[match.index] else {
return nil
}
let attachment = try makeAttachment(hostname: match.hostname, index: match.index, macAddress: macAddress, status: status)
switch match.kind {
case .hostname:
return NetworkMatch(attachment: attachment, kind: .hostname)
case .alias:
return NetworkMatch(attachment: attachment, kind: .alias)
case .subtree(let baseLabelCount):
return NetworkMatch(attachment: attachment, kind: .subtree(baseLabelCount: baseLabelCount))
}
}
private func makeAttachment(hostname: String, index: UInt32, macAddress: MACAddress, status: NetworkStatus) throws -> Attachment {
let address = IPv4Address(index)
let subnet = status.ipv4Subnet
let ipv4Address = try CIDRv4(address, prefix: subnet.prefix)
let ipv6Address = try status.ipv6Subnet
.map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) }
let attachment = Attachment(
network: network.id,
hostname: hostname,
ipv4Address: ipv4Address,
ipv4Gateway: status.ipv4Gateway,
ipv6Address: ipv6Address,
macAddress: macAddress,
variant: network.variant
)
log.debug(
"lookup attachment",
metadata: [
"hostname": "\(hostname)",
"address": "\(address)",
])
return attachment
}
}