Skip to content

Deliver the sensor-offline alert out of band from the app (#63) - #78

Merged
mattmacrocket merged 4 commits into
mainfrom
feature/sensor-offline-dispatch
Oct 18, 2026
Merged

mattmacrocket merged 4 commits into
mainfrom
feature/sensor-offline-dispatch

Conversation

@mattmacrocket

Copy link
Copy Markdown
Collaborator

Closes #63.

Why

An attacker who finds the sensor host has stopping it as the obvious first move. Today the app notices after five minutes, appends a Medium alert to its own list, and tells nobody. Two gaps, both fixed here:

  1. The synthetic alert never went through onAlertNotificationsRequested, so it did not reach Notification Center at all. With the window closed, nothing showed.
  2. Nothing left the Mac. The sensor is the only Slack sender, and the sensor is the thing that is gone.

What

Connection service (SensorConnectionService)

  • After the outage delay the app raises system.sensor_offline, adds it to the feed, routes it through the same Notification Center hook WebSocket alerts use, then hands it to an injected offlineDelivery closure.
  • Severity: High when the sensor had been .live earlier in this app session, Medium when it never connected.
  • Guard fix: the old timer required exactly .disconnected. Reconnects run every 30 s, so a health check in flight at the five-minute mark silently dropped the alert for the rest of the outage. isOffline now treats .connecting as offline; authFailed and incompatible are not, since the sensor answered.
  • offlineAlertDelay is injectable (default 300 s) so the timer is testable.

Out-of-band delivery (Desktop/OfflineAlertDelivery.swift, new)

  • SlackAlertFormatter mirrors the sensor's format_slack_payload block for block (header, severity/type fields, time or source-IP section, italic detail, same emoji map), so an app-sent message is indistinguishable in the channel.
  • SlackDeliveryCache: the sensor redacts webhook_url on every read, so the only moment the app holds the real URL is when the operator types it in Settings. The cache stores it then, as a Keychain generic password under the app's service name; enabled and minimum severity live in defaults and refresh from every config load. The redaction sentinel never overwrites a cached URL; an emptied field deletes it; only https URLs with a host are accepted.
  • OfflineAlertDispatcher: honors enabled and minimum severity, POSTs through an ephemeral URLSession, never throws (the caller is a connection-loss path), and reports delivered / skipped(reason) / failed(message).
  • AlertSeverityRank is shared with MacNotificationPolicy instead of a second private copy.

Settings: saveAlertMethod("slack", …) and the Slack branch of loadConfig both feed the cache.

App: wires OfflineAlertDispatcher.shared into the connection service.

Scoping decision

APNs stays sensor-side. The app never receives relay_url/relay_token/device_token (redacted) and has no UI to enter them, so there is nothing to cache. The docs say so explicitly.

Security model

One new place a configuration credential lives: the Slack webhook URL in the Mac app's Keychain, written only on operator entry, deleted on clear, used only for this alert, posted over an ephemeral session. docs/SECURITY_MODEL.md "Stored secrets" and docs/USER_GUIDE.md "Credential Safety" record it. Control surface only; nothing attacker-visible changes.

Tests

Red first (compile failures on the new types and parameters), then green:

  • SlackAlertFormatterTests: block shape matches the sensor for an offline alert; emoji map; source-IP section variant.
  • SlackDeliveryCacheTests: real URL cached; redaction sentinel ignored; disable keeps URL; empty clears; non-https rejected; empty cache.
  • OfflineAlertDispatcherTests: posts formatted JSON to the cached webhook; below-minimum skipped; disabled and unconfigured skipped; HTTP 500 reported not thrown.
  • OfflineAlertOutOfBandTests (connection service, 50 ms delay): never-connected outage is Medium and reaches both Notification Center and the delivery hook; losing a live sensor is High; isOffline table; synthetic severity.
  • MockWSManager gained finishStreamAfterFrames so a test can take the service from live to disconnected.

Full swift test: 421 + 121 + 39 passed.

Docs

USER_GUIDE.md: Sensor Disconnected row (Medium or High, delivered by the app), the reconnect paragraph, a "Where deliveries come from" note under Alert Methods, Credential Safety. SECURITY_MODEL.md: Stored secrets.

The one alert the sensor can never send is that it has gone away. Until
now the app noticed after five minutes, appended a Medium alert to its
own list, and told nobody: the synthetic alert never went through
onAlertNotificationsRequested, so it did not even reach Notification
Center, and nothing left the Mac.

Now the app delivers that alert itself:

- Notification Center, through the same hook WebSocket alerts use, so
  the banner shows with the window closed.
- Slack, through the operator's configured webhook, formatted exactly as
  the sensor formats its own Slack messages and honoring the Slack
  minimum severity. The sensor redacts the webhook URL on every read, so
  the app caches it in its own Keychain item at the one moment it holds
  the real value: when the operator types it in Settings. Clearing the
  field removes the copy. Posting uses an ephemeral URLSession.
- Severity is High when the sensor had been live earlier in this app
  session and Medium when it never connected.

Also fixes the outage timer guard. It required exactly .disconnected, but
reconnects run every 30 s, so a health check in flight at the deadline
dropped the alert for the rest of the outage. .connecting now counts as
offline; authFailed and incompatible still do not, since the sensor
answered.

APNs stays sensor-side: the app never holds relay credentials and has no
UI to enter them.

Docs: USER_GUIDE alert table and reconnect paragraph, a "where deliveries
come from" note under Alert Methods, Credential Safety, and the
SECURITY_MODEL stored-secrets section.
…e-dispatch

# Conflicts:
#	docs/USER_GUIDE.md
@mattmacrocket
mattmacrocket merged commit 878fb09 into main Oct 18, 2026
4 checks passed
@mattmacrocket
mattmacrocket deleted the feature/sensor-offline-dispatch branch October 20, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deliver sensor-offline detection out of band, not only in the app

2 participants