First version
This commit is contained in:
commit
9b6fd627ac
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
34
Package.swift
Normal 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
0
Public/.gitkeep
Normal file
52
Sources/App/Logger.swift
Normal file
52
Sources/App/Logger.swift
Normal 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)
|
||||
}
|
30
Sources/App/Model/AppState.swift
Normal file
30
Sources/App/Model/AppState.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
183
Sources/App/Model/DNSChangeResponse.swift
Normal file
183
Sources/App/Model/DNSChangeResponse.swift
Normal 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 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<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"
|
||||
}
|
||||
}
|
||||
}
|
31
Sources/App/Model/DNSError.swift
Normal file
31
Sources/App/Model/DNSError.swift
Normal 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)
|
||||
}
|
185
Sources/App/Model/DNSUpdater.swift
Normal file
185
Sources/App/Model/DNSUpdater.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
66
Sources/App/Model/DnsConfiguration.swift
Normal file
66
Sources/App/Model/DnsConfiguration.swift
Normal 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
|
||||
}
|
||||
}
|
102
Sources/App/Model/DomainState.swift
Normal file
102
Sources/App/Model/DomainState.swift
Normal 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
|
||||
}
|
||||
}
|
113
Sources/App/Model/Files.swift
Normal file
113
Sources/App/Model/Files.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
36
Sources/App/Model/GlobalState.swift
Normal file
36
Sources/App/Model/GlobalState.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
120
Sources/App/Model/IPAddressCheck.swift
Normal file
120
Sources/App/Model/IPAddressCheck.swift
Normal 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
|
||||
}
|
||||
}
|
31
Sources/App/Util/String+Extensions.swift
Normal file
31
Sources/App/Util/String+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
27
Sources/App/Util/URLSession+Async.swift
Normal file
27
Sources/App/Util/URLSession+Async.swift
Normal 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
|
10
Sources/App/Util/Wait.swift
Normal file
10
Sources/App/Util/Wait.swift
Normal 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()
|
||||
}
|
10
Sources/App/configure.swift
Normal file
10
Sources/App/configure.swift
Normal 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)
|
||||
}
|
21
Sources/App/entrypoint.swift
Normal file
21
Sources/App/entrypoint.swift
Normal 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
8
Sources/App/routes.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Vapor
|
||||
|
||||
func routes(_ app: Application) throws {
|
||||
|
||||
app.get { req async in
|
||||
"Success"
|
||||
}
|
||||
}
|
33
Tests/AppTests/AppTests.swift
Normal file
33
Tests/AppTests/AppTests.swift
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user