DnsUpdater/Sources/App/Model/DNSUpdater.swift
Christoph Hagen 9b6fd627ac First version
2024-11-15 10:46:29 +01:00

186 lines
6.5 KiB
Swift

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)
}
}
}