From 9b6fd627aceced2b9b95513831a42768e1c8a4fd Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Fri, 15 Nov 2024 10:46:29 +0100 Subject: [PATCH] First version --- .gitignore | 13 ++ Package.swift | 34 ++++ Public/.gitkeep | 0 Sources/App/Logger.swift | 52 ++++++ Sources/App/Model/AppState.swift | 30 ++++ Sources/App/Model/DNSChangeResponse.swift | 183 +++++++++++++++++++++ Sources/App/Model/DNSError.swift | 31 ++++ Sources/App/Model/DNSUpdater.swift | 185 ++++++++++++++++++++++ Sources/App/Model/DnsConfiguration.swift | 66 ++++++++ Sources/App/Model/DomainState.swift | 102 ++++++++++++ Sources/App/Model/Files.swift | 113 +++++++++++++ Sources/App/Model/GlobalState.swift | 36 +++++ Sources/App/Model/IPAddressCheck.swift | 120 ++++++++++++++ Sources/App/Util/String+Extensions.swift | 31 ++++ Sources/App/Util/URLSession+Async.swift | 27 ++++ Sources/App/Util/Wait.swift | 10 ++ Sources/App/configure.swift | 10 ++ Sources/App/entrypoint.swift | 21 +++ Sources/App/routes.swift | 8 + Tests/AppTests/AppTests.swift | 33 ++++ 20 files changed, 1105 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Public/.gitkeep create mode 100644 Sources/App/Logger.swift create mode 100644 Sources/App/Model/AppState.swift create mode 100644 Sources/App/Model/DNSChangeResponse.swift create mode 100644 Sources/App/Model/DNSError.swift create mode 100644 Sources/App/Model/DNSUpdater.swift create mode 100644 Sources/App/Model/DnsConfiguration.swift create mode 100644 Sources/App/Model/DomainState.swift create mode 100644 Sources/App/Model/Files.swift create mode 100644 Sources/App/Model/GlobalState.swift create mode 100644 Sources/App/Model/IPAddressCheck.swift create mode 100644 Sources/App/Util/String+Extensions.swift create mode 100644 Sources/App/Util/URLSession+Async.swift create mode 100644 Sources/App/Util/Wait.swift create mode 100644 Sources/App/configure.swift create mode 100644 Sources/App/entrypoint.swift create mode 100644 Sources/App/routes.swift create mode 100644 Tests/AppTests/AppTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..866eec3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +Packages +.build +xcuserdata +*.xcodeproj +DerivedData/ +.DS_Store +db.sqlite +.swiftpm +.env +.env.* +! .env.example +.vscode +Package.resolved diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f954812 --- /dev/null +++ b/Package.swift @@ -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"), +] } diff --git a/Public/.gitkeep b/Public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Sources/App/Logger.swift b/Sources/App/Logger.swift new file mode 100644 index 0000000..4836c0a --- /dev/null +++ b/Sources/App/Logger.swift @@ -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) +} diff --git a/Sources/App/Model/AppState.swift b/Sources/App/Model/AppState.swift new file mode 100644 index 0000000..c0d92bd --- /dev/null +++ b/Sources/App/Model/AppState.swift @@ -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)") + } + } + } +} diff --git a/Sources/App/Model/DNSChangeResponse.swift b/Sources/App/Model/DNSChangeResponse.swift new file mode 100644 index 0000000..c69d8b4 --- /dev/null +++ b/Sources/App/Model/DNSChangeResponse.swift @@ -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 user’s 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 user’s 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) { + 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" + } + } +} diff --git a/Sources/App/Model/DNSError.swift b/Sources/App/Model/DNSError.swift new file mode 100644 index 0000000..7e59678 --- /dev/null +++ b/Sources/App/Model/DNSError.swift @@ -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) +} diff --git a/Sources/App/Model/DNSUpdater.swift b/Sources/App/Model/DNSUpdater.swift new file mode 100644 index 0000000..fcb3aa3 --- /dev/null +++ b/Sources/App/Model/DNSUpdater.swift @@ -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 = [] + + 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.. + + 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.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 + } +} diff --git a/Sources/App/Model/DomainState.swift b/Sources/App/Model/DomainState.swift new file mode 100644 index 0000000..ad34934 --- /dev/null +++ b/Sources/App/Model/DomainState.swift @@ -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 + + /** + 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, 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) -> Bool { + guard now >= nextUpdate else { + return false + } + return !self.addresses.subtracting(addresses).isEmpty + } + + func updated(_ now: Date, from result: DNSChangeResponse, expecting addresses: Set) -> 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 + } +} diff --git a/Sources/App/Model/Files.swift b/Sources/App/Model/Files.swift new file mode 100644 index 0000000..1a74fae --- /dev/null +++ b/Sources/App/Model/Files.swift @@ -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) + } + } + +} diff --git a/Sources/App/Model/GlobalState.swift b/Sources/App/Model/GlobalState.swift new file mode 100644 index 0000000..de81b19 --- /dev/null +++ b/Sources/App/Model/GlobalState.swift @@ -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" + } + } +} diff --git a/Sources/App/Model/IPAddressCheck.swift b/Sources/App/Model/IPAddressCheck.swift new file mode 100644 index 0000000..d72fbbb --- /dev/null +++ b/Sources/App/Model/IPAddressCheck.swift @@ -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 = """ + + + + + + + """ + 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: "").last! + .components(separatedBy: "").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 + } +} diff --git a/Sources/App/Util/String+Extensions.swift b/Sources/App/Util/String+Extensions.swift new file mode 100644 index 0000000..60de955 --- /dev/null +++ b/Sources/App/Util/String+Extensions.swift @@ -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 + } +} diff --git a/Sources/App/Util/URLSession+Async.swift b/Sources/App/Util/URLSession+Async.swift new file mode 100644 index 0000000..a565e5c --- /dev/null +++ b/Sources/App/Util/URLSession+Async.swift @@ -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 diff --git a/Sources/App/Util/Wait.swift b/Sources/App/Util/Wait.swift new file mode 100644 index 0000000..582dd99 --- /dev/null +++ b/Sources/App/Util/Wait.swift @@ -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() +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift new file mode 100644 index 0000000..60955a4 --- /dev/null +++ b/Sources/App/configure.swift @@ -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) +} diff --git a/Sources/App/entrypoint.swift b/Sources/App/entrypoint.swift new file mode 100644 index 0000000..e875aee --- /dev/null +++ b/Sources/App/entrypoint.swift @@ -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() + } +} diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift new file mode 100644 index 0000000..37c503b --- /dev/null +++ b/Sources/App/routes.swift @@ -0,0 +1,8 @@ +import Vapor + +func routes(_ app: Application) throws { + + app.get { req async in + "Success" + } +} diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift new file mode 100644 index 0000000..0ebef88 --- /dev/null +++ b/Tests/AppTests/AppTests.swift @@ -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) + } +}