diff --git a/app/Sources/SquirrelOpsHome/App.swift b/app/Sources/SquirrelOpsHome/App.swift index 01ffe75..b440804 100644 --- a/app/Sources/SquirrelOpsHome/App.swift +++ b/app/Sources/SquirrelOpsHome/App.swift @@ -148,6 +148,12 @@ struct SquirrelOpsHomeApp: App { localSensorProbe: { [pairingManager] in await pairingManager.detectLocalSensor() != nil }, + offlineDelivery: { alert in + await OfflineAlertDispatcher.shared.deliver( + alert, + detail: "The SquirrelOps Home app has not been able to reach the sensor for five minutes." + ) + }, onEvent: { [appState] frame in Task { @MainActor in WSEventProcessor.process(frame, into: appState) diff --git a/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift b/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift index a7ad254..28a81f5 100644 --- a/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift +++ b/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift @@ -43,6 +43,13 @@ public final class SensorConnectionService: @unchecked Sendable { private let appState: AppState? private let localSensorProbe: @Sendable () async -> Bool private let onEvent: @Sendable (WSFrame) -> Void + /// How long an outage lasts before the app raises its own alert. + private let offlineAlertDelay: Duration + /// Out-of-band delivery for the one alert the sensor cannot send about itself. + private let offlineDelivery: @Sendable (AlertSummary) async -> Void + /// Whether this session has ever reached `.live`. Losing a sensor that was + /// working is High; never finding one is Medium. + private var hasBeenLive = false private var reconnectTask: Task? private var listenTask: Task? private var learningPollTask: Task? @@ -58,12 +65,16 @@ public final class SensorConnectionService: @unchecked Sendable { webSocketManager: any WebSocketManagerProtocol, appState: AppState? = nil, localSensorProbe: @escaping @Sendable () async -> Bool = { false }, + offlineAlertDelay: Duration = .seconds(300), + offlineDelivery: @escaping @Sendable (AlertSummary) async -> Void = { _ in }, onEvent: @escaping @Sendable (WSFrame) -> Void ) { self.sensorClient = sensorClient self.webSocketManager = webSocketManager self.appState = appState self.localSensorProbe = localSensorProbe + self.offlineAlertDelay = offlineAlertDelay + self.offlineDelivery = offlineDelivery self.onEvent = onEvent } @@ -71,15 +82,17 @@ public final class SensorConnectionService: @unchecked Sendable { @MainActor private static var syntheticIdCounter: Int = -1 - /// Creates a synthetic "Sensor Disconnected" alert for local display. - @MainActor public static func makeDisconnectAlert() -> AlertSummary { + /// Creates a synthetic "Sensor Disconnected" alert. It is local to the app + /// (the sensor cannot report its own absence) and is also what the app + /// hands to out-of-band delivery. + @MainActor public static func makeDisconnectAlert(previouslyConnected: Bool = false) -> AlertSummary { let id = syntheticIdCounter syntheticIdCounter -= 1 return AlertSummary( id: id, incidentId: nil, alertType: "system.sensor_offline", - severity: "medium", + severity: previouslyConnected ? "high" : "medium", title: "Sensor Disconnected", sourceIp: nil, readAt: nil, @@ -242,6 +255,7 @@ public final class SensorConnectionService: @unchecked Sendable { // Go live state = .live + hasBeenLive = true await syncStateAsync() disconnectAlertTask?.cancel() disconnectAlertTask = nil @@ -405,21 +419,48 @@ public final class SensorConnectionService: @unchecked Sendable { await self.connect(baseURL: baseURL, certFingerprint: certFingerprint) } - // Start 5-minute alert timer if not already running + // Start the outage timer if not already running. It is cancelled only + // by going live or by an explicit disconnect, so one outage raises one + // alert. if disconnectAlertTask == nil { disconnectAlertTask = Task { [weak self] in do { - try await Task.sleep(for: .seconds(300)) + try await self?.sleepForOfflineAlert() } catch { return } - guard let self, self.state == .disconnected else { return } - await MainActor.run { - let alert = SensorConnectionService.makeDisconnectAlert() - self.appState?.addAlert(alert) - } + guard let self else { return } + await self.raiseOfflineAlert() } } } + private func sleepForOfflineAlert() async throws { + try await Task.sleep(for: offlineAlertDelay) + } + + /// States in which the sensor is not answering. A reconnect attempt that is + /// mid-flight when the timer fires is still an outage; `authFailed` and + /// `incompatible` mean the sensor answered and are not. + static func isOffline(_ state: ConnectionState) -> Bool { + switch state { + case .disconnected, .connecting: return true + case .connected, .syncing, .live, .authFailed, .incompatible: return false + } + } + + private func raiseOfflineAlert() async { + guard Self.isOffline(state) else { return } + let previouslyConnected = hasBeenLive + let alert = await MainActor.run { + let alert = SensorConnectionService.makeDisconnectAlert(previouslyConnected: previouslyConnected) + self.appState?.addAlert(alert) + // Same path WebSocket alerts take to Notification Center, so the + // banner shows even when the window is closed. + self.appState?.onAlertNotificationsRequested?([alert]) + return alert + } + await offlineDelivery(alert) + } + private func reconnectDelay() -> Int { 30 } diff --git a/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift b/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift index d34d6e5..b5e59a4 100644 --- a/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift +++ b/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift @@ -15,16 +15,7 @@ enum MacNotificationPolicy { isSilenced: Bool ) -> Bool { guard enabled, !isSilenced, alert.readAt == nil else { return false } - return severityRank(alert.severity) >= severityRank(minimumSeverity) - } - - private static func severityRank(_ severity: String) -> Int { - switch severity.lowercased() { - case "critical": return 3 - case "high": return 2 - case "medium": return 1 - default: return 0 - } + return AlertSeverityRank.rank(alert.severity) >= AlertSeverityRank.rank(minimumSeverity) } } diff --git a/app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift b/app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift new file mode 100644 index 0000000..9869dc4 --- /dev/null +++ b/app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift @@ -0,0 +1,278 @@ +import Foundation +import os + +// The sensor is the normal alert dispatcher. The one alert it can never send +// is "the sensor is gone", so the app delivers that one itself through the +// same Slack webhook the operator configured, formatted the way the sensor +// formats everything else. +// +// The sensor redacts the webhook URL on every configuration response, so the +// only moment the app ever sees it is when the operator types it in Settings. +// `SlackDeliveryCache` keeps that value in the app's Keychain at that moment; +// the non-secret parts (enabled, minimum severity) ride along in defaults and +// are refreshed from every configuration load. + +// MARK: - Severity ranking + +enum AlertSeverityRank { + static func rank(_ severity: String) -> Int { + switch severity.lowercased() { + case "critical": return 3 + case "high": return 2 + case "medium": return 1 + default: return 0 + } + } +} + +// MARK: - Formatter + +/// Mirrors `format_slack_payload` in the sensor so an app-sent message is +/// indistinguishable from a sensor-sent one in the channel. +enum SlackAlertFormatter { + static func emoji(for severity: String) -> String { + switch severity.lowercased() { + case "critical": return "\u{1F534}" + case "high": return "\u{1F7E0}" + case "medium": return "\u{1F7E1}" + default: return "\u{1F535}" + } + } + + static func payload(for alert: AlertSummary, detail: String?) -> [String: Any] { + let severity = alert.severity.lowercased() + let emoji = emoji(for: severity) + let upper = severity.uppercased() + + var blocks: [[String: Any]] = [ + [ + "type": "header", + "text": ["type": "plain_text", "text": "\(emoji) \(alert.title)"], + ], + [ + "type": "section", + "fields": [ + ["type": "mrkdwn", "text": "*Severity:* \(emoji) \(upper)"], + ["type": "mrkdwn", "text": "*Type:* `\(alert.alertType)`"], + ], + ], + ] + + if let sourceIp = alert.sourceIp, !sourceIp.isEmpty { + blocks.append([ + "type": "section", + "fields": [ + ["type": "mrkdwn", "text": "*Source IP:* `\(sourceIp)`"], + ["type": "mrkdwn", "text": "*Time:* \(alert.createdAt)"], + ], + ]) + } else { + blocks.append([ + "type": "section", + "fields": [["type": "mrkdwn", "text": "*Time:* \(alert.createdAt)"]], + ]) + } + + if let detail, !detail.isEmpty { + blocks.append([ + "type": "section", + "text": ["type": "mrkdwn", "text": "_\(detail)_"], + ]) + } + + return ["text": "\(emoji) [\(upper)] \(alert.title)", "blocks": blocks] + } +} + +// MARK: - Secret storage + +protocol SlackSecretStore: Sendable { + func save(_ value: String, account: String) throws + func load(account: String) throws -> String? + func delete(account: String) throws +} + +struct KeychainSlackSecretStore: SlackSecretStore { + func save(_ value: String, account: String) throws { + try KeychainStore.storePassword(value, account: account) + } + + func load(account: String) throws -> String? { + do { + return try KeychainStore.loadPassword(account: account) + } catch KeychainError.itemNotFound { + return nil + } + } + + func delete(account: String) throws { + try KeychainStore.deletePassword(account: account) + } +} + +// MARK: - Cache + +struct SlackDeliveryTarget: Equatable, Sendable { + let enabled: Bool + let webhookURL: URL + let minimumSeverity: String +} + +final class SlackDeliveryCache: @unchecked Sendable { + /// The sensor's `config_secrets.REDACTED`. Echoed back on every load, it + /// means "unchanged" and must never overwrite a real cached URL. + static let redactedSentinel = "__squirrelops_redacted__" + + static let shared = SlackDeliveryCache( + defaults: .standard, + secrets: KeychainSlackSecretStore() + ) + + private static let webhookAccount = "alert_methods.slack.webhook_url" + private static let enabledKey = "slackDeliveryEnabled" + private static let minimumSeverityKey = "slackDeliveryMinimumSeverity" + + private let defaults: UserDefaults + private let secrets: any SlackSecretStore + private let lock = NSLock() + private let logger = Logger(subsystem: "io.squirrelops.home", category: "SlackDeliveryCache") + + init(defaults: UserDefaults, secrets: any SlackSecretStore) { + self.defaults = defaults + self.secrets = secrets + } + + /// Record what the operator saved or what the sensor reported. `webhookURL` + /// is written only when it is a real URL; the redaction sentinel leaves the + /// stored secret alone and an empty string removes it. + func record(enabled: Bool, webhookURL: String?, minimumSeverity: String?) { + lock.withLock { + defaults.set(enabled, forKey: Self.enabledKey) + if let minimumSeverity, !minimumSeverity.isEmpty { + defaults.set(minimumSeverity, forKey: Self.minimumSeverityKey) + } + + guard let webhookURL else { return } + let trimmed = webhookURL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == Self.redactedSentinel { return } + do { + if trimmed.isEmpty { + try secrets.delete(account: Self.webhookAccount) + } else if Self.validWebhook(trimmed) != nil { + try secrets.save(trimmed, account: Self.webhookAccount) + } else { + // Not something we would ever post to; drop any stale value + // rather than keep delivering to an address the operator + // has since changed. + try secrets.delete(account: Self.webhookAccount) + } + } catch { + logger.error("Slack webhook cache update failed: \(error.localizedDescription, privacy: .public)") + } + } + } + + func target() -> SlackDeliveryTarget? { + lock.withLock { + let stored: String? + do { + stored = try secrets.load(account: Self.webhookAccount) + } catch { + logger.error("Slack webhook cache read failed: \(error.localizedDescription, privacy: .public)") + return nil + } + guard let stored, let url = Self.validWebhook(stored) else { return nil } + return SlackDeliveryTarget( + enabled: defaults.bool(forKey: Self.enabledKey), + webhookURL: url, + minimumSeverity: defaults.string(forKey: Self.minimumSeverityKey) ?? "low" + ) + } + } + + func clear() { + lock.withLock { + defaults.removeObject(forKey: Self.enabledKey) + defaults.removeObject(forKey: Self.minimumSeverityKey) + try? secrets.delete(account: Self.webhookAccount) + } + } + + private static func validWebhook(_ raw: String) -> URL? { + guard let url = URL(string: raw), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty else { return nil } + return url + } +} + +// MARK: - Dispatcher + +enum OfflineDeliveryOutcome: Equatable, Sendable { + enum SkipReason: Equatable, Sendable { + case notConfigured + case disabled + case belowMinimumSeverity + } + + case delivered + case skipped(SkipReason) + case failed(String) +} + +final class OfflineAlertDispatcher: Sendable { + typealias Post = @Sendable (URLRequest) async throws -> (Data, URLResponse) + + static let shared = OfflineAlertDispatcher(cache: .shared) + + private let cache: SlackDeliveryCache + private let post: Post + private let logger = Logger(subsystem: "io.squirrelops.home", category: "OfflineAlertDispatcher") + + init(cache: SlackDeliveryCache, post: Post? = nil) { + self.cache = cache + self.post = post ?? { request in + // Ephemeral: nothing about the webhook or the message touches disk. + let session = URLSession(configuration: .ephemeral) + defer { session.finishTasksAndInvalidate() } + return try await session.data(for: request) + } + } + + /// Deliver one alert through the cached Slack target. Never throws; the + /// caller is a connection-loss path that must not fail on a webhook error. + @discardableResult + func deliver(_ alert: AlertSummary, detail: String?) async -> OfflineDeliveryOutcome { + guard let target = cache.target() else { return .skipped(.notConfigured) } + guard target.enabled else { return .skipped(.disabled) } + guard AlertSeverityRank.rank(alert.severity) >= AlertSeverityRank.rank(target.minimumSeverity) else { + return .skipped(.belowMinimumSeverity) + } + + var request = URLRequest(url: target.webhookURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 10 + do { + request.httpBody = try JSONSerialization.data( + withJSONObject: SlackAlertFormatter.payload(for: alert, detail: detail) + ) + let (_, response) = try await post(request) + guard let http = response as? HTTPURLResponse else { + return failure("Slack webhook returned a non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + return failure("Slack webhook returned HTTP \(http.statusCode)") + } + logger.info("Delivered \(alert.alertType, privacy: .public) to Slack from the app") + return .delivered + } catch { + return failure("Slack webhook request failed (\(type(of: error)))") + } + } + + private func failure(_ message: String) -> OfflineDeliveryOutcome { + logger.error("\(message, privacy: .public)") + return .failed(message) + } +} diff --git a/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift b/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift index 4c576bd..987a6e0 100644 --- a/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift +++ b/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift @@ -792,6 +792,9 @@ struct SettingsView: View { } } if case .object(let slack) = methods["slack"] { + // Non-secret fields refresh the offline-delivery cache; the + // redacted URL leaves the cached secret untouched. + Self.recordSlackDelivery(slack) if case .bool(let enabled) = slack["enabled"] { slackEnabled = enabled } @@ -1110,8 +1113,28 @@ struct SettingsView: View { scheduleLLMConfigSave() } + private static func recordSlackDelivery(_ config: [String: AnyCodableValue]) { + var enabled = false + if case .bool(let value) = config["enabled"] { enabled = value } + var webhookURL: String? + if case .string(let value) = config["webhook_url"] { webhookURL = value } + var minimumSeverity: String? + if case .string(let value) = config["min_severity"] { minimumSeverity = value } + SlackDeliveryCache.shared.record( + enabled: enabled, + webhookURL: webhookURL, + minimumSeverity: minimumSeverity + ) + } + private func saveAlertMethod(_ method: String, config: [String: AnyCodableValue]) { saveError = nil + if method == "slack" { + // The sensor redacts the webhook on every read, so this save is the + // only time the app holds the real URL. Cache it for the alert the + // sensor can never send: that it has gone offline. + Self.recordSlackDelivery(config) + } Task { do { let body: [String: AnyCodableValue] = [method: .object(config)] diff --git a/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift b/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift index d0389b1..d7c57bf 100644 --- a/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift +++ b/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift @@ -127,6 +127,9 @@ final class MockWSManager: WebSocketManagerProtocol, @unchecked Sendable { var requestedReplaySeq: Int? { lock.withLock { _requestedReplaySeq } } var framesToDeliver: [WSFrame] = [] + /// When set, the stream ends after the queued frames, which the service + /// treats as a lost connection. + var finishStreamAfterFrames = false func connect() { lock.withLock { _isConnected = true } } func disconnect() { lock.withLock { _isConnected = false } } @@ -148,6 +151,7 @@ final class MockWSManager: WebSocketManagerProtocol, @unchecked Sendable { for frame in frames { continuation.yield(frame) } // A live WebSocket remains open after replay completes. Individual // tests disconnect the service, which cancels the consumer task. + if finishStreamAfterFrames { continuation.finish() } } } } @@ -537,3 +541,107 @@ struct DisconnectAlertTests { #expect(alert1.id != alert2.id) } } + + +// MARK: - Offline alert delivery + +/// Collects what the service hands to the out-of-band delivery hook. +private final class OfflineDeliveryRecorder: @unchecked Sendable { + private let lock = NSLock() + private var _alerts: [AlertSummary] = [] + var alerts: [AlertSummary] { lock.withLock { _alerts } } + func record(_ alert: AlertSummary) { lock.withLock { _alerts.append(alert) } } +} + +@MainActor +private func waitUntil(timeout: Duration = .seconds(3), _ condition: @MainActor () -> Bool) async { + let deadline = ContinuousClock.now + timeout + while !condition() && ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(20)) + } +} + +@Suite("Offline alert out of band") +struct OfflineAlertOutOfBandTests { + + @Test("Never-connected outage is Medium and goes to Notification Center and the delivery hook") + @MainActor + func neverConnectedIsMedium() async { + let client = MockSensorClient() + client.shouldFail = true + let appState = AppState() + let notified = OfflineDeliveryRecorder() + let delivered = OfflineDeliveryRecorder() + appState.onAlertNotificationsRequested = { alerts in alerts.forEach(notified.record) } + + let service = SensorConnectionService( + sensorClient: client, + webSocketManager: MockWSManager(), + appState: appState, + offlineAlertDelay: .milliseconds(50), + offlineDelivery: { alert in delivered.record(alert) }, + onEvent: { _ in } + ) + defer { service.disconnect() } + + await service.connect(baseURL: URL(string: "https://192.168.1.50:8443")!, certFingerprint: "sha256:test") + #expect(service.state == .disconnected) + + await waitUntil { !delivered.alerts.isEmpty } + + let alert = appState.alerts.first { $0.alertType == "system.sensor_offline" } + #expect(alert?.severity == "medium") + #expect(notified.alerts.map(\.id) == [alert?.id]) + #expect(delivered.alerts.map(\.id) == [alert?.id]) + } + + @Test("Losing a live sensor is High") + @MainActor + func previouslyLiveIsHigh() async { + let client = MockSensorClient() + let wsManager = MockWSManager() + wsManager.framesToDeliver = [.replayComplete(lastSeq: 0)] + wsManager.finishStreamAfterFrames = true + let appState = AppState() + let delivered = OfflineDeliveryRecorder() + + let service = SensorConnectionService( + sensorClient: client, + webSocketManager: wsManager, + appState: appState, + offlineAlertDelay: .milliseconds(50), + offlineDelivery: { alert in delivered.record(alert) }, + onEvent: { _ in } + ) + defer { service.disconnect() } + + await service.connect(baseURL: URL(string: "https://192.168.1.50:8443")!, certFingerprint: "sha256:test") + // The stream ends right after replay, which the listener treats as a lost connection. + await waitUntil { !delivered.alerts.isEmpty } + + #expect(service.state == .disconnected) + #expect(delivered.alerts.first?.severity == "high") + #expect(appState.alerts.first { $0.alertType == "system.sensor_offline" }?.severity == "high") + } + + @Test("A reconnect attempt in flight at the deadline still raises the alert") + @MainActor + func connectingAtDeadlineStillAlerts() async { + // Reconnects run every 30 s and a health check can be mid-flight when the + // five-minute timer fires. The old guard required exactly .disconnected + // and silently dropped the alert for the rest of the outage. + #expect(SensorConnectionService.isOffline(.connecting)) + #expect(SensorConnectionService.isOffline(.disconnected)) + #expect(!SensorConnectionService.isOffline(.live)) + #expect(!SensorConnectionService.isOffline(.authFailed)) + #expect(!SensorConnectionService.isOffline(.incompatible)) + } + + @Test("Synthetic alert severity follows prior connectivity") + @MainActor + func syntheticSeverity() { + #expect(SensorConnectionService.makeDisconnectAlert(previouslyConnected: false).severity == "medium") + #expect(SensorConnectionService.makeDisconnectAlert(previouslyConnected: true).severity == "high") + #expect(SensorConnectionService.makeDisconnectAlert().severity == "medium") + } +} diff --git a/app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift b/app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift new file mode 100644 index 0000000..3c06576 --- /dev/null +++ b/app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift @@ -0,0 +1,233 @@ +import Foundation +import Testing + +@testable import SquirrelOpsHome + +// MARK: - Test doubles + +final class InMemorySlackSecretStore: SlackSecretStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: String] = [:] + + func save(_ value: String, account: String) throws { + lock.withLock { values[account] = value } + } + + func load(account: String) throws -> String? { + lock.withLock { values[account] } + } + + func delete(account: String) throws { + _ = lock.withLock { values.removeValue(forKey: account) } + } +} + +private func makeCache() -> SlackDeliveryCache { + let defaults = UserDefaults(suiteName: "OfflineAlertDeliveryTests.\(UUID().uuidString)")! + return SlackDeliveryCache(defaults: defaults, secrets: InMemorySlackSecretStore()) +} + +private func offlineAlert(severity: String = "high") -> AlertSummary { + AlertSummary( + id: -1, + alertType: "system.sensor_offline", + severity: severity, + title: "Sensor Disconnected", + createdAt: "2026-10-18T12:00:00Z" + ) +} + +// MARK: - Formatter + +@Suite("Slack alert formatter") +struct SlackAlertFormatterTests { + + @Test("Offline alert renders the same Block Kit shape the sensor sends") + func matchesSensorShape() throws { + let payload = SlackAlertFormatter.payload( + for: offlineAlert(severity: "high"), + detail: "The app has not reached the sensor for 5 minutes." + ) + + #expect(payload["text"] as? String == "\u{1F7E0} [HIGH] Sensor Disconnected") + + let blocks = try #require(payload["blocks"] as? [[String: Any]]) + #expect(blocks.count == 4) + + let header = try #require(blocks[0]["text"] as? [String: String]) + #expect(blocks[0]["type"] as? String == "header") + #expect(header["type"] == "plain_text") + #expect(header["text"] == "\u{1F7E0} Sensor Disconnected") + + let fields = try #require(blocks[1]["fields"] as? [[String: String]]) + #expect(fields.map { $0["text"] } == ["*Severity:* \u{1F7E0} HIGH", "*Type:* `system.sensor_offline`"]) + + // No source IP on an offline alert, so the time stands alone. + let timeFields = try #require(blocks[2]["fields"] as? [[String: String]]) + #expect(timeFields.map { $0["text"] } == ["*Time:* 2026-10-18T12:00:00Z"]) + + let detail = try #require(blocks[3]["text"] as? [String: String]) + #expect(detail["text"] == "_The app has not reached the sensor for 5 minutes._") + } + + @Test("Severity emoji follow the sensor's map") + func severityEmoji() { + #expect(SlackAlertFormatter.emoji(for: "critical") == "\u{1F534}") + #expect(SlackAlertFormatter.emoji(for: "high") == "\u{1F7E0}") + #expect(SlackAlertFormatter.emoji(for: "medium") == "\u{1F7E1}") + #expect(SlackAlertFormatter.emoji(for: "low") == "\u{1F535}") + } + + @Test("Source IP, when present, shares a section with the time") + func sourceIPSection() throws { + let alert = AlertSummary( + id: 5, alertType: "decoy.trip", severity: "medium", title: "Decoy connection", + sourceIp: "192.168.1.9", createdAt: "2026-10-18T12:00:00Z" + ) + let payload = SlackAlertFormatter.payload(for: alert, detail: nil) + let blocks = try #require(payload["blocks"] as? [[String: Any]]) + #expect(blocks.count == 3) + let fields = try #require(blocks[2]["fields"] as? [[String: String]]) + #expect(fields.map { $0["text"] } == ["*Source IP:* `192.168.1.9`", "*Time:* 2026-10-18T12:00:00Z"]) + } +} + +// MARK: - Cache + +@Suite("Slack delivery cache") +struct SlackDeliveryCacheTests { + + @Test("Records a real webhook URL typed by the operator") + func recordsRealURL() throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "high") + + let target = try #require(cache.target()) + #expect(target.enabled) + #expect(target.webhookURL.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + #expect(target.minimumSeverity == "high") + } + + @Test("The sensor's redaction sentinel never replaces a cached URL") + func ignoresRedactedSentinel() throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + cache.record(enabled: true, webhookURL: SlackDeliveryCache.redactedSentinel, minimumSeverity: "critical") + + let target = try #require(cache.target()) + #expect(target.webhookURL.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + #expect(target.minimumSeverity == "critical") + } + + @Test("Disabling keeps the URL but yields a disabled target") + func disableKeepsURL() throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + cache.record(enabled: false, webhookURL: SlackDeliveryCache.redactedSentinel, minimumSeverity: "low") + + let target = try #require(cache.target()) + #expect(target.enabled == false) + #expect(target.webhookURL.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + } + + @Test("An emptied URL removes the cached secret") + func emptyURLClears() { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + cache.record(enabled: true, webhookURL: "", minimumSeverity: "low") + #expect(cache.target() == nil) + } + + @Test("Only https Slack-shaped URLs are cached") + func rejectsNonHTTPS() { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "http://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + #expect(cache.target() == nil) + cache.record(enabled: true, webhookURL: "not a url", minimumSeverity: "low") + #expect(cache.target() == nil) + } + + @Test("Nothing cached means no target") + func emptyCache() { + #expect(makeCache().target() == nil) + } +} + +// MARK: - Dispatcher + +@Suite("Offline alert dispatcher") +struct OfflineAlertDispatcherTests { + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var _requests: [URLRequest] = [] + var requests: [URLRequest] { lock.withLock { _requests } } + func append(_ r: URLRequest) { lock.withLock { _requests.append(r) } } + } + + private func dispatcher( + cache: SlackDeliveryCache, + status: Int = 200, + recorder: Recorder + ) -> OfflineAlertDispatcher { + OfflineAlertDispatcher(cache: cache) { request in + recorder.append(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + return (Data("ok".utf8), response) + } + } + + @Test("Posts the formatted payload to the cached webhook") + func postsToWebhook() async throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "medium") + let recorder = Recorder() + + let delivered = await dispatcher(cache: cache, recorder: recorder) + .deliver(offlineAlert(severity: "high"), detail: "detail") + + #expect(delivered == .delivered) + let request = try #require(recorder.requests.first) + #expect(request.url?.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try JSONSerialization.jsonObject(with: try #require(request.httpBody)) as! [String: Any] + #expect(body["text"] as? String == "\u{1F7E0} [HIGH] Sensor Disconnected") + } + + @Test("Honors the operator's minimum severity") + func belowMinimumSkips() async { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "high") + let recorder = Recorder() + + let outcome = await dispatcher(cache: cache, recorder: recorder) + .deliver(offlineAlert(severity: "medium"), detail: nil) + + #expect(outcome == .skipped(.belowMinimumSeverity)) + #expect(recorder.requests.isEmpty) + } + + @Test("Disabled or unconfigured Slack sends nothing") + func disabledSkips() async { + let disabled = makeCache() + disabled.record(enabled: false, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + let recorder = Recorder() + + #expect(await dispatcher(cache: disabled, recorder: recorder).deliver(offlineAlert(), detail: nil) == .skipped(.disabled)) + #expect(await dispatcher(cache: makeCache(), recorder: recorder).deliver(offlineAlert(), detail: nil) == .skipped(.notConfigured)) + #expect(recorder.requests.isEmpty) + } + + @Test("A webhook failure is reported, not thrown") + func failureIsReported() async { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + let recorder = Recorder() + + let outcome = await dispatcher(cache: cache, status: 500, recorder: recorder) + .deliver(offlineAlert(), detail: nil) + + #expect(outcome == .failed("Slack webhook returned HTTP 500")) + } +} diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index e7ecbdc..62ea865 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -239,6 +239,15 @@ TLS keys and configuration credentials are stored in the encrypted secret store. Runtime YAML contains non-secret configuration only. Legacy plaintext configuration credentials are migrated and scrubbed at startup. +The Mac app holds one configuration credential of its own: a copy of the Slack +webhook URL, in a Keychain generic-password item under the app's service name. +The sensor redacts the URL in every configuration response, so the copy is +written only when the operator enters the URL in Settings and is deleted when +the field is cleared. The app uses it for the single alert the sensor cannot +send, Sensor Disconnected, and posts through an ephemeral URLSession so neither +the URL nor the message is cached on disk. APNs relay credentials are never +held by the app. + Python components resolve operating-system tools through an absolute path allowlist, never the service `PATH`. An allow-listed path is accepted only if the resolved binary is a root-owned, non-group/other-writable regular diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9844d26..f231e6a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -920,7 +920,7 @@ Every individual connection is still retained in the decoy's forensic log. | New Device | Medium | A device not already approved joined the LAN; one alert per device | | Device Needs Verification | Medium | The fingerprint matcher is unsure a device is the one it matched and the device is not approved; a dismissed alert returns if it recurs | | MAC Address Changed | High | A known device answered from a different hardware address; raised regardless of trust, folded per device, and reactivated on every further change | -| Sensor Disconnected | Medium | The Mac app has been disconnected for five minutes; this alert is local to the app | +| Sensor Disconnected | Medium or High | The Mac app has not reached the sensor for five minutes. High when the sensor was connected earlier in this app session, Medium when it never was. Raised by the app, not the sensor, and delivered by the app to Notification Center and to your Slack webhook (see Alert Methods) | Device alerts are gated on trust: approving a device in its detail view stops new-device and verification alerts for it. MAC changes still alert for approved @@ -1038,6 +1038,18 @@ app. APNs delivery is not a ready-to-enable iPhone/Mac feature in Settings. **Slack Webhook** — Posts alert summaries to a Slack channel via an incoming webhook URL. +**Where deliveries come from.** The sensor sends every alert it raises (decoy +trips, credential trips, security insights, device alerts) through the methods +above. One alert cannot come from the sensor: that the sensor itself is +unreachable. The app sends that one (**Sensor Disconnected**) itself, to +Notification Center and to the same Slack webhook, honoring the Slack minimum +severity. To make that possible the app keeps a copy of the webhook URL in its +own macOS Keychain entry from the moment you type it in Settings; the sensor +redacts the URL in every response, so there is no other time the app sees it. +Clearing the Webhook URL field removes the app's copy. The sensor's manually +configured APNs relay is not used by the app: the app never holds the relay +credentials. + When Slack is enabled, additional options appear: - **Webhook URL** — Your Slack incoming webhook URL - **Minimum Severity** — Which alerts trigger a Slack message @@ -1263,8 +1275,14 @@ checking automatically for 20 minutes and shows how long it has waited. The Sensor Not Responding screen appears only after that recovery window expires. The app reconnects automatically on a 30-second interval. After five minutes -of disconnection, it adds a Medium-severity **Sensor Disconnected** alert to -the local app view; this is not a sensor-persisted alert. +of disconnection it raises a **Sensor Disconnected** alert: High if the sensor +had been connected earlier in this app session, Medium if it never was. The +alert is created by the app, so it is not persisted by the sensor, but it is +delivered the same way sensor alerts are: a macOS notification (subject to your +macOS Notifications setting) and a Slack message when Slack is enabled and the +alert meets your Slack minimum severity. The Slack message comes from the Mac +running the app, which is the point: it still arrives when the sensor host has +been stopped. ### No Alerts Appearing @@ -1479,5 +1497,8 @@ On macOS, the sensor uses pfctl packet filter rules (loaded into a dedicated `co The `planted_credentials` table contains generated deception bait. Separately, operator-supplied Home Assistant tokens, AI keys, Slack webhook URLs, and APNs delivery secrets are stored in the encrypted secret store and redacted from -configuration responses. Pairing and TLS keys are also retained locally. +configuration responses. The Mac app additionally keeps the Slack webhook URL +in its own Keychain entry so it can report a sensor outage to Slack; that copy +is written only when you enter the URL in Settings and is removed when you +clear the field. Pairing and TLS keys are also retained locally. Synthetic bait must never be replaced with real service credentials.