import Foundation import SwiftUI import Security struct UniFiSite: Codable, Identifiable, Hashable { let id: String let name: String } struct DNSRecord: Codable, Identifiable, Hashable { var id: String var type: String var enabled: Bool var domain: String? var ipv4Address: String? var ipv6Address: String? var targetDomain: String? var ttlSeconds: Int? var domainName: String { domain ?? "—" } var recordType: String { type.replacingOccurrences(of: "_RECORD", with: "") } var value: String { ipv4Address ?? ipv6Address ?? targetDomain ?? "See UniFi Network" } static let examples = [ DNSRecord(id: "demo-home", type: "A_RECORD", enabled: true, domain: "home.lab", ipv4Address: "192.168.1.20", ttlSeconds: 14400), DNSRecord(id: "demo-photos", type: "A_RECORD", enabled: true, domain: "photos.lab", ipv4Address: "192.168.1.24", ttlSeconds: 14400) ] static func isValidDomain(_ value: String) -> Bool { guard !value.isEmpty, value.count <= 127 else { return false } return value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { label in !label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-" && label.utf8.allSatisfy { (97...122).contains($0) || (65...90).contains($0) || (48...57).contains($0) || $0 == 45 } } } static func isValidIPv4(_ value: String) -> Bool { let parts = value.split(separator: ".", omittingEmptySubsequences: false) return parts.count == 4 && parts.allSatisfy { !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } && Int($0).map { (0...255).contains($0) } == true && ($0.count == 1 || $0.first != "0") } } func payload() throws -> Data { guard type == "A_RECORD", Self.isValidDomain(domainName), Self.isValidIPv4(ipv4Address ?? ""), let ttlSeconds, (0...86400).contains(ttlSeconds) else { throw ServiceError.message("Enter a valid domain, IPv4 address, and TTL between 0 and 86400 seconds.") } return try JSONSerialization.data(withJSONObject: [ "type": "A_RECORD", "domain": domainName, "ipv4Address": ipv4Address ?? "", "ttlSeconds": ttlSeconds, "enabled": enabled ]) } } // The optional trust exception applies only to this gateway session and exact host. // Redirects are rejected so the API key cannot be forwarded to another endpoint. nonisolated final class UniFiSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { let host: String let allowUntrusted: Bool init(host: String, allowUntrusted: Bool) { self.host = host self.allowUntrusted = allowUntrusted } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { if allowUntrusted, challenge.protectionSpace.host == host, challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let trust = challenge.protectionSpace.serverTrust { completionHandler(.useCredential, URLCredential(trust: trust)) } else { completionHandler(.performDefaultHandling, nil) } } func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } } @MainActor @Observable final class UniFiStore { private(set) var sites = [UniFiSite(id: "demo", name: "Default")] private(set) var selectedSiteID = "demo" private(set) var records = DNSRecord.examples private(set) var isDemo = true private(set) var isBusy = false private(set) var gatewayURL = UserDefaults.standard.string(forKey: "unifiGatewayURL") ?? "" private(set) var lastSynced: Date? var error: String? var activity: [ActivityEntry] = [] private var apiKey = "" private var didRestoreCredentials = false private var session: URLSession? private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { self.defaults = defaults if let data = defaults.data(forKey: "demoDNSRecords"), let saved = try? JSONDecoder().decode([DNSRecord].self, from: data) { records = saved } } var connectionLabel: String { isDemo ? "Demo Gateway" : URL(string: gatewayURL)?.host ?? "UniFi Gateway" } func restoreConnection() async { guard !didRestoreCredentials, !isBusy, isDemo else { return } didRestoreCredentials = true do { guard let saved = try CredentialStore.load(UniFiCredentials.self, account: CredentialStore.unifiAccount) else { return } try await connect(url: saved.url, key: saved.apiKey, allowUntrusted: saved.allowUntrusted, saveCredentials: false) } catch { self.error = "Could not restore the UniFi connection. " + error.localizedDescription } } func forgetCredentials() throws { try CredentialStore.delete(account: CredentialStore.unifiAccount) didRestoreCredentials = true } func connect(url: String, key: String, allowUntrusted: Bool, saveCredentials: Bool = true) async throws { guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") } didRestoreCredentials = true guard let parsed = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), parsed.scheme == "https", let host = parsed.host, parsed.user == nil, parsed.password == nil, parsed.query == nil, parsed.fragment == nil, parsed.path.isEmpty || parsed.path == "/" else { throw ServiceError.message("Enter the gateway HTTPS address, such as https://192.168.1.1, without an API path.") } let key = key.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { throw ServiceError.message("Enter your UniFi Network API key.") } isBusy = true defer { isBusy = false } let base = parsed.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let candidate = URLSession(configuration: .ephemeral, delegate: UniFiSessionDelegate(host: host, allowUntrusted: allowUntrusted), delegateQueue: nil) do { let loadedSites: [UniFiSite] = try await pages("sites", base: base, key: key, session: candidate) guard let first = loadedSites.first else { throw ServiceError.message("This API key has no accessible sites.") } let loadedRecords: [DNSRecord] = try await pages("sites/\(first.id)/dns/policies", base: base, key: key, session: candidate) if saveCredentials { try CredentialStore.save(UniFiCredentials(url: base, apiKey: key, allowUntrusted: allowUntrusted), account: CredentialStore.unifiAccount) } session?.invalidateAndCancel() session = candidate gatewayURL = base apiKey = key sites = loadedSites selectedSiteID = first.id records = loadedRecords isDemo = false lastSynced = Date() error = nil defaults.set(base, forKey: "unifiGatewayURL") activity.insert(ActivityEntry(title: "Connected to UniFi", detail: host), at: 0) } catch { candidate.invalidateAndCancel() throw error } } func loadSite(_ id: String) async { guard !isBusy, sites.contains(where: { $0.id == id }) else { return } isBusy = true defer { isBusy = false } do { if !isDemo, let session { let loaded: [DNSRecord] = try await pages("sites/\(id)/dns/policies", base: gatewayURL, key: apiKey, session: session) records = loaded } selectedSiteID = id lastSynced = Date() error = nil } catch { self.error = error.localizedDescription } } func save(_ record: DNSRecord, isNew: Bool) async throws { guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") } let body = try record.payload() if records.contains(where: { $0.id != record.id && $0.domainName.caseInsensitiveCompare(record.domainName) == .orderedSame && $0.type == record.type }) { throw ServiceError.message("An A record already exists for this domain in the selected site.") } isBusy = true defer { isBusy = false } var saved = record if isDemo { if isNew { saved.id = UUID().uuidString } } else { guard let session else { throw ServiceError.message("Connect to a UniFi gateway first.") } let path = "sites/\(selectedSiteID)/dns/policies" + (isNew ? "" : "/\(record.id)") let data = try await request(path, method: isNew ? "POST" : "PUT", body: body, base: gatewayURL, key: apiKey, session: session) saved = try JSONDecoder().decode(DNSRecord.self, from: data) } if let index = records.firstIndex(where: { $0.id == saved.id }) { records[index] = saved } else { records.append(saved) } activity.insert(ActivityEntry(title: isNew ? "DNS record created" : "DNS record updated", detail: saved.domainName), at: 0) persist() } func delete(_ record: DNSRecord) async { guard !isBusy else { return } isBusy = true defer { isBusy = false } do { if !isDemo { guard let session else { throw ServiceError.message("Connect to a UniFi gateway first.") } _ = try await request("sites/\(selectedSiteID)/dns/policies/\(record.id)", method: "DELETE", base: gatewayURL, key: apiKey, session: session) } records.removeAll { $0.id == record.id } activity.insert(ActivityEntry(title: "DNS record deleted", detail: record.domainName), at: 0) persist() } catch { self.error = error.localizedDescription } } private func persist() { if isDemo, let data = try? JSONEncoder().encode(records) { defaults.set(data, forKey: "demoDNSRecords") } } private struct Page: Decodable { let data: [T] let totalCount: Int? } private func pages(_ path: String, base: String, key: String, session: URLSession) async throws -> [T] { var items: [T] = [] var offset = 0 while offset < 100000 { let data = try await request(path + "?offset=\(offset)&limit=100", base: base, key: key, session: session) let page = try JSONDecoder().decode(Page.self, from: data) items.append(contentsOf: page.data) offset += page.data.count if page.data.isEmpty || offset >= (page.totalCount ?? Int.max) || (page.totalCount == nil && page.data.count < 100) { return items } } throw ServiceError.message("The gateway returned too many results.") } private func request(_ path: String, method: String = "GET", body: Data? = nil, base: String, key: String, session: URLSession) async throws -> Data { guard let url = URL(string: base + "/proxy/network/integration/v1/" + path) else { throw ServiceError.message("Invalid gateway URL.") } var request = URLRequest(url: url) request.httpMethod = method request.httpBody = body request.timeoutInterval = 25 request.setValue(key, forHTTPHeaderField: "X-API-KEY") request.setValue("application/json", forHTTPHeaderField: "Accept") if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { throw ServiceError.message("Invalid gateway response.") } guard (200..<300).contains(response.statusCode) else { switch response.statusCode { case 401, 403: throw ServiceError.message("UniFi rejected the API key or its permissions. Check the key in UniFi Network.") case 404: throw ServiceError.message("The site or DNS endpoint was not found. Check that your gateway supports the Network Integration DNS API.") default: throw ServiceError.message("UniFi returned HTTP \(response.statusCode). The operation was not confirmed.") } } return data } }