From b3eb70307a2cd1cd4ef334865593678aef1890a8 Mon Sep 17 00:00:00 2001 From: J Logan Date: Wed, 14 Oct 2026 10:11:00 -0700 Subject: [PATCH] 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. --- Package.swift | 8 + .../Network/AttachmentConfiguration.swift | 49 ++- Sources/DNSServer/AliasPolicy.swift | 111 ++++++ Sources/DNSServer/DNSMatchRule.swift | 78 +++++ .../ContainerAPIService/Client/Flags.swift | 2 +- .../ContainerAPIService/Client/Parser.swift | 28 +- .../ContainerAPIService/Client/Utility.swift | 39 ++- .../Containers/AttachmentNameValidator.swift | 125 +++++++ .../Server/Containers/ContainersService.swift | 22 +- .../Networks/NetworkMatchSelector.swift | 86 +++++ .../Server/Networks/NetworksService.swift | 30 +- .../Network/Client/NetworkClient.swift | 33 +- .../Services/Network/Client/NetworkKeys.swift | 4 + .../Network/Client/NetworkMatch.swift | 86 +++++ .../Network/Server/AttachmentAllocator.swift | 128 ++++++- .../Server/DefaultNetworkService.swift | 35 +- .../Network/Server/NetworkHarness.swift | 10 +- .../Network/Server/NetworkService.swift | 15 +- .../RuntimeLinux/Server/RuntimeService.swift | 1 + .../ContainerAPIClientTests/ParserTest.swift | 51 +++ .../UtilityTests.swift | 114 +++++++ .../AttachmentNameValidatorTests.swift | 322 ++++++++++++++++++ .../NetworkMatchSelectorTests.swift | 235 +++++++++++++ .../AttachmentAllocatorTest.swift | 205 +++++++++++ .../NetworkAliasProtocolTests.swift | 81 +++++ .../AttachmentOptionsTests.swift | 80 +++++ Tests/DNSServerTests/AliasPolicyTests.swift | 113 ++++++ Tests/DNSServerTests/DNSMatchRuleTests.swift | 168 +++++++++ .../Network/TestCLINetworkAlias.swift | 185 ++++++++++ docs/architecture/dns.md | 97 ++++++ docs/command-reference.md | 7 +- docs/networking.md | 45 +++ 32 files changed, 2530 insertions(+), 63 deletions(-) create mode 100644 Sources/DNSServer/AliasPolicy.swift create mode 100644 Sources/DNSServer/DNSMatchRule.swift create mode 100644 Sources/Services/ContainerAPIService/Server/Containers/AttachmentNameValidator.swift create mode 100644 Sources/Services/ContainerAPIService/Server/Networks/NetworkMatchSelector.swift create mode 100644 Sources/Services/Network/Client/NetworkMatch.swift create mode 100644 Tests/ContainerAPIServiceTests/AttachmentNameValidatorTests.swift create mode 100644 Tests/ContainerAPIServiceTests/NetworkMatchSelectorTests.swift create mode 100644 Tests/ContainerNetworkServerTests/NetworkAliasProtocolTests.swift create mode 100644 Tests/ContainerResourceTests/AttachmentOptionsTests.swift create mode 100644 Tests/DNSServerTests/AliasPolicyTests.swift create mode 100644 Tests/DNSServerTests/DNSMatchRuleTests.swift create mode 100644 Tests/IntegrationTests/Network/TestCLINetworkAlias.swift create mode 100644 docs/architecture/dns.md diff --git a/Package.swift b/Package.swift index 966ab32e..c803af78 100644 --- a/Package.swift +++ b/Package.swift @@ -258,6 +258,7 @@ let package = Package( "ContainerRuntimeClient", "ContainerVersion", "ContainerXPC", + "DNSServer", "TerminalProgress", ], path: "Sources/Services/ContainerAPIService/Server" @@ -266,8 +267,10 @@ let package = Package( name: "ContainerAPIServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), "ContainerAPIService", "ContainerAPIClient", + "ContainerNetworkClient", "ContainerPersistence", "ContainerPlugin", "ContainerResource", @@ -392,6 +395,7 @@ let package = Package( "ContainerNetworkClient", "ContainerResource", "ContainerXPC", + "DNSServer", ], path: "Sources/Services/Network/Server" ), @@ -399,7 +403,11 @@ let package = Package( name: "ContainerNetworkServerTests", dependencies: [ .product(name: "ContainerizationExtras", package: "containerization"), + "ContainerNetworkClient", "ContainerNetworkServer", + "ContainerResource", + "ContainerXPC", + "DNSServer", ] ), .target( diff --git a/Sources/ContainerResource/Network/AttachmentConfiguration.swift b/Sources/ContainerResource/Network/AttachmentConfiguration.swift index d7e3dff6..362dd2ec 100644 --- a/Sources/ContainerResource/Network/AttachmentConfiguration.swift +++ b/Sources/ContainerResource/Network/AttachmentConfiguration.swift @@ -41,9 +41,56 @@ public struct AttachmentOptions: Codable, Sendable { /// The MTU for the network interface. public let mtu: UInt32? - public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil) { + /// Additional DNS names that resolve to the attachment. + /// + /// Each value is the normalized string form of a `DNSMatchRule`: an exact name such as + /// `api.example.test`, or a name with a leading dot such as `.example.test` that matches every + /// name below it. The list is empty when the attachment has no aliases. + public let aliases: [String] + + public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil, aliases: [String] = []) { self.hostname = hostname self.macAddress = macAddress self.mtu = mtu + self.aliases = aliases + } + + private enum CodingKeys: String, CodingKey { + case hostname + case macAddress + case mtu + case aliases + } + + // Configurations stored before aliases existed have no `aliases` key, and decode with none. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.hostname = try container.decode(String.self, forKey: .hostname) + self.macAddress = try container.decodeIfPresent(MACAddress.self, forKey: .macAddress) + self.mtu = try container.decodeIfPresent(UInt32.self, forKey: .mtu) + self.aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? [] + } + + // The key is left out when there are no aliases, so a configuration without them is stored as it was before. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hostname, forKey: .hostname) + try container.encodeIfPresent(macAddress, forKey: .macAddress) + try container.encodeIfPresent(mtu, forKey: .mtu) + if !aliases.isEmpty { + try container.encode(aliases, forKey: .aliases) + } + } +} + +extension AttachmentOptions { + /// Returns the form of a hostname used to compare hostnames and to look them up. + /// + /// DNS names are case-insensitive, and a trailing dot only marks a name as fully qualified, + /// so `Foo.Test.` and `foo.test` are the same name. The result is lowercase with any + /// trailing dot removed. + public static func canonicalHostname(_ hostname: String) -> String { + let name = hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname + return name.lowercased() } } diff --git a/Sources/DNSServer/AliasPolicy.swift b/Sources/DNSServer/AliasPolicy.swift new file mode 100644 index 00000000..f68b3a15 --- /dev/null +++ b/Sources/DNSServer/AliasPolicy.swift @@ -0,0 +1,111 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// The rules that the aliases of one network attachment must follow. +/// +/// The API server checks these rules when a container is created, again when a stored container is +/// started, and the network helper checks them again when it registers the aliases. Each of those +/// reads the aliases from somewhere that can be wrong or malicious, so none of them takes the aliases +/// on faith. Rules that involve other containers, such as a name that is already taken, are not here. +public enum AliasPolicy { + /// The most aliases that a single attachment may declare. + public static let maximumAliasesPerAttachment = 32 + + /// The fewest labels that an exact alias, or the base of a subtree alias, may have. + /// + /// A name with one label is not under any domain, so the system resolver never sends queries for + /// it to the container DNS server, and an alias like that would never resolve. For a subtree it + /// also keeps an alias from capturing a whole top-level domain. + public static let minimumLabels = 2 + + /// A rule that an alias breaks. The description says which alias and why. + public struct Violation: Error, CustomStringConvertible, Equatable { + public let description: String + + init(_ description: String) { + self.description = description + } + } + + /// Parses the aliases of one attachment and checks that they follow the rules. + /// + /// - There are at most `maximumAliasesPerAttachment` of them, and each one is a valid rule. + /// - No alias is listed twice, and an exact alias is not the hostname of the attachment. + /// - An exact alias has at least `minimumLabels` labels. + /// - A subtree alias has a base with at least `minimumLabels` labels, and the base is the hostname + /// of the attachment or one of its exact aliases, so an attachment cannot claim the names below + /// a name that it does not own. + /// + /// - Parameters: + /// - aliases: The aliases in their string form. + /// - hostname: The hostname of the attachment, or nil if it is not a valid DNS name. + /// - network: The name of the network, which is used in error descriptions. + /// - Returns: The aliases as rules, in the order they were given. + /// - Throws: `Violation` for the first rule that is broken. + public static func validate(aliases: [String], hostname: DNSName?, network: String) throws -> [DNSMatchRule] { + guard aliases.count <= Self.maximumAliasesPerAttachment else { + throw Violation("network \(network) has \(aliases.count) aliases, but at most \(Self.maximumAliasesPerAttachment) are allowed") + } + + var rules = [(alias: String, rule: DNSMatchRule)]() + for alias in aliases { + do { + rules.append((alias, try DNSMatchRule(alias))) + } catch { + throw Violation("invalid alias \"\(alias)\" for network \(network): \(error)") + } + } + + // The names this attachment owns, which are the roots a subtree alias may use. + var owned = Set() + if let hostname { + owned.insert(hostname) + } + for case (_, .exact(let name)) in rules { + owned.insert(name) + } + + var seen = Set() + for (alias, rule) in rules { + guard seen.insert(rule).inserted else { + throw Violation("alias \"\(alias)\" is listed more than once for network \(network)") + } + switch rule { + case .exact(let name): + guard name.labels.count >= Self.minimumLabels else { + throw Violation( + "alias \"\(alias)\" must be a fully qualified name with at least \(Self.minimumLabels) labels, such as \"api.example.test\". Configure a default DNS domain to have the domain added to a name that has none" + ) + } + guard name != hostname else { + throw Violation("alias \"\(alias)\" is already a hostname of the container") + } + case .subtree(let base): + guard base.labels.count >= Self.minimumLabels else { + throw Violation( + "alias \"\(alias)\" must have at least \(Self.minimumLabels) labels after the leading dot. Configure a default DNS domain to have the domain added to a name that has none" + ) + } + guard owned.contains(base) else { + throw Violation( + "alias \"\(alias)\" must be below the hostname or an exact alias of network \(network), but \"\(base.pqdn)\" is neither" + ) + } + } + } + return rules.map { $0.rule } + } +} diff --git a/Sources/DNSServer/DNSMatchRule.swift b/Sources/DNSServer/DNSMatchRule.swift new file mode 100644 index 00000000..0373b135 --- /dev/null +++ b/Sources/DNSServer/DNSMatchRule.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// A rule that matches DNS names, as written in an alias. +/// +/// The string form is the name itself for an exact rule (`api.example.test`) and the name with a +/// leading dot for a subtree rule (`.example.test`). A subtree rule matches every name below its +/// base, at one or more labels, and does not match the base itself, which follows the wildcard +/// semantics of RFC 4592. Matching is by label, so `evilexample.test` is not below `example.test`. +public enum DNSMatchRule: Sendable, Hashable, CustomStringConvertible { + /// Matches exactly one name. + case exact(DNSName) + /// Matches every name below the base name. + case subtree(DNSName) + + /// Creates a rule from its string form. + /// + /// The name is validated and lowercased by `DNSName`, and a trailing dot is accepted. + /// + /// - Throws: `DNSBindError.invalidName` if the name is empty or is only a dot, contains `*`, + /// or is not a valid DNS name. + public init(_ string: String) throws { + guard !string.contains("*") else { + throw DNSBindError.invalidName( + "\"\(string)\" contains \"*\"; use a leading dot to match every name below a domain, for example \".example.test\"" + ) + } + + let isSubtree = string.hasPrefix(".") + let name = try DNSName(isSubtree ? String(string.dropFirst()) : string) + guard !name.labels.isEmpty else { + throw DNSBindError.invalidName("\"\(string)\" does not name a host") + } + self = isSubtree ? .subtree(name) : .exact(name) + } + + /// The normalized string form: lowercase, without a trailing dot, and with a leading dot for a subtree. + public var description: String { + switch self { + case .exact(let name): + return name.pqdn + case .subtree(let base): + return "." + base.pqdn + } + } + + /// Returns whether the rule matches the name. + public func matches(_ name: DNSName) -> Bool { + switch self { + case .exact(let exact): + return name == exact + case .subtree(let base): + return name.labels.count > base.labels.count && name.labels.suffix(base.labels.count) == base.labels + } + } + + /// Returns the names a subtree rule would need as its base to match `name`. + /// + /// These are the proper suffixes of `name` that have at least one label, longest first. For + /// `a.b.example.test` they are `b.example.test`, `example.test` and `test`. A lookup that + /// wants the most specific subtree tries them in order. + public static func subtreeBases(of name: DNSName) -> [DNSName] { + (1..[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE])") + @Option(name: [.customLong("network")], help: "Attach the container to a network (format: [,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE][,alias=NAME]...)") public var networks: [String] = [] @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 591d1827..9a442fe5 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -21,6 +21,7 @@ import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS +import DNSServer import Foundation import SystemPackage @@ -882,17 +883,24 @@ public struct Parser { public let name: String public let macAddress: String? public let mtu: UInt32? + /// Additional DNS names for the attachment, each in the normalized form of a `DNSMatchRule`. + public let aliases: [String] - public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil) { + public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil, aliases: [String] = []) { self.name = name self.macAddress = macAddress self.mtu = mtu + self.aliases = aliases } } /// Parse network attachment with optional properties - /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] - /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500" + /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE][,alias=NAME]... + /// + /// An alias is an exact name such as `api.example.test`, or a name with a leading dot such as + /// `.example.test` that matches every name below it. The `alias` property can be repeated. + /// + /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500,alias=api.example.test,alias=.example.test" public static func network(_ networkSpec: String) throws -> ParsedNetwork { guard !networkSpec.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") @@ -911,6 +919,7 @@ public struct Parser { var macAddress: String? var mtu: UInt32? + var aliases = [String]() // Parse properties if any for part in parts.dropFirst() { @@ -945,15 +954,24 @@ public struct Parser { ) } mtu = mtuValue + case "alias": + do { + aliases.append(try DNSMatchRule(value).description) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "invalid alias '\(value)': \(error)" + ) + } default: throw ContainerizationError( .invalidArgument, - message: "unknown network property '\(key)'. Available properties: mac, mtu" + message: "unknown network property '\(key)'. Available properties: mac, mtu, alias" ) } } - return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu) + return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu, aliases: aliases) } // MARK: DNS diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index f6329c35..bc503ef3 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -20,6 +20,7 @@ import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI +import DNSServer import Foundation import Logging import TerminalProgress @@ -308,15 +309,16 @@ public struct Utility { return try networks.enumerated().map { item in let macAddress = try item.element.macAddress.map { try MACAddress($0) } let mtu = item.element.mtu ?? 1280 + let aliases = try qualifyAliases(item.element.aliases, dnsDomain: dnsDomain) guard item.offset == 0 else { return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu, aliases: aliases) ) } return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu, aliases: aliases) ) } } @@ -328,6 +330,39 @@ public struct Utility { return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] } + /// Appends the default DNS domain to the aliases that have no domain of their own. + /// + /// This follows the rule that makes `--name foo` the hostname `foo.test.` when the default domain + /// is `test`: a name without a dot gets the domain, and a name with a dot is taken to be + /// qualified already. For a name with a leading dot, which matches every name below it, the rule + /// applies to the name after the dot. Without a default domain the aliases are left as they are, + /// and the server rejects any that is not fully qualified. + /// + /// - Parameters: + /// - aliases: The aliases of an attachment, in their normalized form. + /// - dnsDomain: The default DNS domain from the system configuration, if there is one. + /// - Returns: The qualified aliases. + static func qualifyAliases(_ aliases: [String], dnsDomain: String?) throws -> [String] { + guard let dnsDomain else { + return aliases + } + return try aliases.map { alias in + let isSubtree = alias.hasPrefix(".") + let name = isSubtree ? String(alias.dropFirst()) : alias + guard !name.contains(".") else { + return alias + } + do { + return try DNSMatchRule("\(isSubtree ? "." : "")\(name).\(dnsDomain)").description + } catch { + throw ContainerizationError( + .invalidArgument, + message: "cannot qualify alias \"\(alias)\" with the default DNS domain \"\(dnsDomain)\": \(error)" + ) + } + } + } + private static func getKernel(management: Flags.Management) async throws -> Kernel { // For the image itself we'll take the user input and try with it as we can do userspace // emulation for x86, but for the kernel we need it to match the hosts architecture. diff --git a/Sources/Services/ContainerAPIService/Server/Containers/AttachmentNameValidator.swift b/Sources/Services/ContainerAPIService/Server/Containers/AttachmentNameValidator.swift new file mode 100644 index 00000000..20172b39 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Containers/AttachmentNameValidator.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import ContainerizationError +import DNSServer + +/// Validates the DNS names that the network attachments of a new container claim. +/// +/// A container claims the hostname of each attachment and the aliases it declares. Exact names must be +/// unique across all containers, whether they are running or stopped. A subtree alias such as +/// `.example.test` must be rooted at a name that the same attachment owns, so a container cannot claim +/// the part of the namespace below another container's name. +enum AttachmentNameValidator { + /// The most aliases that a single attachment may declare. + static let maximumAliasesPerAttachment = AliasPolicy.maximumAliasesPerAttachment + + /// The fewest labels that an exact alias, or the base of a subtree alias, may have. + static let minimumAliasLabels = AliasPolicy.minimumLabels + + /// Validates the attachments of a new container against the attachments of all existing containers. + /// + /// Names are compared case-insensitively and without a trailing dot. + /// + /// - Parameters: + /// - networks: The attachments of the container being created. + /// - existing: The attachments of every existing container. + /// - Throws: `ContainerizationError` with code `.exists` if a hostname or alias is already claimed, + /// and with code `.invalidArgument` if an alias is malformed or breaks a rule. + static func validate(networks: [AttachmentConfiguration], existing: [AttachmentConfiguration]) throws { + var claimed = Set() + for attachment in existing { + claimed.insert(AttachmentOptions.canonicalHostname(attachment.options.hostname)) + for alias in attachment.options.aliases { + claimed.insert(Self.canonicalAlias(alias)) + } + } + + var conflictingHostnames = [String]() + for attachment in networks where claimed.contains(AttachmentOptions.canonicalHostname(attachment.options.hostname)) { + conflictingHostnames.append(attachment.options.hostname) + } + guard conflictingHostnames.isEmpty else { + throw ContainerizationError(.exists, message: "hostname(s) already exist: \(conflictingHostnames)") + } + + let ownHostnames = Set(networks.map { AttachmentOptions.canonicalHostname($0.options.hostname) }) + var ownAliases = Set() + var conflictingAliases = [String]() + for attachment in networks { + let aliases = attachment.options.aliases + guard !aliases.isEmpty else { + continue + } + + // The rules that depend only on this attachment are shared with the network helper. + let rules: [DNSMatchRule] + do { + let hostname = try? DNSName(AttachmentOptions.canonicalHostname(attachment.options.hostname)) + rules = try AliasPolicy.validate(aliases: aliases, hostname: hostname, network: attachment.network) + } catch let violation as AliasPolicy.Violation { + throw ContainerizationError(.invalidArgument, message: violation.description) + } + + // The rules that involve the other attachments and the other containers. + for (alias, rule) in zip(aliases, rules) { + guard case .exact = rule else { + continue + } + let name = rule.description + guard !ownHostnames.contains(name) else { + throw ContainerizationError(.invalidArgument, message: "alias \"\(alias)\" is already a hostname of the container") + } + guard ownAliases.insert(name).inserted else { + throw ContainerizationError(.invalidArgument, message: "alias \"\(alias)\" is declared on more than one network") + } + if claimed.contains(name) { + conflictingAliases.append(alias) + } + } + } + guard conflictingAliases.isEmpty else { + throw ContainerizationError(.exists, message: "alias(es) already exist: \(conflictingAliases)") + } + } + + /// Validates the attachments of a container that was read back from storage. + /// + /// The stored configuration is not trusted to be what `validate` accepted when the container was + /// created, since the file can be damaged or edited. This checks every rule that does not depend on + /// other containers, so a stored container with bad names fails to start with an explanation. + /// + /// - Throws: `ContainerizationError` with code `.invalidState` if the stored names break a rule. + static func validateStored(id: String, networks: [AttachmentConfiguration]) throws { + do { + try validate(networks: networks, existing: []) + } catch let error as ContainerizationError { + throw ContainerizationError( + .invalidState, + message: "the stored configuration of container \(id) has invalid network names: \(error)" + ) + } + } + + /// The normalized form of an alias that was stored with an existing container. + private static func canonicalAlias(_ alias: String) -> String { + guard let rule = try? DNSMatchRule(alias) else { + return AttachmentOptions.canonicalHostname(alias) + } + return rule.description + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 06a798cf..42e22895 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -290,26 +290,11 @@ public actor ContainersService { ) } - var allHostnames = Set() + var existingNetworks = [AttachmentConfiguration]() for container in await self.containers.values { - for attachmentConfiguration in container.snapshot.configuration.networks { - allHostnames.insert(attachmentConfiguration.options.hostname) - } - } - - var conflictingHostnames = [String]() - for attachmentConfiguration in configuration.networks { - if allHostnames.contains(attachmentConfiguration.options.hostname) { - conflictingHostnames.append(attachmentConfiguration.options.hostname) - } - } - - guard conflictingHostnames.isEmpty else { - throw ContainerizationError( - .exists, - message: "hostname(s) already exist: \(conflictingHostnames)" - ) + existingNetworks.append(contentsOf: container.snapshot.configuration.networks) } + try AttachmentNameValidator.validate(networks: configuration.networks, existing: existingNetworks) guard self.runtimePlugins.first(where: { $0.name == configuration.runtimeHandler }) != nil else { throw ContainerizationError( @@ -419,6 +404,7 @@ public actor ContainersService { let path = self.containerRoot.appendingPathComponent(id) let (config, _) = try Self.getContainerConfiguration(at: path) + try AttachmentNameValidator.validateStored(id: id, networks: config.networks) var networkBootstrapInfos = [NetworkBootstrapInfo]() for n in config.networks { diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworkMatchSelector.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworkMatchSelector.swift new file mode 100644 index 00000000..7812ca9a --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworkMatchSelector.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Chooses the best answer to a name from the matches that individual networks report. +/// +/// A hostname or an exact alias is decisive. Both are unique across all networks, so no other network +/// can have a better match, and the search can stop. A subtree alias is not decisive: another network +/// may have a hostname, an exact alias, or a subtree alias with a longer base, which is the most +/// specific. Two different subtrees that match a name and have the same number of labels in the base +/// must have the same base, and a base belongs to one container, so there is no tie to break. +struct NetworkMatchSelector { + /// The best match offered so far. + private(set) var best: NetworkMatch? + + /// Considers a match reported by a network. + /// + /// - Returns: `true` if the match is decisive and the search can stop. + @discardableResult + mutating func offer(_ match: NetworkMatch) -> Bool { + switch match.kind { + case .hostname, .alias: + best = match + return true + case .subtree(let baseLabelCount): + switch best?.kind { + case .hostname?, .alias?: + // A decisive match was already found, and nothing beats it. + return true + case .subtree(let bestBaseLabelCount)?: + if bestBaseLabelCount >= baseLabelCount { + return false + } + case nil: + break + } + best = match + return false + } + } +} + +extension NetworkMatchSelector { + /// Looks the name up on each source, and returns the best match. + /// + /// The sources are asked in order, and the search stops at the first decisive match. A source that + /// throws is skipped, and reported to `onFailure`. A network whose helper is starting up or shutting + /// down cannot answer, and it should not make the answers of the other networks fail. + /// + /// - Parameters: + /// - sources: The networks to ask, each with a name for reporting and a function that does the lookup. + /// - onFailure: Called with the name of a source and the error when a source throws. + static func select( + from sources: [(name: String, lookup: @Sendable () async throws -> NetworkMatch?)], + onFailure: @Sendable (String, any Error) -> Void + ) async -> NetworkMatch? { + var selector = NetworkMatchSelector() + for source in sources { + let match: NetworkMatch? + do { + match = try await source.lookup() + } catch { + onFailure(source.name, error) + continue + } + if let match, selector.offer(match) { + break + } + } + return selector.best + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 7fe35fae..2ce1dc01 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -301,17 +301,31 @@ public actor NetworksService { /// Perform a hostname lookup on all networks. /// + /// The name can be the hostname of an attachment, an alias of it, or a name below a subtree alias. + /// A hostname or an exact alias is returned as soon as a network reports it. Otherwise every network + /// is asked, and the subtree alias with the longest base wins. + /// + /// The network helpers are called without holding `stateLock`. Network create and delete hold it + /// for as long as a helper takes to start or stop, and a DNS answer must not wait for them. A + /// network that cannot answer, because its helper is starting or stopping, is skipped. + /// /// - Parameter hostname: A canonical DNS hostname with a trailing dot (e.g. `"example.com."`). public func lookup(hostname: String) async throws -> Attachment? { - try await self.stateLock.withLock { _ in - for state in await self.serviceStates.values { - guard let allocation = try await state.client.lookup(hostname: hostname) else { - continue - } - return allocation - } - return nil + let sources = self.serviceStates.map { id, entry in + let client = entry.client + return (name: id, lookup: { @Sendable in try await client.lookup(name: hostname) }) } + let log = self.log + let match = await NetworkMatchSelector.select(from: sources) { network, error in + log.warning( + "network lookup failed", + metadata: [ + "network": "\(network)", + "name": "\(hostname)", + "error": "\(error)", + ]) + } + return match?.attachment } public func plugin(for id: String) throws -> String { diff --git a/Sources/Services/Network/Client/NetworkClient.swift b/Sources/Services/Network/Client/NetworkClient.swift index 97598a78..1270eda2 100644 --- a/Sources/Services/Network/Client/NetworkClient.swift +++ b/Sources/Services/Network/Client/NetworkClient.swift @@ -67,9 +67,13 @@ extension NetworkClient { /// Use `connect()` to obtain a session, then pass it here. The session /// must remain open for the lifetime of the allocation; closing it /// releases the allocation on the network helper automatically. + /// + /// - Parameter aliases: Additional names that resolve to the attachment, in their normalized + /// string form. public func allocate( hostname: String, macAddress: MACAddress? = nil, + aliases: [String] = [], on session: XPCClientSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) @@ -77,22 +81,35 @@ extension NetworkClient { if let macAddress = macAddress { request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description) } + if !aliases.isEmpty { + try request.setAliases(aliases) + } let response = try await session.send(request) let attachment = try response.attachment() let additionalData = response.additionalData() return (attachment, additionalData) } - public func lookup(hostname: String) async throws -> Attachment? { + /// Look up a name on the network. + /// + /// The name can be the hostname of an attachment, an alias of it, or a name below a subtree alias. + /// + /// - Returns: The attachment that the name resolves to and how the name matched, or nil if the + /// name does not resolve on this network. + public func lookup(name: String) async throws -> NetworkMatch? { let request = XPCMessage(route: NetworkRoutes.lookup.rawValue) - request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + request.set(key: NetworkKeys.name.rawValue, value: name) let client = createClient() let response = try await client.send(request) - return try response.dataNoCopy(key: NetworkKeys.attachment.rawValue).map { - try JSONDecoder().decode(Attachment.self, from: $0) + guard let data = response.dataNoCopy(key: NetworkKeys.attachment.rawValue) else { + return nil } + return NetworkMatch( + attachment: try JSONDecoder().decode(Attachment.self, from: data), + kind: try response.matchKind() + ) } private func createClient() -> XPCClient { @@ -124,6 +141,14 @@ extension XPCMessage { return hostname } + public func name() throws -> String { + let name = self.string(key: NetworkKeys.name.rawValue) + guard let name else { + throw ContainerizationError(.invalidArgument, message: "no name data in message") + } + return name + } + public func status() throws -> NetworkStatus { let data = self.dataNoCopy(key: NetworkKeys.status.rawValue) guard let data else { diff --git a/Sources/Services/Network/Client/NetworkKeys.swift b/Sources/Services/Network/Client/NetworkKeys.swift index b2ec7418..9693e3cb 100644 --- a/Sources/Services/Network/Client/NetworkKeys.swift +++ b/Sources/Services/Network/Client/NetworkKeys.swift @@ -16,9 +16,13 @@ public enum NetworkKeys: String { case additionalData + case aliases case attachment + case baseLabelCount case hostname case macAddress + case matchKind + case name case network case status } diff --git a/Sources/Services/Network/Client/NetworkMatch.swift b/Sources/Services/Network/Client/NetworkMatch.swift new file mode 100644 index 00000000..1c72d90f --- /dev/null +++ b/Sources/Services/Network/Client/NetworkMatch.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import ContainerXPC +import ContainerizationError +import Foundation + +/// The kind of name that matched a lookup on a network. +public enum NetworkMatchKind: Sendable, Equatable { + /// The hostname of an attachment. + case hostname + /// An exact alias of an attachment. + case alias + /// A subtree alias of an attachment, with the number of labels in its base. + case subtree(baseLabelCount: Int) +} + +/// The attachment that a name resolved to on a network, and how the name matched. +public struct NetworkMatch: Sendable { + public let attachment: Attachment + public let kind: NetworkMatchKind + + public init(attachment: Attachment, kind: NetworkMatchKind) { + self.attachment = attachment + self.kind = kind + } +} + +extension XPCMessage { + /// Sets the aliases of an attachment, in their normalized string form. + public func setAliases(_ aliases: [String]) throws { + self.set(key: NetworkKeys.aliases.rawValue, value: try JSONEncoder().encode(aliases)) + } + + /// The aliases in the message, which are empty if the message has none. + public func aliases() throws -> [String] { + guard let data = self.dataNoCopy(key: NetworkKeys.aliases.rawValue) else { + return [] + } + return try JSONDecoder().decode([String].self, from: data) + } + + /// Sets how a name matched. + public func setMatchKind(_ kind: NetworkMatchKind) { + switch kind { + case .hostname: + self.set(key: NetworkKeys.matchKind.rawValue, value: "hostname") + case .alias: + self.set(key: NetworkKeys.matchKind.rawValue, value: "alias") + case .subtree(let baseLabelCount): + self.set(key: NetworkKeys.matchKind.rawValue, value: "subtree") + self.set(key: NetworkKeys.baseLabelCount.rawValue, value: Int64(baseLabelCount)) + } + } + + /// How a name matched. + public func matchKind() throws -> NetworkMatchKind { + guard let kind = self.string(key: NetworkKeys.matchKind.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "no match kind in message") + } + switch kind { + case "hostname": + return .hostname + case "alias": + return .alias + case "subtree": + return .subtree(baseLabelCount: Int(self.int64(key: NetworkKeys.baseLabelCount.rawValue))) + default: + throw ContainerizationError(.invalidArgument, message: "unknown match kind \"\(kind)\" in message") + } + } +} diff --git a/Sources/Services/Network/Server/AttachmentAllocator.swift b/Sources/Services/Network/Server/AttachmentAllocator.swift index b7d3aeeb..6ce25166 100644 --- a/Sources/Services/Network/Server/AttachmentAllocator.swift +++ b/Sources/Services/Network/Server/AttachmentAllocator.swift @@ -14,12 +14,39 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import ContainerizationError import ContainerizationExtras +import DNSServer actor AttachmentAllocator { + /// The kind of name that matched a query. + enum MatchKind: Sendable, Equatable { + /// The hostname of an attachment. + case hostname + /// An exact alias of an attachment. + case alias + /// A subtree alias of an attachment, with the number of labels in its base. + case subtree(baseLabelCount: Int) + } + + /// The attachment that a name resolves to. + struct Match: Sendable, Equatable { + /// The allocator index of the attachment. + let index: UInt32 + /// The canonical hostname of the attachment, which is not necessarily the name that matched. + let hostname: String + let kind: MatchKind + } + private let allocator: any AddressAllocator private var hostnames: [String: UInt32] = [:] + // Exact aliases, as the canonical alias mapped to the canonical hostname that owns it. + private var exactAliases: [String: String] = [:] + // Subtree aliases, as the base name mapped to the canonical hostname that owns it. + private var subtreeAliases: [DNSName: String] = [:] + // The aliases registered for each canonical hostname, so that they can be removed with it. + private var aliasesByHostname: [String: [DNSMatchRule]] = [:] init(lower: UInt32, size: Int) throws { allocator = try UInt32.rotatingAllocator( @@ -28,32 +55,113 @@ actor AttachmentAllocator { ) } - /// Allocate a network address for a host. - func allocate(hostname: String) async throws -> UInt32 { - // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists - if let index = hostnames[hostname] { - return index + /// Allocate a network address for a host, and register aliases that resolve to it. + /// + /// The allocation fails without changing any state if an alias is already in use by another host + /// on this network, either as a hostname or as an alias. + /// + /// - Throws: `ContainerizationError` with code `.exists` if a name is already in use. + @discardableResult + func allocate(hostname: String, aliases: [DNSMatchRule] = []) async throws -> UInt32 { + let hostname = AttachmentOptions.canonicalHostname(hostname) + + if hostnames[hostname] == nil, let owner = exactAliases[hostname], owner != hostname { + throw ContainerizationError(.exists, message: "hostname \(hostname) is already an alias of \(owner)") } - let index = try allocator.allocate() - hostnames[hostname] = index + var newAliases = [DNSMatchRule]() + for rule in aliases where !newAliases.contains(rule) { + switch rule { + case .exact: + let name = rule.description + if hostnames[name] != nil || name == hostname { + throw ContainerizationError(.exists, message: "alias \(name) is already a hostname") + } + if let owner = exactAliases[name] { + if owner == hostname { + continue + } + throw ContainerizationError(.exists, message: "alias \(name) is already an alias of \(owner)") + } + case .subtree(let base): + if let owner = subtreeAliases[base] { + if owner == hostname { + continue + } + throw ContainerizationError(.exists, message: "alias \(rule) is already an alias of \(owner)") + } + } + newAliases.append(rule) + } + + // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists + let index: UInt32 + if let existing = hostnames[hostname] { + index = existing + } else { + index = try allocator.allocate() + hostnames[hostname] = index + } + + for rule in newAliases { + switch rule { + case .exact: + exactAliases[rule.description] = hostname + case .subtree(let base): + subtreeAliases[base] = hostname + } + } + aliasesByHostname[hostname, default: []].append(contentsOf: newAliases) return index } - /// Free an allocated network address by hostname. + /// Free an allocated network address by hostname, along with the aliases registered for it. @discardableResult func deallocate(hostname: String) async throws -> UInt32? { + let hostname = AttachmentOptions.canonicalHostname(hostname) guard let index = hostnames.removeValue(forKey: hostname) else { return nil } + for rule in aliasesByHostname.removeValue(forKey: hostname) ?? [] { + switch rule { + case .exact: + exactAliases.removeValue(forKey: rule.description) + case .subtree(let base): + subtreeAliases.removeValue(forKey: base) + } + } + try allocator.release(index) return index } - /// Retrieve the allocator index for a hostname. + /// Retrieve the allocator index for a hostname. Aliases are not considered. func lookup(hostname: String) async throws -> UInt32? { - hostnames[hostname] + hostnames[AttachmentOptions.canonicalHostname(hostname)] + } + + /// Find the attachment that a name resolves to. + /// + /// A hostname wins over an exact alias, and an exact alias wins over a subtree alias. Among subtree + /// aliases the one with the longest base wins. + func match(name: String) async throws -> Match? { + let name = AttachmentOptions.canonicalHostname(name) + if let index = hostnames[name] { + return Match(index: index, hostname: name, kind: .hostname) + } + if let owner = exactAliases[name], let index = hostnames[owner] { + return Match(index: index, hostname: owner, kind: .alias) + } + guard let dnsName = try? DNSName(name) else { + return nil + } + for base in DNSMatchRule.subtreeBases(of: dnsName) { + if let owner = subtreeAliases[base], let index = hostnames[owner] { + return Match(index: index, hostname: owner, kind: .subtree(baseLabelCount: base.labels.count)) + } + } + return nil } } diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 70d17d39..340c70e9 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -14,10 +14,12 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerNetworkClient import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras +import DNSServer import Logging public actor DefaultNetworkService: NetworkService { @@ -57,6 +59,7 @@ public actor DefaultNetworkService: NetworkService { public func allocate( hostname: String, macAddress: MACAddress?, + aliases: [String], session: XPCServerSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { log.debug("enter", metadata: ["func": "\(#function)"]) @@ -67,7 +70,16 @@ public actor DefaultNetworkService: NetworkService { } let macAddress = macAddress ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) - let index = try await allocator.allocate(hostname: hostname) + // 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) @@ -119,7 +131,7 @@ public actor DefaultNetworkService: NetworkService { } @Sendable - public func lookup(hostname: String) async throws -> Attachment? { + public func lookup(name: String) async throws -> NetworkMatch? { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } @@ -127,15 +139,26 @@ public actor DefaultNetworkService: NetworkService { throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") } - // Invariant: hostname -> index if and only if index -> MAC address - let index = try await allocator.lookup(hostname: hostname) - guard let index else { + guard let match = try await allocator.match(name: name) else { return nil } - guard let macAddress = macAddresses[index] else { + // 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) diff --git a/Sources/Services/Network/Server/NetworkHarness.swift b/Sources/Services/Network/Server/NetworkHarness.swift index 6c9a96a1..b05e648d 100644 --- a/Sources/Services/Network/Server/NetworkHarness.swift +++ b/Sources/Services/Network/Server/NetworkHarness.swift @@ -42,9 +42,12 @@ public actor NetworkHarness: Sendable { try message.string(key: NetworkKeys.macAddress.rawValue) .map { try MACAddress($0) } + let aliases = try message.aliases() + let (attachment:attachment, additionalData:additionalData) = try await service.allocate( hostname: hostname, macAddress: macAddress, + aliases: aliases, session: session ) @@ -59,13 +62,14 @@ public actor NetworkHarness: Sendable { @Sendable public func lookup(_ message: XPCMessage) async throws -> XPCMessage { - let hostname = try message.hostname() + let name = try message.name() let reply = message.reply() - guard let attachment = try await service.lookup(hostname: hostname) else { + guard let match = try await service.lookup(name: name) else { return reply } - try reply.setAttachment(attachment) + try reply.setAttachment(match.attachment) + reply.setMatchKind(match.kind) return reply } } diff --git a/Sources/Services/Network/Server/NetworkService.swift b/Sources/Services/Network/Server/NetworkService.swift index 6e9f2b78..77f90a7f 100644 --- a/Sources/Services/Network/Server/NetworkService.swift +++ b/Sources/Services/Network/Server/NetworkService.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerNetworkClient import ContainerResource import ContainerXPC import ContainerizationExtras @@ -23,13 +24,21 @@ public protocol NetworkService: Sendable { /// Gets the properties of the realized network. func status() async throws -> NetworkStatus - /// Register a hostname and allocate associated addresses. + /// Register a hostname and aliases that resolve to it, and allocate associated addresses. + /// + /// Each alias is in its normalized string form: an exact name, or a name with a leading dot that + /// matches every name below it. func allocate( hostname: String, macAddress: MACAddress?, + aliases: [String], session: XPCServerSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) - /// Return the attachment for a hostname if it is registered with the network. - func lookup(hostname: String) async throws -> Attachment? + /// Look up a name on the network. + /// + /// The name can be the hostname of an attachment, an alias of it, or a name below a subtree alias. + /// Returns the attachment that the name resolves to and how the name matched, or nil if the name + /// does not resolve on this network. + func lookup(name: String) async throws -> NetworkMatch? } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index cbb36b25..8ecc932d 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -193,6 +193,7 @@ public actor RuntimeService { var (attachment, additionalData) = try await client.allocate( hostname: attachmentConfig.options.hostname, macAddress: attachmentConfig.options.macAddress, + aliases: attachmentConfig.options.aliases, on: session ) if let mtu = attachmentConfig.options.mtu { diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index bf295791..3a0df13f 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -944,6 +944,57 @@ struct ParserTest { } } + @Test + func testParseNetworkWithoutAliasesHasNone() throws { + let result = try Parser.network("backend") + #expect(result.aliases.isEmpty) + } + + @Test + func testParseNetworkWithAliases() throws { + let result = try Parser.network("backend,alias=api.example.test,alias=.example.test") + #expect(result.name == "backend") + #expect(result.aliases == ["api.example.test", ".example.test"]) + } + + @Test + func testParseNetworkAliasesAreNormalized() throws { + let result = try Parser.network("backend,alias=API.Example.TEST.,alias=.Example.Test.") + #expect(result.aliases == ["api.example.test", ".example.test"]) + } + + @Test + func testParseNetworkAliasesWithOtherProperties() throws { + let result = try Parser.network("backend,mac=02:42:ac:11:00:02,alias=a.test,mtu=1500,alias=.a.test") + #expect(result.macAddress == "02:42:ac:11:00:02") + #expect(result.mtu == 1500) + #expect(result.aliases == ["a.test", ".a.test"]) + } + + @Test(arguments: ["backend,alias=", "backend,alias=.", "backend,alias=a..test", "backend,alias=exa mple.test"]) + func testParseNetworkInvalidAlias(spec: String) throws { + #expect { + _ = try Parser.network(spec) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid alias") + } + } + + @Test + func testParseNetworkAliasWithAsteriskSuggestsLeadingDot() throws { + #expect { + _ = try Parser.network("backend,alias=*.example.test") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid alias") && error.description.contains("leading dot") + } + } + @Test func testParseNetworkInvalidPropertyFormat() throws { #expect { diff --git a/Tests/ContainerAPIClientTests/UtilityTests.swift b/Tests/ContainerAPIClientTests/UtilityTests.swift index 3a9b3a49..8f3f4478 100644 --- a/Tests/ContainerAPIClientTests/UtilityTests.swift +++ b/Tests/ContainerAPIClientTests/UtilityTests.swift @@ -131,4 +131,118 @@ struct UtilityTests { #expect(ports[1].proto == .udp) #expect(ports[1].count == 100) } + + @Test("Aliases from the network specification land on the attachment they were declared for") + func testAttachmentConfigurationsCarryAliases() throws { + let networks = [ + Parser.ParsedNetwork(name: "default", aliases: ["api.test", ".foo.test"]), + Parser.ParsedNetwork(name: "backend"), + ] + + let attachments = try Utility.getAttachmentConfigurations( + containerId: "foo", + builtinNetworkId: "default", + networks: networks, + dnsDomain: "test" + ) + + #expect(attachments.count == 2) + #expect(attachments[0].network == "default") + #expect(attachments[0].options.hostname == "foo.test.") + #expect(attachments[0].options.aliases == ["api.test", ".foo.test"]) + #expect(attachments[1].network == "backend") + #expect(attachments[1].options.hostname == "foo") + #expect(attachments[1].options.aliases.isEmpty) + } + + @Test("Aliases are carried to a later attachment") + func testAttachmentConfigurationsCarryAliasesOnLaterNetworks() throws { + let networks = [ + Parser.ParsedNetwork(name: "default"), + Parser.ParsedNetwork(name: "backend", aliases: ["db.internal"]), + ] + + let attachments = try Utility.getAttachmentConfigurations( + containerId: "foo", + builtinNetworkId: "default", + networks: networks, + dnsDomain: "test" + ) + + #expect(attachments[0].options.aliases.isEmpty) + #expect(attachments[1].options.aliases == ["db.internal"]) + } + + @Test("A container with no network specification has no aliases") + func testDefaultAttachmentHasNoAliases() throws { + let attachments = try Utility.getAttachmentConfigurations( + containerId: "foo", + builtinNetworkId: "default", + networks: [], + dnsDomain: "test" + ) + + #expect(attachments.count == 1) + #expect(attachments[0].options.aliases.isEmpty) + } + + @Test("Aliases without a domain get the default domain") + func testQualifyAliasesAddsTheDefaultDomain() throws { + let aliases = try Utility.qualifyAliases(["api", ".gateway"], dnsDomain: "test") + + #expect(aliases == ["api.test", ".gateway.test"]) + } + + @Test("Aliases with a dot are taken to be qualified") + func testQualifyAliasesLeavesQualifiedNamesAlone() throws { + let aliases = try Utility.qualifyAliases(["web.internal", ".a.b", "api.test"], dnsDomain: "test") + + #expect(aliases == ["web.internal", ".a.b", "api.test"]) + } + + @Test("Aliases are left as they are without a default domain") + func testQualifyAliasesWithoutADefaultDomain() throws { + let aliases = try Utility.qualifyAliases(["api", ".gateway", "web.internal"], dnsDomain: nil) + + #expect(aliases == ["api", ".gateway", "web.internal"]) + } + + @Test("No aliases stay empty") + func testQualifyAliasesWithNoAliases() throws { + #expect(try Utility.qualifyAliases([], dnsDomain: "test").isEmpty) + #expect(try Utility.qualifyAliases([], dnsDomain: nil).isEmpty) + } + + @Test("The default domain is normalized when it is added") + func testQualifyAliasesNormalizesTheDomain() throws { + let aliases = try Utility.qualifyAliases(["api", ".gateway"], dnsDomain: "Example.TEST.") + + #expect(aliases == ["api.example.test", ".gateway.example.test"]) + } + + @Test("A default domain that is not a valid name is reported") + func testQualifyAliasesWithAnInvalidDomain() { + #expect(throws: (any Error).self) { + try Utility.qualifyAliases(["api"], dnsDomain: "not a domain") + } + } + + @Test("Aliases in a network specification are qualified when the attachments are built") + func testAttachmentConfigurationsQualifyAliases() throws { + let networks = [ + Parser.ParsedNetwork(name: "default", aliases: ["api", ".foo"]), + Parser.ParsedNetwork(name: "backend", aliases: ["db"]), + ] + + let attachments = try Utility.getAttachmentConfigurations( + containerId: "foo", + builtinNetworkId: "default", + networks: networks, + dnsDomain: "test" + ) + + #expect(attachments[0].options.hostname == "foo.test.") + #expect(attachments[0].options.aliases == ["api.test", ".foo.test"]) + #expect(attachments[1].options.aliases == ["db.test"]) + } } diff --git a/Tests/ContainerAPIServiceTests/AttachmentNameValidatorTests.swift b/Tests/ContainerAPIServiceTests/AttachmentNameValidatorTests.swift new file mode 100644 index 00000000..256e104f --- /dev/null +++ b/Tests/ContainerAPIServiceTests/AttachmentNameValidatorTests.swift @@ -0,0 +1,322 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import ContainerizationError +import Testing + +@testable import ContainerAPIService + +struct AttachmentNameValidatorTests { + private func attachment(_ hostname: String, network: String = "default", aliases: [String] = []) -> AttachmentConfiguration { + AttachmentConfiguration(network: network, options: AttachmentOptions(hostname: hostname, aliases: aliases)) + } + + private func validate(_ networks: [AttachmentConfiguration], existing: [AttachmentConfiguration] = []) throws { + try AttachmentNameValidator.validate(networks: networks, existing: existing) + } + + private func expectError(_ code: ContainerizationError.Code, containing text: String? = nil, _ body: () throws -> Void) { + do { + try body() + Issue.record("expected a \(code) error") + } catch let error as ContainerizationError { + #expect(error.code == code, "unexpected error: \(error)") + if let text { + #expect("\(error)".contains(text), "unexpected error: \(error)") + } + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + // MARK: - Hostnames + + @Test("Attachments without aliases and without conflicts are accepted") + func noAliases() throws { + try validate([attachment("foo.test.")], existing: [attachment("bar.test.")]) + } + + @Test("A hostname that an existing container uses is rejected") + func hostnameConflict() { + expectError(.exists, containing: "hostname(s) already exist") { + try validate([attachment("foo.test.")], existing: [attachment("foo.test.")]) + } + } + + @Test("Hostnames are compared without regard to case or a trailing dot") + func hostnameConflictNormalized() { + expectError(.exists) { + try validate([attachment("Foo.Test.")], existing: [attachment("foo.test")]) + } + } + + @Test("A hostname that an existing container has as an exact alias is rejected") + func hostnameConflictsWithAlias() { + expectError(.exists, containing: "hostname(s) already exist") { + try validate( + [attachment("api.test.")], + existing: [attachment("foo.test.", aliases: ["api.test"])] + ) + } + } + + @Test("A hostname below an existing subtree alias is accepted") + func hostnameBelowSubtree() throws { + try validate( + [attachment("web.foo.test.")], + existing: [attachment("foo.test.", aliases: [".foo.test"])] + ) + } + + // MARK: - Exact aliases + + @Test("An exact alias that nothing else claims is accepted") + func exactAlias() throws { + try validate( + [attachment("foo.test.", aliases: ["api.test", "Other.Test."])], + existing: [attachment("bar.test.")] + ) + } + + @Test("An exact alias that matches an existing hostname is rejected") + func aliasConflictsWithHostname() { + expectError(.exists, containing: "alias(es) already exist") { + try validate( + [attachment("foo.test.", aliases: ["bar.test"])], + existing: [attachment("bar.test.")] + ) + } + } + + @Test("An exact alias that matches an existing alias is rejected") + func aliasConflictsWithAlias() { + expectError(.exists, containing: "alias(es) already exist") { + try validate( + [attachment("foo.test.", aliases: ["API.test."])], + existing: [attachment("bar.test.", aliases: ["api.test"])] + ) + } + } + + @Test("An exact alias that matches the container's own hostname is rejected") + func aliasMatchesOwnHostname() { + expectError(.invalidArgument, containing: "already a hostname") { + try validate([attachment("foo.test.", aliases: ["foo.test"])]) + } + } + + @Test("An exact alias that matches the hostname of another attachment of the container is rejected") + func aliasMatchesOtherOwnHostname() { + expectError(.invalidArgument, containing: "already a hostname") { + try validate([ + attachment("foo.test.", aliases: ["bar.test"]), + attachment("bar.test", network: "other"), + ]) + } + } + + @Test("The same exact alias on two networks is rejected") + func aliasOnTwoNetworks() { + expectError(.invalidArgument, containing: "more than one network") { + try validate([ + attachment("foo.test.", aliases: ["api.test"]), + attachment("foo", network: "other", aliases: ["api.test"]), + ]) + } + } + + @Test("An exact alias with a single label is rejected", arguments: ["api", "API.", "localhost"]) + func unqualifiedExactAlias(alias: String) { + expectError(.invalidArgument, containing: "fully qualified") { + try validate([attachment("foo.test.", aliases: [alias])]) + } + } + + @Test("An exact alias with two labels is accepted") + func qualifiedExactAlias() throws { + try validate([attachment("foo.test.", aliases: ["api.test"])]) + } + + @Test("An alias listed twice is rejected") + func duplicateAlias() { + expectError(.invalidArgument, containing: "more than once") { + try validate([attachment("foo.test.", aliases: ["api.test", "API.test."])]) + } + } + + // MARK: - Malformed aliases and limits + + @Test("Malformed aliases are rejected", arguments: ["", ".", "..", "exa mple.test", "-bad.test", "a..b"]) + func malformedAlias(alias: String) { + expectError(.invalidArgument, containing: "invalid alias") { + try validate([attachment("foo.test.", aliases: [alias])]) + } + } + + @Test("An asterisk is rejected with a hint about the leading dot") + func asterisk() { + expectError(.invalidArgument, containing: "leading dot") { + try validate([attachment("foo.test.", aliases: ["*.foo.test"])]) + } + } + + @Test("The alias limit is enforced per attachment") + func aliasLimit() throws { + let limit = AttachmentNameValidator.maximumAliasesPerAttachment + let atLimit = (0.. NetworkMatch { + NetworkMatch( + attachment: NetworkAttachment( + network: network, + hostname: "foo.test", + ipv4Address: try CIDRv4("192.168.64.2/24"), + ipv4Gateway: try IPv4Address("192.168.64.1"), + ipv6Address: nil, + macAddress: nil + ), + kind: kind + ) + } + + @Test func testNothingOfferedSelectsNothing() { + #expect(NetworkMatchSelector().best == nil) + } + + @Test func testHostnameIsDecisive() throws { + var selector = NetworkMatchSelector() + + #expect(selector.offer(try match("a", .hostname))) + #expect(selector.best?.attachment.network == "a") + } + + @Test func testExactAliasIsDecisive() throws { + var selector = NetworkMatchSelector() + + #expect(selector.offer(try match("a", .alias))) + #expect(selector.best?.attachment.network == "a") + } + + @Test func testSubtreeIsNotDecisive() throws { + var selector = NetworkMatchSelector() + + #expect(!selector.offer(try match("a", .subtree(baseLabelCount: 2)))) + #expect(selector.best?.attachment.network == "a") + } + + @Test func testLongerSubtreeReplacesShorter() throws { + var selector = NetworkMatchSelector() + + selector.offer(try match("short", .subtree(baseLabelCount: 2))) + selector.offer(try match("long", .subtree(baseLabelCount: 3))) + + #expect(selector.best?.attachment.network == "long") + } + + @Test func testShorterSubtreeDoesNotReplaceLonger() throws { + var selector = NetworkMatchSelector() + + selector.offer(try match("long", .subtree(baseLabelCount: 3))) + selector.offer(try match("short", .subtree(baseLabelCount: 2))) + + #expect(selector.best?.attachment.network == "long") + } + + @Test func testSubtreeOfEqualLengthKeepsTheFirst() throws { + var selector = NetworkMatchSelector() + + selector.offer(try match("first", .subtree(baseLabelCount: 2))) + selector.offer(try match("second", .subtree(baseLabelCount: 2))) + + #expect(selector.best?.attachment.network == "first") + } + + @Test func testHostnameAfterSubtreeWins() throws { + var selector = NetworkMatchSelector() + + #expect(!selector.offer(try match("subtree", .subtree(baseLabelCount: 4)))) + #expect(selector.offer(try match("exact", .hostname))) + + #expect(selector.best?.attachment.network == "exact") + } + + @Test func testExactAliasAfterSubtreeWins() throws { + var selector = NetworkMatchSelector() + + selector.offer(try match("subtree", .subtree(baseLabelCount: 4))) + selector.offer(try match("alias", .alias)) + + #expect(selector.best?.attachment.network == "alias") + } + + @Test func testSubtreeSelectionDoesNotDependOnTheOrderNetworksAreAsked() throws { + let matches = [ + try match("one", .subtree(baseLabelCount: 2)), + try match("two", .subtree(baseLabelCount: 4)), + try match("three", .subtree(baseLabelCount: 3)), + ] + let orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] + + for order in orders { + var selector = NetworkMatchSelector() + for index in order { + selector.offer(matches[index]) + } + #expect(selector.best?.attachment.network == "two", "order \(order)") + } + } + + @Test func testSubtreeAfterADecisiveMatchDoesNotReplaceIt() throws { + var selector = NetworkMatchSelector() + + selector.offer(try match("exact", .hostname)) + #expect(selector.offer(try match("subtree", .subtree(baseLabelCount: 5)))) + + #expect(selector.best?.attachment.network == "exact") + } + + // MARK: - Selecting from sources + + private struct SourceError: Error {} + + /// Records which sources were asked. + private final class Asked: @unchecked Sendable { + var names = [String]() + } + + private func source( + _ name: String, + asked: Asked, + _ result: @escaping @Sendable () throws -> NetworkMatch? + ) -> (name: String, lookup: @Sendable () async throws -> NetworkMatch?) { + ( + name: name, + lookup: { + asked.names.append(name) + return try result() + } + ) + } + + @Test func testSelectReturnsTheBestSubtreeFromAllSources() async throws { + let asked = Asked() + let short = try match("short", .subtree(baseLabelCount: 2)) + let long = try match("long", .subtree(baseLabelCount: 4)) + + let selected = await NetworkMatchSelector.select( + from: [ + source("a", asked: asked) { short }, + source("b", asked: asked) { nil }, + source("c", asked: asked) { long }, + ], + onFailure: { _, _ in } + ) + + #expect(selected?.attachment.network == "long") + #expect(asked.names == ["a", "b", "c"]) + } + + @Test func testSelectStopsAtTheFirstDecisiveMatch() async throws { + let asked = Asked() + let subtree = try match("subtree", .subtree(baseLabelCount: 3)) + let exact = try match("exact", .alias) + + let selected = await NetworkMatchSelector.select( + from: [ + source("a", asked: asked) { subtree }, + source("b", asked: asked) { exact }, + source("c", asked: asked) { throw SourceError() }, + ], + onFailure: { _, _ in } + ) + + #expect(selected?.attachment.network == "exact") + #expect(asked.names == ["a", "b"]) + } + + @Test func testSelectSkipsASourceThatFailsAndReportsIt() async throws { + let asked = Asked() + let exact = try match("exact", .hostname) + let failures = Asked() + + let selected = await NetworkMatchSelector.select( + from: [ + source("broken", asked: asked) { throw SourceError() }, + source("good", asked: asked) { exact }, + ], + onFailure: { name, _ in failures.names.append(name) } + ) + + #expect(selected?.attachment.network == "exact") + #expect(failures.names == ["broken"]) + } + + @Test func testSelectReturnsNothingWhenEverySourceFailsOrHasNoMatch() async throws { + let asked = Asked() + + let selected = await NetworkMatchSelector.select( + from: [ + source("broken", asked: asked) { throw SourceError() }, + source("empty", asked: asked) { nil }, + ], + onFailure: { _, _ in } + ) + + #expect(selected == nil) + #expect(asked.names == ["broken", "empty"]) + } + + @Test func testSelectWithNoSourcesReturnsNothing() async { + let selected = await NetworkMatchSelector.select(from: [], onFailure: { _, _ in }) + + #expect(selected == nil) + } +} diff --git a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift index 86ea3eff..154dec25 100644 --- a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift +++ b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import DNSServer import Testing @testable import ContainerNetworkServer @@ -155,4 +156,208 @@ struct AttachmentAllocatorTest { let secondDeallocate = try await allocator.deallocate(hostname: "test-host") #expect(secondDeallocate == nil) } + + @Test func testLookupIgnoresCase() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "Foo.Test.") + + #expect(try await allocator.lookup(hostname: "foo.test.") == allocatedAddress) + #expect(try await allocator.lookup(hostname: "FOO.TEST.") == allocatedAddress) + } + + @Test func testLookupIgnoresTrailingDot() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "foo.test") + + #expect(try await allocator.lookup(hostname: "foo.test.") == allocatedAddress) + } + + @Test func testAllocateSameHostnameDifferentCaseReusesAddress() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "foo.test.") + let address2 = try await allocator.allocate(hostname: "FOO.test.") + + #expect(address1 == address2) + } + + @Test func testDeallocateIgnoresCase() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "Foo.Test.") + let deallocatedAddress = try await allocator.deallocate(hostname: "foo.test.") + + #expect(deallocatedAddress == allocatedAddress) + #expect(try await allocator.lookup(hostname: "foo.test.") == nil) + } + + // MARK: - Aliases + + private func rules(_ strings: String...) throws -> [DNSMatchRule] { + try strings.map { try DNSMatchRule($0) } + } + + @Test func testMatchResolvesHostnameAndAliasesToTheSameAddress() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let index = try await allocator.allocate(hostname: "gw.test.", aliases: rules("api.test", ".gw.test")) + + #expect(try await allocator.match(name: "gw.test.") == AttachmentAllocator.Match(index: index, hostname: "gw.test", kind: .hostname)) + #expect(try await allocator.match(name: "api.test.") == AttachmentAllocator.Match(index: index, hostname: "gw.test", kind: .alias)) + #expect(try await allocator.match(name: "web.gw.test.") == AttachmentAllocator.Match(index: index, hostname: "gw.test", kind: .subtree(baseLabelCount: 2))) + #expect(try await allocator.match(name: "a.b.gw.test.") == AttachmentAllocator.Match(index: index, hostname: "gw.test", kind: .subtree(baseLabelCount: 2))) + } + + @Test func testMatchIgnoresCaseAndTrailingDot() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let index = try await allocator.allocate(hostname: "gw.test.", aliases: rules("API.test", ".GW.test")) + + #expect(try await allocator.match(name: "Api.Test")?.index == index) + #expect(try await allocator.match(name: "WEB.gw.test.")?.index == index) + } + + @Test func testSubtreeDoesNotMatchItsBaseOrUnrelatedNames() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + try await allocator.allocate(hostname: "host.test.", aliases: rules(".example.test")) + + #expect(try await allocator.match(name: "example.test.") == nil) + #expect(try await allocator.match(name: "evilexample.test.") == nil) + #expect(try await allocator.match(name: "web.example.com.") == nil) + #expect(try await allocator.match(name: "test.") == nil) + #expect(try await allocator.match(name: "") == nil) + } + + @Test func testHostnameBeatsAliasAndAliasBeatsSubtree() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let gateway = try await allocator.allocate(hostname: "gw.test.", aliases: rules(".gw.test")) + let other = try await allocator.allocate(hostname: "other.test.", aliases: rules("web.gw.test")) + let api = try await allocator.allocate(hostname: "api.gw.test.") + + #expect(try await allocator.match(name: "api.gw.test.")?.index == api) + #expect(try await allocator.match(name: "api.gw.test.")?.kind == .hostname) + #expect(try await allocator.match(name: "web.gw.test.")?.index == other) + #expect(try await allocator.match(name: "web.gw.test.")?.kind == .alias) + #expect(try await allocator.match(name: "x.gw.test.")?.index == gateway) + #expect(try await allocator.match(name: "x.gw.test.")?.kind == .subtree(baseLabelCount: 2)) + } + + @Test func testLongestSubtreeWins() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let outer = try await allocator.allocate(hostname: "example.test.", aliases: rules(".example.test")) + let inner = try await allocator.allocate(hostname: "sub.example.test.", aliases: rules(".sub.example.test")) + + #expect(try await allocator.match(name: "a.sub.example.test.") == AttachmentAllocator.Match(index: inner, hostname: "sub.example.test", kind: .subtree(baseLabelCount: 3))) + #expect(try await allocator.match(name: "a.b.sub.example.test.")?.index == inner) + #expect(try await allocator.match(name: "a.example.test.") == AttachmentAllocator.Match(index: outer, hostname: "example.test", kind: .subtree(baseLabelCount: 2))) + // The inner base is a hostname, so it resolves as one and not as part of the outer subtree. + #expect(try await allocator.match(name: "sub.example.test.")?.kind == .hostname) + } + + @Test func testLookupDoesNotConsiderAliases() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + try await allocator.allocate(hostname: "gw.test.", aliases: rules("api.test", ".gw.test")) + + #expect(try await allocator.lookup(hostname: "api.test.") == nil) + #expect(try await allocator.lookup(hostname: "web.gw.test.") == nil) + } + + @Test func testAliasAlreadyUsedByAnotherHostIsRejectedWithoutChangingState() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + try await allocator.allocate(hostname: "one.test.", aliases: rules("api.test")) + + await #expect(throws: (any Error).self) { + try await allocator.allocate(hostname: "two.test.", aliases: rules("fresh.test", "api.test")) + } + + #expect(try await allocator.lookup(hostname: "two.test.") == nil) + #expect(try await allocator.match(name: "fresh.test.") == nil) + #expect(try await allocator.match(name: "api.test.")?.hostname == "one.test") + } + + @Test func testSubtreeAlreadyUsedByAnotherHostIsRejected() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + try await allocator.allocate(hostname: "one.test.", aliases: rules(".gw.test")) + + await #expect(throws: (any Error).self) { + try await allocator.allocate(hostname: "two.test.", aliases: rules(".gw.test")) + } + #expect(try await allocator.lookup(hostname: "two.test.") == nil) + } + + @Test func testAliasMatchingAHostnameIsRejected() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + try await allocator.allocate(hostname: "one.test.") + + await #expect(throws: (any Error).self) { + try await allocator.allocate(hostname: "two.test.", aliases: rules("one.test")) + } + await #expect(throws: (any Error).self) { + try await allocator.allocate(hostname: "three.test.", aliases: rules("three.test")) + } + } + + @Test func testHostnameMatchingAnAliasIsRejected() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + try await allocator.allocate(hostname: "one.test.", aliases: rules("api.test")) + + await #expect(throws: (any Error).self) { + try await allocator.allocate(hostname: "api.test.") + } + #expect(try await allocator.match(name: "api.test.")?.hostname == "one.test") + } + + @Test func testAllocatingAgainWithTheSameAliasesIsIdempotent() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let index1 = try await allocator.allocate(hostname: "gw.test.", aliases: rules("api.test", ".gw.test")) + let index2 = try await allocator.allocate(hostname: "gw.test.", aliases: rules("api.test", ".gw.test")) + #expect(index1 == index2) + + try await allocator.deallocate(hostname: "gw.test.") + #expect(try await allocator.match(name: "api.test.") == nil) + #expect(try await allocator.match(name: "web.gw.test.") == nil) + } + + @Test func testAllocatingAgainAddsNewAliases() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let index = try await allocator.allocate(hostname: "gw.test.", aliases: rules("one.test")) + _ = try await allocator.allocate(hostname: "gw.test.", aliases: rules("one.test", "two.test")) + + #expect(try await allocator.match(name: "one.test.")?.index == index) + #expect(try await allocator.match(name: "two.test.")?.index == index) + } + + @Test func testDeallocateRemovesAliasesAndFreesThemForReuse() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + try await allocator.allocate(hostname: "one.test.", aliases: rules("api.test", ".one.test")) + + try await allocator.deallocate(hostname: "one.test.") + + #expect(try await allocator.match(name: "api.test.") == nil) + #expect(try await allocator.match(name: "web.one.test.") == nil) + + let index = try await allocator.allocate(hostname: "two.test.", aliases: rules("api.test", ".one.test")) + #expect(try await allocator.match(name: "api.test.")?.index == index) + #expect(try await allocator.match(name: "web.one.test.")?.index == index) + } + + @Test func testFailedAllocationDoesNotRegisterAliases() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 1) + let first = try await allocator.allocate(hostname: "one.test.", aliases: rules("one-alias.test")) + + await #expect(throws: (any Error).self) { + try await allocator.allocate(hostname: "two.test.", aliases: rules("two-alias.test")) + } + + #expect(try await allocator.match(name: "two-alias.test.") == nil) + #expect(try await allocator.match(name: "one-alias.test.")?.index == first) + } } diff --git a/Tests/ContainerNetworkServerTests/NetworkAliasProtocolTests.swift b/Tests/ContainerNetworkServerTests/NetworkAliasProtocolTests.swift new file mode 100644 index 00000000..87a5f122 --- /dev/null +++ b/Tests/ContainerNetworkServerTests/NetworkAliasProtocolTests.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Testing + +@testable import ContainerNetworkClient +@testable import ContainerNetworkServer + +struct NetworkAliasProtocolTests { + private func message() -> XPCMessage { + XPCMessage(route: "test") + } + + // MARK: - Aliases in messages + + @Test func testAliasesRoundTrip() throws { + let message = message() + try message.setAliases(["api.example.test", ".example.test"]) + + #expect(try message.aliases() == ["api.example.test", ".example.test"]) + } + + @Test func testMessageWithoutAliasesHasNone() throws { + #expect(try message().aliases().isEmpty) + } + + // MARK: - Match kind in messages + + @Test func testMatchKindRoundTrip() throws { + let kinds: [NetworkMatchKind] = [.hostname, .alias, .subtree(baseLabelCount: 3)] + for kind in kinds { + let message = message() + message.setMatchKind(kind) + #expect(try message.matchKind() == kind) + } + } + + @Test func testMessageWithoutMatchKindIsRejected() { + #expect(throws: (any Error).self) { + try message().matchKind() + } + } + + @Test func testUnknownMatchKindIsRejected() throws { + let message = message() + message.set(key: NetworkKeys.matchKind.rawValue, value: "wildcard") + + #expect(throws: (any Error).self) { + try message.matchKind() + } + } + + // MARK: - Name in messages + + @Test func testNameRoundTrip() throws { + let message = message() + message.set(key: NetworkKeys.name.rawValue, value: "web.example.test.") + + #expect(try message.name() == "web.example.test.") + } + + @Test func testMessageWithoutNameIsRejected() { + #expect(throws: (any Error).self) { + try message().name() + } + } +} diff --git a/Tests/ContainerResourceTests/AttachmentOptionsTests.swift b/Tests/ContainerResourceTests/AttachmentOptionsTests.swift new file mode 100644 index 00000000..e118ed6d --- /dev/null +++ b/Tests/ContainerResourceTests/AttachmentOptionsTests.swift @@ -0,0 +1,80 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct AttachmentOptionsTests { + @Test( + "canonicalHostname lowercases and removes a trailing dot", + arguments: [ + ("foo.test.", "foo.test"), + ("foo.test", "foo.test"), + ("Foo.Test.", "foo.test"), + ("FOO", "foo"), + ("foo", "foo"), + ("", ""), + ]) + func canonicalHostname(input: String, expected: String) { + #expect(AttachmentOptions.canonicalHostname(input) == expected) + } + + @Test("Options encoded before aliases existed decode with no aliases") + func decodeWithoutAliases() throws { + let json = #"{"hostname":"foo.test.","mtu":1280}"# + let options = try JSONDecoder().decode(AttachmentOptions.self, from: Data(json.utf8)) + #expect(options.hostname == "foo.test.") + #expect(options.mtu == 1280) + #expect(options.aliases.isEmpty) + } + + @Test("Options without aliases do not encode an aliases key") + func encodeWithoutAliases() throws { + let data = try JSONEncoder().encode(AttachmentOptions(hostname: "foo.test.")) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("aliases")) + } + + @Test("Aliases round trip through JSON") + func aliasesRoundTrip() throws { + let aliases = ["api.example.test", ".example.test"] + let original = AttachmentOptions(hostname: "foo.test.", mtu: 1500, aliases: aliases) + let decoded = try JSONDecoder().decode(AttachmentOptions.self, from: JSONEncoder().encode(original)) + #expect(decoded.hostname == original.hostname) + #expect(decoded.mtu == original.mtu) + #expect(decoded.aliases == aliases) + } + + @Test("Aliases round trip inside an attachment configuration") + func aliasesRoundTripInConfiguration() throws { + let original = AttachmentConfiguration( + network: "default", + options: AttachmentOptions(hostname: "foo.test.", aliases: [".foo.test"]) + ) + let decoded = try JSONDecoder().decode(AttachmentConfiguration.self, from: JSONEncoder().encode(original)) + #expect(decoded.network == "default") + #expect(decoded.options.aliases == [".foo.test"]) + } + + @Test("An empty aliases list in stored options decodes as no aliases") + func decodeEmptyAliases() throws { + let json = #"{"hostname":"foo.test.","aliases":[]}"# + let options = try JSONDecoder().decode(AttachmentOptions.self, from: Data(json.utf8)) + #expect(options.aliases.isEmpty) + } +} diff --git a/Tests/DNSServerTests/AliasPolicyTests.swift b/Tests/DNSServerTests/AliasPolicyTests.swift new file mode 100644 index 00000000..94b8a44e --- /dev/null +++ b/Tests/DNSServerTests/AliasPolicyTests.swift @@ -0,0 +1,113 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import DNSServer + +@Suite("AliasPolicy") +struct AliasPolicyTests { + // The names used in the tests are valid, so a failure here is a bug in the test itself. + private func name(_ string: String) -> DNSName { + try! DNSName(string) + } + + private func validate(_ aliases: [String], hostname: String? = "gateway.test") throws -> [DNSMatchRule] { + try AliasPolicy.validate(aliases: aliases, hostname: hostname.map { name($0) }, network: "default") + } + + private func expectViolation(_ text: String, _ aliases: [String], hostname: String? = "gateway.test") { + do { + _ = try validate(aliases, hostname: hostname) + Issue.record("expected \(aliases) to be rejected") + } catch let violation as AliasPolicy.Violation { + #expect(violation.description.contains(text), "\(violation)") + } catch { + Issue.record("unexpected error \(error)") + } + } + + @Test("No aliases is valid") + func noAliases() throws { + #expect(try validate([]).isEmpty) + } + + @Test("Valid aliases are returned as rules in order") + func validAliases() throws { + let rules = try validate(["api.test", ".gateway.test", "Web.Example.Test."]) + #expect(rules == [.exact(name("api.test")), .subtree(name("gateway.test")), .exact(name("web.example.test"))]) + } + + @Test("The limit is applied to the number of aliases") + func aliasLimit() throws { + let atLimit = (0.. DNSName { + try! DNSName(string) + } + + // MARK: - Parsing + + @Test("Parses an exact rule") + func parseExact() throws { + let rule = try DNSMatchRule("api.example.test") + #expect(rule == .exact(name("api.example.test"))) + } + + @Test("Parses a subtree rule from a leading dot") + func parseSubtree() throws { + let rule = try DNSMatchRule(".example.test") + #expect(rule == .subtree(name("example.test"))) + } + + @Test("Accepts a trailing dot") + func parseTrailingDot() throws { + #expect(try DNSMatchRule("api.example.test.") == .exact(name("api.example.test"))) + #expect(try DNSMatchRule(".example.test.") == .subtree(name("example.test"))) + } + + @Test("Lowercases the name") + func parseLowercases() throws { + #expect(try DNSMatchRule("API.Example.TEST") == .exact(name("api.example.test"))) + #expect(try DNSMatchRule(".Example.TEST") == .subtree(name("example.test"))) + } + + @Test( + "The string form is normalized and parses back to the same rule", + arguments: [ + ("api.example.test", "api.example.test"), + ("API.Example.test.", "api.example.test"), + (".example.test", ".example.test"), + (".Example.TEST.", ".example.test"), + ("localhost", "localhost"), + ]) + func descriptionRoundTrip(input: String, expected: String) throws { + let rule = try DNSMatchRule(input) + #expect(rule.description == expected) + #expect(try DNSMatchRule(rule.description) == rule) + } + + @Test( + "Rejects invalid rules", + arguments: [ + "", + ".", + "..", + "..example.test", + "example..test", + ".example..test", + "-example.test", + "example-.test", + " example.test", + "example.test ", + "exa mple.test", + String(repeating: "a", count: 64) + ".test", + String(repeating: "abcdefgh.", count: 30) + "test", + ]) + func parseInvalid(input: String) { + #expect(throws: DNSBindError.self) { + try DNSMatchRule(input) + } + } + + @Test( + "Rejects an asterisk and points to the leading-dot form", + arguments: ["*.example.test", "*", "api.*.test", "example.*", "*example.test"]) + func parseAsterisk(input: String) { + do { + _ = try DNSMatchRule(input) + Issue.record("expected \(input) to be rejected") + } catch { + #expect("\(error)".contains("leading dot")) + } + } + + // MARK: - Matching + + @Test("An exact rule matches only that name") + func exactMatches() throws { + let rule = try DNSMatchRule("api.example.test") + #expect(rule.matches(name("api.example.test"))) + #expect(rule.matches(name("API.example.test."))) + #expect(!rule.matches(name("example.test"))) + #expect(!rule.matches(name("web.api.example.test"))) + #expect(!rule.matches(name("api.example.com"))) + } + + @Test("A subtree rule matches names below the base at one or more labels") + func subtreeMatchesBelowBase() throws { + let rule = try DNSMatchRule(".example.test") + #expect(rule.matches(name("web.example.test"))) + #expect(rule.matches(name("a.b.example.test"))) + #expect(rule.matches(name("WEB.Example.Test."))) + } + + @Test("A subtree rule does not match its base") + func subtreeDoesNotMatchBase() throws { + let rule = try DNSMatchRule(".example.test") + #expect(!rule.matches(name("example.test"))) + } + + @Test("A subtree rule matches by label and not by string suffix") + func subtreeMatchesByLabel() throws { + let rule = try DNSMatchRule(".example.test") + #expect(!rule.matches(name("evilexample.test"))) + #expect(!rule.matches(name("web.evilexample.test"))) + #expect(!rule.matches(name("example.test.evil"))) + } + + @Test("A subtree rule does not match unrelated or shorter names") + func subtreeDoesNotMatchUnrelated() throws { + let rule = try DNSMatchRule(".example.test") + #expect(!rule.matches(name("web.example.com"))) + #expect(!rule.matches(name("test"))) + #expect(!rule.matches(DNSName())) + } + + // MARK: - Subtree bases + + @Test("Lists the proper suffixes of a name, longest first") + func subtreeBases() { + let bases = DNSMatchRule.subtreeBases(of: name("a.b.example.test")) + #expect(bases == [name("b.example.test"), name("example.test"), name("test")]) + } + + @Test("A single-label name has no subtree bases") + func subtreeBasesSingleLabel() { + #expect(DNSMatchRule.subtreeBases(of: name("test")).isEmpty) + #expect(DNSMatchRule.subtreeBases(of: DNSName()).isEmpty) + } + + @Test("Every subtree base of a name matches the name as a subtree rule") + func subtreeBasesMatch() { + let queried = name("a.b.example.test") + for base in DNSMatchRule.subtreeBases(of: queried) { + #expect(DNSMatchRule.subtree(base).matches(queried)) + } + } +} diff --git a/Tests/IntegrationTests/Network/TestCLINetworkAlias.swift b/Tests/IntegrationTests/Network/TestCLINetworkAlias.swift new file mode 100644 index 00000000..b705ed3f --- /dev/null +++ b/Tests/IntegrationTests/Network/TestCLINetworkAlias.swift @@ -0,0 +1,185 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import Testing + +/// Tests for DNS aliases on network attachments. The aliases are resolved by querying the container +/// DNS server directly, so they do not depend on any resolver file under /etc/resolver. +@Suite +struct TestCLINetworkAlias { + private static let dnsPort = 2053 + + private struct DigResult { + let status: String + let answers: [String] + } + + /// Asks the container DNS server for a record, and returns the response code and the answer data. + private func dig(_ name: String, type: String = "A") throws -> DigResult { + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/dig") + process.arguments = [ + "@127.0.0.1", "-p", "\(Self.dnsPort)", "+time=5", "+tries=1", "+noall", "+answer", "+comments", name, type, + ] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + let lines = String(decoding: data, as: UTF8.self).components(separatedBy: .newlines) + let status = + lines.compactMap { line -> String? in + guard let range = line.range(of: "status: ") else { return nil } + return line[range.upperBound...].prefix { $0.isLetter }.description + }.first ?? "" + let answers = lines.compactMap { line -> String? in + guard !line.isEmpty, !line.hasPrefix(";") else { return nil } + return line.split(whereSeparator: \.isWhitespace).last.map(String.init) + } + return DigResult(status: status, answers: answers) + } + + /// Waits until the container DNS server answers a query with the expected address records. + /// + /// The DNS server shares a lock with network creation and deletion, and other suites create and + /// delete networks while this one runs, so an answer can be late. A query that times out gives no + /// answers, and it is asked again. + private func expectAnswers( + _ f: ContainerFixture, _ name: String, _ expected: [String], type: String = "A", + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + var last = DigResult(status: "", answers: []) + do { + try await f.retry(attempts: 10) { + last = (try? self.dig(name, type: type)) ?? last + return last.answers == expected + } + } catch {} + #expect(last.answers == expected, "answers for \(name) \(type)", sourceLocation: sourceLocation) + } + + /// Waits until the container DNS server answers a query with the expected response code. + private func expectStatus( + _ f: ContainerFixture, _ name: String, _ expected: String, type: String = "A", + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + var last = DigResult(status: "", answers: []) + do { + try await f.retry(attempts: 10) { + last = (try? self.dig(name, type: type)) ?? last + return last.status == expected + } + } catch {} + #expect(last.status == expected, "status for \(name) \(type)", sourceLocation: sourceLocation) + } + + private func startContainer(_ f: ContainerFixture, name: String, aliases: [String]) async throws -> String { + let spec = (["default"] + aliases.map { "alias=\($0)" }).joined(separator: ",") + try await f.doLongRun(name: name, args: ["--network", spec], autoRemove: false, waitUntilRunning: true) + f.addCleanup { + try? f.doStop(name) + try? f.doRemove(name) + } + let container = try f.inspectContainer(name) + return "\(container.networks[0].ipv4Address.address)" + } + + @Test func testAliasesResolveToTheContainer() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + let base = "\(f.testID).alias.test" + let ip = try await startContainer(f, name: c, aliases: [base, ".\(base)"]) + + // An exact alias resolves, and so does every name below a subtree alias. + try await expectAnswers(f, base, [ip]) + try await expectAnswers(f, "web.\(base)", [ip]) + try await expectAnswers(f, "a.b.\(base)", [ip]) + + // Names are case-insensitive. + try await expectAnswers(f, "WEB.\(base.uppercased())", [ip]) + + // Matching is by label: a name that only ends with the base string does not match. + try await expectStatus(f, "evil\(base)", "NXDOMAIN") + try await expectStatus(f, "\(f.testID).other.test", "NXDOMAIN") + + // A name that exists but has no IPv6 address is NODATA, and not NXDOMAIN. + try await expectStatus(f, "web.\(base)", "NOERROR", type: "AAAA") + try await expectStatus(f, base, "NOERROR", type: "AAAA") + } + } + + @Test func testAliasesStopResolvingWhenTheContainerStops() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + let base = "\(f.testID).alias.test" + let ip = try await startContainer(f, name: c, aliases: [base, ".\(base)"]) + try await expectAnswers(f, base, [ip]) + + try f.doStop(c) + + try await expectStatus(f, base, "NXDOMAIN") + try await expectStatus(f, "web.\(base)", "NXDOMAIN") + } + } + + @Test func testAliasConflictsAreRejected() async throws { + try await ContainerFixture.with { f in + let first = "\(f.testID)-first" + let alias = "\(f.testID).alias.test" + _ = try await startContainer(f, name: first, aliases: [alias]) + + // Another container cannot declare the same alias. + let second = "\(f.testID)-second" + f.addCleanup { try? f.doRemove(second) } + let sameAlias = try f.run(["run", "-d", "--name", second, "--network", "default,alias=\(alias)", WarmupImage.alpine320.rawValue, "sleep", "infinity"]) + #expect(sameAlias.status != 0) + #expect(sameAlias.error.contains("already exist")) + + // And it cannot take the alias as its own name. + let sameName = try f.run(["run", "-d", "--name", alias, WarmupImage.alpine320.rawValue, "sleep", "infinity"]) + f.addCleanup { try? f.doRemove(alias) } + #expect(sameName.status != 0) + #expect(sameName.error.contains("already exist")) + } + } + + @Test func testInvalidAliasesAreRejected() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + f.addCleanup { try? f.doRemove(c) } + + // An asterisk is not accepted, and the error points to the leading-dot form. + let asterisk = try f.run(["run", "-d", "--name", c, "--network", "default,alias=*.\(f.testID).alias.test", WarmupImage.alpine320.rawValue, "sleep", "infinity"]) + #expect(asterisk.status != 0) + #expect(asterisk.error.contains("leading dot")) + + // A subtree must be rooted at a name that the same attachment owns. + let notOwned = try f.run(["run", "-d", "--name", c, "--network", "default,alias=.\(f.testID).unowned.test", WarmupImage.alpine320.rawValue, "sleep", "infinity"]) + #expect(notOwned.status != 0) + #expect(notOwned.error.contains("must be below")) + + // A subtree base needs at least two labels, once the default domain is added. + let tooShort = try f.run(["run", "-d", "--name", c, "--network", "default,alias=.test", WarmupImage.alpine320.rawValue, "sleep", "infinity"]) + #expect(tooShort.status != 0) + // With a default domain configured the name is qualified first, and it is then not owned. + #expect(tooShort.error.contains("at least 2 labels") || tooShort.error.contains("must be below")) + } + } +} diff --git a/docs/architecture/dns.md b/docs/architecture/dns.md new file mode 100644 index 00000000..7f1c4ba3 --- /dev/null +++ b/docs/architecture/dns.md @@ -0,0 +1,97 @@ +# DNS architecture + +This document describes the DNS architecture of `container`. It covers: + +* The components that work together to resolve names +* The names a container can have +* The path of a query through the system +* The invariants that the implementation relies on +* The security properties of the design + +For user-level instructions, see [Networking](../networking.md). + +## Components + +Name resolution involves four pieces. + +* **Resolver files.** `container system dns create ` writes `/etc/resolver/containerization.` and signals mDNSResponder. A file for a regular domain contains `nameserver 127.0.0.1` and `port 2053`, which tells macOS to send queries for names under that domain to the container DNS server. A domain created with `--localhost
` contains `port 1053` and an `options localhost:
` line, and is served by a different handler. These files are host state, and `container` does not control their contents after writing them. macOS chooses the resolver whose domain is the most specific match for a query. +* **The container DNS server.** `container-apiserver` runs a DNS server on `127.0.0.1:2053`. Each query passes through `StandardQueryValidator`, which accepts only single-question standard queries, and then through a `CompositeResolver` that tries `ContainerDNSHandler` and then `NxDomainResolver`. `ContainerDNSHandler` answers A and AAAA queries with a TTL of 5 seconds. Anything it does not answer becomes NXDOMAIN. +* **The localhost DNS server.** A second server on `127.0.0.1:1053` runs `LocalhostDNSHandler`, which answers the exact domain names of resolver files that have the localhost option, using the address in that option. It reads only those files and never consults a network helper. +* **Network helpers.** Each network is served by a helper process, such as the vmnet helper. The helper allocates addresses for attachments and is the source of truth for which names exist on its network. `NetworksService` in the API server holds a client for each helper. + +Both DNS servers listen on the loopback address only, so they cannot be queried from other machines. + +## Names of a container + +Each network attachment of a container has a hostname. It is derived from the container ID and from the default DNS domain, which is the `domain` key in the `[dns]` table of the system configuration. The container ID is the value of `--name`from `container create` or `container run`, or a generated lowercase UUID when there is no `--name`. The `--dns-domain` option of `container create` or `container run` sets the search domain inside the container and does not change the hostname. + +The hostname of the first attachment depends on which of three cases applies. + +* **The container ID contains a dot.** The ID is taken to be a fully qualified name, and the hostname is the ID with a trailing dot. `--name web.example.test` gives `web.example.test.`. The default domain is not appended, even when one is configured. +* **The container ID has no dot, and a default domain is configured.** The hostname is the ID, the domain and a trailing dot. `--name foo` with the domain `test` gives `foo.test.`. +* **The container ID has no dot, and no default domain is configured.** The hostname is the bare ID, such as `foo`. It is not under any domain, so no resolver file sends queries for it to the container DNS server. + +A container started without `--name` has a generated ID with no dot, so it follows the second or third case. Every later attachment gets the container ID as its hostname, with no domain added, in all three cases. Only a hostname under a registered domain can be resolved from macOS, because the resolver files forward queries by domain. That means the first attachment of a container in the first two cases is reachable by name, and an unqualified hostname, whether from the third case or from a later attachment, is not. + +An attachment can also declare aliases, which are additional names that resolve to the same address. An alias is one of two forms, written as the value of the `alias` property of `--network`. + +* An **exact alias** is a name, such as `api.example.test`. +* A **subtree alias** is a name with a leading dot, such as `.example.test`. It matches every name below its base, at one or more labels, and does not match the base itself. The wildcard semantics follow RFC 4592. + +An alias that has no dot is qualified with the default DNS domain when the container is created, the same way a container ID without a dot becomes a hostname. `alias=api` with the domain `test` is stored as `api.test`, and `alias=.gateway` is stored as `.gateway.test`. A name with a dot is stored as it is. The qualification happens in the CLI, so the stored configuration, the API server and the helpers only ever see the qualified names, and a later change to the default domain does not alter them. Without a default domain, the name stays as it is and fails the check for a fully qualified name. + +Aliases are part of the attachment configuration (`AttachmentOptions.aliases`), are stored in their normalized string form, and cannot be changed after the container is created. `DNSMatchRule` in the `DNSServer` module parses and matches them. Names are compared by label, are not case sensitive, and are compared without a trailing dot. + +## Resolving a query + +1. The application asks the system resolver for a name. macOS finds a resolver file whose domain matches and sends the query to `127.0.0.1:2053`. +2. The container DNS server decodes the question and `ContainerDNSHandler` calls `NetworksService.lookup` with the name. +3. `NetworksService` calls `lookup()` on each network helper with the name. A helper looks the name up among its own attachments in a fixed order: the hostname of an attachment, then an exact alias, then the subtree alias with the longest base. It replies with a `NetworkMatch`, which holds the attachment and how the name matched, for the best match on that network, or with nothing if the name does not resolve there. +4. `NetworksService` takes the first `NetworkMatch` that is a hostname or exact alias match. If there is none, it takes the subtree match with the most labels in its base, once every helper has answered. +5. The handler answers with the address of the attachment, using the queried name as the owner name of the record. When the name exists but the attachment has no IPv6 address, an AAAA query gets NODATA (no error, no records) instead of NXDOMAIN. musl libc treats NXDOMAIN for AAAA as the whole name not existing, so this is required for such names to resolve. + +## Registration lifecycle + +Names are registered in two places, and the order matters. + +When a container is created, `ContainersService.create` validates the names of its attachments under the container creation lock, before the container is created. This is the only place that sees every container, so it is where uniqueness is enforced. The rules that involve only one attachment (the limit, the label minimum, the ownership rule, duplicates) are in `AliasPolicy` in the `DNSServer` module, so the later checks use the same code. + +When a container starts, the API server validates its stored names again with the same rules, since a stored configuration is not trusted to be what creation accepted. A container whose stored names break a rule fails to start with an error that names it. Then the runtime service opens one XPC session to the helper of each network and calls `allocate` with the hostname, the optional MAC address, and the aliases. The helper checks the aliases against `AliasPolicy` before it registers anything, because they arrive over XPC and are not taken on faith, and it registers the hostname and aliases for the lifetime of the session. When the session ends, whether the container stops, crashes or disconnects, the helper releases the address and the names. If any allocation fails during startup, the runtime service closes every session it opened, which releases the earlier allocations. + +## Invariants + +* **Exact names are unique.** A hostname or an exact alias is claimed by at most one attachment across all networks, whether its container is running or stopped. Creation fails if a new hostname or alias matches an existing hostname or alias, in either direction. The lookup relies on this to stop at the first hostname or exact alias match. +* **Aliases are qualified.** Every exact alias is a fully qualified name with at least two labels once the default domain has been added, because a name with a single label is never under a registered domain and could not be resolved from macOS. The API server enforces this for every client, whether or not the CLI qualified the name. +* **A subtree is owned.** The base of a subtree alias is the hostname or an exact alias of the same attachment, and has at least two labels. A container can therefore not claim the names below another container's name, and two containers cannot declare the same subtree. A subtree and its base always resolve to the same attachment. +* **Precedence is total.** An exact name beats a subtree, and a longer subtree base beats a shorter one. Two different subtrees that match a name and have the same number of base labels would have the same base, which one container owns, so there is no tie. +* **Names are canonical.** Hostnames and aliases are compared lowercase and without a trailing dot, and the helper stores them that way. A container named `Foo` is therefore found by a query for `foo`. +* **The helper is authoritative.** The addresses in answers come only from the allocation that a helper made for the attachment. No configuration value can supply an address. +* **Names live as long as the allocation.** An allocation and its aliases are released together, so a stopped container does not keep answering. +* **Existing configurations stay valid.** The `aliases` key of an attachment configuration is optional when it is decoded and is left out when it is empty, so a configuration stored before aliases existed decodes with no aliases, and a configuration without aliases is stored as it was before. + +## Security considerations + +* Aliases come only from the request that creates the container, through the CLI or the API. They are never read from image metadata, labels or anything that a workload controls. +* Both DNS servers accept connections from the loopback address only. +* The create-time checks run under the creation lock, and aliases cannot change afterward, so there is no later mutation path to re-check. Even so, the per-attachment rules are applied three times: at creation, when a stored container starts, and when the network helper registers the aliases. A damaged or edited stored configuration, or a caller of the helper that skipped validation, cannot get past the helper with an alias that the API server would have rejected. The helper also refuses an alias that is already registered on its own network, as a second line of defense for the rules that involve other containers. +* Matching is by label, which keeps `evilexample.test` from matching `.example.test`. +* The ownership rule and the two-label minimum keep a subtree alias from capturing a whole top-level domain or the names of other containers. Exact names always win over a subtree. +* A subtree makes every name below its base resolve to the container. A proxy behind such a name receives the requested name in the `Host` header and must treat it as untrusted input. +* Answers are a single record from an in-memory table, with no recursion, so aliases add no amplification. +* The checks do not look at the resolver files. They are host state that can change at any time, and they do not limit what the server answers, because a client can query port 2053 directly. + +## Known limitations + +* **Case of the answer.** `DNSName` lowercases labels when a query is decoded, so a response echoes a lowercased question and owner name even when the client used mixed case. DNS is case preserving (RFC 4343), and resolvers that randomize the case of query names (0x20 encoding) may discard a response whose case does not match. This predates aliases. Fixing it means keeping the original labels in the decoded question. +* **Domains outside the resolver files.** An alias is reachable from macOS only when the most specific registered domain that covers it is a regular one. A name under a domain created with `--localhost`, or under no registered domain, does not reach the container DNS server through the system resolver. Container creation does not warn about this. +* **Unavailable networks.** `NetworksService.lookup` calls the helpers one at a time and does not hold the networks state lock while it does, so a network being created or deleted does not delay DNS answers. A helper that is starting up, shutting down or has failed cannot answer, so its network is skipped and the failure is logged. A name that only that network could answer is reported as not found for that query. A helper that hangs, instead of failing, still delays the queries that reach it, because the calls have no timeout. +* **Broad domains.** The two-label minimum does not stop a subtree alias whose base is itself a public suffix, such as `.co.uk`. This adds no exposure beyond what registering such a domain already allows. A user who runs `container system dns create com` sends every name under `com` to the container DNS server, which answers NXDOMAIN for the ones it does not know, with or without aliases. A subtree alias also needs the container to own the base as its hostname or as an exact alias. +* **Other kinds of container.** Container machines and the builder build attachment configurations without aliases. + +## Possible future work + +* A create-time warning when an alias is not covered by a registered regular domain. +* Concurrent helper calls in the lookup, and a timeout for each helper call. +* Alias support for container machines. +* A shared library for the alias matching code that third-party helpers can link. +* Showing aliases in list output. diff --git a/docs/command-reference.md b/docs/command-reference.md index a65b08a9..58edd714 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -61,7 +61,7 @@ container run [] [ ...] * `--masked-path `: **Experimental.** Hide a path inside the container, in addition to the runtime defaults (or `NONE` to clear prior values and the defaults) * `--mount `: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly) * `--name `: Use the specified name as the container ID -* `--network `: Attach the container to a network (format: `[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`) +* `--network `: Attach the container to a network (format: `[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE][,alias=NAME]...`). Use `alias` to give the container additional DNS names; see [Give a container additional DNS names](networking.md#give-a-container-additional-dns-names) * `--no-dns`: Do not configure DNS in the container * `--os `: Set OS if image can target multiple operating systems (default: linux) * `-p, --publish `: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol]) @@ -105,6 +105,9 @@ container run -e NODE_ENV=production --cpus 2 --memory 1G node:18 # run a container with a specific MAC address container run --network default,mac=02:42:ac:11:00:02 ubuntu:latest +# run a container that also answers to api.test and to every name below gateway.test +container run --name gateway --network default,alias=api.test,alias=.gateway.test nginx:latest + # run a container with an init process to reap zombies and forward signals container run --init ubuntu:latest my-app @@ -226,7 +229,7 @@ container create [] [ ...] * `--masked-path `: **Experimental.** Hide a path inside the container, in addition to the runtime defaults (or `NONE` to clear prior values and the defaults) * `--mount `: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly) * `--name `: Use the specified name as the container ID -* `--network `: Attach the container to a network (format: `[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`) +* `--network `: Attach the container to a network (format: `[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE][,alias=NAME]...`). Use `alias` to give the container additional DNS names; see [Give a container additional DNS names](networking.md#give-a-container-additional-dns-names) * `--no-dns`: Do not configure DNS in the container * `--os `: Set OS if image can target multiple operating systems (default: linux) * `-p, --publish `: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol]) diff --git a/docs/networking.md b/docs/networking.md index db21fe27..80618f10 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -152,6 +152,51 @@ To verify the MAC address is set correctly, read the interface MAC directly from If you don't specify a MAC address, `container` will generate one for you. The generated address has a first nibble set to hexadecimal `f` (`fX:XX:XX:XX:XX:XX`) in case you want to minimize the very small chance of conflict between your MAC address and generated addresses. +## Give a container additional DNS names + +By default a container is reachable by one DNS name, built from its name and your DNS domain. Use the `alias` option of `--network` to give a container more names that resolve to the same address. This is useful when one container, such as a reverse proxy or an ingress gateway, answers for several names, or for every name under a domain. + +Aliases are served by the same DNS service as container names, so set up a DNS domain first, as described in [Set up DNS-based container names](#set-up-dns-based-container-names). The examples below assume the domain is `test`. + +An alias is either an exact name, or a name with a leading dot that matches every name below it. Repeat `alias` to add more than one: + +```bash +container run -d --name gateway --network default,alias=api.test,alias=.gateway.test nginx:latest +``` + +This container is reachable as `gateway.test` by its name, as `api.test` by the exact alias, and as `web.gateway.test`, `a.b.gateway.test` and any other name below `gateway.test` by the subtree alias: + +```console +% curl http://web.gateway.test +``` + +A name with a leading dot matches every name below it, at one or more labels, but not the name itself. `.gateway.test` matches `web.gateway.test` and `a.b.gateway.test`, and does not match `gateway.test`, which the container already has as its own name. Matching is by whole labels, so `evilgateway.test` does not match. Names are not case sensitive. + +Like container names, an alias that has no dot gets your default DNS domain. With the domain `test`, `alias=api` is `api.test` and `alias=.gateway` is `.gateway.test`, so the example above can also be written `--name gateway --network default,alias=api,alias=.gateway`. A name that contains a dot is used as written, so `alias=api.internal` is not given the domain. The names are stored with the domain added, so they stay the same if you later change the default domain. + +An alias that contains `*` is rejected. Use the leading-dot form instead, which also avoids quoting in the shell. + +### Rules for aliases + +* A name with a leading dot must be below a name that the same network attachment owns, which is its own hostname or one of its exact aliases. `gateway.test` with `.gateway.test` is allowed. `.apps.test` on a container that has no `apps.test` name is not. +* An exact alias needs at least two labels, such as `api.test`, and the part after the leading dot of a subtree alias needs at least two labels too. A name with a single label is not under any domain, so macOS would never send queries for it to the container DNS service. When a default DNS domain is configured, a name without a dot gets the domain, as described above. When none is configured, `api` and `.test` are rejected. +* Every exact name is unique across all containers, whether they are running or stopped. A new container cannot use an alias or a hostname that another container already has, and an alias cannot match another container's name. +* A container can have at most 32 aliases for each network. +* Aliases are fixed when the container is created. + +The exact name always wins over a subtree. If another container is named `db.gateway.test`, then `db.gateway.test` goes to that container, and the other names below `gateway.test` go to the gateway. + +### Things to know + +* A proxy that answers for every name under a domain receives the requested name in the `Host` header of each request. Treat that header as untrusted input. +* macOS sends a query to the container DNS service only for domains you registered with `sudo container system dns create`. An alias outside those domains is not reachable from your Mac. A domain created with `--localhost` is answered by a different service that does not know about aliases, so aliases under it do not resolve. +* To check an alias without changing your Mac's resolver configuration, query the DNS service directly: + + ```console + % dig +short @127.0.0.1 -p 2053 web.gateway.test + 192.168.64.5 + ``` + ## Create and use a separate isolated network > [!NOTE]