mirror of
https://github.com/apple/container.git
synced 2026-07-23 09:51:30 +00:00
Container-to-host networking. (#1078)
- Closes #346. - This PR enables connecting host's localhost ports from containers. - It adds an option `--localhost <localhost>` to DNS create command, after which the packets heading ip address in container are redirected to localhost in host machine. Packet filter rule is added and deleted along with the creation and deletion of localhost domain.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<IPv4>(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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,35 @@ Test access using `curl`:
|
||||
</body></html>
|
||||
```
|
||||
|
||||
## Access a host service from a container
|
||||
|
||||
Create a DNS domain with `--localhost <ipv4-address>` to make a domain used by a container to access a host service. Any IPv4 address can be used as `<ipv4-address>`, 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user