This commit is contained in:
+177
-14
@@ -7,6 +7,47 @@ struct UniFiSite: Codable, Identifiable, Hashable {
|
||||
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
|
||||
@@ -16,24 +57,91 @@ struct DNSRecord: Codable, Identifiable, Hashable {
|
||||
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 domainName: String { domain ?? "—" }
|
||||
var recordType: String { type.replacingOccurrences(of: "_RECORD", with: "") }
|
||||
var value: String { ipv4Address ?? ipv6Address ?? targetDomain ?? "See UniFi Network" }
|
||||
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) -> Bool {
|
||||
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 }
|
||||
&& 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 {
|
||||
@@ -44,14 +152,62 @@ struct DNSRecord: Codable, Identifiable, Hashable {
|
||||
}
|
||||
|
||||
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.")
|
||||
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.")
|
||||
}
|
||||
return try JSONSerialization.data(withJSONObject: [
|
||||
"type": "A_RECORD", "domain": domainName, "ipv4Address": ipv4Address ?? "",
|
||||
"ttlSeconds": ttlSeconds, "enabled": enabled
|
||||
])
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +339,15 @@ final class UniFiStore {
|
||||
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.")
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user