From 2979cf25ea263143d3ee1db307a82cbef3b445b2 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:07:55 -0800 Subject: [PATCH 1/7] Sentry: Add automatic relay enrollment --- docs/design/sentry-relay-enrollment.md | 48 ++++++ .../galaxy/web/js/sentry-notifications.js | 50 +++++- .../starpilot/sentry_mode/notifications.py | 77 ++++++++- .../starpilot/sentry_mode/relay_config.py | 2 + .../starpilot/sentry_mode/relay_enrollment.py | 119 ++++++++++++++ .../tests/test_relay_enrollment.py | 151 ++++++++++++++++++ 6 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 docs/design/sentry-relay-enrollment.md create mode 100644 openpilot/starpilot/sentry_mode/relay_config.py create mode 100644 openpilot/starpilot/sentry_mode/relay_enrollment.py create mode 100644 openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md new file mode 100644 index 0000000000..1b53cf56b8 --- /dev/null +++ b/docs/design/sentry-relay-enrollment.md @@ -0,0 +1,48 @@ +# Automatic Sentry relay enrollment + +Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18), [Android/Worker #3](https://github.com/dirwin31/StarPilotAndroid/pull/3), and [iOS #4](https://github.com/natehuby76/ios-clone/pull/4). StarPilot is based on FixesTest; Android is based on Play-Dev. + +## User flow + +The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. + +The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. Hosting is not deployed yet, so that value is intentionally empty: the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. + +Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. + +## Credential ownership and recovery + +Comma creates a random installation ID and 256-bit sending secret and saves them privately before enrollment. It sends only the SHA-256 digest at enrollment. Later requests use an installation bearer credential. The Worker stores digests; phone apps never receive the comma credential. Provider keys remain Worker secrets. Snapshots expose only capability, lifecycle state, installation ID and destination count. + +Pending enrollment, rotation, revocation and destination reconciliation survive restarts. Requests use HTTPS without redirects; credentials are bound to the configured origin. A lost enrollment response is recovered by authenticating with the saved candidate. Rotation checks the candidate before retrying, including after the old credential's ten-minute overlap expires. Confirmation retires the old credential. + +Revocation stops new local sending immediately and retries remote revocation offline. The Worker keeps a revocation tombstone so retries cannot reactivate the old ID. An already authorized provider request may finish. Forgetting locally does not revoke a copied credential. An explicit new enrollment generates a new ID; anonymous enrollment cannot prove that it represents the same hardware. + +## Public enrollment and capacity + +Automatic enrollment is intentionally public. It does not provide hardware attestation or verified ownership. The Worker requires operator configuration and uses transactional Durable Object state for signup limits, credential checks, destination scope, revocation, send quotas and deduplication. Approximate ingress rate limiting adds another layer. + +Default limits are 5 new installations per IP/day, 100 per relay/day, 1,000 total automatic installations including revoked IDs, 8 destinations per installation, 60 sends/hour/installation and 1,000 sends/day/relay. Operators can change these bounded settings. Capacity exhaustion fails closed. Daily IP hashes expire after two days and are pruned by an alarm. Event receipts expire after a day and are pruned on later sends. + +An attacker can consume capacity or generate new identities. These controls limit resource use; they do not establish physical-device identity. A stronger trust boundary would require accounts, attestation or a challenge, which is outside the one-action anonymous flow. A registered installation can only send to its own registered destinations; provider-token secrecy still matters because this phase does not prove phone ownership cryptographically. + +## Protocol + +| Route | Purpose | +| --- | --- | +| `POST /v2/enroll` | Register installation and digest; an empty invitation selects automatic enrollment | +| `GET /v2/installation` | Recover enrollment or rotation with the saved credential | +| `PUT /v2/subscriptions/{id}` | Register or replace a scoped destination | +| `DELETE /v2/subscriptions/{id}` | Remove a destination | +| `POST /v2/sentry` | Send an allowlisted, bounded event to a registered destination | +| `POST /v2/credentials/rotate` | Activate a replacement with bounded overlap | +| `POST /v2/credentials/confirm` | Retire the old credential | +| `DELETE /v2/installation` | Revoke the installation | + +Operator-only routes issue optional private invitations and revoke installations. Admin credentials never enter apps or comma. Provider responses are acceptance, not proof of display; interrupted provider responses may lead to duplicates. Camera media stays on comma. Authentication failures pause v2 delivery; they never fall back to anonymous or legacy sending. + +## Setup and validation + +Follow the [operator setup guide](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Firebase configuration for Android, Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Nate already has Apple developer membership. Cloudflare Free and Firebase's no-cost FCM support a pilot within their limits; no paid domain is required. + +Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. Full Galaxy HTTP tests need Linux GUI dependencies; Wrangler dry-run needs working npm DNS; full iOS compilation needs upstream's newer SDK. Live delivery has not been tested without provider credentials. diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 55267e9b73..fdcb1582fa 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -61,7 +61,7 @@ export const SentryNotifications = { components: { GxNotice }, name: "SentryNotifications", props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, - data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', channels, labels, notifications: null, notificationBusy: false, notificationError: "", + data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', enrollmentCode: '', relayConfirmed: false, channels, labels, notifications: null, notificationBusy: false, notificationError: "", drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }), created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, mounted() { @@ -83,6 +83,25 @@ export const SentryNotifications = { removePhone(id) { return this.client.run({ action: 'appUnsubscribe', id }) }, simulate() { return this.client.run({ action: 'simulate' }) }, canSimulate() { return !!window.StarPilotApp || location.protocol === 'http:' }, + async enableHostedAlerts() { + if (await this.client.run({ action: 'relayEnroll', confirmed: true })) { + window.StarPilotApp?.setSentryPush?.(true) + this.refreshPush() + setTimeout(() => this.client.run(), 1000) + } + }, + async enrollRelay() { + if (await this.client.run({ action: 'relayEnroll', url: this.relayUrl, invitation: this.enrollmentCode.trim(), confirmed: this.relayConfirmed })) { + this.enrollmentCode = ''; this.relayUrl = ''; this.relayConfirmed = false + } + }, + async relayAction(action) { + if (await this.client.run({ action, confirmed: this.relayConfirmed })) this.relayConfirmed = false + }, + relayStatus() { + const state = this.notifications?.relayEnrollment?.state + return ({ unenrolled: 'Enable alerts to set up this comma and phone automatically.', enrolling: 'Enrolling — waiting for the relay. Retries continue when internet returns.', enrolled: 'Relay enrolled. Enable alerts on each phone, then send a test.', rotating: 'Updating the relay credential.', confirming: 'Confirming the new relay credential.', revoking: 'Sending paused. Revocation is pending until the relay acknowledges it.', revoked: 'Alerts are disabled. Enable alerts to enroll again.', actionRequired: 'Relay authorization needs attention. Ask the operator to check or revoke the installation before enrolling again.' })[state] || 'This comma does not support relay enrollment.' + }, async saveRelay() { if (await this.client.run({ action: 'configure', channel: 'appPush', enabled: true, url: this.relayUrl, token: '' })) this.relayUrl = '' }, @@ -137,8 +156,33 @@ export const SentryNotifications = {

{{ notifications.deliverySemantics }}

Galaxy app phones

- - +

{{ pushState.status }}

+ +

Configure a push relay to deliver phone alerts.

diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py index 4ee2ccd817..1f10947390 100644 --- a/openpilot/starpilot/sentry_mode/notifications.py +++ b/openpilot/starpilot/sentry_mode/notifications.py @@ -11,6 +11,7 @@ import os from pathlib import Path import re +import secrets import stat import threading import time @@ -21,7 +22,8 @@ from openpilot.starpilot.storage import starpilot_storage_root from openpilot.starpilot.sentry_mode.storage import EventStore -from openpilot.starpilot.sentry_mode import web_push +from openpilot.starpilot.sentry_mode import web_push, relay_enrollment +from openpilot.starpilot.sentry_mode.relay_config import DEFAULT_RELAY_URL CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush') APP_PUSH_RELAY_URL = '' @@ -54,7 +56,7 @@ def endpoint(value: object, *, allow_http: bool = False) -> str: def fresh_state() -> dict: return {'version': 1, 'channels': {channel: {'enabled': False, 'url': '', 'token': '', 'generation': uuid.uuid4().hex, 'since': 0} for channel in CHANNELS}, - 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False} + 'relayEnrollment': relay_enrollment.fresh(), 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False} def wall_seconds() -> float: @@ -142,6 +144,8 @@ def unique(pairs): state['appSubscriptions'] = [] if type(state.get('channels')) is dict: state['channels']['appPush'] = fresh_state()['channels']['appPush'] + if type(state) is dict: + state.setdefault('relayEnrollment', relay_enrollment.fresh()) self._validate_state(state) self.state = state self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode() @@ -149,6 +153,7 @@ def unique(pairs): os.close(directory) def _validate_state(self, state: dict) -> None: + relay_enrollment.validate(state.get('relayEnrollment') if isinstance(state, dict) else None) if (type(state) is not dict or set(state) != set(fresh_state()) or type(state['version']) is not int or state['version'] != 1 or type(state['channels']) is not dict or set(state['channels']) != set(CHANNELS) or type(state['subscriptions']) is not list or len(state['subscriptions']) > 16 or @@ -241,7 +246,11 @@ def snapshot(self) -> dict: result['removedAppSubscriptions'] = [ {'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']} for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:] + enrollment = self.state['relayEnrollment'] + result['relayEnrollment'] = {'supported': True, 'automaticConfigured': bool(DEFAULT_RELAY_URL), 'state': enrollment['state'], 'installationId': enrollment['id'], 'registeredCount': len(enrollment['registered'])} result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL) + if enrollment['state'] != 'unenrolled': + result['appPushAvailable'] = result['appPushAvailable'] and enrollment['state'] == 'enrolled' for name, cfg in self.state['channels'].items(): relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']] latest = max(relevant, key=lambda j: j['created'], default=None) @@ -265,10 +274,40 @@ def action(self, payload: dict, *, permitted=lambda: True) -> dict: previous = copy.deepcopy(self.state) action = payload['action'] now = self.clock() - if action == 'configure': + if action == 'relayEnroll': + automatic = set(payload) == {'action', 'confirmed'} + if (not automatic and set(payload) != {'action', 'url', 'invitation', 'confirmed'}) or payload.get('confirmed') is not True: + raise ValueError('Confirm relay enrollment') + current = self.state['relayEnrollment'] + if automatic and current['state'] in ('enrolling', 'enrolled', 'rotating', 'confirming'): + return self.snapshot() + if current['state'] not in ('unenrolled', 'revoked', 'actionRequired'): + raise ValueError('Revoke or forget the current enrollment first') + url = DEFAULT_RELAY_URL if automatic else payload['url'] + if not url: + raise ValueError('Hosted alerts are not configured in this release yet') + self.state['relayEnrollment'] = relay_enrollment.begin(url, '' if automatic else payload['invitation']) + self.state['channels']['appPush'].update(url=self.state['relayEnrollment']['url'], enabled=True, generation=uuid.uuid4().hex, since=now) + elif action in ('relayRotate', 'relayRevoke', 'relayForget'): + if set(payload) != {'action', 'confirmed'} or payload['confirmed'] is not True: + raise ValueError('Confirm the relay action') + enrollment = self.state['relayEnrollment'] + if action == 'relayForget': + self.state['relayEnrollment'] = relay_enrollment.fresh() + self.state['channels']['appPush'] = fresh_state()['channels']['appPush'] + elif action == 'relayRotate': + if enrollment['state'] != 'enrolled': + raise ValueError('Enroll the relay before rotating its credential') + enrollment.update(state='rotating', candidate=secrets.token_hex(32), requestId=uuid.uuid4().hex, next=0, attempts=0) + elif enrollment['id']: + enrollment.update(state='revoking', next=0, attempts=0) + self.state['channels']['appPush']['enabled'] = False + elif action == 'configure': if set(payload) != {'action', 'channel', 'enabled', 'url', 'token'} or payload['channel'] not in CHANNELS: raise ValueError('Invalid notification channel') name = payload['channel'] + if name == 'appPush' and self.state['relayEnrollment']['state'] != 'unenrolled': + raise ValueError('Use relay enrollment controls for this channel') if (type(payload['enabled']) is not bool or type(payload['token']) is not str or len(payload['token']) > 512 or any(ord(c) < 32 for c in payload['token'])): raise ValueError('Invalid notification setting') @@ -292,6 +331,8 @@ def action(self, payload: dict, *, permitted=lambda: True) -> dict: elif action == 'forget': if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS: raise ValueError('Choose a notification channel') + if payload['channel'] == 'appPush' and self.state['relayEnrollment']['state'] != 'unenrolled': + raise ValueError('Revoke or forget the relay enrollment first') self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']] if payload['channel'] == 'webPush': self.state['subscriptions'] = [] @@ -420,6 +461,17 @@ def tick(self) -> None: if not self.available: return now = self.clock() + original_enrollment = copy.deepcopy(self.state['relayEnrollment']) + updated_enrollment = copy.deepcopy(original_enrollment) + subscriptions = copy.deepcopy(self.state['appSubscriptions']) + self.lock.release() + try: + relay_enrollment.step(updated_enrollment, subscriptions, now) + finally: + self.lock.acquire() + if self.state['relayEnrollment'] == original_enrollment: + self.state['relayEnrollment'] = updated_enrollment + self._save() self.state['jobs'] = {key: job for key, job in self.state['jobs'].items() if now - job['created'] <= MAX_AGE} self.state['queueFull'] = False if not any(cfg["enabled"] for cfg in self.state["channels"].values()): @@ -436,6 +488,9 @@ def tick(self) -> None: cfg = self.state['channels'][job['channel']] sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None) if job['channel'] == 'appPush': + enrollment = self.state['relayEnrollment'] + if enrollment['state'] != 'unenrolled' and (enrollment['state'] != 'enrolled' or job['subscriptionId'] not in enrollment['registered']): + continue sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None) if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] in ('webPush', 'appPush') and sub is None): job['state'] = 'cancelled' @@ -469,6 +524,12 @@ def tick(self) -> None: current_sub['lastAt'] = self.clock() current_sub['lastResult'] = ('Delivered to relay' if 200 <= code < 300 else 'Relay not configured' if code == 0 else 'Phone unregistered — removed' if code == 410 else 'Rate limited' if code == 429 else f'Relay error {code}') + enrollment = self.state['relayEnrollment'] + if enrollment['state'] != 'unenrolled' and code in (401, 403): + enrollment['state'] = 'actionRequired' + if enrollment['state'] == 'enrolled' and code == 409: + enrollment['registered'].pop(job['subscriptionId'], None) + code = 503 if code == 410: self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']] if 200 <= code < 300: @@ -525,7 +586,15 @@ def _send(self, name: str, cfg: dict, subscription: dict | None, job: dict, key: relay = cfg['url'] or APP_PUSH_RELAY_URL if not relay: return 0 - response = requests.post(relay.rstrip('/') + '/v1/sentry', json={ + protocol = '/v1/sentry' + with self.lock: + enrollment = copy.deepcopy(self.state['relayEnrollment']) + if enrollment['state'] != 'unenrolled': + if enrollment['state'] != 'enrolled' or relay != enrollment['url']: + return 401 + protocol = '/v2/sentry' + headers['Authorization'] = 'Bearer ' + enrollment['id'] + '.' + enrollment['secret'] + response = requests.post(relay.rstrip('/') + protocol, json={ 'token': subscription['token'], 'appId': subscription['appId'], 'kind': job['kind'], 'eventId': job['eventId'], 'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label']}, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) diff --git a/openpilot/starpilot/sentry_mode/relay_config.py b/openpilot/starpilot/sentry_mode/relay_config.py new file mode 100644 index 0000000000..ebf96a3163 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/relay_config.py @@ -0,0 +1,2 @@ +"""Set once by the release maintainer after deploying the hosted relay.""" +DEFAULT_RELAY_URL = '' diff --git a/openpilot/starpilot/sentry_mode/relay_enrollment.py b/openpilot/starpilot/sentry_mode/relay_enrollment.py new file mode 100644 index 0000000000..5413448618 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/relay_enrollment.py @@ -0,0 +1,119 @@ +"""Private relay credential lifecycle; the owner persists each transition.""" +import hashlib +import re +import secrets +import uuid +from urllib.parse import urlsplit + +import requests + + +def fresh(): + return {'version': 1, 'url': '', 'id': '', 'secret': '', 'invitation': '', 'requestId': '', + 'candidate': '', 'state': 'unenrolled', 'registered': {}, 'next': 0, 'attempts': 0} + + +def validate(value): + if not isinstance(value, dict) or set(value) != set(fresh()) or value['version'] != 1: + raise ValueError('Invalid relay enrollment') + if value['state'] not in ('unenrolled', 'enrolling', 'enrolled', 'rotating', 'confirming', 'revoking', 'revoked', 'actionRequired'): + raise ValueError('Invalid relay enrollment state') + for key, size in (('id', 32), ('secret', 64), ('invitation', 64), ('requestId', 32), ('candidate', 64)): + if not isinstance(value[key], str) or (value[key] and not re.fullmatch('[a-f0-9]{' + str(size) + '}', value[key])): + raise ValueError('Invalid relay credential') + if not isinstance(value['url'], str) or len(value['url']) > 2048: + raise ValueError('Invalid relay address') + if value['url']: + normalized_url(value['url']) + if (not isinstance(value['registered'], dict) or len(value['registered']) > 8 or + any(not re.fullmatch(r'[a-f0-9]{12}', k) or not isinstance(v, str) or not re.fullmatch(r'[a-f0-9]{64}', v) + for k, v in value['registered'].items()) or + type(value['next']) not in (int, float) or not 0 <= value['next'] < 2**63 or + type(value['attempts']) is not int or not 0 <= value['attempts'] <= 16): + raise ValueError('Invalid relay recovery state') + if value['state'] not in ('unenrolled', 'revoked') and not all(value[k] for k in ('url', 'id', 'secret')): + raise ValueError('Missing relay credential') + + +def normalized_url(url): + if not isinstance(url, str) or any(ord(c) <= 32 for c in url): + raise ValueError('Use an HTTPS relay base URL') + parts = urlsplit(url) + if parts.scheme != 'https' or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment: + raise ValueError('Use an HTTPS relay base URL without a secret or query') + if parts.path.strip('/'): + raise ValueError('Use the relay host URL without a path') + port = parts.port + return 'https://' + parts.hostname.lower() + (f':{port}' if port and port != 443 else '') + + +def begin(url, invitation=''): + if not isinstance(invitation, str) or (invitation and not re.fullmatch(r'[a-f0-9]{64}', invitation)): + raise ValueError('Enter the operator’s enrollment code') + return {**fresh(), 'url': normalized_url(url), 'id': uuid.uuid4().hex, 'secret': secrets.token_hex(32), + 'invitation': invitation, 'requestId': uuid.uuid4().hex, 'state': 'enrolling'} + + +def fingerprint(sub): + return hashlib.sha256((sub['appId'] + ':' + sub['token']).encode()).hexdigest() + + +def request(state, method, path, data=None, *, secret=None): + headers = {'Authorization': 'Bearer ' + state['id'] + '.' + (secret or state['secret'])} + with requests.request(method, state['url'] + '/v2/' + path, json=data, headers=headers, + timeout=(3, 10), allow_redirects=False, stream=True) as response: + return response.status_code + + +def step(state, subscriptions, now, send=request): + if state['state'] in ('unenrolled', 'revoked', 'actionRequired') or state['next'] > now: + return + phase = state['state'] + try: + if phase == 'enrolling': + code = send(state, 'GET', 'installation') + if code == 401: + code = send(state, 'POST', 'enroll', {'invitation': state['invitation'], 'installationId': state['id'], + 'secretDigest': hashlib.sha256(state['secret'].encode()).hexdigest(), 'requestId': state['requestId']}) + if 200 <= code < 300: + state.update(state='enrolled', invitation='') + elif phase == 'rotating': + code = send({**state, 'secret': state['candidate']}, 'GET', 'installation') + if code == 401: + code = send(state, 'POST', 'credentials/rotate', {'secretDigest': hashlib.sha256(state['candidate'].encode()).hexdigest(), + 'requestId': state['requestId']}) + if 200 <= code < 300: + state.update(secret=state['candidate'], candidate='', state='confirming') + elif phase == 'confirming': + code = send(state, 'POST', 'credentials/confirm', {}) + if 200 <= code < 300: + state['state'] = 'enrolled' + elif phase == 'revoking': + code = send(state, 'DELETE', 'installation') + if 200 <= code < 300 or code == 401: + state.update(fresh()); state['state'] = 'revoked' + return + else: + desired = {sub['id']: fingerprint(sub) for sub in subscriptions} + removed = next((key for key in state['registered'] if key not in desired), None) + pending = next((sub for sub in subscriptions if state['registered'].get(sub['id']) != desired[sub['id']]), None) + if removed: + code = send(state, 'DELETE', 'subscriptions/' + removed) + if 200 <= code < 300: + del state['registered'][removed] + elif pending: + code = send(state, 'PUT', 'subscriptions/' + pending['id'], {key: pending[key] for key in ('token', 'appId')}) + if 200 <= code < 300: + state['registered'][pending['id']] = desired[pending['id']] + else: + return + if 200 <= code < 300: + state.update(attempts=0, next=0) + return + if code not in (408, 429) and code < 500: + state.update(state='actionRequired', invitation='') + return + except requests.RequestException: + pass + state['attempts'] = min(16, state['attempts'] + 1) + state['next'] = now + min(600, 5 * 2 ** min(7, state['attempts'])) diff --git a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py new file mode 100644 index 0000000000..b72abd8d49 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py @@ -0,0 +1,151 @@ +from openpilot.starpilot.sentry_mode import relay_enrollment as relay + + +def test_enrollment_recovers_saved_candidate_without_redeeming_twice(): + state = relay.begin('https://relay.example/', 'a' * 64) + original = state['secret'] + calls = [] + def send(state, method, path, data=None): + calls.append(path) + return 200 + relay.step(state, [], 100, send) + assert calls == ['installation'] + assert state['state'] == 'enrolled' + assert state['secret'] == original + assert state['invitation'] == '' + relay.validate(state) + + +def test_offline_revoke_retries_and_retains_credential_until_acknowledged(): + state = relay.begin('https://relay.example', 'a' * 64) + state['state'] = 'revoking' + relay.step(state, [], 100, lambda *args: 503) + assert state['state'] == 'revoking' and state['secret'] + assert state['next'] > 100 + relay.step(state, [], state['next'], lambda *args: 200) + assert state['state'] == 'revoked' and not state['secret'] + + +def test_subscription_reconciliation_removes_before_adding(): + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='enrolled', invitation='', registered={'0123456789ab': 'b' * 64}) + sub = {'id': 'abcdef012345', 'token': 'phone', 'appId': 'com.starpilot.galaxy'} + calls = [] + def send(state, method, path, data=None): + calls.append((method, path)) + return 200 + relay.step(state, [sub], 100, send) + relay.step(state, [sub], 101, send) + assert calls == [('DELETE', 'subscriptions/0123456789ab'), ('PUT', 'subscriptions/abcdef012345')] + assert state['registered'] == {sub['id']: relay.fingerprint(sub)} + + +def test_owner_persists_candidate_redacts_secrets_and_recovers_restart(tmp_path, monkeypatch): + import json + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + root = tmp_path / 'notifications' + store = SimpleNamespace(snapshot=lambda: {'events': []}) + owner = NotificationOwner(root, store) + result = owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': True}) + assert result['relayEnrollment']['state'] == 'enrolling' + saved = owner.state['relayEnrollment'].copy() + assert saved['secret'] not in json.dumps(result) and 'a' * 64 not in json.dumps(result) + owner.close() + owner = NotificationOwner(root, store) + assert owner.state['relayEnrollment'] == saved + original_step = relay.step + monkeypatch.setattr(relay, 'step', lambda state, subs, now: original_step(state, subs, now, lambda *args: 200)) + owner.tick() + assert owner.snapshot()['appPushAvailable'] + owner.action({'action': 'relayRevoke', 'confirmed': True}) + assert not owner.snapshot()['appPushAvailable'] + owner.tick() + assert owner.snapshot()['relayEnrollment']['state'] == 'revoked' + owner.close() + + +def test_owner_rejects_silent_enrollment_and_host_changes(tmp_path): + import pytest + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + try: + with pytest.raises(ValueError): + owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': False}) + owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': True}) + with pytest.raises(ValueError): + owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://other.example', 'token': ''}) + finally: + owner.close() + + +def test_rotation_recovers_after_old_credential_overlap_expired(): + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='rotating', invitation='', candidate='b' * 64) + calls = [] + def send(current, method, path, data=None): + calls.append((current['secret'], method, path)) + return 200 if current['secret'] == 'b' * 64 else 401 + relay.step(state, [], 100, send) + assert state['state'] == 'confirming' and state['secret'] == 'b' * 64 + assert calls == [('b' * 64, 'GET', 'installation')] + relay.step(state, [], 101, send) + assert state['state'] == 'enrolled' + + +def test_v2_sender_binds_bearer_to_host_and_disables_redirects(tmp_path, monkeypatch): + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + from openpilot.starpilot.sentry_mode import notifications + owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='enrolled', invitation='') + owner.state['relayEnrollment'] = state + calls = [] + monkeypatch.setattr(notifications.requests, 'post', lambda url, **kwargs: calls.append((url, kwargs)) or SimpleNamespace(status_code=200, close=lambda: None)) + sub = {'token': 'phone', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'} + job = {'kind': 'test', 'eventId': 'b' * 32, 'created': 100} + try: + assert owner._send('appPush', {'url': state['url']}, sub, job, 'key', '') == 200 + assert calls[0][0] == 'https://relay.example/v2/sentry' + assert calls[0][1]['headers']['Authorization'] == 'Bearer ' + state['id'] + '.' + state['secret'] + assert calls[0][1]['allow_redirects'] is False + assert owner._send('appPush', {'url': 'https://elsewhere.example'}, sub, job, 'key', '') == 401 + assert len(calls) == 1 + state['state'] = 'revoking' + assert owner._send('appPush', {'url': state['url']}, sub, job, 'key', '') == 401 + assert len(calls) == 1 + finally: + owner.close() + + +def test_one_tap_enrollment_uses_release_host_and_preserves_candidate_on_retry(tmp_path, monkeypatch): + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode import notifications + owner = notifications.NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + monkeypatch.setattr(notifications, 'DEFAULT_RELAY_URL', 'https://hosted.example') + try: + status = owner.action({'action': 'relayEnroll', 'confirmed': True}) + saved = owner.state['relayEnrollment'].copy() + assert status['relayEnrollment']['automaticConfigured'] + assert saved['url'] == 'https://hosted.example' and saved['invitation'] == '' + owner.action({'action': 'relayEnroll', 'confirmed': True}) + assert owner.state['relayEnrollment'] == saved + finally: + owner.close() + + +def test_unconfigured_release_does_not_claim_automatic_alerts(tmp_path, monkeypatch): + import pytest + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode import notifications + monkeypatch.setattr(notifications, 'DEFAULT_RELAY_URL', '') + owner = notifications.NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + try: + assert not owner.snapshot()['relayEnrollment']['automaticConfigured'] + with pytest.raises(ValueError, match='not configured'): + owner.action({'action': 'relayEnroll', 'confirmed': True}) + assert owner.state['relayEnrollment']['state'] == 'unenrolled' + finally: + owner.close() From 7154c3217dbd402870609c499ed78c293786849c Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:06:30 -0800 Subject: [PATCH 2/7] Sentry: Skip phones the relay rejects instead of blocking the comma --- docs/design/sentry-relay-enrollment.md | 4 +- .../starpilot/sentry_mode/notifications.py | 10 ++++- .../starpilot/sentry_mode/relay_enrollment.py | 16 ++++--- .../tests/test_relay_enrollment.py | 43 +++++++++++++++++++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md index 1b53cf56b8..90bfeed213 100644 --- a/docs/design/sentry-relay-enrollment.md +++ b/docs/design/sentry-relay-enrollment.md @@ -43,6 +43,6 @@ Operator-only routes issue optional private invitations and revoke installations ## Setup and validation -Follow the [operator setup guide](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Firebase configuration for Android, Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Nate already has Apple developer membership. Cloudflare Free and Firebase's no-cost FCM support a pilot within their limits; no paid domain is required. +The host follows [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md); the launch checklist is in [SETUP.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Firebase is configured. -Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. Full Galaxy HTTP tests need Linux GUI dependencies; Wrangler dry-run needs working npm DNS; full iOS compilation needs upstream's newer SDK. Live delivery has not been tested without provider credentials. +Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. The Worker was also run locally in the Cloudflare runtime against the real Firebase project. A relay rejecting one phone skips that phone instead of blocking the comma. Full Galaxy HTTP tests need Linux GUI dependencies; full iOS compilation needs upstream's newer SDK. Live delivery to phones has not been tested. diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py index 1f10947390..2689d00ee2 100644 --- a/openpilot/starpilot/sentry_mode/notifications.py +++ b/openpilot/starpilot/sentry_mode/notifications.py @@ -146,6 +146,8 @@ def unique(pairs): state['channels']['appPush'] = fresh_state()['channels']['appPush'] if type(state) is dict: state.setdefault('relayEnrollment', relay_enrollment.fresh()) + if type(state['relayEnrollment']) is dict: + state['relayEnrollment'].setdefault('rejected', {}) self._validate_state(state) self.state = state self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode() @@ -241,13 +243,16 @@ def snapshot(self) -> dict: 'subscriptionCount': len(self.state['subscriptions']), 'subscriptions': [{'id': sub['id']} for sub in self.state['subscriptions']], 'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'} - result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} + rejected = self.state['relayEnrollment']['rejected'] + result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} | + ({'lastResult': 'Relay does not accept this app'} if sub['id'] in rejected else {}) for sub in self.state['appSubscriptions']] result['removedAppSubscriptions'] = [ {'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']} for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:] enrollment = self.state['relayEnrollment'] - result['relayEnrollment'] = {'supported': True, 'automaticConfigured': bool(DEFAULT_RELAY_URL), 'state': enrollment['state'], 'installationId': enrollment['id'], 'registeredCount': len(enrollment['registered'])} + result['relayEnrollment'] = {'supported': True, 'automaticConfigured': bool(DEFAULT_RELAY_URL), 'state': enrollment['state'], + 'installationId': enrollment['id'], 'registeredCount': len(enrollment['registered'])} result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL) if enrollment['state'] != 'unenrolled': result['appPushAvailable'] = result['appPushAvailable'] and enrollment['state'] == 'enrolled' @@ -369,6 +374,7 @@ def action(self, payload: dict, *, permitted=lambda: True) -> dict: raise ValueError('Invalid app subscription') current = self.state['appSubscriptions'] existing = next((sub for sub in current if sub['token'] == payload['token']), None) + self.state['relayEnrollment']['rejected'].pop(hashlib.sha256(payload['token'].encode()).hexdigest()[:12], None) if existing: existing.update(appId=payload['appId'], label=payload['label']) elif len(current) < 8: diff --git a/openpilot/starpilot/sentry_mode/relay_enrollment.py b/openpilot/starpilot/sentry_mode/relay_enrollment.py index 5413448618..549b9c0855 100644 --- a/openpilot/starpilot/sentry_mode/relay_enrollment.py +++ b/openpilot/starpilot/sentry_mode/relay_enrollment.py @@ -10,7 +10,7 @@ def fresh(): return {'version': 1, 'url': '', 'id': '', 'secret': '', 'invitation': '', 'requestId': '', - 'candidate': '', 'state': 'unenrolled', 'registered': {}, 'next': 0, 'attempts': 0} + 'candidate': '', 'state': 'unenrolled', 'registered': {}, 'rejected': {}, 'next': 0, 'attempts': 0} def validate(value): @@ -25,9 +25,9 @@ def validate(value): raise ValueError('Invalid relay address') if value['url']: normalized_url(value['url']) - if (not isinstance(value['registered'], dict) or len(value['registered']) > 8 or - any(not re.fullmatch(r'[a-f0-9]{12}', k) or not isinstance(v, str) or not re.fullmatch(r'[a-f0-9]{64}', v) - for k, v in value['registered'].items()) or + if (any(not isinstance(value[key], dict) or len(value[key]) > 8 or + any(not re.fullmatch(r'[a-f0-9]{12}', k) or not isinstance(v, str) or not re.fullmatch(r'[a-f0-9]{64}', v) + for k, v in value[key].items()) for key in ('registered', 'rejected')) or type(value['next']) not in (int, float) or not 0 <= value['next'] < 2**63 or type(value['attempts']) is not int or not 0 <= value['attempts'] <= 16): raise ValueError('Invalid relay recovery state') @@ -91,12 +91,13 @@ def step(state, subscriptions, now, send=request): elif phase == 'revoking': code = send(state, 'DELETE', 'installation') if 200 <= code < 300 or code == 401: - state.update(fresh()); state['state'] = 'revoked' + state.update(fresh(), state='revoked') return else: desired = {sub['id']: fingerprint(sub) for sub in subscriptions} + state['rejected'] = {key: value for key, value in state['rejected'].items() if desired.get(key) == value} removed = next((key for key in state['registered'] if key not in desired), None) - pending = next((sub for sub in subscriptions if state['registered'].get(sub['id']) != desired[sub['id']]), None) + pending = next((sub for sub in subscriptions if desired[sub['id']] not in (state['registered'].get(sub['id']), state['rejected'].get(sub['id']))), None) if removed: code = send(state, 'DELETE', 'subscriptions/' + removed) if 200 <= code < 300: @@ -105,6 +106,9 @@ def step(state, subscriptions, now, send=request): code = send(state, 'PUT', 'subscriptions/' + pending['id'], {key: pending[key] for key in ('token', 'appId')}) if 200 <= code < 300: state['registered'][pending['id']] = desired[pending['id']] + elif code in (400, 409): + state['rejected'][pending['id']] = desired[pending['id']] + code = 200 else: return if 200 <= code < 300: diff --git a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py index b72abd8d49..37ad04c8bd 100644 --- a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py +++ b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py @@ -40,6 +40,49 @@ def send(state, method, path, data=None): assert state['registered'] == {sub['id']: relay.fingerprint(sub)} +def test_rejected_destination_is_skipped_without_blocking_other_phones(): + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='enrolled', invitation='') + iphone = {'id': 'aaaaaaaaaaaa', 'token': 'apns.production.' + 'ab' * 32, 'appId': 'link.galaxy.bluetooth.natehuby76'} + android = {'id': 'bbbbbbbbbbbb', 'token': 'fcm', 'appId': 'com.starpilot.galaxy'} + calls = [] + def send(state, method, path, data=None): + calls.append(path) + return 400 if data and data['appId'] == iphone['appId'] else 200 + for now in range(100, 104): + relay.step(state, [iphone, android], now, send) + assert state['state'] == 'enrolled' + assert calls == ['subscriptions/' + iphone['id'], 'subscriptions/' + android['id']] + assert state['registered'] == {android['id']: relay.fingerprint(android)} + assert state['rejected'] == {iphone['id']: relay.fingerprint(iphone)} + relay.validate(state) + relay.step(state, [android], 104, send) + assert state['rejected'] == {} + + +def test_resubscribing_retries_a_rejected_phone_and_old_state_loads(tmp_path): + import json + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + root = tmp_path / 'notifications' + store = SimpleNamespace(snapshot=lambda: {'events': []}) + owner = NotificationOwner(root, store) + token = 'apns.production.' + 'ab' * 32 + owner.action({'action': 'appSubscribe', 'token': token, 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'}) + sub_id = owner.state['appSubscriptions'][0]['id'] + owner.state['relayEnrollment']['rejected'] = {sub_id: relay.fingerprint(owner.state['appSubscriptions'][0])} + assert owner.snapshot()['appSubscriptions'][0]['lastResult'] == 'Relay does not accept this app' + owner.action({'action': 'appSubscribe', 'token': token, 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'}) + assert owner.state['relayEnrollment']['rejected'] == {} + del owner.state['relayEnrollment']['rejected'] + saved = json.dumps(owner.state) + owner.close() + (root / 'state.json').write_text(saved) + owner = NotificationOwner(root, store) + assert owner.state['relayEnrollment']['rejected'] == {} + owner.close() + + def test_owner_persists_candidate_redacts_secrets_and_recovers_restart(tmp_path, monkeypatch): import json from types import SimpleNamespace From 0c956af49b4d3fe43f1af48de3c5283eded34089 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:23:55 -0800 Subject: [PATCH 3/7] Sentry: Point one-tap alerts at the hosted relay --- docs/design/sentry-relay-enrollment.md | 2 +- openpilot/starpilot/sentry_mode/relay_config.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md index 90bfeed213..1e2fcb863a 100644 --- a/docs/design/sentry-relay-enrollment.md +++ b/docs/design/sentry-relay-enrollment.md @@ -6,7 +6,7 @@ Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18 The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. -The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. Hosting is not deployed yet, so that value is intentionally empty: the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. +The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. It points at `https://galaxy-sentry-relay.dannyirwin31.workers.dev`. With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. diff --git a/openpilot/starpilot/sentry_mode/relay_config.py b/openpilot/starpilot/sentry_mode/relay_config.py index ebf96a3163..32a4474bc4 100644 --- a/openpilot/starpilot/sentry_mode/relay_config.py +++ b/openpilot/starpilot/sentry_mode/relay_config.py @@ -1,2 +1,2 @@ -"""Set once by the release maintainer after deploying the hosted relay.""" -DEFAULT_RELAY_URL = '' +"""Hosted Sentry push relay used for one-tap enrollment.""" +DEFAULT_RELAY_URL = 'https://galaxy-sentry-relay.dannyirwin31.workers.dev' From 2abe0e0ccf4c5e85155b8cf774c183ef3f19ba75 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:32:14 -0800 Subject: [PATCH 4/7] Sentry: Mark relay setup steps that are done --- docs/design/sentry-relay-enrollment.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md index 1e2fcb863a..7c92efd462 100644 --- a/docs/design/sentry-relay-enrollment.md +++ b/docs/design/sentry-relay-enrollment.md @@ -6,7 +6,7 @@ Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18 The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. -The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. It points at `https://galaxy-sentry-relay.dannyirwin31.workers.dev`. With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. +The relay is deployed with its provider credentials, and `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` points at it (`https://galaxy-sentry-relay.dannyirwin31.workers.dev`). With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. @@ -43,6 +43,6 @@ Operator-only routes issue optional private invitations and revoke installations ## Setup and validation -The host follows [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md); the launch checklist is in [SETUP.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Firebase is configured. +The relay is running (see [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md)). Remaining work is registering the iOS app and Nate's APNs key in Firebase, signed app builds and delivery tests on real phones. Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. The Worker was also run locally in the Cloudflare runtime against the real Firebase project. A relay rejecting one phone skips that phone instead of blocking the comma. Full Galaxy HTTP tests need Linux GUI dependencies; full iOS compilation needs upstream's newer SDK. Live delivery to phones has not been tested. From 0a5402beef68668f181b062411cd478fb31fd159 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:56:36 -0800 Subject: [PATCH 5/7] Sentry: Tidy the events page and phone alerts card --- openpilot/starpilot/galaxy/web/css/galaxy.css | 9 ++ .../galaxy/web/js/sentry-notifications.js | 98 +++++++++++-------- openpilot/starpilot/galaxy/web/js/sentry.js | 21 ++-- 3 files changed, 80 insertions(+), 48 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 6db0ad1924..e0e9fed98a 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1292,6 +1292,15 @@ button.gx-chip:hover { .gx-sentry-channel__fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; } .gx-sentry-channel__fields .gx-field { width: 100%; min-width: 0; box-sizing: border-box; } +.gx-sentry-events { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: var(--sp-3); } +.gx-sentry-event { display: flex; flex-direction: column; overflow: hidden; } +.gx-sentry-event .gx-row__info { display: grid; gap: var(--sp-1); } +.gx-sentry-event .gx-row__info small { word-break: break-all; } +.gx-sentry-event__media { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; grid-template-rows: minmax(0, 1fr); gap: 2px; aspect-ratio: 4 / 3; overflow: hidden; } +.gx-sentry-event__media .gx-media { width: 100%; height: 100%; object-fit: cover; } +.gx-sentry-events__bar { justify-content: space-between; } +.gx-sentry-events__bar p { margin: 0; } +.gx-sentry-relay { margin-top: var(--sp-3); } /* Recording library follows the shared section/row control hierarchy. */ .gx-recordings__library, .gx-recordings__list, .gx-recordings__delete { overflow: hidden; } diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index fdcb1582fa..0f6f886dfe 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -70,6 +70,7 @@ export const SentryNotifications = { this.start() }, beforeUnmount() { clearInterval(this.testPoll); this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) }, + computed: { relay() { return this.notifications?.relayEnrollment?.supported ? this.notifications.relayEnrollment : null } }, methods: { reportSave, refreshPush() { try { this.pushState = JSON.parse(window.StarPilotApp?.sentryPush?.() || '{}') } catch { this.pushState = {} } }, @@ -98,6 +99,9 @@ export const SentryNotifications = { async relayAction(action) { if (await this.client.run({ action, confirmed: this.relayConfirmed })) this.relayConfirmed = false }, + relayChip() { + return ({ unenrolled: 'Not set up', enrolling: 'Setting up', enrolled: 'On', rotating: 'Updating', confirming: 'Updating', revoking: 'Paused', revoked: 'Off', actionRequired: 'Needs attention' })[this.relay?.state] || 'Unknown' + }, relayStatus() { const state = this.notifications?.relayEnrollment?.state return ({ unenrolled: 'Enable alerts to set up this comma and phone automatically.', enrolling: 'Enrolling — waiting for the relay. Retries continue when internet returns.', enrolled: 'Relay enrolled. Enable alerts on each phone, then send a test.', rotating: 'Updating the relay credential.', confirming: 'Confirming the new relay credential.', revoking: 'Sending paused. Revocation is pending until the relay acknowledges it.', revoked: 'Alerts are disabled. Enable alerts to enroll again.', actionRequired: 'Relay authorization needs attention. Ask the operator to check or revoke the installation before enrolling again.' })[state] || 'This comma does not support relay enrollment.' @@ -154,45 +158,61 @@ export const SentryNotifications = { The notification queue is full. Some events could not be queued.
`, diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index b9518d0e0e..448e4ffba8 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -63,6 +63,7 @@ export const SentryEventsPage = { }, beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, methods: { systemTime, storedSize }, + computed: { kinds: () => ({ alarm: { label: 'Alarm', icon: 'bi-bell' }, warning: { label: 'Motion', icon: 'bi-exclamation-triangle' }, selfie: { label: 'Selfie', icon: 'bi-camera' } }) }, template: `

Sentry

@@ -76,18 +77,20 @@ export const SentryEventsPage = { Reading local motion events… {{ error }}
`, From be15747a75cf2206b39bf19cd1b0d1d6ae295c56 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:59:48 -0800 Subject: [PATCH 6/7] Sentry: Move channel switches into the card headers --- openpilot/starpilot/galaxy/web/js/sentry-notifications.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 0f6f886dfe..4bb103800e 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -214,13 +214,12 @@ export const SentryNotifications = {
-
{{ labels[name] }}
-
Enabled - {{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}
+
{{ labels[name] }}
-

Delivery: {{ notifications.channels[name].lastState }} · Pending: {{ notifications.channels[name].pending }}

+
+

{{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}

Delivery status: {{ notifications.channels[name].lastError.replaceAll('_', ' ') }}
From bfa3f1cd53fda8625af83877841614fd9f9c4d2e Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 09:02:59 -0800 Subject: [PATCH 7/7] Cameras: Show the white balance switch only on the cameras page --- openpilot/starpilot/galaxy/web/js/pip.js | 6 +++--- openpilot/starpilot/galaxy/web/js/sentry.js | 5 ++--- openpilot/starpilot/galaxy/web/js/vasm.js | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/js/pip.js b/openpilot/starpilot/galaxy/web/js/pip.js index ef068cfd9e..113d62acff 100644 --- a/openpilot/starpilot/galaxy/web/js/pip.js +++ b/openpilot/starpilot/galaxy/web/js/pip.js @@ -3,7 +3,7 @@ import { GxState } from "./state.js" import { GxNotice } from "./notice.js" import { reactive } from "../vendor/vue/vue.esm-browser.js" import { LiveCameraPreview } from "./cameras.js" -import { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.js" +import { whiteBalancePref } from "./white-balance.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { SettingsFeed, isPipCropRow, settingsBusy } from "./settings.js" import { GalaxySettingRow } from "./galaxy-setting-row.js" @@ -16,7 +16,7 @@ const SIDES = [ export const PipPage = { name: "PipPage", - components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow, WhiteBalanceSwitch }, + components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -46,6 +46,7 @@ export const PipPage = { return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { + this.pref.load() this._dragIndex = -1 this._dragSide = null this.liveCamera = new LiveCameraPreview({ @@ -191,7 +192,6 @@ export const PipPage = {
Camera Crop

Drag a circle onto the cabin window you want to watch. Both crops share one size.

-

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index 448e4ffba8..3a0e042962 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -4,7 +4,7 @@ import { GxNotice } from "./notice.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { SentryNotifications } from "./sentry-notifications.js" import { SnapshotFeed } from "./snapshot-feed.js" -import { BalancedImage, WhiteBalanceSwitch } from "./white-balance.js" +import { BalancedImage } from "./white-balance.js" import { deleteRecords, storedSize } from "./record-deletion.js" // Historical motion records and independently configured notifications. @@ -51,7 +51,7 @@ export class SentryEventsFeed extends SnapshotFeed { export const SentryEventsPage = { name: "SentryEventsPage", - components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage, WhiteBalanceSwitch }, + components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, data: () => ({ bluetooth: bluetoothConnection(), status: "idle", data: null, error: "", deleting: false }), @@ -82,7 +82,6 @@ export const SentryEventsPage = { This scan was incomplete. More local motion events may exist. No motion events yet. Events appear here when Sentry detects motion while parked. -
{{ kinds[event.kind].label }}
diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js index 4f5d99955d..dec0666462 100644 --- a/openpilot/starpilot/galaxy/web/js/vasm.js +++ b/openpilot/starpilot/galaxy/web/js/vasm.js @@ -4,14 +4,14 @@ import { reactive } from "../vendor/vue/vue.esm-browser.js" import { SettingsFeed, settingsBusy } from "./settings.js" import { GalaxySettingRow } from "./galaxy-setting-row.js" import { LiveCameraPreview } from "./cameras.js" -import { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.js" +import { whiteBalancePref } from "./white-balance.js" import { GxNotice } from "./notice.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js" export const VasmPage = { name: "VasmPage", - components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice, WhiteBalanceSwitch }, + components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -38,6 +38,7 @@ export const VasmPage = { return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { + this.pref.load() this._dragIndex = -1 this._dragSide = null this.liveCamera = new LiveCameraPreview({ @@ -183,7 +184,6 @@ export const VasmPage = {
Camera Window Regions

The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.

-

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}