First version

This commit is contained in:
Christoph Hagen 2024-11-15 10:46:29 +01:00
commit 9b6fd627ac
20 changed files with 1105 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
Packages
.build
xcuserdata
*.xcodeproj
DerivedData/
.DS_Store
db.sqlite
.swiftpm
.env
.env.*
! .env.example
.vscode
Package.resolved

34
Package.swift Normal file
View File

@ -0,0 +1,34 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "DNSUpdater",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.92.4"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
],
swiftSettings: swiftSettings
),
.testTarget(
name: "AppTests",
dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
],
swiftSettings: swiftSettings
)
]
)
var swiftSettings: [SwiftSetting] { [
.enableUpcomingFeature("DisableOutwardActorInference"),
.enableExperimentalFeature("StrictConcurrency"),
] }

0
Public/.gitkeep Normal file
View File

52
Sources/App/Logger.swift Normal file
View File

@ -0,0 +1,52 @@
import Foundation
final class Log: @unchecked Sendable {
enum Level: String, Codable {
case debug
case info
case warning
case error
var level: Int {
switch self {
case .debug: return 0
case .info: return 1
case .warning: return 2
case .error: return 3
}
}
}
private var logLevel: Level = .warning
private let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
init(logLevel: Level) {
self.logLevel = logLevel
}
private func log(_ level: Level, message: String) {
guard level.rawValue >= logLevel.rawValue else {
return
}
print("[\(formatter.string(from: Date()))][\(level.rawValue.uppercased())] \(message)")
}
func debug(_ message: String) { log(.debug, message: message) }
func info(_ message: String) { log(.info, message: message) }
func warning(_ message: String) { log(.warning, message: message) }
func error(_ message: String) { log(.error, message: message) }
static func debug(_ message: String) { log.debug(message) }
static func info(_ message: String) { log.info(message) }
static func warning(_ message: String) { log.warning(message) }
static func error(_ message: String) { log.error(message) }
static let log = Log(logLevel: .warning)
}

View File

@ -0,0 +1,30 @@
import Foundation
struct AppState: Codable {
var date: Date
var state: GlobalState
var domainStates: [String : DomainState]
}
extension AppState: Equatable {
}
extension AppState {
func print(to log: Log) {
log.debug("AppState \(date.formatted()): \(state)")
for (domain, state) in domainStates {
log.debug(" \(domain): \(state.lastResult)")
if state.currentWaitPeriod > 0 || state.serverErrorCount > 0 || state.clientErrorCount > 0 {
log.debug(" Last update: \(state.date.formatted()), wait time: \(state.currentWaitPeriod) s, \(state.serverErrorCount)/\(state.clientErrorCount) errors")
}
if !state.addresses.isEmpty {
log.debug(" IPs: \(state.addressList)")
}
}
}
}

View File

@ -0,0 +1,183 @@
import Foundation
enum DNSChangeResponse: RawRepresentable, Codable {
/**
The update was successful. You should not attempt another update until your IP address changes.
The response is followed by a list of the users IP addresses
*/
case success
/**
The supplied IP address is already set for this host. You should not attempt another update until your IP address changes.
The response is followed by a list of the users IP addresses
*/
case noChange
/**
The hostname doesn't exist, or doesn't have Dynamic DNS enabled.
*/
case invalidHostname
/**
The username/password combination isn't valid for the specified host.
*/
case badAuthentication
/**
The supplied hostname isn't a valid fully-qualified domain name.
*/
case notFullyQualifiedDomainName
/**
Your Dynamic DNS client makes bad requests. Ensure the user agent is set in the request.
*/
case badUserAgent
/**
Too many hosts (more than 20) specified in an update. Also returned if trying to update a round robin (which is not allowed).
*/
case tooManyHosts
/**
Dynamic DNS access for the hostname has been blocked due to failure to interpret previous responses correctly.
*/
case abuse
/**
An error happened on the server end. Wait 5 minutes and retry.
*/
case unexpectedServerError
/**
A custom A or AAAA resource record conflicts with the update. Delete the indicated resource record within the DNS settings page and try the update again.
*/
case conflict
case noResponseString
case unknown(String)
/// The IP addresses reported after update are not the same as the requested
case ipMismatch
init(rawValue: String) {
switch rawValue {
case "good": self = .success
case "nochg": self = .noChange
case "nohost": self = .invalidHostname
case "badauth": self = .badAuthentication
case "notfqdn": self = .notFullyQualifiedDomainName
case "badagent": self = .badUserAgent
case "abuse": self = .abuse
case "numhost": self = .tooManyHosts
case "911": self = .unexpectedServerError
case "conflict": self = .conflict
case "nostring": self = .noResponseString
case "mismatch": self = .ipMismatch
default: self = .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .success: return "good"
case .noChange: return "nochg"
case .invalidHostname: return "nohost"
case .badAuthentication: return "badauth"
case .notFullyQualifiedDomainName: return "notfqdn"
case .badUserAgent: return "badagent"
case .abuse: return "abuse"
case .tooManyHosts: return "numhost"
case .unexpectedServerError: return "911"
case .conflict: return "conflict"
case .noResponseString: return "nostring"
case .ipMismatch: return "mismatch"
case .unknown(let string): return string
}
}
init(line: String, expecting addresses: Set<String>) {
let parts = line.components(separatedBy: " ").compactMap { $0.trimmed }
self.init(rawValue: parts[0])
if case .unknown = self {
// Insert full response for better logging
self = .unknown(line)
return
}
guard isSuccessStatus else {
return
}
guard Set(parts.dropFirst()) != addresses else {
return
}
self = .ipMismatch
}
var isSuccessStatus: Bool {
switch self {
case .success, .noChange, .noResponseString, .ipMismatch:
// Treat no response and ip mismatch as success,
// otherwise we risk spamming the server
return true
default:
return false
}
}
var isServerError: Bool {
switch self {
case .invalidHostname, .badAuthentication, .notFullyQualifiedDomainName, .badUserAgent, .abuse, .unexpectedServerError, .conflict, .tooManyHosts:
return true
default: return false
}
}
var isClientError: Bool {
switch self {
case .noResponseString, .unknown, .ipMismatch:
return true
default: return false
}
}
func delayTime(previous time: TimeInterval) -> TimeInterval {
switch self {
case .invalidHostname, .badAuthentication, .notFullyQualifiedDomainName, .badUserAgent, .conflict, .noChange:
// Increase wait time by 10 minutes, up to 24 hours
return min(86400, time + 600)
case .abuse:
// Increase wait time by one hour, until abuse is lifted
return time + 3600
case .unknown:
// Don't spam on unknown errors
return time + 3600
default:
// Try again next time
return 0
}
}
}
extension DNSChangeResponse: CustomStringConvertible {
var description: String {
switch self {
case .success: return "success"
case .noChange: return "No change"
case .invalidHostname: return "Invalid hostname"
case .badAuthentication: return "Bad authentication"
case .notFullyQualifiedDomainName: return "Not FQDN"
case .badUserAgent: return "Bad user agent"
case .tooManyHosts: return "Too many hosts"
case .abuse: return "Abuse"
case .unexpectedServerError: return "Server error"
case .conflict: return "Conflict"
case .noResponseString: return "No response string"
case .unknown(let string): return string
case .ipMismatch: return "IP mismatch"
}
}
}

View File

@ -0,0 +1,31 @@
import Foundation
enum DNSError: Error {
case failedToOpenLogFile
case failedToReadLogFile(Error)
case failedToDecodeLogFile(Error)
case failedToReadLastState(Error)
case failedToDecodeLastState(Error)
case failedToEncodeState(Error)
case failedToWriteState(Error)
case failedToCreateDNSLog(Error)
case failedToOpenDNSLogForWriting
case failedToWriteDNSLog(Error)
case routerRequestFailedForIPv4(Error)
case invalidRouterResponseForIPv4
case invalidRouterResponseCodeForIPv4(Int)
case invalidRouterResponseBodyForIPv4
case invalidRouterResponseIpForIPv4(String)
case failedToPerformCommandForIPv6(Error)
case invalidCommandResultForIPv6(Int, String)
case invalidIpAddressForIPv6(String)
case failedToReadDomainConfiguration(Error)
case failedToDecodeDomainConfiguration(Error)
}

View File

@ -0,0 +1,185 @@
import Foundation
struct DNSUpdater {
private let log: Log
private let files: Files
init(configurationFileUrl: URL) throws {
let files = try Files(configurationFileURL: configurationFileUrl)
self.log = Log(logLevel: files.configuration.logLevel)
self.files = files
log.debug("Starting DNS updater")
log.debug(" Last update file: \(files.configuration.lastUpdateFilePath)")
log.debug(" Log file: \(files.configuration.logFilePath)")
}
func performDnsUpdateIfNeeded(log: Log) async throws {
let now = Date()
let configuration = try files.loadDomainConfiguration()
log.debug("Running DNS updater")
log.debug(" Domains: \(configuration.domains.sortedList)")
log.debug(" IPv4: \(configuration.useIPv4 ? "yes" : "no")")
log.debug(" IPv6: \(configuration.useIPv6 ? "yes" : "no")")
let oldState = try files.loadLastState()
oldState.print(to: log)
var newState = oldState
newState.state = .nominal
newState.domainStates = oldState.domainStates.filter { (domain, _) in
guard configuration.domains.contains(domain) else {
log.info("Removing state of domain \(domain)")
return false
}
return true
}
guard !configuration.domains.isEmpty else {
log.warning("No domains configured")
newState.state = .noDomainConfigured
try files.save(lastState: newState)
return
}
var currentIPs: Set<String> = []
let ipChecker = IPAddressCheck()
if configuration.useIPv4 {
do {
let ipV4 = try await ipChecker.determineIPv4()
currentIPs.insert(ipV4)
} catch {
log.error(error.localizedDescription)
newState.state = .missingIPv4
}
}
if configuration.useIPv6 {
do {
let ipV6 = try ipChecker.determineIPv6()
currentIPs.insert(ipV6)
} catch {
log.error(error.localizedDescription)
newState.state = .missingIPv6
}
}
guard !currentIPs.isEmpty else {
log.warning("No addresses available to update")
newState.state = .missingAddresses
try files.save(lastState: newState)
return
}
let domainsNeedingUpdate = updates(from: newState.domainStates, with: currentIPs, for: configuration.domains)
guard !domainsNeedingUpdate.isEmpty else {
log.info("No update needed")
return
}
log.info("Updating \(domainsNeedingUpdate.list) to \(currentIPs.sortedList)")
let updatedDomains = await updateStratoEntry(
domains: domainsNeedingUpdate,
addresses: currentIPs,
password: configuration.password)
var updates = [String : DomainState]()
for (domain, response) in updatedDomains {
log.debug("\(domain): \(response)")
let old = newState.domainStates[domain] ?? .init(initial: now)
let new = old.updated(now, from: response, expecting: currentIPs)
updates[domain] = new
newState.domainStates[domain] = new
if response.isServerError {
newState.state = .serverError
}
if response.isClientError && newState.state != .serverError {
newState.state = .clientError
}
}
if newState != oldState {
do {
try files.save(lastState: newState)
log.debug("State updated")
} catch {
log.error(error.localizedDescription)
}
}
if !updates.isEmpty {
try files.addLogEntry(changedDomains: updates)
log.debug("Log updated")
}
}
private func updates(from lastState: [String : DomainState], with currentIPs: Set<String>, for domains: Set<String>) -> [String] {
let now = Date()
// Add all new domains
var updates = domains.subtracting(lastState.keys)
for (domain, state) in lastState {
guard state.needsUpdate(at: now, to: currentIPs) else {
continue
}
updates.insert(domain)
}
return updates.sorted()
}
private func updateStratoEntry(domains: [String], addresses: Set<String>, password: String) async -> [String : DNSChangeResponse] {
func fail(_ error: DNSChangeResponse) -> [String : DNSChangeResponse] {
domains.reduce(into: [:]) { $0[$1] = error }
}
func unknown(_ message: String) -> [String : DNSChangeResponse] {
fail(.unknown(message))
}
guard let domain = domains.first else {
return fail(.success)
}
let domainList = domains.joined(separator: ",")
let addressList = addresses.sorted().joined(separator: ",")
let url = "https://dyndns.strato.com/nic/update?hostname=\(domainList)&myip=\(addressList)"
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "GET"
let auth = "\(domain):\(password)".data(using: .utf8)!.base64EncodedString()
request.setValue("Basic \(auth)", forHTTPHeaderField: "Authorization")
request.setValue("CH DNS Updater 1.0", forHTTPHeaderField: "User-Agent")
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
return unknown("Request failed (\(error.localizedDescription.singleLined))")
}
let httpResponse = response as! HTTPURLResponse
guard httpResponse.statusCode == 200 else {
return unknown("Response \(httpResponse.statusCode)")
}
guard let responseBody = String(data: data, encoding: .utf8) else {
return fail(.noResponseString)
}
let domainResults = responseBody
.components(separatedBy: "\n")
.compactMap { $0.trimmed.nonEmpty }
guard domainResults.count == domains.count else {
return unknown("Result count mismatch: \(domainResults.count) != \(domains.count)")
}
return (0..<domains.count).reduce(into: [:]) { dict, index in
dict[domains[index]] = DNSChangeResponse(line: domainResults[index], expecting: addresses)
}
}
}

View File

@ -0,0 +1,66 @@
import Foundation
struct DnsConfiguration: Codable, Equatable {
let domainConfigFilePath: String
let logFilePath: String
let lastUpdateFilePath: String
let logLevel: Log.Level
}
struct DomainConfiguration {
let domains: Set<String>
let password: String
var useIPv4: Bool = true
var useIPv6: Bool = true
}
extension DomainConfiguration: Equatable {
}
extension DomainConfiguration: Hashable {
}
extension DomainConfiguration: Encodable {
enum CodingKeys: String, CodingKey {
case domains
case password
case useIPv4
case useIPv6
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(domains, forKey: .domains)
try container.encode(password, forKey: .password)
if !useIPv4 {
try container.encode(false, forKey: .useIPv4)
}
if !useIPv6 {
try container.encode(false, forKey: .useIPv6)
}
}
}
extension DomainConfiguration: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.domains = try container.decode(Set<String>.self, forKey: .domains)
self.password = try container.decode(String.self, forKey: .password)
self.useIPv4 = try container.decodeIfPresent(Bool.self, forKey: .useIPv4) ?? true
self.useIPv6 = try container.decodeIfPresent(Bool.self, forKey: .useIPv6) ?? true
}
}

View File

@ -0,0 +1,102 @@
import Foundation
struct DomainState: Codable {
/// The date of the last successful update
let date: Date
/// The last registered addresses
let addresses: Set<String>
/**
Number of successive errors signaled by the server.
Needed to prevent excessive requests and being flagged as abuse.
*/
let serverErrorCount: Int
/**
Number of errors on the client side.
Errors here will not force the algorithm to back off.
*/
let clientErrorCount: Int
/**
The time to wait before the next update attempt
*/
let currentWaitPeriod: TimeInterval
let lastResult: DNSChangeResponse
init(date: Date, addresses: Set<String>, serverErrorCount: Int, clientErrorCount: Int, currentWaitPeriod: TimeInterval, lastResult: DNSChangeResponse) {
self.date = date
self.addresses = addresses
self.serverErrorCount = serverErrorCount
self.clientErrorCount = clientErrorCount
self.currentWaitPeriod = currentWaitPeriod
self.lastResult = lastResult
}
init(initial date: Date) {
self.date = date
self.addresses = []
self.serverErrorCount = 0
self.clientErrorCount = 0
self.currentWaitPeriod = 0
self.lastResult = .success
}
var nextUpdate: Date {
date.addingTimeInterval(currentWaitPeriod)
}
private static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
func logEntry(domain: String) -> String {
"\(domain);\(DomainState.formatter.string(from: date));\(addressList);\(serverErrorCount);\(clientErrorCount);\(currentWaitPeriod);\(lastResult.rawValue)"
}
var addressList: String {
addresses.sortedList
}
func needsUpdate(at now: Date, to addresses: Set<String>) -> Bool {
guard now >= nextUpdate else {
return false
}
return !self.addresses.subtracting(addresses).isEmpty
}
func updated(_ now: Date, from result: DNSChangeResponse, expecting addresses: Set<String>) -> DomainState {
let addresses = result.isSuccessStatus ? addresses : self.addresses
let serverError = result.isServerError ? serverErrorCount + 1 : 0
let clientError = result.isClientError ? clientErrorCount + 1 : 0
let waitTime = result.delayTime(previous: currentWaitPeriod)
return .init(
date: now,
addresses: addresses,
serverErrorCount: serverError,
clientErrorCount: clientError,
currentWaitPeriod: waitTime,
lastResult: result)
}
}
extension DomainState: Equatable {
static func == (lhs: DomainState, rhs: DomainState) -> Bool {
lhs.date == rhs.date &&
lhs.addresses == rhs.addresses &&
lhs.clientErrorCount == rhs.clientErrorCount &&
lhs.serverErrorCount == rhs.serverErrorCount &&
lhs.currentWaitPeriod == rhs.currentWaitPeriod &&
lhs.lastResult == rhs.lastResult
}
}

View File

@ -0,0 +1,113 @@
import Foundation
struct Files {
let configuration: DnsConfiguration
var lastDataURL: URL {
.init(fileURLWithPath: configuration.lastUpdateFilePath)
}
var logFileURL: URL {
.init(fileURLWithPath: configuration.logFilePath)
}
var domainConfigurationURL: URL {
.init(fileURLWithPath: configuration.domainConfigFilePath)
}
init(configurationFileURL: URL) throws {
let data: Data
do {
data = try Data(contentsOf: configurationFileURL)
} catch {
throw DNSError.failedToReadLogFile(error)
}
do {
self.configuration = try JSONDecoder().decode(DnsConfiguration.self, from: data)
} catch {
throw DNSError.failedToDecodeLogFile(error)
}
}
func loadDomainConfiguration() throws -> DomainConfiguration {
let data: Data
do {
data = try Data(contentsOf: domainConfigurationURL)
} catch {
throw DNSError.failedToReadDomainConfiguration(error)
}
do {
return try JSONDecoder().decode(DomainConfiguration.self, from: data)
} catch {
throw DNSError.failedToDecodeDomainConfiguration(error)
}
}
func loadLastState() throws -> AppState {
guard FileManager.default.fileExists(atPath: configuration.lastUpdateFilePath) else {
return .init(date: Date(), state: .nominal, domainStates: [:])
}
let data: Data
do {
data = try Data(contentsOf: lastDataURL)
} catch {
throw DNSError.failedToReadLastState(error)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let state: AppState
do {
state = try decoder.decode(AppState.self, from: data)
} catch {
throw DNSError.failedToDecodeLastState(error)
}
return state
}
func save(lastState: AppState) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
encoder.outputFormatting = .prettyPrinted
let data: Data
do {
data = try encoder.encode(lastState)
} catch {
throw DNSError.failedToEncodeState(error)
}
do {
try data.write(to: lastDataURL)
} catch {
throw DNSError.failedToWriteState(error)
}
}
func addLogEntry(changedDomains: [String : DomainState]) throws {
if !FileManager.default.fileExists(atPath: configuration.logFilePath) {
do {
try Data().write(to: logFileURL)
} catch {
throw DNSError.failedToCreateDNSLog(error)
}
}
guard let handle = FileHandle(forUpdatingAtPath: logFileURL.path) else {
throw DNSError.failedToOpenDNSLogForWriting
}
let entry = changedDomains.map {
$1.logEntry(domain: $0)
}
.joined(separator: "\n") + "\n"
let entryData = entry.data(using: .utf8)!
do {
try handle.seekToEnd()
try handle.write(contentsOf: entryData)
try handle.close()
} catch {
throw DNSError.failedToWriteDNSLog(error)
}
}
}

View File

@ -0,0 +1,36 @@
import Foundation
enum GlobalState: UInt8, Codable {
case nominal = 0
/// No domains set in configuration file
case noDomainConfigured = 1
/// Both addresses are missing
case missingAddresses = 2
/// Missing IPv4 address
case missingIPv4 = 3
/// Missing IPv6 address
case missingIPv6 = 4
case serverError = 5
case clientError = 6
}
extension GlobalState: CustomStringConvertible {
var description: String {
switch self {
case .nominal: return "Nominal"
case .noDomainConfigured: return "No domains"
case .missingAddresses: return "Missing addresses"
case .missingIPv4: return "Missing IPv4"
case .missingIPv6: return "Missing IPv6"
case .serverError: return "Server error"
case .clientError: return "Client error"
}
}
}

View File

@ -0,0 +1,120 @@
import Foundation
struct IPAddressCheck {
private var ipv4Request: URLRequest {
let url = URL(string: "http://fritz.box:49000/igdupnp/control/WANIPConn1")!
var request = URLRequest(url: url)
request.setValue("text/xml; charset=utf-8",
forHTTPHeaderField: "Content-Type")
request.setValue("urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress",
forHTTPHeaderField: "SOAPAction")
let body = """
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddress xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1" />
</s:Body>
</s:Envelope>
"""
request.httpMethod = "POST"
request.httpBody = body.data(using: .utf8)!
return request
}
func determineIPv4() async throws -> String {
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: ipv4Request)
} catch {
throw DNSError.routerRequestFailedForIPv4(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw DNSError.invalidRouterResponseForIPv4
}
guard httpResponse.statusCode == 200 else {
throw DNSError.invalidRouterResponseCodeForIPv4(httpResponse.statusCode)
}
guard let responseBody = String(data: data, encoding: .utf8) else {
throw DNSError.invalidRouterResponseBodyForIPv4
}
let ipString = responseBody
.components(separatedBy: "<NewExternalIPAddress>").last!
.components(separatedBy: "</NewExternalIPAddress>").first!
let parts = ipString.components(separatedBy: ".")
guard parts.count == 4 else {
throw DNSError.invalidRouterResponseIpForIPv4(ipString)
}
let numbers = parts.compactMap(Int.init)
guard numbers.count == 4 else {
throw DNSError.invalidRouterResponseIpForIPv4(ipString)
}
guard numbers.contains(where: { $0 != 0 }) else {
throw DNSError.invalidRouterResponseIpForIPv4(ipString)
}
return ipString
}
private func execute(_ command: String) throws -> (code: Int, output: String) {
let task = Process()
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-cl", command]
task.executableURL = URL(fileURLWithPath: "/bin/bash")
task.standardInput = nil
try task.run()
task.waitUntilExit()
let code = task.terminationStatus
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)!
return (Int(code), output)
}
func determineIPv6() throws -> String {
#if os(Linux)
let ipv6Command = "ip addr show"
#elseif os(macOS)
let ipv6Command = "ifconfig"
#else
fatalError("Unsupported OS")
#endif
let code: Int
let output: String
do {
(code, output) = try execute(ipv6Command)
} catch {
throw DNSError.failedToPerformCommandForIPv6(error)
}
guard code == 0 else {
throw DNSError.invalidCommandResultForIPv6(code, output)
}
let addresses: [String] = output
.components(separatedBy: "inet6 ")
.dropFirst()
.compactMap { $0
.components(separatedBy: "/").first?
.components(separatedBy: " ").first
}
.compactMap {
let parts = $0.components(separatedBy: ":")
guard parts.count == 8 else {
return nil
}
return $0
}
guard let ip = addresses.first else {
throw DNSError.invalidIpAddressForIPv6(output)
}
return ip
}
}

View File

@ -0,0 +1,31 @@
import Foundation
extension String {
var singleLined: String {
components(separatedBy: .newlines)
.joined(separator: " ")
}
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
var nonEmpty: String? {
self != "" ? self : nil
}
}
extension Array where Element == String {
var list: String {
joined(separator: ", ")
}
}
extension Sequence where Element == String {
var sortedList: String {
sorted().list
}
}

View File

@ -0,0 +1,27 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
extension URLSession {
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let result: Result<(response: URLResponse, data: Data), Error> = await withCheckedContinuation { continuation in
let task = dataTask(with: request) { data, response, error in
if let error {
continuation.resume(returning: .failure(error))
} else {
continuation.resume(returning: .success((response!, data!)))
}
}
task.resume()
}
switch result {
case .failure(let error):
throw error
case .success(let result):
return (result.data, result.response)
}
}
}
#endif

View File

@ -0,0 +1,10 @@
import Foundation
func wait(for block: @escaping @Sendable () async -> Void) {
let semaphore = DispatchSemaphore(value: 0)
Task {
await block()
semaphore.signal()
}
semaphore.wait()
}

View File

@ -0,0 +1,10 @@
import Vapor
public func configure(_ app: Application) async throws {
let resourceDirectoryUrl = URL(fileURLWithPath: app.directory.resourcesDirectory)
let configurationFileUrl = resourceDirectoryUrl.appending(path: "config.json", directoryHint: .notDirectory)
try routes(app)
}

View File

@ -0,0 +1,21 @@
import Vapor
import Logging
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }
do {
try await configure(app)
} catch {
app.logger.report(error: error)
throw error
}
try await app.execute()
}
}

8
Sources/App/routes.swift Normal file
View File

@ -0,0 +1,8 @@
import Vapor
func routes(_ app: Application) throws {
app.get { req async in
"Success"
}
}

View File

@ -0,0 +1,33 @@
@testable import App
import XCTVapor
final class AppTests: XCTestCase {
func testHelloWorld() async throws {
let app = Application(.testing)
defer { app.shutdown() }
try await configure(app)
try app.test(.GET, "hello", afterResponse: { res in
XCTAssertEqual(res.status, .ok)
XCTAssertEqual(res.body.string, "Hello, world!")
})
}
func testDecodeConfigurationWithoutOptionalValues() throws {
let domainConfig = DomainConfiguration(domains: ["some.more"], password: "secret", useIPv4: true, useIPv6: false)
let content =
"""
{
"domains": [
"some.more"
],
"useIPv6": false,
"password":"secret"
}
"""
let data = content.data(using: .utf8)!
let decoded = try JSONDecoder().decode(DomainConfiguration.self, from: data)
XCTAssertEqual(domainConfig, decoded)
}
}