diff --git a/Sources/APIServer/Containers/ContainersService.swift b/Sources/APIServer/Containers/ContainersService.swift index 5c572895..d46d7637 100644 --- a/Sources/APIServer/Containers/ContainersService.swift +++ b/Sources/APIServer/Containers/ContainersService.swift @@ -36,25 +36,7 @@ actor ContainersService { private let runtimePlugins: [Plugin] private let lock = AsyncLock() - private var containers: [String: Item] - - struct Item: Sendable { - let bundle: ContainerClient.Bundle - var state: State - - enum State: Sendable { - case dead - case alive(SandboxClient) - case exited(Int32) - - func isDead() -> Bool { - switch self { - case .dead: return true - default: return false - } - } - } - } + private var containers: [String: ContainerSnapshot] public init(appRoot: URL, pluginLoader: PluginLoader, log: Logger) throws { let containerRoot = appRoot.appendingPathComponent("containers") @@ -66,7 +48,7 @@ actor ContainersService { self.containers = try Self.loadAtBoot(root: containerRoot, loader: pluginLoader, log: log) } - static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: Item] { + static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: ContainerSnapshot] { var directories = try FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey] @@ -76,12 +58,12 @@ actor ContainersService { } let runtimePlugins = loader.findPlugins().filter { $0.hasType(.runtime) } - var results = [String: Item]() + var results = [String: ContainerSnapshot]() for dir in directories { do { let bundle = ContainerClient.Bundle(path: dir) let config = try bundle.configuration - results[config.id] = .init(bundle: bundle, state: .dead) + results[config.id] = .init(configuration: config, status: .stopped, networks: []) let plugin = runtimePlugins.first { $0.name == config.runtimeHandler } guard let plugin else { throw ContainerizationError(.internalError, message: "Failed to find runtime plugin \(config.runtimeHandler)") @@ -95,7 +77,7 @@ actor ContainersService { return results } - private func setContainer(_ id: String, _ item: Item, context: AsyncLock.Context) async { + private func setContainer(_ id: String, _ item: ContainerSnapshot, context: AsyncLock.Context) async { self.containers[id] = item } @@ -103,17 +85,7 @@ actor ContainersService { public func list() async throws -> [ContainerSnapshot] { self.log.debug("\(#function)") return await lock.withLock { context in - var snapshots = [ContainerSnapshot]() - - for (id, item) in await self.containers { - do { - let result = try await item.asSnapshot() - snapshots.append(result.0) - } catch { - self.log.error("unable to load bundle for \(id) \(error)") - } - } - return snapshots + Array(await self.containers.values) } } @@ -121,17 +93,7 @@ actor ContainersService { /// This prevents race conditions where containers are created during the operation public func withContainerList(_ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T) async throws -> T { try await lock.withLock { context in - var snapshots = [ContainerSnapshot]() - - for (id, item) in await self.containers { - do { - let result = try await item.asSnapshot() - snapshots.append(result.0) - } catch { - self.log.error("unable to load bundle for \(id) \(error)") - } - } - + let snapshots = Array(await self.containers.values) return try await operation(snapshots) } } @@ -140,6 +102,30 @@ actor ContainersService { public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions) async throws { self.log.debug("\(#function)") + guard containers[configuration.id] == nil else { + throw ContainerizationError(.exists, message: "container already exists: \(configuration.id)") + } + + var allHostnames = Set() + for container in containers.values { + for attachmentConfiguration in container.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)") + } + + self.containers[configuration.id] = ContainerSnapshot(configuration: configuration, status: .stopped, networks: []) + let runtimePlugin = self.runtimePlugins.filter { $0.name == configuration.runtimeHandler }.first @@ -177,7 +163,6 @@ actor ContainersService { } throw error } - self.containers[configuration.id] = Item(bundle: bundle, state: .dead) } private func getInitBlock(for platform: Platform) async throws -> Filesystem { @@ -206,11 +191,11 @@ actor ContainersService { ) } - private func get(id: String, context: AsyncLock.Context) throws -> Item { + private func get(id: String, context: AsyncLock.Context) throws -> ContainerSnapshot { try self._get(id: id) } - private func _get(id: String) throws -> Item { + private func _get(id: String) throws -> ContainerSnapshot { let item = self.containers[id] guard let item else { throw ContainerizationError( @@ -225,17 +210,13 @@ actor ContainersService { public func delete(id: String) async throws { self.log.debug("\(#function)") let item = try self._get(id: id) - switch item.state { - case .alive(let client): - let state = try await client.state() - if state.status == .running || state.status == .stopping { - throw ContainerizationError( - .invalidState, - message: "container \(id) is not yet stopped and can not be deleted" - ) - } - try self._cleanup(id: id, item: item) - case .dead, .exited(_): + switch item.status { + case .running, .stopping: + throw ContainerizationError( + .invalidState, + message: "container \(id) is not yet stopped and can not be deleted" + ) + default: try self._cleanup(id: id, item: item) } } @@ -244,38 +225,42 @@ actor ContainersService { "\(Self.launchdDomainString)/\(Self.machServicePrefix).\(runtimeName).\(instanceId)" } - private func _cleanup(id: String, item: Item) throws { + private func _cleanup(id: String, item: ContainerSnapshot) throws { self.log.debug("\(#function)") - let config = try item.bundle.configuration + + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerClient.Bundle(path: path) + let config = try bundle.configuration let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id) try ServiceManager.deregister(fullServiceLabel: label) - try item.bundle.delete() + try bundle.delete() self.containers.removeValue(forKey: id) } - private func _shutdown(id: String, item: Item) throws { - let config = try item.bundle.configuration + private func _shutdown(id: String, item: ContainerSnapshot) throws { + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerClient.Bundle(path: path) + let config = try bundle.configuration + let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id) try ServiceManager.kill(fullServiceLabel: label) } - private func cleanup(id: String, item: Item, context: AsyncLock.Context) throws { + private func cleanup(id: String, item: ContainerSnapshot, context: AsyncLock.Context) throws { try self._cleanup(id: id, item: item) } private func containerProcessExitHandler(_ id: String, _ exitCode: Int32, context: AsyncLock.Context) async { self.log.info("Handling container \(id) exit. Code \(exitCode)") do { - var item = try self.get(id: id, context: context) - switch item.state { - case .dead, .exited(_): - break - case .alive(_): - item.state = .exited(exitCode) - await self.setContainer(id, item, context: context) - } - let options: ContainerCreateOptions = try item.bundle.load(filename: "options.json") + let item = try self.get(id: id, context: context) + let snapshot = ContainerSnapshot(configuration: item.configuration, status: .stopped, networks: []) + await self.setContainer(id, snapshot, context: context) + + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerClient.Bundle(path: path) + let options: ContainerCreateOptions = try bundle.load(filename: "options.json") if options.autoRemove { try self.cleanup(id: id, item: item, context: context) } @@ -293,11 +278,11 @@ actor ContainersService { self.log.debug("\(#function)") self.log.info("Handling container \(id) Start.") do { - var item = try self.get(id: id, context: context) - let configuration = try item.bundle.configuration - let client = SandboxClient(id: configuration.id, runtime: configuration.runtimeHandler) - item.state = .alive(client) - await self.setContainer(id, item, context: context) + let currentSnapshot = try self.get(id: id, context: context) + let client = SandboxClient(id: currentSnapshot.configuration.id, runtime: currentSnapshot.configuration.runtimeHandler) + let sandboxSnapshot = try await client.state() + let snapshot = ContainerSnapshot(configuration: currentSnapshot.configuration, status: .running, networks: sandboxSnapshot.networks) + await self.setContainer(id, snapshot, context: context) } catch { self.log.error( "Failed to handle container start", @@ -328,11 +313,12 @@ extension ContainersService { self.log.debug("\(#function)") try await lock.withLock { context in let item = try await self.get(id: id, context: context) - switch item.state { - case .dead, .exited(_): - return - case .alive(let client): + switch item.status { + case .running: + let client = SandboxClient(id: item.configuration.id, runtime: item.configuration.runtimeHandler) try await client.stop(options: options) + default: + return } } } @@ -342,10 +328,11 @@ extension ContainersService { // Logs doesn't care if the container is running or not, just that // the bundle is there, and that the files actually exist. do { - let item = try self._get(id: id) + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerClient.Bundle(path: path) return [ - try FileHandle(forReadingFrom: item.bundle.containerLog), - try FileHandle(forReadingFrom: item.bundle.bootlog), + try FileHandle(forReadingFrom: bundle.containerLog), + try FileHandle(forReadingFrom: bundle.bootlog), ] } catch { throw ContainerizationError( @@ -356,29 +343,3 @@ extension ContainersService { } } - -extension ContainersService.Item { - func asSnapshot() async throws -> (ContainerSnapshot, RuntimeStatus) { - let config = try self.bundle.configuration - - switch self.state { - case .dead, .exited(_): - return ( - .init( - configuration: config, - status: RuntimeStatus.stopped, - networks: [] - ), .stopped - ) - case .alive(let client): - let state = try await client.state() - return ( - .init( - configuration: config, - status: state.status, - networks: state.networks - ), state.status - ) - } - } -} diff --git a/Sources/APIServer/Networks/NetworksService.swift b/Sources/APIServer/Networks/NetworksService.swift index 89e15da9..44353a99 100644 --- a/Sources/APIServer/Networks/NetworksService.swift +++ b/Sources/APIServer/Networks/NetworksService.swift @@ -177,8 +177,8 @@ actor NetworksService { // find all containers that refer to the network var referringContainers = Set() for container in containers { - for containerNetworkId in container.configuration.networks { - if containerNetworkId == id { + for attachmentConfiguration in container.configuration.networks { + if attachmentConfiguration.network == id { referringContainers.insert(container.configuration.id) break } diff --git a/Sources/CLI/Builder/BuilderStart.swift b/Sources/CLI/Builder/BuilderStart.swift index f5c3b861..95aafecf 100644 --- a/Sources/CLI/Builder/BuilderStart.swift +++ b/Sources/CLI/Builder/BuilderStart.swift @@ -203,7 +203,7 @@ extension Application { guard case .running(_, let networkStatus) = network else { throw ContainerizationError(.invalidState, message: "default network is not running") } - config.networks = [network.id] + config.networks = [AttachmentConfiguration(network: network.id, options: AttachmentOptions(hostname: id))] let subnet = try CIDRAddress(networkStatus.address) let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description let nameservers = [nameserver] diff --git a/Sources/ContainerClient/Core/ContainerConfiguration.swift b/Sources/ContainerClient/Core/ContainerConfiguration.swift index 64c5c683..b65c358d 100644 --- a/Sources/ContainerClient/Core/ContainerConfiguration.swift +++ b/Sources/ContainerClient/Core/ContainerConfiguration.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerNetworkService import ContainerizationOCI public struct ContainerConfiguration: Sendable, Codable { @@ -32,13 +33,11 @@ public struct ContainerConfiguration: Sendable, Codable { /// System controls for the container. public var sysctls: [String: String] = [:] /// The networks the container will be added to. - public var networks: [String] = [] + public var networks: [AttachmentConfiguration] = [] /// The DNS configuration for the container. public var dns: DNSConfiguration? = nil /// Whether to enable rosetta x86-64 translation for the container. public var rosetta: Bool = false - /// The hostname for the container. - public var hostname: String? = nil /// Initial or main process of the container. public var initProcess: ProcessConfiguration /// Platform for the container @@ -61,7 +60,6 @@ public struct ContainerConfiguration: Sendable, Codable { case networks case dns case rosetta - case hostname case initProcess case platform case resources @@ -81,10 +79,21 @@ public struct ContainerConfiguration: Sendable, Codable { publishedSockets = try container.decodeIfPresent([PublishSocket].self, forKey: .publishedSockets) ?? [] labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:] - networks = try container.decodeIfPresent([String].self, forKey: .networks) ?? [] + + // NOTE: migrates [String] to [AttachmentConfiguration]; remove [String] support in a later release + if container.contains(.networks) { + do { + networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) + } catch { + let networkIds = try container.decode([String].self, forKey: .networks) + networks = try Utility.getAttachmentConfigurations(containerId: id, networkIds: networkIds) + } + } else { + networks = [] + } + dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns) rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false - hostname = try container.decodeIfPresent(String.self, forKey: .hostname) initProcess = try container.decode(ProcessConfiguration.self, forKey: .initProcess) platform = try container.decodeIfPresent(ContainerizationOCI.Platform.self, forKey: .platform) ?? .current resources = try container.decodeIfPresent(Resources.self, forKey: .resources) ?? .init() diff --git a/Sources/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift index 56961962..2366466e 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/ContainerClient/Utility.swift @@ -137,7 +137,6 @@ public struct Utility { var config = ContainerConfiguration(id: id, image: description, process: pc) config.platform = requestedPlatform - config.hostname = id config.resources = try Parser.resources( cpus: resource.cpus, @@ -177,23 +176,12 @@ public struct Utility { config.virtualization = management.virtualization - if management.networks.isEmpty { - config.networks = [ClientNetwork.defaultNetworkName] - } else { - // networks may only be specified for macOS 26+ - guard #available(macOS 26, *) else { - throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") + config.networks = try getAttachmentConfigurations(containerId: config.id, networkIds: management.networks) + for attachmentConfiguration in config.networks { + let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network) + guard case .running(_, _) = network else { + throw ContainerizationError(.invalidState, message: "network \(attachmentConfiguration.network) is not running") } - config.networks = management.networks - } - - var networkStatuses: [NetworkStatus] = [] - for networkName in config.networks { - let network: NetworkState = try await ClientNetwork.get(id: networkName) - guard case .running(_, let networkStatus) = network else { - throw ContainerizationError(.invalidState, message: "network \(networkName) is not running") - } - networkStatuses.append(networkStatus) } if management.dnsDisabled { @@ -223,6 +211,39 @@ public struct Utility { return (config, kernel) } + static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] { + // make an FQDN for the first interface + let fqdn: String? + if !containerId.contains(".") { + // add default domain if it exists, and container ID is unqualified + if let dnsDomain = DefaultsStore.getOptional(key: .defaultDNSDomain) { + fqdn = "\(containerId).\(dnsDomain)." + } else { + fqdn = nil + } + } else { + // use container ID directly if fully qualified + fqdn = "\(containerId)." + } + + guard networkIds.isEmpty else { + // networks may only be specified for macOS 26+ + guard #available(macOS 26, *) else { + throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") + } + + // attach the first network using the fqdn, and the rest using just the container ID + return networkIds.enumerated().map { item in + guard item.offset == 0 else { + return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId)) + } + return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId)) + } + } + // if no networks specified, attach to the default network + return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId))] + } + 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/ContainerPersistence/DefaultsStore.swift b/Sources/ContainerPersistence/DefaultsStore.swift index 551760de..54ac2f6b 100644 --- a/Sources/ContainerPersistence/DefaultsStore.swift +++ b/Sources/ContainerPersistence/DefaultsStore.swift @@ -28,6 +28,7 @@ public enum DefaultsStore { case defaultInitImage = "image.init" case defaultKernelURL = "kernel.url" case defaultKernelBinaryPath = "kernel.binaryPath" + case defaultSubnet = "network.subnet" case buildRosetta = "build.rosetta" } @@ -85,6 +86,8 @@ extension DefaultsStore.Keys { return "vminit:latest" } return "ghcr.io/apple/containerization/vminit:\(tag)" + case .defaultSubnet: + return "192.168.64.1/24" case .buildRosetta: // This is a boolean key, not used with the string get() method return "true" diff --git a/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift index 3700c23c..a3d76c89 100644 --- a/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift +++ b/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift @@ -55,7 +55,6 @@ public actor AllocationOnlyVmnetNetwork: Network { guard case .created(let configuration) = _state else { throw ContainerizationError(.invalidState, message: "cannot start network \(_state.id) in \(_state.state) state") } - var defaultSubnet = "192.168.64.1/24" log.info( "starting allocation-only network", @@ -65,21 +64,16 @@ public actor AllocationOnlyVmnetNetwork: Network { ] ) - if let suite = UserDefaults.init(suiteName: UserDefaults.appSuiteName) { - // TODO: Make the suiteName a constant defined in DefaultsStore and use that. - // This will need some re-working of dependencies between NetworkService and Client - defaultSubnet = suite.string(forKey: "network.subnet") ?? defaultSubnet - } - - let subnet = try CIDRAddress(defaultSubnet) - let gateway = IPv4Address(fromValue: subnet.lower.value + 1) - self._state = .running(configuration, NetworkStatus(address: subnet.description, gateway: gateway.description)) + let subnet = DefaultsStore.get(key: .defaultSubnet) + let subnetCIDR = try CIDRAddress(subnet) + let gateway = IPv4Address(fromValue: subnetCIDR.lower.value + 1) + self._state = .running(configuration, NetworkStatus(address: subnetCIDR.description, gateway: gateway.description)) log.info( "started allocation-only network", metadata: [ "id": "\(configuration.id)", "mode": "\(configuration.mode)", - "cidr": "\(defaultSubnet)", + "cidr": "\(subnet)", ] ) } diff --git a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift index 4d2bee88..2533e053 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift +++ b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift @@ -30,9 +30,11 @@ actor AttachmentAllocator { /// Allocate a network address for a host. func allocate(hostname: String) async throws -> UInt32 { - guard hostnames[hostname] == nil else { - throw ContainerizationError(.exists, message: "Hostname \(hostname) already exists on the network") + // 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 } + let index = try allocator.allocate() hostnames[hostname] = index diff --git a/Sources/Services/ContainerNetworkService/UserDefaults+Container.swift b/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift similarity index 52% rename from Sources/Services/ContainerNetworkService/UserDefaults+Container.swift rename to Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift index 3bfdd3f8..6864f591 100644 --- a/Sources/Services/ContainerNetworkService/UserDefaults+Container.swift +++ b/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift @@ -14,8 +14,26 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import Foundation +/// Configuration information for attaching a container network interface to a network. +public struct AttachmentConfiguration: Codable, Sendable { + /// The network ID associated with the attachment. + public let network: String -extension UserDefaults { - public static let appSuiteName = "com.apple.container.defaults" + /// The option information for the attachment + public let options: AttachmentOptions + + public init(network: String, options: AttachmentOptions) { + self.network = network + self.options = options + } +} + +// Option information for a network attachment. +public struct AttachmentOptions: Codable, Sendable { + /// The hostname associated with the attachment. + public let hostname: String + + public init(hostname: String) { + self.hostname = hostname + } } diff --git a/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift index 077d413a..1d57cad0 100644 --- a/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift +++ b/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerPersistence import ContainerXPC import Containerization import ContainerizationError @@ -100,8 +101,7 @@ public final class ReservedVmnetNetwork: Network { "mode": "\(configuration.mode)", ] ) - let suite = UserDefaults.init(suiteName: UserDefaults.appSuiteName) - let subnetText = configuration.subnet ?? suite?.string(forKey: "network.subnet") + let subnetText = configuration.subnet ?? DefaultsStore.getOptional(key: .defaultSubnet) // with the reservation API, subnet priority is CLI argument, UserDefault, auto let subnet = try subnetText.map { try CIDRAddress($0) } diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index 81d5f025..bfa2602a 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -94,7 +94,7 @@ public actor SandboxService { // Dynamically configure the DNS nameserver from a network if no explicit configuration if let dns = config.dns, dns.nameservers.isEmpty { - if let nameserver = try await self.getDefaultNameserver(networks: config.networks) { + if let nameserver = try await self.getDefaultNameserver(attachmentConfigurations: config.networks) { config.dns = ContainerConfiguration.DNSConfiguration( nameservers: [nameserver], domain: dns.domain, @@ -104,29 +104,12 @@ public actor SandboxService { } } - let fqdn: String - if let hostname = config.hostname { - if let suite = UserDefaults.init(suiteName: UserDefaults.appSuiteName), - let dnsDomain = suite.string(forKey: "dns.domain"), - !hostname.contains(".") - { - // TODO: Make the suiteName a constant defined in DefaultsStore and use that. - // This will need some re-working of dependencies between SandboxService and Client - fqdn = "\(hostname).\(dnsDomain)." - } else { - fqdn = "\(hostname)." - } - } else { - fqdn = config.id - } - var attachments: [Attachment] = [] var interfaces: [Interface] = [] for index in 0.. String? { - for network in networks { - let client = NetworkClient(id: network) + private func getDefaultNameserver(attachmentConfigurations: [AttachmentConfiguration]) async throws -> String? { + for attachmentConfiguration in attachmentConfigurations { + let client = NetworkClient(id: attachmentConfiguration.network) let state = try await client.state() guard case .running(_, let status) = state else { continue