186 lines
6.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|