1 Commits
Author SHA1 Message Date
niklas 4e79f4bf82 feat: support more DNS types in proxy studio
Build / build (push) Successful in 12s
2026-09-28 23:38:29 +02:00
3 changed files with 301 additions and 36 deletions
+4 -1
View File
@@ -67,7 +67,11 @@ struct ContentView: View {
.padding() .padding()
} }
} detail: { } 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 detail
.frame(minWidth: 520, minHeight: 550)
.navigationTitle(destination?.rawValue ?? "Proxy Studio") .navigationTitle(destination?.rawValue ?? "Proxy Studio")
.navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName)) .navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName))
.toolbar { .toolbar {
@@ -128,7 +132,6 @@ struct ContentView: View {
), ),
prompt: destination == .dns ? "Search DNS Records" : "Search Hosts" prompt: destination == .dns ? "Search DNS Records" : "Search Hosts"
) )
.frame(minWidth: 900, minHeight: 550)
.task { .task {
guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return } guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return }
await store.restoreConnection() await store.restoreConnection()
+120 -21
View File
@@ -38,7 +38,7 @@ struct UniFiDNSView: View {
Table(filteredRecords, selection: $selectedID, sortOrder: $sortOrder) { Table(filteredRecords, selection: $selectedID, sortOrder: $sortOrder) {
TableColumn("Domain", value: \.domainName) TableColumn("Domain", value: \.domainName)
.width(min: 160, ideal: 220) .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("Value", value: \.value).width(min: 150, ideal: 200)
TableColumn("TTL") { record in TableColumn("TTL") { record in
Text(record.ttlSeconds.map { "\($0) s" } ?? "—") Text(record.ttlSeconds.map { "\($0) s" } ?? "—")
@@ -49,16 +49,16 @@ struct UniFiDNSView: View {
} }
.contextMenu(forSelectionType: String.self) { ids in .contextMenu(forSelectionType: String.self) { ids in
if let id = ids.first, let record = store.records.first(where: { $0.id == id }) { 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() Divider()
Button("Delete Record…", role: .destructive) { deleting = record }.disabled(store.isBusy) Button("Delete Record…", role: .destructive) { deleting = record }.disabled(store.isBusy)
} }
} primaryAction: { ids in } 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 { .overlay {
if filteredRecords.isEmpty { 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() Divider()
@@ -83,7 +83,7 @@ struct UniFiDNSView: View {
.help("Add DNS Record").keyboardShortcut("n").disabled(store.isBusy) .help("Add DNS Record").keyboardShortcut("n").disabled(store.isBusy)
Button { if let record = selectedRecord { edit(record) } } label: { Button { if let record = selectedRecord { edit(record) } } label: {
Label("Edit DNS Record", systemImage: "square.and.pencil") 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) } .sheet(isPresented: $showConnection) { UniFiConnectionSheet(store: store) }
@@ -173,24 +173,64 @@ struct DNSRecordEditor: View {
let isNew: Bool let isNew: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var record: DNSRecord @State private var record: DNSRecord
@State private var kind: DNSRecordKind
@State private var domain: String @State private var domain: String
@State private var address: String @State private var target: String
@State private var ttl: 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? @State private var error: String?
init(store: UniFiStore, record: DNSRecord, isNew: Bool) { init(store: UniFiStore, record: DNSRecord, isNew: Bool) {
self.store = store self.store = store
self.isNew = isNew self.isNew = isNew
_record = State(initialValue: record) _record = State(initialValue: record)
_kind = State(initialValue: record.kind ?? .a)
_domain = State(initialValue: record.domain ?? "") _domain = State(initialValue: record.domain ?? "")
_address = State(initialValue: record.ipv4Address ?? "") _target = State(initialValue: record.target ?? "")
_ttl = State(initialValue: String(record.ttlSeconds ?? 14400)) _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 { /// Builds the record from the form, keeping only the fields that belong to the selected type.
DNSRecord.isValidDomain(domain.trimmingCharacters(in: .whitespacesAndNewlines)) private var draft: DNSRecord {
&& DNSRecord.isValidIPv4(address.trimmingCharacters(in: .whitespacesAndNewlines)) var draft = DNSRecord(id: record.id, type: kind.rawValue, enabled: record.enabled,
&& Int(ttl).map { (0...86400).contains($0) } == true 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 { var body: some View {
@@ -200,15 +240,37 @@ struct DNSRecordEditor: View {
Spacer() Spacer()
}.padding(20) }.padding(20)
Form { Form {
Section("A Record") { Section(kind == .forwardDomain ? "Forward Domain" : "\(kind.label) Record") {
LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "") LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "")
TextField("Domain", text: $domain, prompt: Text("app.example.com")) Picker("Type", selection: $kind) {
TextField("IPv4 Address", text: $address, prompt: Text("192.168.1.20")) ForEach(DNSRecordKind.allCases) { kind in
TextField("TTL (seconds)", text: $ttl) 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) Toggle("Enabled", isOn: $record.enabled)
} }
Section { 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) .font(.callout).foregroundStyle(.secondary)
} }
} }
@@ -223,19 +285,56 @@ struct DNSRecordEditor: View {
Button(isNew ? "Add" : "Save") { Button(isNew ? "Add" : "Save") {
Task { Task {
error = nil error = nil
record.domain = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
record.ipv4Address = address.trimmingCharacters(in: .whitespacesAndNewlines)
record.ttlSeconds = Int(ttl)
do { do {
try await store.save(record, isNew: isNew) try await store.save(draft, isNew: isNew)
dismiss() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
}.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy) }.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy)
}.padding(20) }.padding(20)
} }
.frame(width: 500, height: 440) .frame(width: 500, height: kind == .srv ? 620 : 480)
.interactiveDismissDisabled(store.isBusy) .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."
}
} }
} }
+177 -14
View File
@@ -7,6 +7,47 @@ struct UniFiSite: Codable, Identifiable, Hashable {
let name: 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 { struct DNSRecord: Codable, Identifiable, Hashable {
var id: String var id: String
var type: String var type: String
@@ -16,23 +57,90 @@ struct DNSRecord: Codable, Identifiable, Hashable {
var ipv6Address: String? var ipv6Address: String?
var targetDomain: String? var targetDomain: String?
var ttlSeconds: Int? 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 kind: DNSRecordKind? { DNSRecordKind(apiType: type) }
var recordType: String { type.replacingOccurrences(of: "_RECORD", with: "") } var recordType: String { kind?.label ?? type.replacingOccurrences(of: "_RECORD", with: "") }
var value: String { ipv4Address ?? ipv6Address ?? targetDomain ?? "See UniFi Network" }
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 = [ 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-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) 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 } guard !value.isEmpty, value.count <= 127 else { return false }
return value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { label in return value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { label in
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-" !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 { static func isValidIPv4(_ value: String) -> Bool {
let parts = value.split(separator: ".", omittingEmptySubsequences: false) let parts = value.split(separator: ".", omittingEmptySubsequences: false)
@@ -44,14 +152,62 @@ struct DNSRecord: Codable, Identifiable, Hashable {
} }
func payload() throws -> Data { func payload() throws -> Data {
guard type == "A_RECORD", Self.isValidDomain(domainName), guard let kind else { throw ServiceError.message("This record type can only be edited in UniFi Network.") }
Self.isValidIPv4(ipv4Address ?? ""), let ttlSeconds, (0...86400).contains(ttlSeconds) else { guard let domain, Self.isValidDomain(domain, allowUnderscore: kind.allowsUnderscoreOwner) else {
throw ServiceError.message("Enter a valid domain, IPv4 address, and TTL between 0 and 86400 seconds.") throw ServiceError.message("Enter a valid domain.")
} }
return try JSONSerialization.data(withJSONObject: [ var body: [String: Any] = ["type": kind.rawValue, "domain": domain, "enabled": enabled]
"type": "A_RECORD", "domain": domainName, "ipv4Address": ipv4Address ?? "", if kind.usesTTL {
"ttlSeconds": ttlSeconds, "enabled": enabled 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 { func save(_ record: DNSRecord, isNew: Bool) async throws {
guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") } guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") }
let body = try record.payload() let body = try record.payload()
if records.contains(where: { $0.id != record.id && $0.domainName.caseInsensitiveCompare(record.domainName) == .orderedSame && $0.type == record.type }) { let allowsMultiple = record.kind?.allowsMultiplePerDomain == true
throw ServiceError.message("An A record already exists for this domain in the selected site.") 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 isBusy = true
defer { isBusy = false } defer { isBusy = false }