162 lines
6.3 KiB
Swift
162 lines
6.3 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 DNSServer
|
|
import Foundation
|
|
import SystemPackage
|
|
|
|
/// Functions for managing local DNS domains for containers.
|
|
public struct HostDNSResolver {
|
|
public static let defaultConfigPath = FilePath("/etc/resolver")
|
|
|
|
// prefix used to mark our files as /etc/resolver/{prefix}{domainName}
|
|
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 configPath: FilePath
|
|
|
|
public init(configPath: FilePath = Self.defaultConfigPath) {
|
|
self.configPath = configPath
|
|
}
|
|
|
|
/// Creates a DNS resolver configuration file for domain resolved by the application.
|
|
public func createDomain(name: DNSName, localhost: IPAddress? = nil) throws {
|
|
let name = name.pqdn
|
|
|
|
let resolverFilename = "\(Self.containerizationPrefix)\(name)"
|
|
guard let component = FilePath.Component(resolverFilename) else {
|
|
throw ContainerizationError(.invalidState, message: "invalid resolver filename \(resolverFilename)")
|
|
}
|
|
let path = self.configPath.appending(component)
|
|
let fm: FileManager = FileManager.default
|
|
|
|
var isDirectory: ObjCBool = false
|
|
if fm.fileExists(atPath: self.configPath.string, isDirectory: &isDirectory) {
|
|
guard isDirectory.boolValue else {
|
|
throw ContainerizationError(.invalidState, message: "expected \(self.configPath.string) to be a directory, but found a file")
|
|
}
|
|
} else {
|
|
try fm.createDirectory(atPath: self.configPath.string, withIntermediateDirectories: true)
|
|
}
|
|
|
|
guard !fm.fileExists(atPath: path.string) else {
|
|
throw ContainerizationError(.exists, message: "domain \(name) already exists")
|
|
}
|
|
|
|
let dnsPort = localhost == nil ? "2053" : "1053"
|
|
let options =
|
|
localhost.map {
|
|
HostDNSResolver.localhostOptionsRegex.replacingOccurrences(
|
|
of: #"\((.*?)\)"#, with: $0.description, options: .regularExpression)
|
|
} ?? ""
|
|
let resolverText = """
|
|
domain \(name)
|
|
search \(name)
|
|
nameserver 127.0.0.1
|
|
port \(dnsPort)
|
|
\(options)
|
|
"""
|
|
|
|
try resolverText.write(toFile: path.string, atomically: true, encoding: .utf8)
|
|
}
|
|
|
|
/// Removes a DNS resolver configuration file for domain resolved by the application.
|
|
public func deleteDomain(name: DNSName) throws -> IPAddress? {
|
|
let name = name.pqdn
|
|
|
|
let resolverFilename = "\(Self.containerizationPrefix)\(name)"
|
|
guard let component = FilePath.Component(resolverFilename) else {
|
|
throw ContainerizationError(.invalidState, message: "invalid resolver filename \(resolverFilename)")
|
|
}
|
|
let path = self.configPath.appending(component)
|
|
let fm = FileManager.default
|
|
guard fm.fileExists(atPath: path.string) else {
|
|
throw ContainerizationError(.notFound, message: "domain \(name) at \(path) not found")
|
|
}
|
|
|
|
var localhost: IPAddress?
|
|
let content = try String(contentsOfFile: path.string, 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.string)
|
|
} catch {
|
|
throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)")
|
|
}
|
|
|
|
return localhost
|
|
}
|
|
|
|
/// Lists application-created local DNS domains.
|
|
public func listDomains() -> [DNSName] {
|
|
let fm: FileManager = FileManager.default
|
|
guard let filenames = try? fm.contentsOfDirectory(atPath: self.configPath.string) else {
|
|
return []
|
|
}
|
|
|
|
return
|
|
filenames
|
|
.filter { $0.starts(with: Self.containerizationPrefix) }
|
|
.compactMap { filename -> DNSName? in
|
|
guard let component = FilePath.Component(filename) else { return nil }
|
|
return try? getDomainFromResolver(path: self.configPath.appending(component))
|
|
}
|
|
.sorted { a, b in a.pqdn < b.pqdn }
|
|
}
|
|
|
|
/// Reinitializes the macOS DNS daemon.
|
|
public static func reinitialize() throws {
|
|
do {
|
|
let kill = Foundation.Process()
|
|
kill.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
|
|
kill.arguments = ["-HUP", "mDNSResponder"]
|
|
|
|
let null = FileHandle.nullDevice
|
|
kill.standardOutput = null
|
|
kill.standardError = null
|
|
|
|
try kill.run()
|
|
kill.waitUntilExit()
|
|
let status = kill.terminationStatus
|
|
guard status == 0 else {
|
|
throw ContainerizationError(.internalError, message: "mDNSResponder restart failed with status \(status)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func getDomainFromResolver(path: FilePath) throws -> DNSName? {
|
|
let text = try String(contentsOfFile: path.string, encoding: .utf8)
|
|
for line in text.components(separatedBy: .newlines) {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
let components = trimmed.split(whereSeparator: { $0.isWhitespace })
|
|
guard components.count == 2 else {
|
|
continue
|
|
}
|
|
guard components[0] == "domain" else {
|
|
continue
|
|
}
|
|
|
|
return try? DNSName(String(components[1]))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|