428 lines
19 KiB
Swift
428 lines
19 KiB
Swift
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<T: Decodable>: Decodable {
|
|
let data: [T]
|
|
let totalCount: Int?
|
|
}
|
|
|
|
private func pages<T: Decodable>(_ 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<T>.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
|
|
}
|
|
}
|