Commit 1715e59287

1715e59287a7b48b29ed8cbf2cd45fcc92ec3126

parent: fd34553ab0

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-25 05:25 UTC

DomainDig v4.0.0 — Integrations

* Outbound webhook delivery
* Native Slack webhook integration
* SMTP-based email alerts
* Per-integration event filtering
* Reliable local delivery queue
* Delivery logs and retry visibility
* Integration management UI
* Normalized event severity model
* Canonical structured event payloads

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -366,7 +366,7 @@
366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
368 CODE_SIGN_STYLE = Automatic; 368 CODE_SIGN_STYLE = Automatic;
369 CURRENT_PROJECT_VERSION = 31; 369 CURRENT_PROJECT_VERSION = 32;
370 DEVELOPMENT_TEAM = ZCNAX3VL9D; 370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
371 ENABLE_PREVIEWS = YES; 371 ENABLE_PREVIEWS = YES;
372 GENERATE_INFOPLIST_FILE = YES; 372 GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
383 "$(inherited)", 383 "$(inherited)",
384 "@executable_path/Frameworks", 384 "@executable_path/Frameworks",
385 ); 385 );
386 MARKETING_VERSION = 3.9.0; 386 MARKETING_VERSION = 4.0.0;
387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
388 PRODUCT_NAME = "$(TARGET_NAME)"; 388 PRODUCT_NAME = "$(TARGET_NAME)";
389 STRING_CATALOG_GENERATE_SYMBOLS = YES; 389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
405 CODE_SIGN_STYLE = Automatic; 405 CODE_SIGN_STYLE = Automatic;
406 CURRENT_PROJECT_VERSION = 31; 406 CURRENT_PROJECT_VERSION = 32;
407 DEVELOPMENT_TEAM = ZCNAX3VL9D; 407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
408 ENABLE_PREVIEWS = YES; 408 ENABLE_PREVIEWS = YES;
409 GENERATE_INFOPLIST_FILE = YES; 409 GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
420 "$(inherited)", 420 "$(inherited)",
421 "@executable_path/Frameworks", 421 "@executable_path/Frameworks",
422 ); 422 );
423 MARKETING_VERSION = 3.9.0; 423 MARKETING_VERSION = 4.0.0;
424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
425 PRODUCT_NAME = "$(TARGET_NAME)"; 425 PRODUCT_NAME = "$(TARGET_NAME)";
426 STRING_CATALOG_GENERATE_SYMBOLS = YES; 426 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +4
@@ -2540,6 +2540,10 @@ struct SettingsView: View {
2540 MonitoringView(viewModel: viewModel) 2540 MonitoringView(viewModel: viewModel)
2541 } 2541 }
2542 2542
2543 NavigationLink("Integrations") {
2544 IntegrationsSettingsView()
2545 }
2546
2543 NavigationLink("iCloud Sync") { 2547 NavigationLink("iCloud Sync") {
2544 CloudSyncSettingsView() 2548 CloudSyncSettingsView()
2545 } 2549 }
DomainDig/DomainDigApp.swift +4
@@ -28,16 +28,19 @@ struct DomainDigApp: App {
28 .task { 28 .task {
29 let _ = purchaseService.currentTier 29 let _ = purchaseService.currentTier
30 let _ = cloudSyncService.status 30 let _ = cloudSyncService.status
31 let _ = IntegrationService.shared.targets.count
31 await purchaseService.refreshEntitlements() 32 await purchaseService.refreshEntitlements()
32 viewModel.refreshMonitoringState() 33 viewModel.refreshMonitoringState()
33 await viewModel.refreshMonitoringAuthorizationStatus() 34 await viewModel.refreshMonitoringAuthorizationStatus()
34 await cloudSyncService.refreshAvailability() 35 await cloudSyncService.refreshAvailability()
35 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) 36 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
36 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 37 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
38 IntegrationService.shared.processQueueNow()
37 } 39 }
38 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in 40 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
39 viewModel.refreshPersistedData() 41 viewModel.refreshPersistedData()
40 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 42 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
43 IntegrationService.shared.refresh()
41 } 44 }
42 } 45 }
43 .onChange(of: scenePhase) { _, newValue in 46 .onChange(of: scenePhase) { _, newValue in
@@ -49,6 +52,7 @@ struct DomainDigApp: App {
49 } 52 }
50 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) 53 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
51 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 54 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
55 IntegrationService.shared.processQueueNow()
52 } 56 }
53 } 57 }
54} 58}
DomainDig/DomainMonitoringService.swift +91
@@ -361,6 +361,12 @@ final class DomainMonitoringService {
361 errors: errors 361 errors: errors
362 ) 362 )
363 saveLog(log) 363 saveLog(log)
364 let outboundEvents = monitoringEvents(from: log)
365 if outboundEvents.isEmpty {
366 IntegrationService.shared.recordNoOutboundEvents(for: log.summary)
367 } else {
368 IntegrationService.shared.enqueue(events: outboundEvents)
369 }
364 370
365 return MonitoringRunOutcome( 371 return MonitoringRunOutcome(
366 success: errors.count < results.count, 372 success: errors.count < results.count,
@@ -375,6 +381,91 @@ final class DomainMonitoringService {
375 MonitoringStorage.saveLogs(logs) 381 MonitoringStorage.saveLogs(logs)
376 } 382 }
377 383
384 private func monitoringEvents(from log: MonitoringLog) -> [MonitoringEvent] {
385 var events: [MonitoringEvent] = log.checkedDomains.compactMap { result in
386 if let errorMessage = result.errorMessage {
387 return MonitoringEvent(
388 type: .monitoringFailure,
389 severity: .critical,
390 domain: result.domain,
391 timestamp: result.checkedAt,
392 summary: errorMessage,
393 details: [
394 "trigger": log.trigger.rawValue,
395 "resultSource": result.resultSource.rawValue
396 ]
397 )
398 }
399
400 if result.certificateWarningLevel == .critical {
401 return MonitoringEvent(
402 type: .certificateExpiring,
403 severity: .critical,
404 domain: result.domain,
405 timestamp: result.checkedAt,
406 summary: result.summaryMessage,
407 details: [
408 "certificateWarningLevel": result.certificateWarningLevel.rawValue,
409 "trigger": log.trigger.rawValue
410 ]
411 )
412 }
413
414 guard let alertSeverity = result.alertSeverity else {
415 return nil
416 }
417
418 return MonitoringEvent(
419 type: eventType(for: result.summaryMessage),
420 severity: EventSeverity(monitoringSeverity: alertSeverity),
421 domain: result.domain,
422 timestamp: result.checkedAt,
423 summary: result.summaryMessage,
424 details: [
425 "alertSeverity": alertSeverity.title,
426 "certificateWarningLevel": result.certificateWarningLevel.rawValue,
427 "resultSource": result.resultSource.rawValue,
428 "trigger": log.trigger.rawValue
429 ]
430 )
431 }
432
433 if !log.errors.isEmpty {
434 events.append(
435 MonitoringEvent(
436 type: .monitoringFailure,
437 severity: .critical,
438 domain: "portfolio",
439 timestamp: log.timestamp,
440 summary: "Monitoring run completed with errors",
441 details: [
442 "errors": log.errors.joined(separator: " | "),
443 "trigger": log.trigger.rawValue
444 ]
445 )
446 )
447 }
448
449 return events
450 }
451
452 private func eventType(for summary: String) -> MonitoringEventType {
453 let normalized = summary.lowercased()
454 if normalized.contains("dns") {
455 return .dnsChanged
456 }
457 if normalized.contains("certificate") {
458 return .certificateUpdated
459 }
460 if normalized.contains("redirect") {
461 return .redirectChanged
462 }
463 if normalized.contains("header") {
464 return .headersChanged
465 }
466 return .changeDetected
467 }
468
378 private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? { 469 private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
379 history.first(where: { entry in 470 history.first(where: { entry in
380 if let trackedDomainID = entry.trackedDomainID { 471 if let trackedDomainID = entry.trackedDomainID {
DomainDig/IntegrationService.swift added +813
@@ -0,0 +1,813 @@
1import Foundation
2import Network
3import Observation
4import Security
5
6@MainActor
7@Observable
8final class IntegrationService {
9 static let shared = IntegrationService()
10
11 var targets: [IntegrationTarget]
12 var deliveryRecords: [DeliveryRecord]
13 var queue: [QueuedDelivery]
14 var statusMessage: String?
15
16 private let defaults: UserDefaults
17 private var processingTask: Task<Void, Never>?
18
19 private init(defaults: UserDefaults = .standard) {
20 self.defaults = defaults
21 self.targets = Self.loadTargets(defaults: defaults)
22 self.deliveryRecords = Self.loadRecords(defaults: defaults)
23 self.queue = Self.loadQueue(defaults: defaults)
24 }
25
26 func refresh() {
27 targets = Self.loadTargets(defaults: defaults)
28 deliveryRecords = Self.loadRecords(defaults: defaults)
29 queue = Self.loadQueue(defaults: defaults)
30 }
31
32 func upsert(
33 target: IntegrationTarget,
34 webhookURL: String? = nil,
35 slackWebhookURL: String? = nil,
36 emailPassword: String? = nil
37 ) throws {
38 var updatedTarget = target
39
40 switch updatedTarget.configuration {
41 case .webhook(var configuration):
42 if let webhookURL {
43 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "webhook")
44 try IntegrationSecretStore.save(secret: webhookURL, reference: reference)
45 configuration.credentialReference = reference
46 configuration.endpointDisplayHost = Self.hostLabel(from: webhookURL)
47 updatedTarget.configuration = .webhook(configuration)
48 }
49 case .slack(var configuration):
50 if let slackWebhookURL {
51 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "slack")
52 try IntegrationSecretStore.save(secret: slackWebhookURL, reference: reference)
53 configuration.credentialReference = reference
54 configuration.destinationLabel = Self.hostLabel(from: slackWebhookURL)
55 updatedTarget.configuration = .slack(configuration)
56 }
57 case .email(var configuration):
58 if let emailPassword {
59 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "smtp")
60 try IntegrationSecretStore.save(secret: emailPassword, reference: reference)
61 configuration.credentialReference = reference
62 updatedTarget.configuration = .email(configuration)
63 }
64 }
65
66 if let index = targets.firstIndex(where: { $0.id == updatedTarget.id }) {
67 targets[index] = updatedTarget
68 } else {
69 targets.append(updatedTarget)
70 }
71 targets.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
72 persistTargets()
73 statusMessage = "Saved integration settings."
74 }
75
76 func delete(targetID: UUID) {
77 guard let target = targets.first(where: { $0.id == targetID }) else { return }
78 deleteSecrets(for: target)
79 targets.removeAll { $0.id == targetID }
80 queue.removeAll { $0.integrationID == targetID }
81 deliveryRecords.removeAll { $0.integrationID == targetID }
82 persistTargets()
83 persistQueue()
84 persistRecords()
85 }
86
87 func setEnabled(_ isEnabled: Bool, for targetID: UUID) {
88 guard let index = targets.firstIndex(where: { $0.id == targetID }) else { return }
89 targets[index].isEnabled = isEnabled
90 persistTargets()
91 }
92
93 func deliveryRecords(for targetID: UUID) -> [DeliveryRecord] {
94 deliveryRecords
95 .filter { $0.integrationID == targetID }
96 .sorted { $0.timestamp > $1.timestamp }
97 }
98
99 func enqueue(events: [MonitoringEvent]) {
100 guard !events.isEmpty else { return }
101 let eligibleTargets = targets.filter(\.isEnabled)
102 for event in events {
103 for target in eligibleTargets {
104 if let reason = filterMismatchReason(for: event, target: target) {
105 appendRecord(
106 DeliveryRecord(
107 integrationID: target.id,
108 eventID: event.id,
109 status: .skipped,
110 destination: destinationLabel(for: target),
111 summary: event.summary,
112 failureReason: reason
113 )
114 )
115 continue
116 }
117
118 queue.append(QueuedDelivery(integrationID: target.id, event: event))
119 appendRecord(
120 DeliveryRecord(
121 integrationID: target.id,
122 eventID: event.id,
123 status: .pending,
124 destination: destinationLabel(for: target),
125 summary: event.summary
126 )
127 )
128 }
129 }
130 persistQueue()
131 scheduleProcessing()
132 }
133
134 func recordNoOutboundEvents(for runSummary: String) {
135 let eligibleTargets = targets.filter(\.isEnabled)
136 for target in eligibleTargets {
137 appendRecord(
138 DeliveryRecord(
139 integrationID: target.id,
140 eventID: UUID(),
141 status: .skipped,
142 destination: destinationLabel(for: target),
143 summary: runSummary,
144 failureReason: "Monitoring run produced no outbound events."
145 )
146 )
147 }
148 }
149
150 func sendTest(for targetID: UUID) {
151 guard targets.contains(where: { $0.id == targetID }) else { return }
152 let event = MonitoringEvent(
153 type: .test,
154 severity: .info,
155 domain: "example.com",
156 summary: "DomainDig integration test",
157 details: [
158 "source": "manual test",
159 "environment": "local-first"
160 ]
161 )
162 queue.append(QueuedDelivery(integrationID: targetID, event: event))
163 appendRecord(
164 DeliveryRecord(
165 integrationID: targetID,
166 eventID: event.id,
167 status: .pending,
168 destination: targets.first(where: { $0.id == targetID }).map(destinationLabel(for:)) ?? "Unknown",
169 summary: event.summary
170 )
171 )
172 persistQueue()
173 scheduleProcessing()
174 }
175
176 func processQueueNow() {
177 scheduleProcessing(force: true)
178 }
179
180 private func scheduleProcessing(force: Bool = false) {
181 if force {
182 processingTask?.cancel()
183 processingTask = nil
184 }
185 guard processingTask == nil else { return }
186 processingTask = Task { [weak self] in
187 guard let self else { return }
188 await self.processQueueLoop()
189 }
190 }
191
192 private func processQueueLoop() async {
193 defer { processingTask = nil }
194
195 while true {
196 let dueItems = queue
197 .enumerated()
198 .filter { $0.element.nextAttemptAt <= Date() }
199
200 if dueItems.isEmpty {
201 guard let nextAttemptAt = queue.map(\.nextAttemptAt).min() else {
202 break
203 }
204
205 let delay = max(0.25, nextAttemptAt.timeIntervalSinceNow)
206 do {
207 try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
208 continue
209 } catch {
210 break
211 }
212 }
213
214 for entry in dueItems.reversed() {
215 guard entry.offset < queue.count else { continue }
216 let item = queue[entry.offset]
217 await process(item: item, at: entry.offset)
218 }
219 }
220 }
221
222 private func process(item: QueuedDelivery, at index: Int) async {
223 guard let target = targets.first(where: { $0.id == item.integrationID }) else {
224 queue.remove(at: index)
225 persistQueue()
226 return
227 }
228
229 guard item.expiresAt > Date() else {
230 queue.remove(at: index)
231 persistQueue()
232 appendRecord(
233 DeliveryRecord(
234 integrationID: target.id,
235 eventID: item.event.id,
236 status: .expired,
237 destination: destinationLabel(for: target),
238 summary: item.event.summary,
239 failureReason: "Delivery expired before succeeding.",
240 attemptCount: item.attemptCount
241 )
242 )
243 return
244 }
245
246 do {
247 try await deliver(item.event, to: target)
248 queue.remove(at: index)
249 persistQueue()
250 appendRecord(
251 DeliveryRecord(
252 integrationID: target.id,
253 eventID: item.event.id,
254 status: .delivered,
255 destination: destinationLabel(for: target),
256 summary: item.event.summary,
257 attemptCount: item.attemptCount + 1
258 )
259 )
260 statusMessage = "Delivered \(item.event.summary)."
261 } catch {
262 var updated = item
263 updated.attemptCount += 1
264 updated.lastError = error.localizedDescription
265
266 if updated.attemptCount >= 5 {
267 queue.remove(at: index)
268 appendRecord(
269 DeliveryRecord(
270 integrationID: target.id,
271 eventID: item.event.id,
272 status: .failed,
273 destination: destinationLabel(for: target),
274 summary: item.event.summary,
275 failureReason: error.localizedDescription,
276 attemptCount: updated.attemptCount
277 )
278 )
279 } else {
280 let backoff = min(pow(2, Double(updated.attemptCount)) * 30, 3600)
281 updated.nextAttemptAt = Date().addingTimeInterval(backoff)
282 queue[index] = updated
283 appendRecord(
284 DeliveryRecord(
285 integrationID: target.id,
286 eventID: item.event.id,
287 status: .retrying,
288 destination: destinationLabel(for: target),
289 summary: item.event.summary,
290 failureReason: error.localizedDescription,
291 attemptCount: updated.attemptCount
292 )
293 )
294 }
295
296 persistQueue()
297 statusMessage = error.localizedDescription
298 }
299 }
300
301 private func deliver(_ event: MonitoringEvent, to target: IntegrationTarget) async throws {
302 switch target.configuration {
303 case .webhook(let configuration):
304 guard let reference = configuration.credentialReference else {
305 throw IntegrationError.missingSecret
306 }
307 let webhookURLString = try IntegrationSecretStore.secret(reference: reference)
308 try await HTTPIntegrationClient.sendJSON(
309 payload: IntegrationEventPayload(event: event),
310 to: webhookURLString,
311 headers: configuration.additionalHeaders,
312 timeoutSeconds: configuration.timeoutSeconds
313 )
314 case .slack(let configuration):
315 guard let reference = configuration.credentialReference else {
316 throw IntegrationError.missingSecret
317 }
318 let webhookURLString = try IntegrationSecretStore.secret(reference: reference)
319 try await HTTPIntegrationClient.sendJSON(
320 payload: SlackPayload(event: event),
321 to: webhookURLString,
322 headers: [:],
323 timeoutSeconds: 15
324 )
325 case .email(let configuration):
326 guard let reference = configuration.credentialReference else {
327 throw IntegrationError.missingSecret
328 }
329 let password = try IntegrationSecretStore.secret(reference: reference)
330 try await SMTPClient.send(
331 event: event,
332 configuration: configuration,
333 password: password
334 )
335 }
336 }
337
338 private func appendRecord(_ record: DeliveryRecord) {
339 deliveryRecords.insert(record, at: 0)
340 deliveryRecords = Array(deliveryRecords.prefix(250))
341 persistRecords()
342 }
343
344 private func persistTargets() {
345 Self.save(targets, key: StorageKey.targets, defaults: defaults)
346 }
347
348 private func persistRecords() {
349 Self.save(deliveryRecords, key: StorageKey.records, defaults: defaults)
350 }
351
352 private func persistQueue() {
353 Self.save(queue, key: StorageKey.queue, defaults: defaults)
354 }
355
356 private func deleteSecrets(for target: IntegrationTarget) {
357 switch target.configuration {
358 case .webhook(let configuration):
359 if let reference = configuration.credentialReference {
360 try? IntegrationSecretStore.delete(reference: reference)
361 }
362 case .slack(let configuration):
363 if let reference = configuration.credentialReference {
364 try? IntegrationSecretStore.delete(reference: reference)
365 }
366 case .email(let configuration):
367 if let reference = configuration.credentialReference {
368 try? IntegrationSecretStore.delete(reference: reference)
369 }
370 }
371 }
372
373 private func destinationLabel(for target: IntegrationTarget) -> String {
374 switch target.configuration {
375 case .webhook(let configuration):
376 return configuration.endpointDisplayHost.isEmpty ? target.name : configuration.endpointDisplayHost
377 case .slack(let configuration):
378 return configuration.destinationLabel
379 case .email(let configuration):
380 return configuration.recipientAddresses.joined(separator: ", ")
381 }
382 }
383
384 private func filterMismatchReason(for event: MonitoringEvent, target: IntegrationTarget) -> String? {
385 let filters = target.filters
386
387 if event.severity < filters.minimumSeverity {
388 return "Filtered by severity. Event was \(event.severity.title), target requires \(filters.minimumSeverity.title)."
389 }
390
391 if !filters.eventTypes.isEmpty, !filters.eventTypes.contains(event.type) {
392 return "Filtered by event type. Event was \(event.type.title)."
393 }
394
395 if !filters.domains.isEmpty, !filters.domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) {
396 return "Filtered by domain. Event was for \(event.domain)."
397 }
398
399 return nil
400 }
401
402 private static func hostLabel(from string: String) -> String {
403 URL(string: string)?.host ?? "Configured"
404 }
405
406 private static func secretReference(for integrationID: UUID, suffix: String) -> String {
407 "integration.\(integrationID.uuidString).\(suffix)"
408 }
409
410 private static func loadTargets(defaults: UserDefaults) -> [IntegrationTarget] {
411 load([IntegrationTarget].self, key: StorageKey.targets, defaults: defaults) ?? []
412 }
413
414 private static func loadRecords(defaults: UserDefaults) -> [DeliveryRecord] {
415 load([DeliveryRecord].self, key: StorageKey.records, defaults: defaults) ?? []
416 }
417
418 private static func loadQueue(defaults: UserDefaults) -> [QueuedDelivery] {
419 load([QueuedDelivery].self, key: StorageKey.queue, defaults: defaults) ?? []
420 }
421
422 private static func load<T: Decodable>(_ type: T.Type, key: String, defaults: UserDefaults) -> T? {
423 guard let data = defaults.data(forKey: key) else {
424 return nil
425 }
426 return try? JSONDecoder().decode(type, from: data)
427 }
428
429 private static func save<T: Encodable>(_ value: T, key: String, defaults: UserDefaults) {
430 if let data = try? JSONEncoder().encode(value) {
431 defaults.set(data, forKey: key)
432 }
433 }
434
435 private enum StorageKey {
436 static let targets = "integrations.targets"
437 static let records = "integrations.records"
438 static let queue = "integrations.queue"
439 }
440}
441
442private struct IntegrationEventPayload: Encodable {
443 let eventType: String
444 let domain: String
445 let timestamp: Date
446 let severity: String
447 let summary: String
448 let details: [String: String]
449
450 init(event: MonitoringEvent) {
451 self.eventType = event.type.rawValue
452 self.domain = event.domain
453 self.timestamp = event.timestamp
454 self.severity = event.severity.rawValue
455 self.summary = event.summary
456 self.details = event.details
457 }
458}
459
460private struct SlackPayload: Encodable {
461 let text: String
462 let blocks: [SlackBlock]
463
464 init(event: MonitoringEvent) {
465 let title = "\(event.severity.title.uppercased()) • \(event.domain)"
466 let detailLines = event.details
467 .sorted { $0.key < $1.key }
468 .prefix(6)
469 .map { "\($0.key): \($0.value)" }
470 .joined(separator: "\n")
471
472 self.text = "\(title) — \(event.summary)"
473 self.blocks = [
474 SlackBlock(
475 type: "section",
476 text: .init(type: "mrkdwn", text: "*\(title)*\n\(event.summary)")
477 ),
478 SlackBlock(
479 type: "section",
480 text: .init(
481 type: "mrkdwn",
482 text: "*Event*: \(event.type.title)\n*Timestamp*: \(event.timestamp.formatted(date: .abbreviated, time: .shortened))"
483 )
484 ),
485 SlackBlock(
486 type: "section",
487 text: .init(type: "mrkdwn", text: detailLines.isEmpty ? "_No extra details_" : detailLines)
488 )
489 ]
490 }
491}
492
493private struct SlackBlock: Encodable {
494 let type: String
495 let text: SlackText
496}
497
498private struct SlackText: Encodable {
499 let type: String
500 let text: String
501}
502
503private enum IntegrationError: LocalizedError {
504 case invalidURL
505 case missingSecret
506 case invalidResponse(Int)
507 case invalidSMTPPort
508 case smtp(String)
509 case streamClosed
510
511 var errorDescription: String? {
512 switch self {
513 case .invalidURL:
514 return "The integration URL is invalid."
515 case .missingSecret:
516 return "This integration is missing a saved secret."
517 case .invalidResponse(let statusCode):
518 return "The remote endpoint returned \(statusCode)."
519 case .invalidSMTPPort:
520 return "The SMTP port is invalid."
521 case .smtp(let message):
522 return message
523 case .streamClosed:
524 return "The SMTP connection closed unexpectedly."
525 }
526 }
527}
528
529private enum HTTPIntegrationClient {
530 static func sendJSON<T: Encodable>(
531 payload: T,
532 to urlString: String,
533 headers: [String: String],
534 timeoutSeconds: Double
535 ) async throws {
536 guard let url = URL(string: urlString) else {
537 throw IntegrationError.invalidURL
538 }
539
540 var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
541 request.httpMethod = "POST"
542 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
543 for (key, value) in headers {
544 request.setValue(value, forHTTPHeaderField: key)
545 }
546
547 let encoder = JSONEncoder()
548 encoder.dateEncodingStrategy = .iso8601
549 request.httpBody = try encoder.encode(payload)
550
551 let (_, response) = try await URLSession.shared.data(for: request)
552 guard let httpResponse = response as? HTTPURLResponse else {
553 throw IntegrationError.invalidResponse(-1)
554 }
555 guard (200..<300).contains(httpResponse.statusCode) else {
556 throw IntegrationError.invalidResponse(httpResponse.statusCode)
557 }
558 }
559}
560
561private enum IntegrationSecretStore {
562 static func save(secret: String, reference: String) throws {
563 let data = Data(secret.utf8)
564 try? delete(reference: reference)
565
566 let query: [String: Any] = [
567 kSecClass as String: kSecClassGenericPassword,
568 kSecAttrAccount as String: reference,
569 kSecValueData as String: data,
570 kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
571 ]
572
573 let status = SecItemAdd(query as CFDictionary, nil)
574 guard status == errSecSuccess else {
575 throw IntegrationError.smtp("Could not save integration secret.")
576 }
577 }
578
579 static func secret(reference: String) throws -> String {
580 let query: [String: Any] = [
581 kSecClass as String: kSecClassGenericPassword,
582 kSecAttrAccount as String: reference,
583 kSecReturnData as String: true,
584 kSecMatchLimit as String: kSecMatchLimitOne
585 ]
586
587 var result: CFTypeRef?
588 let status = SecItemCopyMatching(query as CFDictionary, &result)
589 guard status == errSecSuccess,
590 let data = result as? Data,
591 let secret = String(data: data, encoding: .utf8) else {
592 throw IntegrationError.missingSecret
593 }
594
595 return secret
596 }
597
598 static func delete(reference: String) throws {
599 let query: [String: Any] = [
600 kSecClass as String: kSecClassGenericPassword,
601 kSecAttrAccount as String: reference
602 ]
603 SecItemDelete(query as CFDictionary)
604 }
605}
606
607private enum SMTPClient {
608 static func send(
609 event: MonitoringEvent,
610 configuration: EmailIntegrationConfiguration,
611 password: String
612 ) async throws {
613 guard let port = NWEndpoint.Port(rawValue: UInt16(configuration.port)) else {
614 throw IntegrationError.invalidSMTPPort
615 }
616
617 let parameters: NWParameters = {
618 switch configuration.securityMode {
619 case .plain:
620 return .tcp
621 case .directTLS:
622 let tls = NWProtocolTLS.Options()
623 return NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
624 }
625 }()
626
627 let channel = SMTPChannel(host: configuration.smtpHost, port: port, parameters: parameters)
628 try await channel.start()
629 _ = try await channel.readResponse(expecting: [220])
630 _ = try await channel.sendCommand("EHLO domaindig.local", expecting: [250])
631
632 if !configuration.username.isEmpty {
633 _ = try await channel.sendCommand("AUTH LOGIN", expecting: [334])
634 _ = try await channel.sendCommand(Data(configuration.username.utf8).base64EncodedString(), expecting: [334])
635 _ = try await channel.sendCommand(Data(password.utf8).base64EncodedString(), expecting: [235])
636 }
637
638 _ = try await channel.sendCommand("MAIL FROM:<\(configuration.senderAddress)>", expecting: [250])
639 for recipient in configuration.recipientAddresses {
640 _ = try await channel.sendCommand("RCPT TO:<\(recipient)>", expecting: [250, 251])
641 }
642 _ = try await channel.sendCommand("DATA", expecting: [354])
643
644 let detailLines = event.details
645 .sorted { $0.key < $1.key }
646 .map { "\($0.key): \($0.value)" }
647 .joined(separator: "\r\n")
648 let body = [
649 "From: DomainDig <\(configuration.senderAddress)>",
650 "To: \(configuration.recipientAddresses.joined(separator: ", "))",
651 "Subject: [DomainDig] \(event.severity.title) \(event.domain) \(event.type.title)",
652 "Date: \(DateFormatter.rfc2822.string(from: Date()))",
653 "",
654 event.summary,
655 "",
656 "Domain: \(event.domain)",
657 "Severity: \(event.severity.title)",
658 "Event: \(event.type.title)",
659 "Timestamp: \(event.timestamp.formatted(date: .abbreviated, time: .shortened))",
660 detailLines
661 ]
662 .joined(separator: "\r\n")
663
664 try await channel.sendRaw(body + "\r\n.\r\n")
665 _ = try await channel.readResponse(expecting: [250])
666 _ = try await channel.sendCommand("QUIT", expecting: [221])
667 channel.cancel()
668 }
669}
670
671private final class SMTPChannel {
672 private let connection: NWConnection
673 private var parsedLines: [String] = []
674 private var lineWaiters: [CheckedContinuation<String, Error>] = []
675 private var receiveBuffer = Data()
676
677 init(host: String, port: NWEndpoint.Port, parameters: NWParameters) {
678 connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: parameters)
679 }
680
681 func start() async throws {
682 try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
683 connection.stateUpdateHandler = { [weak self] state in
684 switch state {
685 case .ready:
686 DispatchQueue.global(qos: .utility).async {
687 self?.startReceiveLoop()
688 }
689 continuation.resume()
690 case .failed(let error):
691 continuation.resume(throwing: error)
692 default:
693 break
694 }
695 }
696 connection.start(queue: .global(qos: .utility))
697 }
698 }
699
700 func cancel() {
701 connection.cancel()
702 }
703
704 func sendCommand(_ command: String, expecting codes: Set<Int>) async throws -> String {
705 try await sendRaw(command + "\r\n")
706 return try await readResponse(expecting: codes)
707 }
708
709 func sendCommand(_ command: String, expecting codes: [Int]) async throws -> String {
710 try await sendCommand(command, expecting: Set(codes))
711 }
712
713 func sendRaw(_ string: String) async throws {
714 let data = Data(string.utf8)
715 try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
716 connection.send(content: data, completion: .contentProcessed { error in
717 if let error {
718 continuation.resume(throwing: error)
719 } else {
720 continuation.resume()
721 }
722 })
723 }
724 }
725
726 func readResponse(expecting codes: Set<Int>) async throws -> String {
727 var lines: [String] = []
728
729 while true {
730 let line = try await readLine()
731 lines.append(line)
732
733 guard line.count >= 4,
734 let code = Int(line.prefix(3)) else {
735 continue
736 }
737
738 let delimiterIndex = line.index(line.startIndex, offsetBy: 3)
739 if line[delimiterIndex] == " " {
740 guard codes.contains(code) else {
741 throw IntegrationError.smtp(line)
742 }
743 return lines.joined(separator: "\n")
744 }
745 }
746 }
747
748 private func readLine() async throws -> String {
749 if !parsedLines.isEmpty {
750 return parsedLines.removeFirst()
751 }
752
753 return try await withCheckedThrowingContinuation { continuation in
754 lineWaiters.append(continuation)
755 }
756 }
757
758 private func startReceiveLoop() {
759 connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
760 guard let self else { return }
761
762 if let error {
763 self.failWaiters(with: error)
764 return
765 }
766
767 if let data, !data.isEmpty {
768 self.receiveBuffer.append(data)
769 self.flushBuffer()
770 }
771
772 if isComplete {
773 self.failWaiters(with: IntegrationError.streamClosed)
774 return
775 }
776
777 self.startReceiveLoop()
778 }
779 }
780
781 private func flushBuffer() {
782 let delimiter = Data("\r\n".utf8)
783 while let range = receiveBuffer.range(of: delimiter) {
784 let lineData = receiveBuffer.subdata(in: receiveBuffer.startIndex..<range.lowerBound)
785 receiveBuffer.removeSubrange(receiveBuffer.startIndex..<range.upperBound)
786 let line = String(data: lineData, encoding: .utf8) ?? ""
787 if !lineWaiters.isEmpty {
788 let continuation = lineWaiters.removeFirst()
789 continuation.resume(returning: line)
790 } else {
791 parsedLines.append(line)
792 }
793 }
794 }
795
796 private func failWaiters(with error: Error) {
797 let waiters = lineWaiters
798 lineWaiters.removeAll()
799 for waiter in waiters {
800 waiter.resume(throwing: error)
801 }
802 }
803}
804
805private extension DateFormatter {
806 static let rfc2822: DateFormatter = {
807 let formatter = DateFormatter()
808 formatter.locale = Locale(identifier: "en_US_POSIX")
809 formatter.timeZone = TimeZone(secondsFromGMT: 0)
810 formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
811 return formatter
812 }()
813}
DomainDig/IntegrationsView.swift added +522
@@ -0,0 +1,522 @@
1import SwiftUI
2
3struct IntegrationsSettingsView: View {
4 @State private var integrationService = IntegrationService.shared
5 @State private var editingTarget: IntegrationTarget?
6 @State private var showingCreateSheet = false
7
8 var body: some View {
9 List {
10 Section("Overview") {
11 LabeledContent("Integrations", value: "\(integrationService.targets.count)")
12 LabeledContent("Queued Deliveries", value: "\(integrationService.queue.count)")
13 LabeledContent("Recent Log Entries", value: "\(integrationService.deliveryRecords.count)")
14
15 if let statusMessage = integrationService.statusMessage {
16 Text(statusMessage)
17 .font(.caption)
18 .foregroundStyle(.secondary)
19 }
20
21 Button("Process Queue Now") {
22 integrationService.processQueueNow()
23 }
24 }
25
26 Section("Targets") {
27 if integrationService.targets.isEmpty {
28 Text("No integrations configured.")
29 .foregroundStyle(.secondary)
30 } else {
31 ForEach(integrationService.targets) { target in
32 NavigationLink {
33 IntegrationDetailView(
34 integrationID: target.id,
35 onEdit: {
36 editingTarget = target
37 }
38 )
39 } label: {
40 VStack(alignment: .leading, spacing: 4) {
41 HStack {
42 Text(target.name)
43 Spacer()
44 Text(target.type.title)
45 .foregroundStyle(.secondary)
46 }
47
48 Text(summary(for: target))
49 .font(.caption)
50 .foregroundStyle(.secondary)
51
52 if !target.isEnabled {
53 Text("Disabled")
54 .font(.caption2)
55 .foregroundStyle(.orange)
56 }
57 }
58 }
59 }
60 }
61
62 Button("Add Integration") {
63 showingCreateSheet = true
64 }
65 }
66 }
67 .navigationTitle("Integrations")
68 .sheet(isPresented: $showingCreateSheet) {
69 NavigationStack {
70 IntegrationEditorView(existingTarget: nil)
71 }
72 }
73 .sheet(item: $editingTarget) { target in
74 NavigationStack {
75 IntegrationEditorView(existingTarget: target)
76 }
77 }
78 .onAppear {
79 integrationService.refresh()
80 }
81 }
82
83 private func summary(for target: IntegrationTarget) -> String {
84 switch target.configuration {
85 case .webhook(let configuration):
86 return configuration.endpointDisplayHost.isEmpty ? "Webhook" : configuration.endpointDisplayHost
87 case .slack(let configuration):
88 return configuration.destinationLabel
89 case .email(let configuration):
90 return configuration.recipientAddresses.joined(separator: ", ")
91 }
92 }
93}
94
95private struct IntegrationDetailView: View {
96 @Environment(\.dismiss) private var dismiss
97 @State private var integrationService = IntegrationService.shared
98
99 let integrationID: UUID
100 let onEdit: () -> Void
101
102 private var target: IntegrationTarget? {
103 integrationService.targets.first(where: { $0.id == integrationID })
104 }
105
106 var body: some View {
107 List {
108 if let target {
109 Section("Configuration") {
110 LabeledContent("Type", value: target.type.title)
111 LabeledContent("Status", value: target.isEnabled ? "Enabled" : "Disabled")
112 LabeledContent("Destination", value: destination(for: target))
113 LabeledContent("Minimum Severity", value: target.filters.minimumSeverity.title)
114 if !target.filters.domains.isEmpty {
115 LabeledContent("Domains", value: target.filters.domains.joined(separator: ", "))
116 }
117 }
118
119 Section("Actions") {
120 Button("Edit Integration") {
121 onEdit()
122 }
123
124 Button("Send Test Event") {
125 integrationService.sendTest(for: target.id)
126 }
127
128 Button(target.isEnabled ? "Disable" : "Enable") {
129 integrationService.setEnabled(!target.isEnabled, for: target.id)
130 }
131
132 Button("Delete Integration", role: .destructive) {
133 integrationService.delete(targetID: target.id)
134 dismiss()
135 }
136 }
137
138 Section("Delivery Log") {
139 if integrationService.deliveryRecords(for: target.id).isEmpty {
140 Text("No deliveries yet.")
141 .foregroundStyle(.secondary)
142 } else {
143 ForEach(integrationService.deliveryRecords(for: target.id), id: \.id) { record in
144 VStack(alignment: .leading, spacing: 4) {
145 HStack {
146 Text(record.status.title)
147 Spacer()
148 Text(record.timestamp.formatted(date: .abbreviated, time: .shortened))
149 .font(.caption)
150 .foregroundStyle(.secondary)
151 }
152
153 Text(record.summary)
154 .font(.subheadline)
155
156 Text(record.destination)
157 .font(.caption)
158 .foregroundStyle(.secondary)
159
160 if let failureReason = record.failureReason {
161 let failureColor: Color = record.status == .skipped ? .secondary : .red
162 Text(failureReason)
163 .font(.caption)
164 .foregroundStyle(failureColor)
165 }
166 }
167 }
168 }
169 }
170 } else {
171 Text("Integration not found.")
172 .foregroundStyle(.secondary)
173 }
174 }
175 .navigationTitle(target?.name ?? "Integration")
176 }
177
178 private func destination(for target: IntegrationTarget) -> String {
179 switch target.configuration {
180 case .webhook(let configuration):
181 return configuration.endpointDisplayHost
182 case .slack(let configuration):
183 return configuration.destinationLabel
184 case .email(let configuration):
185 return configuration.recipientAddresses.joined(separator: ", ")
186 }
187 }
188}
189
190private struct IntegrationEditorView: View {
191 @Environment(\.dismiss) private var dismiss
192 @State private var integrationService = IntegrationService.shared
193
194 let existingTarget: IntegrationTarget?
195
196 @State private var type: IntegrationType = .webhook
197 @State private var name: String = ""
198 @State private var isEnabled = true
199 @State private var minimumSeverity: EventSeverity = .warning
200 @State private var selectedEventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test })
201 @State private var domainsText = ""
202
203 @State private var webhookURL = ""
204 @State private var slackWebhookURL = ""
205 @State private var emailHost = ""
206 @State private var emailPort = "465"
207 @State private var emailUsername = ""
208 @State private var emailPassword = ""
209 @State private var senderAddress = ""
210 @State private var recipientAddresses = ""
211 @State private var smtpSecurity: SMTPSecurityMode = .directTLS
212
213 @State private var validationMessage: String?
214
215 var body: some View {
216 Form {
217 Section("Integration") {
218 Picker("Type", selection: $type) {
219 ForEach(IntegrationType.allCases) { integrationType in
220 Text(integrationType.title).tag(integrationType)
221 }
222 }
223 .disabled(existingTarget != nil)
224
225 TextField("Name", text: $name)
226 Toggle("Enabled", isOn: $isEnabled)
227 }
228
229 Section("Routing Rules") {
230 Picker("Minimum Severity", selection: $minimumSeverity) {
231 ForEach(EventSeverity.allCases) { severity in
232 Text(severity.title).tag(severity)
233 }
234 }
235
236 TextField("Domains (comma-separated)", text: $domainsText)
237 .textInputAutocapitalization(.never)
238 .autocorrectionDisabled()
239
240 ForEach(MonitoringEventType.allCases.filter { $0 != .test }, id: \.self) { eventType in
241 Toggle(
242 eventType.title,
243 isOn: Binding(
244 get: { selectedEventTypes.contains(eventType) },
245 set: { isSelected in
246 if isSelected {
247 selectedEventTypes.insert(eventType)
248 } else {
249 selectedEventTypes.remove(eventType)
250 }
251 }
252 )
253 )
254 }
255 }
256
257 switch type {
258 case .webhook:
259 Section("Webhook") {
260 TextField("https://example.com/webhook", text: $webhookURL)
261 .textInputAutocapitalization(.never)
262 .autocorrectionDisabled()
263 .keyboardType(.URL)
264
265 if existingTarget != nil {
266 Text("Saved webhook URL remains in Keychain unless you replace it.")
267 .font(.caption)
268 .foregroundStyle(.secondary)
269 }
270 }
271 case .slack:
272 Section("Slack") {
273 TextField("https://hooks.slack.com/services/...", text: $slackWebhookURL)
274 .textInputAutocapitalization(.never)
275 .autocorrectionDisabled()
276 .keyboardType(.URL)
277
278 if existingTarget != nil {
279 Text("Saved Slack webhook remains in Keychain unless you replace it.")
280 .font(.caption)
281 .foregroundStyle(.secondary)
282 }
283 }
284 case .email:
285 Section("SMTP") {
286 TextField("SMTP Host", text: $emailHost)
287 .textInputAutocapitalization(.never)
288 .autocorrectionDisabled()
289
290 TextField("Port", text: $emailPort)
291 .keyboardType(.numberPad)
292
293 TextField("Username", text: $emailUsername)
294 .textInputAutocapitalization(.never)
295 .autocorrectionDisabled()
296
297 SecureField(existingTarget == nil ? "Password" : "Replace Password", text: $emailPassword)
298
299 TextField("Sender Address", text: $senderAddress)
300 .textInputAutocapitalization(.never)
301 .autocorrectionDisabled()
302 .keyboardType(.emailAddress)
303
304 TextField("Recipients (comma-separated)", text: $recipientAddresses)
305 .textInputAutocapitalization(.never)
306 .autocorrectionDisabled()
307 .keyboardType(.emailAddress)
308
309 Picker("Security", selection: $smtpSecurity) {
310 ForEach(SMTPSecurityMode.allCases) { mode in
311 Text(mode.title).tag(mode)
312 }
313 }
314
315 if existingTarget != nil {
316 Text("Saved SMTP password remains in Keychain unless you replace it.")
317 .font(.caption)
318 .foregroundStyle(.secondary)
319 }
320 }
321 }
322
323 if let validationMessage {
324 Section {
325 Text(validationMessage)
326 .font(.caption)
327 .foregroundStyle(.red)
328 }
329 }
330 }
331 .navigationTitle(existingTarget == nil ? "Add Integration" : "Edit Integration")
332 .toolbar {
333 ToolbarItem(placement: .cancellationAction) {
334 Button("Cancel") {
335 dismiss()
336 }
337 }
338
339 ToolbarItem(placement: .confirmationAction) {
340 Button("Save") {
341 save()
342 }
343 }
344 }
345 .onAppear {
346 populateFromExisting()
347 }
348 }
349
350 private func populateFromExisting() {
351 guard let existingTarget else { return }
352 type = existingTarget.type
353 name = existingTarget.name
354 isEnabled = existingTarget.isEnabled
355 minimumSeverity = existingTarget.filters.minimumSeverity
356 selectedEventTypes = existingTarget.filters.eventTypes
357 domainsText = existingTarget.filters.domains.joined(separator: ", ")
358
359 switch existingTarget.configuration {
360 case .webhook:
361 break
362 case .slack:
363 break
364 case .email(let configuration):
365 emailHost = configuration.smtpHost
366 emailPort = String(configuration.port)
367 emailUsername = configuration.username
368 senderAddress = configuration.senderAddress
369 recipientAddresses = configuration.recipientAddresses.joined(separator: ", ")
370 smtpSecurity = configuration.securityMode
371 }
372 }
373
374 private func save() {
375 validationMessage = nil
376
377 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
378 guard !trimmedName.isEmpty else {
379 validationMessage = "Name is required."
380 return
381 }
382
383 let filters = IntegrationFilterSet(
384 minimumSeverity: minimumSeverity,
385 eventTypes: selectedEventTypes,
386 domains: domainsText
387 .split(separator: ",")
388 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
389 .filter { !$0.isEmpty }
390 )
391
392 let targetID = existingTarget?.id ?? UUID()
393
394 do {
395 switch type {
396 case .webhook:
397 let existingReference: String? = {
398 guard case .webhook(let configuration) = existingTarget?.configuration else { return nil }
399 return configuration.credentialReference
400 }()
401 if webhookURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
402 validationMessage = "Webhook URL is required."
403 return
404 }
405
406 let target = IntegrationTarget(
407 id: targetID,
408 type: .webhook,
409 name: trimmedName,
410 isEnabled: isEnabled,
411 configuration: .webhook(
412 WebhookIntegrationConfiguration(
413 endpointDisplayHost: existingWebhookDisplayHost(),
414 timeoutSeconds: 15,
415 additionalHeaders: [:],
416 credentialReference: existingReference
417 )
418 ),
419 filters: filters
420 )
421 try integrationService.upsert(
422 target: target,
423 webhookURL: webhookURL.nilIfBlank
424 )
425 case .slack:
426 let existingReference: String? = {
427 guard case .slack(let configuration) = existingTarget?.configuration else { return nil }
428 return configuration.credentialReference
429 }()
430 if slackWebhookURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
431 validationMessage = "Slack webhook URL is required."
432 return
433 }
434
435 let target = IntegrationTarget(
436 id: targetID,
437 type: .slack,
438 name: trimmedName,
439 isEnabled: isEnabled,
440 configuration: .slack(
441 SlackIntegrationConfiguration(
442 destinationLabel: existingSlackDestination(),
443 credentialReference: existingReference
444 )
445 ),
446 filters: filters
447 )
448 try integrationService.upsert(
449 target: target,
450 slackWebhookURL: slackWebhookURL.nilIfBlank
451 )
452 case .email:
453 guard let port = Int(emailPort) else {
454 validationMessage = "SMTP port must be a number."
455 return
456 }
457
458 let recipients = recipientAddresses
459 .split(separator: ",")
460 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
461 .filter { !$0.isEmpty }
462
463 let existingReference: String? = {
464 guard case .email(let configuration) = existingTarget?.configuration else { return nil }
465 return configuration.credentialReference
466 }()
467 if emailPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
468 validationMessage = "SMTP password is required."
469 return
470 }
471
472 let target = IntegrationTarget(
473 id: targetID,
474 type: .email,
475 name: trimmedName,
476 isEnabled: isEnabled,
477 configuration: .email(
478 EmailIntegrationConfiguration(
479 smtpHost: emailHost.trimmingCharacters(in: .whitespacesAndNewlines),
480 port: port,
481 username: emailUsername.trimmingCharacters(in: .whitespacesAndNewlines),
482 senderAddress: senderAddress.trimmingCharacters(in: .whitespacesAndNewlines),
483 recipientAddresses: recipients,
484 securityMode: smtpSecurity,
485 credentialReference: existingReference
486 )
487 ),
488 filters: filters
489 )
490 try integrationService.upsert(
491 target: target,
492 emailPassword: emailPassword.nilIfBlank
493 )
494 }
495
496 dismiss()
497 } catch {
498 validationMessage = error.localizedDescription
499 }
500 }
501
502 private func existingWebhookDisplayHost() -> String {
503 guard case .webhook(let configuration) = existingTarget?.configuration else {
504 return ""
505 }
506 return configuration.endpointDisplayHost
507 }
508
509 private func existingSlackDestination() -> String {
510 guard case .slack(let configuration) = existingTarget?.configuration else {
511 return "Slack"
512 }
513 return configuration.destinationLabel
514 }
515}
516
517private extension String {
518 var nilIfBlank: String? {
519 let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
520 return trimmed.isEmpty ? nil : trimmed
521 }
522}
DomainDig/Models.swift +377
@@ -1336,6 +1336,383 @@ struct MonitoringLog: Codable, Identifiable, Equatable {
1336 } 1336 }
1337} 1337}
1338 1338
1339enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
1340 case info
1341 case warning
1342 case critical
1343
1344 var id: String { rawValue }
1345
1346 static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
1347 lhs.rank < rhs.rank
1348 }
1349
1350 var title: String {
1351 rawValue.capitalized
1352 }
1353
1354 private var rank: Int {
1355 switch self {
1356 case .info:
1357 return 0
1358 case .warning:
1359 return 1
1360 case .critical:
1361 return 2
1362 }
1363 }
1364
1365 init(monitoringSeverity: MonitoringAlertSeverity) {
1366 switch monitoringSeverity {
1367 case .info:
1368 self = .info
1369 case .warning:
1370 self = .warning
1371 case .critical:
1372 self = .critical
1373 }
1374 }
1375}
1376
1377enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
1378 case dnsChanged
1379 case certificateUpdated
1380 case certificateExpiring
1381 case redirectChanged
1382 case headersChanged
1383 case endpointUnreachable
1384 case monitoringFailure
1385 case changeDetected
1386 case test
1387
1388 var id: String { rawValue }
1389
1390 var title: String {
1391 switch self {
1392 case .dnsChanged:
1393 return "DNS Changed"
1394 case .certificateUpdated:
1395 return "Certificate Updated"
1396 case .certificateExpiring:
1397 return "Certificate Expiring"
1398 case .redirectChanged:
1399 return "Redirect Changed"
1400 case .headersChanged:
1401 return "Headers Changed"
1402 case .endpointUnreachable:
1403 return "Endpoint Unreachable"
1404 case .monitoringFailure:
1405 return "Monitoring Failure"
1406 case .changeDetected:
1407 return "Change Detected"
1408 case .test:
1409 return "Test Event"
1410 }
1411 }
1412}
1413
1414struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
1415 var id: UUID
1416 var type: MonitoringEventType
1417 var severity: EventSeverity
1418 var domain: String
1419 var timestamp: Date
1420 var summary: String
1421 var details: [String: String]
1422
1423 init(
1424 id: UUID = UUID(),
1425 type: MonitoringEventType,
1426 severity: EventSeverity,
1427 domain: String,
1428 timestamp: Date = Date(),
1429 summary: String,
1430 details: [String: String] = [:]
1431 ) {
1432 self.id = id
1433 self.type = type
1434 self.severity = severity
1435 self.domain = domain
1436 self.timestamp = timestamp
1437 self.summary = summary
1438 self.details = details
1439 }
1440}
1441
1442enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
1443 case webhook
1444 case slack
1445 case email
1446
1447 var id: String { rawValue }
1448
1449 var title: String {
1450 rawValue.capitalized
1451 }
1452}
1453
1454enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
1455 case plain
1456 case directTLS
1457
1458 var id: String { rawValue }
1459
1460 var title: String {
1461 switch self {
1462 case .plain:
1463 return "Plain"
1464 case .directTLS:
1465 return "Direct TLS"
1466 }
1467 }
1468}
1469
1470struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
1471 var endpointDisplayHost: String
1472 var timeoutSeconds: Double
1473 var additionalHeaders: [String: String]
1474 var credentialReference: String?
1475
1476 init(
1477 endpointDisplayHost: String = "",
1478 timeoutSeconds: Double = 15,
1479 additionalHeaders: [String: String] = [:],
1480 credentialReference: String? = nil
1481 ) {
1482 self.endpointDisplayHost = endpointDisplayHost
1483 self.timeoutSeconds = timeoutSeconds
1484 self.additionalHeaders = additionalHeaders
1485 self.credentialReference = credentialReference
1486 }
1487}
1488
1489struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
1490 var destinationLabel: String
1491 var credentialReference: String?
1492
1493 init(
1494 destinationLabel: String = "Slack",
1495 credentialReference: String? = nil
1496 ) {
1497 self.destinationLabel = destinationLabel
1498 self.credentialReference = credentialReference
1499 }
1500}
1501
1502struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
1503 var smtpHost: String
1504 var port: Int
1505 var username: String
1506 var senderAddress: String
1507 var recipientAddresses: [String]
1508 var securityMode: SMTPSecurityMode
1509 var credentialReference: String?
1510
1511 init(
1512 smtpHost: String = "",
1513 port: Int = 465,
1514 username: String = "",
1515 senderAddress: String = "",
1516 recipientAddresses: [String] = [],
1517 securityMode: SMTPSecurityMode = .directTLS,
1518 credentialReference: String? = nil
1519 ) {
1520 self.smtpHost = smtpHost
1521 self.port = port
1522 self.username = username
1523 self.senderAddress = senderAddress
1524 self.recipientAddresses = recipientAddresses
1525 self.securityMode = securityMode
1526 self.credentialReference = credentialReference
1527 }
1528}
1529
1530enum IntegrationConfiguration: Codable, Equatable, Sendable {
1531 case webhook(WebhookIntegrationConfiguration)
1532 case slack(SlackIntegrationConfiguration)
1533 case email(EmailIntegrationConfiguration)
1534
1535 private enum CodingKeys: String, CodingKey {
1536 case type
1537 case webhook
1538 case slack
1539 case email
1540 }
1541
1542 init(from decoder: Decoder) throws {
1543 let container = try decoder.container(keyedBy: CodingKeys.self)
1544 let type = try container.decode(IntegrationType.self, forKey: .type)
1545 switch type {
1546 case .webhook:
1547 self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
1548 case .slack:
1549 self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
1550 case .email:
1551 self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
1552 }
1553 }
1554
1555 func encode(to encoder: Encoder) throws {
1556 var container = encoder.container(keyedBy: CodingKeys.self)
1557 switch self {
1558 case .webhook(let configuration):
1559 try container.encode(IntegrationType.webhook, forKey: .type)
1560 try container.encode(configuration, forKey: .webhook)
1561 case .slack(let configuration):
1562 try container.encode(IntegrationType.slack, forKey: .type)
1563 try container.encode(configuration, forKey: .slack)
1564 case .email(let configuration):
1565 try container.encode(IntegrationType.email, forKey: .type)
1566 try container.encode(configuration, forKey: .email)
1567 }
1568 }
1569
1570 var type: IntegrationType {
1571 switch self {
1572 case .webhook:
1573 return .webhook
1574 case .slack:
1575 return .slack
1576 case .email:
1577 return .email
1578 }
1579 }
1580}
1581
1582struct IntegrationFilterSet: Codable, Equatable, Sendable {
1583 var minimumSeverity: EventSeverity
1584 var eventTypes: Set<MonitoringEventType>
1585 var domains: [String]
1586
1587 init(
1588 minimumSeverity: EventSeverity = .warning,
1589 eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
1590 domains: [String] = []
1591 ) {
1592 self.minimumSeverity = minimumSeverity
1593 self.eventTypes = eventTypes
1594 self.domains = domains
1595 }
1596
1597 func matches(_ event: MonitoringEvent) -> Bool {
1598 guard event.severity >= minimumSeverity else {
1599 return false
1600 }
1601 guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
1602 return false
1603 }
1604 guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
1605 return false
1606 }
1607 return true
1608 }
1609}
1610
1611struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
1612 var id: UUID
1613 var type: IntegrationType
1614 var name: String
1615 var isEnabled: Bool
1616 var configuration: IntegrationConfiguration
1617 var filters: IntegrationFilterSet
1618
1619 init(
1620 id: UUID = UUID(),
1621 type: IntegrationType,
1622 name: String,
1623 isEnabled: Bool = true,
1624 configuration: IntegrationConfiguration,
1625 filters: IntegrationFilterSet = IntegrationFilterSet()
1626 ) {
1627 self.id = id
1628 self.type = type
1629 self.name = name
1630 self.isEnabled = isEnabled
1631 self.configuration = configuration
1632 self.filters = filters
1633 }
1634}
1635
1636enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
1637 case pending
1638 case retrying
1639 case delivered
1640 case failed
1641 case expired
1642 case skipped
1643
1644 var id: String { rawValue }
1645
1646 var title: String {
1647 rawValue.capitalized
1648 }
1649}
1650
1651struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
1652 var id: UUID
1653 var integrationID: UUID
1654 var eventID: UUID
1655 var timestamp: Date
1656 var status: DeliveryStatus
1657 var destination: String
1658 var summary: String
1659 var failureReason: String?
1660 var attemptCount: Int
1661
1662 init(
1663 id: UUID = UUID(),
1664 integrationID: UUID,
1665 eventID: UUID,
1666 timestamp: Date = Date(),
1667 status: DeliveryStatus,
1668 destination: String,
1669 summary: String,
1670 failureReason: String? = nil,
1671 attemptCount: Int = 0
1672 ) {
1673 self.id = id
1674 self.integrationID = integrationID
1675 self.eventID = eventID
1676 self.timestamp = timestamp
1677 self.status = status
1678 self.destination = destination
1679 self.summary = summary
1680 self.failureReason = failureReason
1681 self.attemptCount = attemptCount
1682 }
1683}
1684
1685struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
1686 var id: UUID
1687 var integrationID: UUID
1688 var event: MonitoringEvent
1689 var createdAt: Date
1690 var attemptCount: Int
1691 var nextAttemptAt: Date
1692 var lastError: String?
1693 var expiresAt: Date
1694
1695 init(
1696 id: UUID = UUID(),
1697 integrationID: UUID,
1698 event: MonitoringEvent,
1699 createdAt: Date = Date(),
1700 attemptCount: Int = 0,
1701 nextAttemptAt: Date = Date(),
1702 lastError: String? = nil,
1703 expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
1704 ) {
1705 self.id = id
1706 self.integrationID = integrationID
1707 self.event = event
1708 self.createdAt = createdAt
1709 self.attemptCount = attemptCount
1710 self.nextAttemptAt = nextAttemptAt
1711 self.lastError = lastError
1712 self.expiresAt = expiresAt
1713 }
1714}
1715
1339// MARK: - DNS Models 1716// MARK: - DNS Models
1340 1717
1341enum DNSRecordType: String, CaseIterable, Codable { 1718enum DNSRecordType: String, CaseIterable, Codable {