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 = [] 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, for domains: Set) -> [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, 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..