import Foundation import SwiftUI import Security struct UniFiSite: Codable, Identifiable, Hashable { let id: String let name: String } /// DNS policy types supported by the UniFi Network Integration API. enum DNSRecordKind: String, CaseIterable, Identifiable { case a = "A_RECORD" case aaaa = "AAAA_RECORD" case cname = "CNAME_RECORD" case mx = "MX_RECORD" case txt = "TXT_RECORD" case srv = "SRV_RECORD" case forwardDomain = "FORWARD_DOMAIN" var id: String { rawValue } /// Accepts both the `A_RECORD` form and the short `A` form some firmware versions report. init?(apiType: String) { let upper = apiType.uppercased() guard let kind = Self(rawValue: upper) ?? Self(rawValue: upper + "_RECORD") else { return nil } self = kind } var label: String { switch self { case .a: "A" case .aaaa: "AAAA" case .cname: "CNAME" case .mx: "MX" case .txt: "TXT" case .srv: "SRV" case .forwardDomain: "Forward" } } /// Only address and alias records carry a TTL in the Integration API. var usesTTL: Bool { self == .a || self == .aaaa || self == .cname } var usesPriority: Bool { self == .mx || self == .srv } var targetIsDomain: Bool { self == .cname || self == .mx || self == .srv } /// Owner names such as `_dmarc` or `_acme-challenge` are common for these types. var allowsUnderscoreOwner: Bool { self == .txt || self == .cname } /// MX, TXT, and SRV records routinely share a domain; other types are one per domain. var allowsMultiplePerDomain: Bool { self == .mx || self == .txt || self == .srv } } 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 mailServerDomain: String? var text: String? var serverDomain: String? var ipAddress: String? var priority: Int? var weight: Int? var port: Int? var service: String? var `protocol`: String? var kind: DNSRecordKind? { DNSRecordKind(apiType: type) } var recordType: String { kind?.label ?? type.replacingOccurrences(of: "_RECORD", with: "") } var domainName: String { guard let domain else { return "—" } if kind == .srv, let service, let `protocol` { return "\(service).\(`protocol`).\(domain)" } return domain } /// The type-specific field holding the record's main value. var target: String? { get { switch kind { case .a: ipv4Address case .aaaa: ipv6Address case .cname: targetDomain case .mx: mailServerDomain case .txt: text case .srv: serverDomain case .forwardDomain: ipAddress case nil: nil } } set { switch kind { case .a: ipv4Address = newValue case .aaaa: ipv6Address = newValue case .cname: targetDomain = newValue case .mx: mailServerDomain = newValue case .txt: text = newValue case .srv: serverDomain = newValue case .forwardDomain: ipAddress = newValue case nil: break } } } var value: String { guard let kind, let target else { return "See UniFi Network" } switch kind { case .mx: return "\(priority.map(String.init) ?? "—") \(target)" case .srv: return "\(priority.map(String.init) ?? "—") \(weight.map(String.init) ?? "—") \(port.map(String.init) ?? "—") \(target)" case .txt: return "\"\(target)\"" case .forwardDomain: return "→ \(target)" case .a, .aaaa, .cname: return target } } 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, allowUnderscore: Bool = false) -> 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 || (allowUnderscore && $0 == 95) } } } /// SRV service and protocol labels, such as `_ldap` and `_tcp`. static func isValidServiceLabel(_ value: String) -> Bool { value.count >= 2 && value.count <= 63 && value.first == "_" && value.dropFirst().first != "-" && value.last != "-" && value.dropFirst().utf8.allSatisfy { (97...122).contains($0) || (65...90).contains($0) || (48...57).contains($0) || $0 == 45 } } static func isValidIPv6(_ value: String) -> Bool { var address = in6_addr() return !value.contains("%") && value.withCString { inet_pton(AF_INET6, $0, &address) } == 1 } 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 let kind else { throw ServiceError.message("This record type can only be edited in UniFi Network.") } guard let domain, Self.isValidDomain(domain, allowUnderscore: kind.allowsUnderscoreOwner) else { throw ServiceError.message("Enter a valid domain.") } var body: [String: Any] = ["type": kind.rawValue, "domain": domain, "enabled": enabled] if kind.usesTTL { guard let ttlSeconds, (0...86400).contains(ttlSeconds) else { throw ServiceError.message("Enter a TTL between 0 and 86400 seconds.") } body["ttlSeconds"] = ttlSeconds } if kind.usesPriority { guard let priority, (0...65535).contains(priority) else { throw ServiceError.message("Enter a priority between 0 and 65535.") } body["priority"] = priority } let target = target ?? "" switch kind { case .a: guard Self.isValidIPv4(target) else { throw ServiceError.message("Enter a valid IPv4 address.") } body["ipv4Address"] = target case .aaaa: guard Self.isValidIPv6(target) else { throw ServiceError.message("Enter a valid IPv6 address.") } body["ipv6Address"] = target case .cname: guard Self.isValidDomain(target) else { throw ServiceError.message("Enter a valid target domain.") } guard target.caseInsensitiveCompare(domain) != .orderedSame else { throw ServiceError.message("A CNAME record cannot point to itself.") } body["targetDomain"] = target case .mx: guard Self.isValidDomain(target) else { throw ServiceError.message("Enter a valid mail server domain.") } body["mailServerDomain"] = target case .txt: guard !target.isEmpty else { throw ServiceError.message("Enter the TXT record text.") } body["text"] = target case .srv: guard let service, Self.isValidServiceLabel(service), let `protocol`, Self.isValidServiceLabel(`protocol`) else { throw ServiceError.message("Enter a valid service and protocol, such as _ldap and _tcp.") } guard Self.isValidDomain(target) else { throw ServiceError.message("Enter a valid target server domain.") } guard let weight, (0...65535).contains(weight) else { throw ServiceError.message("Enter a weight between 0 and 65535.") } guard let port, (1...65535).contains(port) else { throw ServiceError.message("Enter a port between 1 and 65535.") } body["service"] = service body["protocol"] = `protocol` body["serverDomain"] = target body["weight"] = weight body["port"] = port case .forwardDomain: guard Self.isValidIPv4(target) || Self.isValidIPv6(target) else { throw ServiceError.message("Enter the IPv4 or IPv6 address of the DNS server.") } body["ipAddress"] = target } return try JSONSerialization.data(withJSONObject: body) } } // 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() let allowsMultiple = record.kind?.allowsMultiplePerDomain == true if records.contains(where: { $0.id != record.id && $0.kind == record.kind && $0.domainName.caseInsensitiveCompare(record.domainName) == .orderedSame && (!allowsMultiple || $0.value == record.value) }) { throw ServiceError.message(allowsMultiple ? "An identical \(record.recordType) record already exists in the selected site." : "A \(record.recordType) 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 } }