diff --git a/Sources/ContainerCommands/System/DNS/DNSCreate.swift b/Sources/ContainerCommands/System/DNS/DNSCreate.swift index 4c41b87c..2fb9916d 100644 --- a/Sources/ContainerCommands/System/DNS/DNSCreate.swift +++ b/Sources/ContainerCommands/System/DNS/DNSCreate.swift @@ -16,6 +16,7 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence import ContainerizationError import ContainerizationExtras import Foundation @@ -30,22 +31,54 @@ extension Application { @OptionGroup public var logOptions: Flags.Logging + @Option(name: .long, help: "Set the ip address to be redirected to localhost") + var localhost: String? + @Argument(help: "The local domain name") var domainName: String public init() {} public func run() async throws { + var localhostIP: IPAddress? = nil + if let localhost { + localhostIP = try? IPAddress(localhost) + guard let localhostIP, case .v4(_) = localhostIP else { + throw ContainerizationError(.invalidArgument, message: "invalid IPv4 address: \(localhost)") + } + } + let resolver: HostDNSResolver = HostDNSResolver() do { - try resolver.createDomain(name: domainName) - print(domainName) + try resolver.createDomain(name: domainName, localhost: localhostIP) } catch let error as ContainerizationError { throw error } catch { throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") } + let pf = PacketFilter() + if let from = localhostIP { + let to = try! IPAddress("127.0.0.1") + do { + try pf.createRedirectRule(from: from, to: to, domain: domainName) + } catch { + _ = try resolver.deleteDomain(name: domainName) + throw error + } + } + print(domainName) + + if localhostIP != nil { + do { + try pf.reinitialize() + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "failed loading pf rules") + } + } + do { try HostDNSResolver.reinitialize() } catch { diff --git a/Sources/ContainerCommands/System/DNS/DNSDelete.swift b/Sources/ContainerCommands/System/DNS/DNSDelete.swift index 8e6992e9..ee801bf0 100644 --- a/Sources/ContainerCommands/System/DNS/DNSDelete.swift +++ b/Sources/ContainerCommands/System/DNS/DNSDelete.swift @@ -17,6 +17,7 @@ import ArgumentParser import ContainerAPIClient import ContainerizationError +import ContainerizationExtras import Foundation extension Application { @@ -37,9 +38,9 @@ extension Application { public func run() async throws { let resolver = HostDNSResolver() + var localhostIP: IPAddress? do { - try resolver.deleteDomain(name: domainName) - print(domainName) + localhostIP = try resolver.deleteDomain(name: domainName) } catch { throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") } @@ -49,6 +50,23 @@ extension Application { } catch { throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") } + + guard let localhostIP else { + print(domainName) + return + } + + let pf = PacketFilter() + try pf.removeRedirectRule(from: localhostIP, to: try! IPAddress("127.0.0.1"), domain: domainName) + + do { + try pf.reinitialize() + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "failed loading pf rules") + } + print(domainName) } } } diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index 8366a04c..abae90c5 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -33,6 +33,7 @@ extension APIServer { ) static let listenAddress = "127.0.0.1" + static let localhostDNSPort = 1053 static let dnsPort = 2053 @Flag(name: .long, help: "Enable debug logging") @@ -97,13 +98,33 @@ extension APIServer { let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) log.info( - "starting DNS host query resolver", + "starting DNS resolver for container hostnames", metadata: [ "host": "\(Self.listenAddress)", "port": "\(Self.dnsPort)", ] ) try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) + + } + + // start up realhost DNS + group.addTask { + let localhostResolver = LocalhostDNSHandler(log: log) + try localhostResolver.monitorResolvers() + + let nxDomainResolver = NxDomainResolver() + let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) + let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) + let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) + log.info( + "starting DNS resolver for localhost", + metadata: [ + "host": "\(Self.listenAddress)", + "port": "\(Self.localhostDNSPort)", + ] + ) + try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort) } } } catch { diff --git a/Sources/Helpers/APIServer/DirectoryWatcher.swift b/Sources/Helpers/APIServer/DirectoryWatcher.swift new file mode 100644 index 00000000..725d7c6f --- /dev/null +++ b/Sources/Helpers/APIServer/DirectoryWatcher.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. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import Foundation +import Logging + +public class DirectoryWatcher { + public let directoryURL: URL + + private let monitorQueue: DispatchQueue + private var source: DispatchSourceFileSystemObject? + + private let log: Logger + + init(directoryURL: URL, log: Logger) { + self.directoryURL = directoryURL + self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)") + self.log = log + } + + public func startWatching(handler: @escaping ([URL]) throws -> Void) throws { + guard source == nil else { + throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)") + } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) + try handler(files.map { directoryURL.appending(path: $0) }) + } catch { + throw ContainerizationError(.invalidState, message: "failed to start watching on \(directoryURL.path)") + } + + log.info("starting directory watcher for \(directoryURL.path)") + + let descriptor = open(directoryURL.path, O_EVTONLY) + + source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: .write, + queue: monitorQueue + ) + + source?.setEventHandler { [weak self] in + guard let self else { return } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) + try? handler(files.map { directoryURL.appending(path: $0) }) + } catch { + self.log.info("failed to run handler for \(directoryURL.path)") + } + } + + source?.resume() + } + + deinit { + guard let source else { + return + } + + source.cancel() + } +} diff --git a/Sources/Helpers/APIServer/LocalhostDNSHandler.swift b/Sources/Helpers/APIServer/LocalhostDNSHandler.swift new file mode 100644 index 00000000..46293444 --- /dev/null +++ b/Sources/Helpers/APIServer/LocalhostDNSHandler.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerAPIClient +import ContainerPersistence +import ContainerizationError +import DNS +import DNSServer +import Foundation +import Logging + +class LocalhostDNSHandler: DNSHandler { + private let ttl: UInt32 + private let watcher: DirectoryWatcher + + private var dns: [String: IPv4] + + public init(resolversURL: URL = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) { + self.ttl = ttl + + self.watcher = DirectoryWatcher(directoryURL: resolversURL, log: log) + self.dns = [:] + } + + public func monitorResolvers() throws { + try self.watcher.startWatching { fileURLs in + var dns: [String: IPv4] = [:] + let regex = try Regex(HostDNSResolver.localhostOptionsRegex) + + for file in fileURLs.filter({ $0.lastPathComponent.starts(with: HostDNSResolver.containerizationPrefix) }) { + let content = try String(contentsOf: file, encoding: .utf8) + + if let match = content.firstMatch(of: regex), + let ipv4 = IPv4(String(match[1].substring ?? "")) + { + let name = String(file.lastPathComponent.dropFirst(HostDNSResolver.containerizationPrefix.count)) + dns[name + "."] = ipv4 + } + } + self.dns = dns + } + } + + public func answer(query: Message) async throws -> Message? { + let question = query.questions[0] + var record: ResourceRecord? + switch question.type { + case ResourceRecordType.host: + if let ip = dns[question.name] { + record = HostRecord(name: question.name, ttl: ttl, ip: ip) + } + case ResourceRecordType.host6: + return Message( + id: query.id, + type: .response, + returnCode: .noError, + questions: query.questions, + answers: [] + ) + case ResourceRecordType.nameServer, + ResourceRecordType.alias, + ResourceRecordType.startOfAuthority, + ResourceRecordType.pointer, + ResourceRecordType.mailExchange, + ResourceRecordType.text, + ResourceRecordType.service, + ResourceRecordType.incrementalZoneTransfer, + ResourceRecordType.standardZoneTransfer, + ResourceRecordType.all: + return Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + default: + return Message( + id: query.id, + type: .response, + returnCode: .formatError, + questions: query.questions, + answers: [] + ) + } + + guard let record else { + return nil + } + + return Message( + id: query.id, + type: .response, + returnCode: .noError, + questions: query.questions, + answers: [record] + ) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift index fd3fdf5f..c5974db5 100644 --- a/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift +++ b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationExtras import Foundation /// Functions for managing local DNS domains for containers. @@ -22,7 +23,8 @@ public struct HostDNSResolver { public static let defaultConfigPath = URL(filePath: "/etc/resolver") // prefix used to mark our files as /etc/resolver/{prefix}{domainName} - private static let containerizationPrefix = "containerization." + public static let containerizationPrefix = "containerization." + public static let localhostOptionsRegex = #"options localhost:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"# private let configURL: URL @@ -31,7 +33,7 @@ public struct HostDNSResolver { } /// Creates a DNS resolver configuration file for domain resolved by the application. - public func createDomain(name: String) throws { + public func createDomain(name: String, localhost: IPAddress? = nil) throws { let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path let fm: FileManager = FileManager.default @@ -47,33 +49,44 @@ public struct HostDNSResolver { throw ContainerizationError(.exists, message: "domain \(name) already exists") } + let dnsPort = localhost == nil ? "2053" : "1053" + let options = + localhost == nil + ? "" + : HostDNSResolver.localhostOptionsRegex.replacingOccurrences( + of: #"\((.*?)\)"#, with: localhost!.description, options: .regularExpression) let resolverText = """ domain \(name) search \(name) nameserver 127.0.0.1 - port 2053 + port \(dnsPort) + \(options) """ - do { - try resolverText.write(toFile: path, atomically: true, encoding: .utf8) - } catch { - throw ContainerizationError(.invalidState, message: "failed to write resolver configuration for \(name)") - } + try resolverText.write(toFile: path, atomically: true, encoding: .utf8) } /// Removes a DNS resolver configuration file for domain resolved by the application. - public func deleteDomain(name: String) throws { + public func deleteDomain(name: String) throws -> IPAddress? { let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path let fm = FileManager.default guard fm.fileExists(atPath: path) else { throw ContainerizationError(.notFound, message: "domain \(name) at \(path) not found") } + var localhost: IPAddress? + let content = try String(contentsOfFile: path, encoding: .utf8) + if let match = content.firstMatch(of: try Regex(HostDNSResolver.localhostOptionsRegex)) { + localhost = try? IPAddress(String(match[1].substring ?? "")) + } + do { try fm.removeItem(atPath: path) } catch { throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") } + + return localhost } /// Lists application-created local DNS domains. diff --git a/Sources/Services/ContainerAPIService/Client/PacketFilter.swift b/Sources/Services/ContainerAPIService/Client/PacketFilter.swift new file mode 100644 index 00000000..6ba5ccd6 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/PacketFilter.swift @@ -0,0 +1,198 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationExtras +import Foundation + +public struct PacketFilter { + public static let anchor = "com.apple.container" + public static let defaultConfigPath = URL(filePath: "/etc/pf.conf") + public static let defaultAnchorsPath = URL(filePath: "/etc/pf.anchors") + + private let configURL: URL + private let anchorsURL: URL + + public init(configURL: URL = Self.defaultConfigPath, anchorsURL: URL = Self.defaultAnchorsPath) { + self.configURL = configURL + self.anchorsURL = anchorsURL + } + + public func createRedirectRule(from: IPAddress, to: IPAddress, domain: String) throws { + guard type(of: from) == type(of: to) else { + throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)") + } + + let fm: FileManager = FileManager.default + + let anchorURL = self.anchorsURL.appending(path: Self.anchor) + + let inet: String + switch from { + case .v4: inet = "inet" + case .v6: inet = "inet6" + } + let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain)" + + var content = "" + if fm.fileExists(atPath: anchorURL.path) { + content = try String(contentsOfFile: anchorURL.path, encoding: .utf8) + } else { + try addAnchorToConfig() + } + + var lines = content.components(separatedBy: .newlines) + if !content.contains(redirectRule) { + lines.insert(redirectRule, at: lines.endIndex - 1) + } + + try lines.joined(separator: "\n").write(toFile: anchorURL.path, atomically: true, encoding: .utf8) + } + + public func removeRedirectRule(from: IPAddress, to: IPAddress, domain: String) throws { + guard type(of: from) == type(of: to) else { + throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)") + } + + let fm: FileManager = FileManager.default + + let anchorURL = self.anchorsURL.appending(path: Self.anchor) + + let inet: String + switch from { + case .v4: inet = "inet" + case .v6: inet = "inet6" + } + let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain)" + + guard fm.fileExists(atPath: anchorURL.path) else { + return + } + + let content = try String(contentsOfFile: anchorURL.path, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let removedLines = lines.filter { l in + l != redirectRule + } + + if removedLines == [""] { + try fm.removeItem(atPath: anchorURL.path) + try removeAnchorFromConfig() + } else { + try removedLines.joined(separator: "\n").write(toFile: anchorURL.path, atomically: true, encoding: .utf8) + } + } + + private func addAnchorToConfig() throws { + let fm: FileManager = FileManager.default + + let anchorURL = self.anchorsURL.appending(path: Self.anchor) + + /* PF requires strict ordering of anchors: + scrub-anchor, nat-anchor, rdr-anchor, dummynet-anchor, anchor, load anchor + */ + let anchorKeywords = ["scrub-anchor", "nat-anchor", "rdr-anchor", "dummynet-anchor", "anchor", "load anchor"] + let loadAnchorText = "load anchor \"\(Self.anchor)\" from \"\(anchorURL.path)\"" + + var content: String = "" + var lines: [String] = [] + if fm.fileExists(atPath: self.configURL.path) { + content = try String(contentsOfFile: self.configURL.path, encoding: .utf8) + } + lines = content.components(separatedBy: .newlines) + + for (i, keyword) in anchorKeywords[..<(anchorKeywords.endIndex - 1)].enumerated() { + let anchorText = "\(keyword) \"\(Self.anchor)\"" + + if content.contains(anchorText) { + continue + } + + let idx = lines.firstIndex { l in + anchorKeywords[i...].map { k in l.starts(with: k) }.contains(true) + } + lines.insert(anchorText, at: idx ?? lines.endIndex - 1) + } + + if !content.contains(loadAnchorText) { + lines.insert(loadAnchorText, at: lines.endIndex - 1) + } + + do { + try lines.joined(separator: "\n").write(toFile: self.configURL.path, atomically: true, encoding: .utf8) + } catch { + throw ContainerizationError(.invalidState, message: "failed to write \"\(self.configURL.path)\"") + } + } + + private func removeAnchorFromConfig() throws { + let fm: FileManager = FileManager.default + + guard fm.fileExists(atPath: configURL.path) else { + return + } + + let content = try String(contentsOfFile: configURL.path, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let removedLines = lines.filter { l in !l.contains(Self.anchor) } + + do { + try removedLines.joined(separator: "\n").write(toFile: configURL.path, atomically: true, encoding: .utf8) + } catch { + throw ContainerizationError(.invalidState, message: "failed to write \"\(configURL.path)\"") + } + } + + public func reinitialize() throws { + do { + let pfctl = Foundation.Process() + let null = FileHandle.nullDevice + var status: Int32 + + pfctl.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + pfctl.arguments = ["-n", "-f", configURL.path] + pfctl.standardOutput = null + pfctl.standardError = null + + try pfctl.run() + pfctl.waitUntilExit() + status = pfctl.terminationStatus + guard status == 0 else { + throw ContainerizationError(.internalError, message: "invalid pf config \"\(configURL.path)\"") + } + } + + do { + let pfctl = Foundation.Process() + let null = FileHandle.nullDevice + var status: Int32 + + pfctl.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + pfctl.arguments = ["-f", configURL.path] + pfctl.standardOutput = null + pfctl.standardError = null + + try pfctl.run() + pfctl.waitUntilExit() + status = pfctl.terminationStatus + guard status == 0 else { + throw ContainerizationError(.invalidState, message: "pfctl -f \"\(configURL.path)\" failed with status \(status)") + } + } + } +} diff --git a/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift index 7f72d741..15835b27 100644 --- a/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift +++ b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationExtras import Foundation import Testing @@ -41,6 +42,7 @@ struct HostDNSResolverTest { search foo.bar nameserver 127.0.0.1 port 2053 + """ #expect(actualText == expectedText) @@ -86,7 +88,13 @@ struct HostDNSResolverTest { let resolver = HostDNSResolver(configURL: tempURL) try resolver.createDomain(name: "foo.bar") - try resolver.deleteDomain(name: "foo.bar") + _ = try resolver.deleteDomain(name: "foo.bar") + + let localhost = try! IPAddress("127.0.0.1") + try resolver.createDomain(name: "bar.baz", localhost: localhost) + let deletedLocalhost = try resolver.deleteDomain(name: "bar.baz") + #expect(localhost == deletedLocalhost) + let domains = resolver.listDomains() #expect(domains == []) } @@ -105,7 +113,7 @@ struct HostDNSResolverTest { let resolver = HostDNSResolver(configURL: tempURL) try resolver.createDomain(name: "foo.bar") #expect { - try resolver.deleteDomain(name: "bar.foo") + _ = try resolver.deleteDomain(name: "bar.foo") } throws: { error in guard let error = error as? ContainerizationError, error.code == .notFound else { return false diff --git a/Tests/ContainerAPIClientTests/PacketFilterTest.swift b/Tests/ContainerAPIClientTests/PacketFilterTest.swift new file mode 100644 index 00000000..fdb63598 --- /dev/null +++ b/Tests/ContainerAPIClientTests/PacketFilterTest.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationExtras +import Foundation +import Testing + +@testable import ContainerAPIClient + +struct PacketFilterTest { + @Test + func testRedirectRuleUpdate() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let configURL = tempURL.appending(path: "pf.conf") + + let pf = PacketFilter(configURL: configURL, anchorsURL: tempURL) + let from1 = try! IPAddress("203.0.113.113") + let domain1 = "aaa.com" + let to = try! IPAddress("127.0.0.1") + try pf.createRedirectRule(from: from1, to: to, domain: domain1) + + let anchorURL = tempURL.appending(path: "com.apple.container") + var actualAnchorText = try String(contentsOf: anchorURL, encoding: .utf8) + var expectedAnchorTest = """ + rdr inet from any to \(from1) -> \(to) # \(domain1)\n + """ + + #expect(actualAnchorText == expectedAnchorTest) + + let from2 = try! IPAddress("172.31.72.1") + let domain2 = "bbb.com" + try pf.createRedirectRule(from: from2, to: to, domain: domain2) + + actualAnchorText = try String(contentsOf: anchorURL, encoding: .utf8) + expectedAnchorTest += """ + rdr inet from any to \(from2) -> \(to) # \(domain2)\n + """ + #expect(actualAnchorText == expectedAnchorTest) + + let actualConfigText = try String(contentsOf: configURL, encoding: .utf8) + let expectedConfigText = try Regex( + #""" + scrub-anchor "([^"]+)" + nat-anchor "([^"]+)" + rdr-anchor "([^"]+)" + dummynet-anchor "([^"]+)" + anchor "([^"]+)" + load anchor "([^"]+)" from "[^"]+" + """# + ) + + #expect(actualConfigText.contains(expectedConfigText)) + + try pf.removeRedirectRule(from: from1, to: to, domain: domain1) + try pf.removeRedirectRule(from: from2, to: to, domain: domain2) + + #expect(!fm.fileExists(atPath: anchorURL.path)) + let configText = try String(contentsOf: configURL, encoding: .utf8) + #expect(configText == "") + } + + @Test + func testPacketFilterReinitialize() async throws { + let pf = PacketFilter() + #expect(throws: ContainerizationError.self) { + try pf.reinitialize() + } + } +} diff --git a/docs/how-to.md b/docs/how-to.md index d6957042..a67f8d3a 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -198,6 +198,35 @@ Test access using `curl`: ``` +## Access a host service from a container + +Create a DNS domain with `--localhost ` to make a domain used by a container to access a host service. Any IPv4 address can be used as ``, which will be assigned to the domain name in container. + +Choose an IP address that is least likely to conflict with any networks or reserved IP addresses in your environment. Reasonably safe address ranges include: + +- The documentation ranges 192.0.2.0/24, 198.51.100.0/24, and 203.0.113.0/24. +- The 172.16.0.0/12 private range. + +To connect a host HTTP server from a container, run: + +```bash +mkdir -p /tmp/test; cd /tmp/test; echo "hello" > index.html +python3 -m http.server 8000 --bind 127.0.0.1 +``` + +Create a domain for host connection: + +```bash +sudo container system dns create host.container.internal --localhost 203.0.113.113 +``` + +Test access to the host HTTP server from a container: + +```console +% container run -it --rm alpine/curl curl http://host.container.internal:8000 +hello +``` + ## Set a custom MAC address for your container Use the `mac` option to specify a custom MAC address for your container's network interface. This is useful for: diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 715c2db0..1e4e1a3e 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -52,18 +52,6 @@ When `container-apiserver` starts, it launches an XPC helper `container-core-ima With the initial release of `container`, you get basic facilities for building and running containers, but many common containerization features remain to be implemented. Consider [contributing](../CONTRIBUTING.md) new features and bug fixes to `container` and the Containerization projects! -### Container to host networking - -In the initial release, there is no way to route traffic directly from a client in a container to a host-based application listening on the loopback interface at 127.0.0.1. If you were to configure the application in your container to connect to 127.0.0.1 or `localhost`, requests would simply go to the loopback interface in the container, rather than your host-based service. - -You can work around this limitation by configuring the host-based application to listen on the wildcard address 0.0.0.0, but this practice is insecure and not recommended because, without firewall rules, this exposes the application to external requests. - -A more secure approach uses `socat` to redirect traffic from the container network gateway to the host-based service. For example, to forward traffic for port 8000, configure your containerized application to connect to `192.168.64.1:8000` instead of `127.0.0.1:8000`, and then run the following command in a terminal on your Mac to forward the port traffic from the gateway to the host: - -```bash -socat TCP-LISTEN:8000,fork,bind=192.168.64.1 TCP:127.0.0.1:8000 -``` - ### Releasing container memory to macOS The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically use and relinquish host memory. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. For example, you might start a container using the option `--memory 16g`, but see that the application is only using 2 GiBytes of RAM in the macOS Activity Monitor.