Files
proxy-studio/proxy studio/UniFiStore.swift
2026-09-27 15:42:22 +02:00

265 lines
13 KiB
Swift

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<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
}
}