From c4b6b68ce725346ef443a86ef1b3387e4abbef1c Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Fri, 18 Jul 2025 00:54:18 -0700 Subject: [PATCH] LinuxContainer: Add /etc/hosts writing functionality (#207) Closes #206 Much like we have support for supplying DNS configurations and writing out /etc/resolv.conf, this adds a way to write out /etc/hosts for a given container. --- Sources/Containerization/Agent/Vminitd.swift | 26 +++ .../Containerization/HostsConfiguration.swift | 140 ++++++++++++++ Sources/Containerization/LinuxContainer.swift | 12 ++ .../SandboxContext/SandboxContext.grpc.swift | 100 ++++++++++ .../SandboxContext/SandboxContext.pb.swift | 175 ++++++++++++++++++ .../SandboxContext/SandboxContext.proto | 15 ++ .../VirtualMachineAgent.swift | 5 + Sources/Integration/ProcessTests.swift | 33 ++++ Sources/Integration/Suite.swift | 1 + Sources/cctl/RunCommand.swift | 7 + vminitd/Sources/vminitd/Server+GRPC.swift | 48 +++++ 11 files changed, 562 insertions(+) create mode 100644 Sources/Containerization/HostsConfiguration.swift diff --git a/Sources/Containerization/Agent/Vminitd.swift b/Sources/Containerization/Agent/Vminitd.swift index fc88b485..53db0c59 100644 --- a/Sources/Containerization/Agent/Vminitd.swift +++ b/Sources/Containerization/Agent/Vminitd.swift @@ -309,6 +309,11 @@ extension Vminitd { }) } + /// Configure /etc/hosts within the sandbox's environment. + public func configureHosts(config: Hosts, location: String) async throws { + _ = try await client.configureHosts(config.toAgentHostsRequest(location: location)) + } + /// Perform a sync call. public func sync() async throws { _ = try await client.sync(.init()) @@ -337,6 +342,27 @@ extension Vminitd { } } +extension Hosts { + func toAgentHostsRequest(location: String) -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest { + Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.with { + $0.location = location + if let comment { + $0.comment = comment + } + $0.entries = entries.map { + let entry = $0 + return Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry.with { + if let comment = entry.comment { + $0.comment = comment + } + $0.ipAddress = entry.ipAddress + $0.hostnames = entry.hostnames + } + } + } + } +} + extension Vminitd.Client { public init(socket: String, group: MultiThreadedEventLoopGroup) { var config = ClientConnection.Configuration.default( diff --git a/Sources/Containerization/HostsConfiguration.swift b/Sources/Containerization/HostsConfiguration.swift new file mode 100644 index 00000000..26c1765e --- /dev/null +++ b/Sources/Containerization/HostsConfiguration.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// 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. +//===----------------------------------------------------------------------===// + +/// Static table lookups for a container. The values will be used to +/// construct /etc/hosts for a given container. +public struct Hosts: Sendable { + /// Represents one entry in an /etc/hosts file. + public struct Entry: Sendable { + /// The IPV4 or IPV6 address in String form. + public var ipAddress: String + /// The hostname(s) for the entry. + public var hostnames: [String] + /// An optional comment to be placed to the right side of the entry. + public var comment: String? + + public init(ipAddress: String, hostnames: [String], comment: String? = nil) { + self.comment = comment + self.hostnames = hostnames + self.ipAddress = ipAddress + } + + /// The information in the structure rendered to a String representation + /// that matches the format /etc/hosts expects. + public var rendered: String { + var line = ipAddress + if !hostnames.isEmpty { + line += " " + hostnames.joined(separator: " ") + } + if let comment { + line += " # \(comment) " + } + return line + } + + public static func localHostIPV4(comment: String? = nil) -> Self { + Self( + ipAddress: "127.0.0.1", + hostnames: ["localhost"], + comment: comment + ) + } + + public static func localHostIPV6(comment: String? = nil) -> Self { + Self( + ipAddress: "::1", + hostnames: ["localhost", "ip6-localhost", "ip6-loopback"], + comment: comment + ) + } + + public static func ipv6LocalNet(comment: String? = nil) -> Self { + Self( + ipAddress: "fe00::", + hostnames: ["ip6-localnet"], + comment: comment + ) + } + + public static func ipv6MulticastPrefix(comment: String? = nil) -> Self { + Self( + ipAddress: "ff00::", + hostnames: ["ip6-mcastprefix"], + comment: comment + ) + } + + public static func ipv6AllNodes(comment: String? = nil) -> Self { + Self( + ipAddress: "ff02::1", + hostnames: ["ip6-allnodes"], + comment: comment + ) + } + + public static func ipv6AllRouters(comment: String? = nil) -> Self { + Self( + ipAddress: "ff02::2", + hostnames: ["ip6-allrouters"], + comment: comment + ) + } + } + + /// The entries to be written to /etc/hosts. + public var entries: [Entry] + + /// A comment to render at the top of the file. + public var comment: String? + + public init( + entries: [Entry], + comment: String? = nil + ) { + self.entries = entries + self.comment = comment + } +} + +extension Hosts { + /// A default entry that can be used for convenience. It contains a IPV4 + /// and IPV6 localhost entry, as well as ipv6 localnet, ipv6 mcastprefix, + /// ipv6 allnodes, and ipv6 allrouters. + public static let `default` = Hosts(entries: [ + Entry.localHostIPV4(), + Entry.localHostIPV6(), + Entry.ipv6LocalNet(), + Entry.ipv6MulticastPrefix(), + Entry.ipv6AllNodes(), + Entry.ipv6AllRouters(), + ]) + + /// Returns a string variant of the data that can be written to + /// /etc/hosts directly. + public var hostsFile: String { + var lines: [String] = [] + + if let comment { + lines.append("# \(comment)") + } + + for entry in entries { + lines.append(entry.rendered) + } + + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index ceddc97a..1720934d 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -52,6 +52,7 @@ public final class LinuxContainer: Container, Sendable { var ioHandlers: LinuxProcess.IOHandler = .nullIO() var mounts: [Mount] var dns: DNS? = nil + var hosts: Hosts? = nil } @SendablePropertyUnchecked @@ -324,6 +325,12 @@ extension LinuxContainer { } } + /// Hostname mapping configurations for the container. + public var hosts: Hosts? { + get { config.hosts } + set { config.hosts = newValue } + } + /// Unix sockets to share into or out of the container. /// /// The VirtualMachineAgent used to launch the container @@ -545,9 +552,14 @@ extension LinuxContainer { try await agent.routeAddDefault(name: name, gateway: gateway) } } + + // Setup /etc/resolv.conf and /etc/hosts if asked for. if let dns = self.dns { try await agent.configureDNS(config: dns, location: rootfs.destination) } + if let hosts = self.hosts { + try await agent.configureHosts(config: hosts, location: rootfs.destination) + } try state.setCreated(vm: vm, relayManager: relayManager) } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 38dc4c25..44ac6f48 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -144,6 +144,11 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtoc callOptions: CallOptions? ) -> UnaryCall + func configureHosts( + _ request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + callOptions: CallOptions? + ) -> UnaryCall + func sync( _ request: Com_Apple_Containerization_Sandbox_V3_SyncRequest, callOptions: CallOptions? @@ -557,6 +562,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol { ) } + /// Configure /etc/hosts. + /// + /// - Parameters: + /// - request: Request to send to ConfigureHosts. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func configureHosts( + _ request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.configureHosts.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeConfigureHostsInterceptors() ?? [] + ) + } + /// Perform the sync syscall. /// /// - Parameters: @@ -767,6 +790,11 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP callOptions: CallOptions? ) -> GRPCAsyncUnaryCall + func makeConfigureHostsCall( + _ request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall + func makeSyncCall( _ request: Com_Apple_Containerization_Sandbox_V3_SyncRequest, callOptions: CallOptions? @@ -1052,6 +1080,18 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } + public func makeConfigureHostsCall( + _ request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.configureHosts.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeConfigureHostsInterceptors() ?? [] + ) + } + public func makeSyncCall( _ request: Com_Apple_Containerization_Sandbox_V3_SyncRequest, callOptions: CallOptions? = nil @@ -1343,6 +1383,18 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } + public func configureHosts( + _ request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + callOptions: CallOptions? = nil + ) async throws -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse { + return try await self.performAsyncUnaryCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.configureHosts.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeConfigureHostsInterceptors() ?? [] + ) + } + public func sync( _ request: Com_Apple_Containerization_Sandbox_V3_SyncRequest, callOptions: CallOptions? = nil @@ -1453,6 +1505,9 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientInterc /// - Returns: Interceptors to use when invoking 'configureDns'. func makeConfigureDnsInterceptors() -> [ClientInterceptor] + /// - Returns: Interceptors to use when invoking 'configureHosts'. + func makeConfigureHostsInterceptors() -> [ClientInterceptor] + /// - Returns: Interceptors to use when invoking 'sync'. func makeSyncInterceptors() -> [ClientInterceptor] @@ -1487,6 +1542,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.ipRouteAddLink, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.ipRouteAddDefault, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.configureDns, + Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.configureHosts, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.sync, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.kill, ] @@ -1625,6 +1681,12 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { type: GRPCCallType.unary ) + public static let configureHosts = GRPCMethodDescriptor( + name: "ConfigureHosts", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/ConfigureHosts", + type: GRPCCallType.unary + ) + public static let sync = GRPCMethodDescriptor( name: "Sync", path: "/com.apple.containerization.sandbox.v3.SandboxContext/Sync", @@ -1712,6 +1774,9 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider: Ca /// Configure DNS resolver. func configureDns(request: Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest, context: StatusOnlyCallContext) -> EventLoopFuture + /// Configure /etc/hosts. + func configureHosts(request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, context: StatusOnlyCallContext) -> EventLoopFuture + /// Perform the sync syscall. func sync(request: Com_Apple_Containerization_Sandbox_V3_SyncRequest, context: StatusOnlyCallContext) -> EventLoopFuture @@ -1929,6 +1994,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider { userFunction: self.configureDns(request:context:) ) + case "ConfigureHosts": + return UnaryServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeConfigureHostsInterceptors() ?? [], + userFunction: self.configureHosts(request:context:) + ) + case "Sync": return UnaryServerHandler( context: context, @@ -2094,6 +2168,12 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvide context: GRPCAsyncServerCallContext ) async throws -> Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse + /// Configure /etc/hosts. + func configureHosts( + request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + context: GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse + /// Perform the sync syscall. func sync( request: Com_Apple_Containerization_Sandbox_V3_SyncRequest, @@ -2324,6 +2404,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvider { wrapping: { try await self.configureDns(request: $0, context: $1) } ) + case "ConfigureHosts": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeConfigureHostsInterceptors() ?? [], + wrapping: { try await self.configureHosts(request: $0, context: $1) } + ) + case "Sync": return GRPCAsyncServerHandler( context: context, @@ -2438,6 +2527,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextServerInterc /// Defaults to calling `self.makeInterceptors()`. func makeConfigureDnsInterceptors() -> [ServerInterceptor] + /// - Returns: Interceptors to use when handling 'configureHosts'. + /// Defaults to calling `self.makeInterceptors()`. + func makeConfigureHostsInterceptors() -> [ServerInterceptor] + /// - Returns: Interceptors to use when handling 'sync'. /// Defaults to calling `self.makeInterceptors()`. func makeSyncInterceptors() -> [ServerInterceptor] @@ -2474,6 +2567,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.ipRouteAddLink, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.ipRouteAddDefault, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.configureDns, + Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.configureHosts, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.sync, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.kill, ] @@ -2612,6 +2706,12 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { type: GRPCCallType.unary ) + public static let configureHosts = GRPCMethodDescriptor( + name: "ConfigureHosts", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/ConfigureHosts", + type: GRPCCallType.unary + ) + public static let sync = GRPCMethodDescriptor( name: "Sync", path: "/com.apple.containerization.sandbox.v3.SandboxContext/Sync", diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 920e0fe4..3a72fc83 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -830,6 +830,66 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: Sendab public init() {} } +public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var location: String = String() + + public var entries: [Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry] = [] + + public var comment: String { + get {return _comment ?? String()} + set {_comment = newValue} + } + /// Returns true if `comment` has been explicitly set. + public var hasComment: Bool {return self._comment != nil} + /// Clears the value of `comment`. Subsequent reads from it will return its default value. + public mutating func clearComment() {self._comment = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public struct HostsEntry: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var ipAddress: String = String() + + public var hostnames: [String] = [] + + public var comment: String { + get {return _comment ?? String()} + set {_comment = newValue} + } + /// Returns true if `comment` has been explicitly set. + public var hasComment: Bool {return self._comment != nil} + /// Clears the value of `comment`. Subsequent reads from it will return its default value. + public mutating func clearComment() {self._comment = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _comment: String? = nil + } + + public init() {} + + fileprivate var _comment: String? = nil +} + +public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Com_Apple_Containerization_Sandbox_V3_SyncRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -2415,6 +2475,121 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: SwiftProto } } +extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ConfigureHostsRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "location"), + 2: .same(proto: "entries"), + 3: .same(proto: "comment"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.location) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.entries) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._comment) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.location.isEmpty { + try visitor.visitSingularStringField(value: self.location, fieldNumber: 1) + } + if !self.entries.isEmpty { + try visitor.visitRepeatedMessageField(value: self.entries, fieldNumber: 2) + } + try { if let v = self._comment { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, rhs: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest) -> Bool { + if lhs.location != rhs.location {return false} + if lhs.entries != rhs.entries {return false} + if lhs._comment != rhs._comment {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.protoMessageName + ".HostsEntry" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "ipAddress"), + 2: .same(proto: "hostnames"), + 3: .same(proto: "comment"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.ipAddress) }() + case 2: try { try decoder.decodeRepeatedStringField(value: &self.hostnames) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._comment) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.ipAddress.isEmpty { + try visitor.visitSingularStringField(value: self.ipAddress, fieldNumber: 1) + } + if !self.hostnames.isEmpty { + try visitor.visitRepeatedStringField(value: self.hostnames, fieldNumber: 2) + } + try { if let v = self._comment { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry, rhs: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry) -> Bool { + if lhs.ipAddress != rhs.ipAddress {return false} + if lhs.hostnames != rhs.hostnames {return false} + if lhs._comment != rhs._comment {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ConfigureHostsResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse, rhs: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Com_Apple_Containerization_Sandbox_V3_SyncRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SyncRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index d5fd72bb..d771710c 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -52,6 +52,8 @@ service SandboxContext { rpc IpRouteAddDefault(IpRouteAddDefaultRequest) returns (IpRouteAddDefaultResponse); // Configure DNS resolver. rpc ConfigureDns(ConfigureDnsRequest) returns (ConfigureDnsResponse); + // Configure /etc/hosts. + rpc ConfigureHosts(ConfigureHostsRequest) returns (ConfigureHostsResponse); // Perform the sync syscall. rpc Sync(SyncRequest) returns (SyncResponse); // Send a signal to a process via the PID. @@ -239,6 +241,19 @@ message ConfigureDnsRequest { message ConfigureDnsResponse {} +message ConfigureHostsRequest { + message HostsEntry { + string ipAddress = 1; + repeated string hostnames = 2; + optional string comment = 3; + } + string location = 1; + repeated HostsEntry entries = 2; + optional string comment = 3; +} + +message ConfigureHostsResponse {} + message SyncRequest {} message SyncResponse {} diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index b4dc1a89..0d9a8d40 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -60,10 +60,15 @@ public protocol VirtualMachineAgent: Sendable { func addressAdd(name: String, address: String) async throws func routeAddDefault(name: String, gateway: String) async throws func configureDNS(config: DNS, location: String) async throws + func configureHosts(config: Hosts, location: String) async throws } extension VirtualMachineAgent { public func closeProcessStdin(id: String, containerID: String?) async throws { throw ContainerizationError(.unsupported, message: "closeProcessStdin") } + + public func configureHosts(config: Hosts, location: String) async throws { + throw ContainerizationError(.unsupported, message: "configureHosts") + } } diff --git a/Sources/Integration/ProcessTests.swift b/Sources/Integration/ProcessTests.swift index a1f291ff..f863190c 100644 --- a/Sources/Integration/ProcessTests.swift +++ b/Sources/Integration/ProcessTests.swift @@ -418,6 +418,39 @@ extension IntegrationSuite { } } + func testHostsFile() async throws { + let id = "test-container-hosts-file" + + let bs = try await bootstrap() + let container = LinuxContainer( + id, + rootfs: bs.rootfs, + vmm: bs.vmm + ) + container.arguments = ["cat", "/etc/hosts"] + let entry = Hosts.Entry.localHostIPV4(comment: "Testaroo") + container.hosts = Hosts(entries: [entry]) + + let buffer = BufferWriter() + container.stdout = buffer + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status == 0 else { + throw IntegrationError.assert(msg: "process status \(status) != 0") + } + + let expected = entry.rendered + guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else { + throw IntegrationError.assert( + msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'") + } + } + func testProcessStdin() async throws { let id = "test-container-stdin" diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 21d545e0..cb861dbc 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -206,6 +206,7 @@ struct IntegrationSuite: AsyncParsableCommand { "multiple concurrent processes": testMultipleConcurrentProcesses, "multiple concurrent processes with output stress": testMultipleConcurrentProcessesOutputStress, "container hostname": testHostname, + "container hosts": testHostsFile, "container mount": testMounts, "nested virt": testNestedVirtualizationEnabled, ] diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index 3d5c3793..fe424869 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -119,6 +119,7 @@ extension Application { container.mounts.append(czMount) } + var hosts = Hosts.default if let ip { guard let gateway else { throw ContainerizationError(.invalidArgument, message: "gateway must be specified") @@ -128,7 +129,13 @@ extension Application { if nameservers.count > 0 { container.dns = .init(nameservers: nameservers) } + hosts.entries.append( + Hosts.Entry( + ipAddress: ip, + hostnames: [id] + )) } + container.hosts = hosts try await container.create() try await container.start() diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index f49a78b6..84de22ff 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -794,6 +794,38 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid return .init() } + func configureHosts( + request: Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest, + context: GRPC.GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse { + log.debug( + "configureHosts", + metadata: [ + "location": "\(request.location)" + ]) + + do { + let etc = URL(fileURLWithPath: request.location).appendingPathComponent("etc") + try FileManager.default.createDirectory(atPath: etc.path, withIntermediateDirectories: true) + let hostsPath = etc.appendingPathComponent("hosts") + + let config = request.toCZHosts() + let text = config.hostsFile + try text.write(toFile: hostsPath.path, atomically: true, encoding: .utf8) + + log.debug("wrote /etc/hosts configuration", metadata: ["path": "\(hostsPath.path)"]) + } catch { + log.error( + "configureHosts", + metadata: [ + "error": "\(error)" + ]) + throw GRPCStatus(code: .internalError, message: "configureHosts: \(error)") + } + + return .init() + } + private func swiftErrno(_ msg: Logger.Message) -> POSIXError { let error = POSIXError(.init(rawValue: errno)!) log.error( @@ -832,6 +864,22 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid } } +extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest { + func toCZHosts() -> Hosts { + let entries = self.entries.map { + Hosts.Entry( + ipAddress: $0.ipAddress, + hostnames: $0.hostnames, + comment: $0.hasComment ? $0.comment : nil + ) + } + return Hosts( + entries: entries, + comment: self.hasComment ? self.comment : nil + ) + } +} + extension Initd { func ociAlterations(ociSpec: inout ContainerizationOCI.Spec) throws { guard var process = ociSpec.process else {