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