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 }}
{{ pushState.status }}
+ +{{ relayStatus() }}
+Installation ID for operator support: {{ notifications.relayEnrollment.installationId }}
+ +Alerts send phone destinations and Sentry event details to the hosted relay. Your phone will ask for notification permission.
+ +Hosted alerts are not configured in this release yet.
+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{{ notifications.deliverySemantics }}
-{{ pushState.status }}
- -{{ relayStatus() }}
-Installation ID for operator support: {{ notifications.relayEnrollment.installationId }}
- -Alerts send phone destinations and Sentry event details to the hosted relay. Your phone will ask for notification permission.
- -Hosted alerts are not configured in this release yet.
-{{ relay ? relayStatus() : notifications.appPushAvailable ? 'Push relay configured.' : 'Configure a push relay to deliver phone alerts.' }}
+{{ phone.lastResult }}
+Configure a push relay to deliver phone alerts.
- - -{{ phone.label }} · {{ phone.lastResult || 'Not tested' }} · {{ phone.lastAt ? new Date(phone.lastAt * 1000).toLocaleString() : '' }} - -
-{{ phone.lastResult }}
- +Subscribed browsers: {{ notifications.subscriptionCount }}
- +{{ data.events.length }} events · {{ data.storageIncomplete ? 'At least ' : '' }}{{ storedSize(data.storageBytes) }} stored
System time: {{ systemTime(event.systemTimeMs) }}
- Event ID: {{ event.eventId }} - -Delivery: {{ notifications.channels[name].lastState }} · Pending: {{ notifications.channels[name].pending }}
+{{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}, last delivery {{ notifications.channels[name].lastState }}, {{ notifications.channels[name].pending }} pending
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." }}
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." }}