diff --git a/Sora/Utils/Networking/ResolverDNS.swift b/Sora/Utils/Networking/ResolverDNS.swift new file mode 100644 index 0000000..4342f6c --- /dev/null +++ b/Sora/Utils/Networking/ResolverDNS.swift @@ -0,0 +1,280 @@ +// +// ResolverDNS.swift +// Sulfur +// +// Created by seiike on 28/03/2025. +// + +import Foundation +import Network + +// MARK: - DNS Provider Enum + +enum DNSProvider: String, CaseIterable, Hashable { + case cloudflare = "Cloudflare" + case google = "Google" + case openDNS = "OpenDNS" + case quad9 = "Quad9" + case adGuard = "AdGuard" + case cleanbrowsing = "CleanBrowsing" + case controld = "ControlD" + + var servers: [String] { + switch self { + case .cloudflare: + return ["1.1.1.1", "1.0.0.1"] + case .google: + return ["8.8.8.8", "8.8.4.4"] + case .openDNS: + return ["208.67.222.222", "208.67.220.220"] + case .quad9: + return ["9.9.9.9", "149.112.112.112"] + case .adGuard: + return ["94.140.14.14", "94.140.15.15"] + case .cleanbrowsing: + return ["185.228.168.168", "185.228.169.168"] + case .controld: + return ["76.76.2.0", "76.76.10.0"] + } + } + + static var current: DNSProvider { + get { + let raw = UserDefaults.standard.string(forKey: "SelectedDNSProvider") ?? DNSProvider.cloudflare.rawValue + return DNSProvider(rawValue: raw) ?? .cloudflare + } + set { + UserDefaults.standard.setValue(newValue.rawValue, forKey: "SelectedDNSProvider") + } + } +} + +// MARK: - DNS Resolver Errors + +enum DNSResolverError: Error { + case invalidResponse + case noAnswer + case connectionError(String) + case timeout +} + +// MARK: - CustomDNSResolver Class + +class CustomDNSResolver { + + /// Returns an array of DNS servers. + /// If a custom provider ("Custom") is selected in UserDefaults, it returns the custom primary and secondary values; + /// otherwise, it falls back to the default provider's servers. + var dnsServers: [String] { + if let provider = UserDefaults.standard.string(forKey: "CustomDNSProvider"), + provider == "Custom" { + let primary = UserDefaults.standard.string(forKey: "customPrimaryDNS") ?? "" + let secondary = UserDefaults.standard.string(forKey: "customSecondaryDNS") ?? "" + var servers = [String]() + if !primary.isEmpty { servers.append(primary) } + if !secondary.isEmpty { servers.append(secondary) } + if !servers.isEmpty { + return servers + } + } + return DNSProvider.current.servers + } + + /// Resolves the provided hostname by sending a DNS query over UDP. + /// - Parameters: + /// - hostname: The hostname to resolve. + /// - timeout: How long to wait for a response (default 5 seconds). + /// - completion: A closure called with the result: a list of IPv4 addresses or an error. + func resolve(hostname: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<[String], Error>) -> Void) { + // Use the first DNS server from our list + guard let dnsServer = dnsServers.first else { + completion(.failure(DNSResolverError.connectionError("No DNS server available"))) + return + } + + let port: NWEndpoint.Port = 53 + let queryID = UInt16.random(in: 0...UInt16.max) + + guard let queryData = buildDNSQuery(hostname: hostname, queryID: queryID) else { + completion(.failure(DNSResolverError.connectionError("Failed to build DNS query"))) + return + } + + // Create a new UDP connection + let connection = NWConnection(host: NWEndpoint.Host(dnsServer), port: port, using: .udp) + + // Track connection state manually + var localState = NWConnection.State.setup + + connection.stateUpdateHandler = { [weak self] newState in + localState = newState + switch newState { + case .ready: + // Send the DNS query + connection.send(content: queryData, completion: .contentProcessed({ error in + if let error = error { + connection.cancel() + completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) + } else { + // Receive the DNS response + self?.receiveDNSResponse(connection: connection, + expectedQueryID: queryID, + completion: completion) + } + })) + case .failed(let error): + connection.cancel() + completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) + default: + break + } + } + + // Start the connection + connection.start(queue: DispatchQueue.global()) + + // Implement a timeout for the query using a switch on localState + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + switch localState { + case .failed(_), .cancelled: + // Already failed or canceled; do nothing + break + default: + // Not failed or canceled => consider it timed out + connection.cancel() + completion(.failure(DNSResolverError.timeout)) + } + } + } + + // MARK: - Receiving and Parsing + + private func receiveDNSResponse(connection: NWConnection, + expectedQueryID: UInt16, + completion: @escaping (Result<[String], Error>) -> Void) { + connection.receiveMessage { [weak self] data, _, _, error in + connection.cancel() + + if let error = error { + completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) + return + } + guard let data = data else { + completion(.failure(DNSResolverError.invalidResponse)) + return + } + + if let ips = self?.parseDNSResponse(data: data, queryID: expectedQueryID), !ips.isEmpty { + completion(.success(ips)) + } else { + completion(.failure(DNSResolverError.noAnswer)) + } + } + } + + // MARK: - DNS Query Construction + + /// Constructs a DNS query packet for the given hostname. + /// - Parameters: + /// - hostname: The hostname to resolve. + /// - queryID: A randomly generated query identifier. + /// - Returns: A Data object representing the DNS query. + private func buildDNSQuery(hostname: String, queryID: UInt16) -> Data? { + var data = Data() + + // Header: ID (2 bytes) + data.append(contentsOf: withUnsafeBytes(of: queryID.bigEndian, Array.init)) + + // Flags: standard query with recursion desired (0x0100) + let flags: UInt16 = 0x0100 + data.append(contentsOf: withUnsafeBytes(of: flags.bigEndian, Array.init)) + + // QDCOUNT = 1 + let qdcount: UInt16 = 1 + data.append(contentsOf: withUnsafeBytes(of: qdcount.bigEndian, Array.init)) + + // ANCOUNT = 0, NSCOUNT = 0, ARCOUNT = 0 + let zero: UInt16 = 0 + data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // ANCOUNT + data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // NSCOUNT + data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // ARCOUNT + + // Question section: + // QNAME: Encode hostname by splitting into labels. + let labels = hostname.split(separator: ".") + for label in labels { + guard let labelData = label.data(using: .utf8) else { + return nil + } + data.append(UInt8(labelData.count)) + data.append(labelData) + } + // Terminate QNAME with zero byte. + data.append(0) + + // QTYPE: A record (1) + let qtype: UInt16 = 1 + data.append(contentsOf: withUnsafeBytes(of: qtype.bigEndian, Array.init)) + + // QCLASS: IN (1) + let qclass: UInt16 = 1 + data.append(contentsOf: withUnsafeBytes(of: qclass.bigEndian, Array.init)) + + return data + } + + // MARK: - DNS Response Parsing + + /// Parses the DNS response packet and extracts IPv4 addresses from A record answers. + /// - Parameters: + /// - data: The DNS response data. + /// - queryID: The expected query identifier. + /// - Returns: An array of IPv4 address strings, or nil if parsing fails. + private func parseDNSResponse(data: Data, queryID: UInt16) -> [String]? { + // Ensure the response is at least long enough for a header. + guard data.count >= 12 else { return nil } + + // ID is the first 2 bytes + let responseID = data.subdata(in: 0..<2).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + guard responseID == queryID else { return nil } + + // ANCOUNT is at offset 6. + let ancount = data.subdata(in: 6..<8).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + if ancount == 0 { return nil } + + // Skip the header and question section. + var offset = 12 + // Skip QNAME + while offset < data.count && data[offset] != 0 { + offset += Int(data[offset]) + 1 + } + offset += 1 // Skip the terminating zero. + + // Skip QTYPE (2 bytes) and QCLASS (2 bytes) + offset += 4 + + var ips: [String] = [] + + // Loop through answer records. + for _ in 0.. data.count { break } + offset += 2 // Skip NAME (pointer) + let type = data.subdata(in: offset..<(offset+2)).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + offset += 2 // TYPE + offset += 2 // CLASS + offset += 4 // TTL + let rdlength = data.subdata(in: offset..<(offset+2)).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + offset += 2 + + // If the record is an A record and the length is 4 bytes, extract the IPv4 address. + if type == 1 && rdlength == 4 && offset + 4 <= data.count { + let ipBytes = data.subdata(in: offset..<(offset+4)) + let ip = ipBytes.map { String($0) }.joined(separator: ".") + ips.append(ip) + } + offset += Int(rdlength) + } + + return ips + } +} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 154d1d2..a3414dc 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -101,6 +101,26 @@ struct SettingsViewGeneral: View { .tint(.accentColor) } + Section(header: Text("Network"), footer: Text("Try between some of the providers in case something is not loading if it should be, as it might be the fault of your ISP.")){ + HStack { + Text("DNS service") + Spacer() + Menu(customDNSProvider) { + ForEach(customDNSProviderList, id: \.self) { provider in + Button(action: { customDNSProvider = provider }) { + Text(provider) + } + } + } + } + if customDNSProvider == "Custom" { + TextField("Primary DNS", text: $customPrimaryDNS) + .keyboardType(.numbersAndPunctuation) + TextField("Secondary DNS", text: $customSecondaryDNS) + .keyboardType(.numbersAndPunctuation) + } + } + Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) { Toggle("Enable Analytics", isOn: $analyticsEnabled) .tint(.accentColor) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ad75420..66fe45f 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; + 1E2719D92D975126008C4BD0 /* ResolverDNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; @@ -117,6 +118,7 @@ 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; + 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolverDNS.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; @@ -284,6 +286,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 1E2719D72D9750FB008C4BD0 /* Networking */, 13DB7CEA2D7DED50004371D3 /* DownloadManager */, 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, 13103E8C2D58E037000F0673 /* SkeletonCells */, @@ -437,6 +440,14 @@ path = Components; sourceTree = ""; }; + 1E2719D72D9750FB008C4BD0 /* Networking */ = { + isa = PBXGroup; + children = ( + 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */, + ); + path = Networking; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -524,6 +535,7 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */, 139935662D468C450065CEFF /* ModuleManager.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, + 1E2719D92D975126008C4BD0 /* ResolverDNS.swift in Sources */, 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */,