feat: support more DNS types in proxy studio
Build / build (push) Successful in 12s

This commit is contained in:
2026-09-28 23:38:29 +02:00
parent ffe24a4f51
commit 4e79f4bf82
3 changed files with 301 additions and 36 deletions
+120 -21
View File
@@ -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."
}
}
}