diff --git a/proxy studio/ContentView.swift b/proxy studio/ContentView.swift index ec06705..c6cf74e 100644 --- a/proxy studio/ContentView.swift +++ b/proxy studio/ContentView.swift @@ -67,7 +67,11 @@ struct ContentView: View { .padding() } } detail: { + // The minimum size belongs to the detail column, not the split view. A fixed + // window minimum smaller than sidebar + detail + inspector minimums makes the + // split view re-solve its constraints forever and AppKit aborts. detail + .frame(minWidth: 520, minHeight: 550) .navigationTitle(destination?.rawValue ?? "Proxy Studio") .navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName)) .toolbar { @@ -128,7 +132,6 @@ struct ContentView: View { ), prompt: destination == .dns ? "Search DNS Records" : "Search Hosts" ) - .frame(minWidth: 900, minHeight: 550) .task { guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return } await store.restoreConnection() diff --git a/proxy studio/UniFiDNSView.swift b/proxy studio/UniFiDNSView.swift index 84830b2..bd6d4ef 100644 --- a/proxy studio/UniFiDNSView.swift +++ b/proxy studio/UniFiDNSView.swift @@ -38,7 +38,7 @@ struct UniFiDNSView: View { Table(filteredRecords, selection: $selectedID, sortOrder: $sortOrder) { TableColumn("Domain", value: \.domainName) .width(min: 160, ideal: 220) - TableColumn("Type", value: \.recordType).width(70) + TableColumn("Type", value: \.recordType).width(80) TableColumn("Value", value: \.value).width(min: 150, ideal: 200) TableColumn("TTL") { record in Text(record.ttlSeconds.map { "\($0) s" } ?? "—") @@ -49,16 +49,16 @@ struct UniFiDNSView: View { } .contextMenu(forSelectionType: String.self) { ids in if let id = ids.first, let record = store.records.first(where: { $0.id == id }) { - Button("Edit A Record…") { edit(record) }.disabled(record.type != "A_RECORD" || store.isBusy) + Button("Edit \(record.recordType) Record…") { edit(record) }.disabled(record.kind == nil || store.isBusy) Divider() Button("Delete Record…", role: .destructive) { deleting = record }.disabled(store.isBusy) } } primaryAction: { ids in - if let id = ids.first, let record = store.records.first(where: { $0.id == id }), record.type == "A_RECORD", !store.isBusy { edit(record) } + if let id = ids.first, let record = store.records.first(where: { $0.id == id }), record.kind != nil, !store.isBusy { edit(record) } } .overlay { if filteredRecords.isEmpty { - ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add an A record for the selected site." : "No records match your search.")) + ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add a DNS record for the selected site." : "No records match your search.")) } } Divider() @@ -83,7 +83,7 @@ struct UniFiDNSView: View { .help("Add DNS Record").keyboardShortcut("n").disabled(store.isBusy) Button { if let record = selectedRecord { edit(record) } } label: { Label("Edit DNS Record", systemImage: "square.and.pencil") - }.help("Edit A Record").disabled(selectedRecord?.type != "A_RECORD" || store.isBusy) + }.help("Edit DNS Record").disabled(selectedRecord?.kind == nil || store.isBusy) } } .sheet(isPresented: $showConnection) { UniFiConnectionSheet(store: store) } @@ -173,24 +173,64 @@ struct DNSRecordEditor: View { let isNew: Bool @Environment(\.dismiss) private var dismiss @State private var record: DNSRecord + @State private var kind: DNSRecordKind @State private var domain: String - @State private var address: String + @State private var target: String @State private var ttl: String + @State private var priority: String + @State private var weight: String + @State private var port: String + @State private var service: String + @State private var srvProtocol: String @State private var error: String? init(store: UniFiStore, record: DNSRecord, isNew: Bool) { self.store = store self.isNew = isNew _record = State(initialValue: record) + _kind = State(initialValue: record.kind ?? .a) _domain = State(initialValue: record.domain ?? "") - _address = State(initialValue: record.ipv4Address ?? "") + _target = State(initialValue: record.target ?? "") _ttl = State(initialValue: String(record.ttlSeconds ?? 14400)) + _priority = State(initialValue: String(record.priority ?? 10)) + _weight = State(initialValue: String(record.weight ?? 0)) + _port = State(initialValue: record.port.map(String.init) ?? "") + _service = State(initialValue: record.service ?? "") + _srvProtocol = State(initialValue: record.protocol ?? "_tcp") } - private var valid: Bool { - DNSRecord.isValidDomain(domain.trimmingCharacters(in: .whitespacesAndNewlines)) - && DNSRecord.isValidIPv4(address.trimmingCharacters(in: .whitespacesAndNewlines)) - && Int(ttl).map { (0...86400).contains($0) } == true + /// Builds the record from the form, keeping only the fields that belong to the selected type. + private var draft: DNSRecord { + var draft = DNSRecord(id: record.id, type: kind.rawValue, enabled: record.enabled, + domain: Self.normalizedDomain(domain)) + draft.target = kind == .txt ? target : kind.targetIsDomain + ? Self.normalizedDomain(target) : target.trimmingCharacters(in: .whitespacesAndNewlines) + if kind.usesTTL { draft.ttlSeconds = Int(ttl) } + if kind.usesPriority { draft.priority = Int(priority) } + if kind == .srv { + draft.weight = Int(weight) + draft.port = Int(port) + draft.service = Self.normalizedServiceLabel(service) + draft.protocol = Self.normalizedServiceLabel(srvProtocol) + } + return draft + } + + private var valid: Bool { (try? draft.payload()) != nil } + + private var protocolOptions: [String] { + ["_tcp", "_udp"].contains(srvProtocol) ? ["_tcp", "_udp"] : ["_tcp", "_udp", srvProtocol] + } + + private static func normalizedDomain(_ value: String) -> String { + var value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value.hasSuffix(".") { value.removeLast() } + return value + } + + private static func normalizedServiceLabel(_ value: String) -> String { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value.isEmpty || value.hasPrefix("_") ? value : "_" + value } var body: some View { @@ -200,15 +240,37 @@ struct DNSRecordEditor: View { Spacer() }.padding(20) Form { - Section("A Record") { + Section(kind == .forwardDomain ? "Forward Domain" : "\(kind.label) Record") { LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "") - TextField("Domain", text: $domain, prompt: Text("app.example.com")) - TextField("IPv4 Address", text: $address, prompt: Text("192.168.1.20")) - TextField("TTL (seconds)", text: $ttl) + Picker("Type", selection: $kind) { + ForEach(DNSRecordKind.allCases) { kind in + Text(kind == .forwardDomain ? "Forward Domain" : kind.label).tag(kind) + } + } + .disabled(!isNew) + if kind == .srv { + TextField("Service", text: $service, prompt: Text("_ldap")) + Picker("Protocol", selection: $srvProtocol) { + ForEach(protocolOptions, id: \.self) { Text($0).tag($0) } + } + } + TextField("Domain", text: $domain, prompt: Text(kind == .srv ? "example.com" : "app.example.com")) + if kind == .txt { + TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt), axis: .vertical) + .lineLimit(1...4) + } else { + TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt)) + } + if kind.usesPriority { TextField("Priority", text: $priority) } + if kind == .srv { + TextField("Weight", text: $weight) + TextField("Port", text: $port, prompt: Text("389")) + } + if kind.usesTTL { TextField("TTL (seconds)", text: $ttl) } Toggle("Enabled", isOn: $record.enabled) } Section { - Text("TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy.") + Text(kind.helpText) .font(.callout).foregroundStyle(.secondary) } } @@ -223,19 +285,56 @@ struct DNSRecordEditor: View { Button(isNew ? "Add" : "Save") { Task { error = nil - record.domain = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - record.ipv4Address = address.trimmingCharacters(in: .whitespacesAndNewlines) - record.ttlSeconds = Int(ttl) do { - try await store.save(record, isNew: isNew) + try await store.save(draft, isNew: isNew) dismiss() } catch { self.error = error.localizedDescription } } }.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy) }.padding(20) } - .frame(width: 500, height: 440) + .frame(width: 500, height: kind == .srv ? 620 : 480) .interactiveDismissDisabled(store.isBusy) + // A value typed for one type (such as an IPv4 address) rarely fits another. + .onChange(of: kind) { target = "" } + } +} + +private extension DNSRecordKind { + var targetLabel: String { + switch self { + case .a: "IPv4 Address" + case .aaaa: "IPv6 Address" + case .cname: "Target Domain" + case .mx: "Mail Server" + case .txt: "Text" + case .srv: "Target Server" + case .forwardDomain: "DNS Server" + } + } + + var targetPrompt: String { + switch self { + case .a: "192.168.1.20" + case .aaaa: "fd00::20" + case .cname: "server.example.com" + case .mx: "mail.example.com" + case .txt: "v=spf1 -all" + case .srv: "server.example.com" + case .forwardDomain: "192.168.1.53" + } + } + + var helpText: String { + switch self { + case .a: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy." + case .aaaa: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IPv6 address of your reverse proxy." + case .cname: "The domain resolves to the same address as the target domain. TTL must be between 0 and 86400 seconds." + case .mx: "Mail servers with lower priority values are preferred. Priority must be between 0 and 65535." + case .txt: "TXT records hold free-form text, commonly used for domain verification and SPF." + case .srv: "The record is published as _service._protocol.domain. Lower priority values are preferred; weight balances servers with the same priority." + case .forwardDomain: "Queries for this domain and its subdomains are forwarded to the DNS server instead of the gateway's upstream resolvers." + } } } diff --git a/proxy studio/UniFiStore.swift b/proxy studio/UniFiStore.swift index ab7d609..764bc46 100644 --- a/proxy studio/UniFiStore.swift +++ b/proxy studio/UniFiStore.swift @@ -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 }