From 1a7bd7d7aa958cda919a11a7a39b06c5dac8a3ca Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:07:59 -0800 Subject: [PATCH 1/9] Sentry: Add cloud push with automatic enrollment --- app/build.gradle.kts | 3 + .../java/com/starpilot/galaxy/SentryPush.kt | 62 +++++- .../starpilot/galaxy/SentryEnrollmentTest.kt | 46 +++++ push-relay/README.md | 8 +- push-relay/SETUP.md | 66 ++++++- push-relay/apns.js | 36 ++++ push-relay/apns.test.js | 54 +++++ push-relay/enrollment.js | 186 ++++++++++++++++++ push-relay/enrollment.test.js | 115 +++++++++++ push-relay/operator.mjs | 21 ++ push-relay/package.json | 2 +- push-relay/relay.js | 2 + push-relay/worker.js | 25 +++ push-relay/worker.test.js | 26 +++ push-relay/wrangler.jsonc | 23 +++ 15 files changed, 655 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/com/starpilot/galaxy/SentryEnrollmentTest.kt create mode 100644 push-relay/apns.js create mode 100644 push-relay/apns.test.js create mode 100644 push-relay/enrollment.js create mode 100644 push-relay/enrollment.test.js create mode 100644 push-relay/operator.mjs create mode 100644 push-relay/worker.js create mode 100644 push-relay/worker.test.js create mode 100644 push-relay/wrangler.jsonc diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c1f1ad..626f22b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,9 @@ plugins { id("com.android.application") } val firebaseEnabled = file("google-services.json").exists() +check(providers.gradleProperty("requireSentryPush").orNull != "true" || firebaseEnabled) { + "Sentry push requires app/google-services.json. Follow push-relay/SETUP.md before building." +} // Google Play app signing certificate of com.starpilot.auto (verified from the installed Play APK). val playAaSignerSha256 = "13AE59E1A17F956D77E46F6E10C23913CBE47291164ECB43C5D9F107B157319A" val aaSignerSha256 = (System.getenv("AA_COMPANION_CERT_SHA256") ?: playAaSignerSha256).uppercase() diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt index 9dcf0a0..fb87bd4 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt @@ -22,13 +22,15 @@ object SentryPush { java.security.MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).joinToString("") { byte -> "%02x".format(byte) }.take(12) }.orEmpty() return JSONObject().put("id", id).put("available", FirebaseRegistration.available).put("enabled", p.getBoolean("sentryPushEnabled", false)) - .put("registered", p.getString("fcmRegisteredOrigin", "").orEmpty().isNotEmpty()).toString() + .put("registered", p.getString("fcmRegisteredOrigin", "").orEmpty().isNotEmpty()) + .put("status", p.getString("fcmAcknowledgement", "Enable phone alerts, then enroll the relay in Galaxy → Sentry.")).toString() } fun enable(ctx: Context, on: Boolean): Boolean { if (on && !permitted(ctx)) return false val p = prefs(ctx) if (on && !p.contains("sentryLastSeenNs")) p.edit().putLong("sentryLastSeenNs", System.currentTimeMillis() * 1000000).apply() - p.edit().putBoolean("sentryPushEnabled", on).apply() + p.edit().putBoolean("sentryPushEnabled", on).putBoolean("sentryEnrollRequested", on).apply() + if (on && !FirebaseRegistration.available) p.edit().putString("fcmAcknowledgement", "Polling only: Firebase is not configured in this build").apply() if (on) { FirebaseRegistration.start(ctx); SentryPollJob.schedule(ctx); api?.let { registerIfNeeded(ctx, it) } } else { FirebaseRegistration.stop(ctx) @@ -48,16 +50,62 @@ object SentryPush { if (suspended) return api = current val p = prefs(ctx) - if (!p.getBoolean("sentryPushEnabled", false)) return + if (!p.getBoolean("sentryPushEnabled", false)) { + val registrationKey = "fcmSubscription:" + current.origin + Thread { synchronized(this) { runCatching { + if (!p.getBoolean("sentryPushEnabled", false)) { + val previous = p.getString(registrationKey, "").orEmpty() + if (previous.isNotEmpty()) { + current.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", previous)) + p.edit().remove(registrationKey).apply() + } + } + } } }.start() + return + } val token = p.getString("fcmToken", "").orEmpty() if (!FirebaseRegistration.available || token.isEmpty()) return - Thread { runCatching { - val result = current.request("/api/sentry/notifications", JSONObject().put("action", "appSubscribe").put("token", token) + Thread { synchronized(this) { runCatching { + if (!p.getBoolean("sentryPushEnabled", false) || p.getString("fcmToken", "") != token) return@runCatching + val registrationKey = "fcmSubscription:" + current.origin + val previous = p.getString(registrationKey, "").orEmpty() + if (previous.isNotEmpty() && previous != token) { + current.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", previous)) + p.edit().remove(registrationKey).apply() + } + var result = current.request("/api/sentry/notifications", JSONObject().put("action", "appSubscribe").put("token", token) .put("appId", BuildConfig.APPLICATION_ID).put("label", Build.MODEL.take(64))) + p.edit().putString(registrationKey, token).apply() + result = enrollIfRequested(ctx, current, result) if (p.getBoolean("sentryPushEnabled", false) && p.getString("fcmToken", "") == token) { - p.edit().putString("fcmRegisteredOrigin", current.origin).putString("fcmAcknowledgement", if (result.optBoolean("appPushAvailable")) "Registered" else "Polling; relay not configured").apply() + p.edit().putString("fcmRegisteredOrigin", current.origin).putString("fcmAcknowledgement", relayStatus(result)).apply() + } else { + current.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", token)) + p.edit().remove(registrationKey).apply() } - }.onFailure { p.edit().putString("fcmAcknowledgement", "Registration failed").apply() } }.start() + }.onFailure { p.edit().putString("fcmAcknowledgement", "Registration failed; reconnect to retry").apply() } } }.start() + } + + internal fun enrollIfRequested(ctx: Context, current: SentryApi, snapshot: JSONObject): JSONObject { + val p = prefs(ctx) + var result = snapshot + if (p.getBoolean("sentryEnrollRequested", false) && p.getBoolean("sentryPushEnabled", false)) { + val enrollment = result.optJSONObject("relayEnrollment") + if (enrollment?.optBoolean("automaticConfigured") == true || enrollment?.optString("state") in listOf("enrolling", "enrolled", "rotating", "confirming")) { + result = current.request("/api/sentry/notifications", JSONObject().put("action", "relayEnroll").put("confirmed", true)) + p.edit().putBoolean("sentryEnrollRequested", false).apply() + } else if (enrollment == null) p.edit().putBoolean("sentryEnrollRequested", false).apply() + } + return result + } + internal fun relayStatus(result: JSONObject): String { + return when (result.optJSONObject("relayEnrollment")?.optString("state")) { + "enrolling", "rotating", "confirming" -> "Phone registered; waiting for relay enrollment in Galaxy → Sentry" + "unenrolled", "revoked" -> if (result.optJSONObject("relayEnrollment")?.optBoolean("automaticConfigured") == true) "Enable alerts to enroll this comma automatically" else "Phone registered; hosted alerts are not configured in this release yet" + "revoking" -> "Relay revocation pending; push alerts paused" + "actionRequired" -> "Relay authorization needs attention in Galaxy → Sentry" + else -> if (result.optBoolean("appPushAvailable")) "Registered; send a test from Galaxy → Sentry" else "Polling; relay not configured" + } } @Synchronized fun receive(ctx: Context, data: Map, fromPush: Boolean = true, demo: Boolean = false): Boolean { val p = prefs(ctx) diff --git a/app/src/test/java/com/starpilot/galaxy/SentryEnrollmentTest.kt b/app/src/test/java/com/starpilot/galaxy/SentryEnrollmentTest.kt new file mode 100644 index 0000000..5f92089 --- /dev/null +++ b/app/src/test/java/com/starpilot/galaxy/SentryEnrollmentTest.kt @@ -0,0 +1,46 @@ +package com.starpilot.galaxy + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +class SentryEnrollmentTest { + @Test fun registrationDoesNotClaimDeliveryWhenRelayNeedsEnrollment() { + val result = JSONObject().put("appPushAvailable", false) + .put("relayEnrollment", JSONObject().put("state", "unenrolled").put("automaticConfigured", true)) + assertTrue(SentryPush.relayStatus(result).contains("enroll this comma")) + result.getJSONObject("relayEnrollment").put("state", "actionRequired") + assertTrue(SentryPush.relayStatus(result).contains("authorization needs attention")) + result.getJSONObject("relayEnrollment").put("state", "revoking") + assertTrue(SentryPush.relayStatus(result).contains("paused")) + } + @Test fun olderCommaStatusRemainsSupported() { + assertTrue(SentryPush.relayStatus(JSONObject().put("appPushAvailable", true)).contains("send a test")) + assertTrue(SentryPush.relayStatus(JSONObject()).contains("relay not configured")) + } + @Test fun explicitEnableEnrollsWithoutCodesAndDoesNotRepeatOnReconnect() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("companion", android.content.Context.MODE_PRIVATE) + prefs.edit().clear().putBoolean("sentryPushEnabled", true).putBoolean("sentryEnrollRequested", true).commit() + var requests = 0 + val api = SentryApi("https://comma.test", "", bridge = { path, body -> + assertEquals("/api/sentry/notifications", path) + assertEquals("relayEnroll", body!!.getString("action")) + assertTrue(body.getBoolean("confirmed")) + assertEquals(2, body.length()) + requests++ + JSONObject().put("relayEnrollment", JSONObject().put("state", "enrolling")) + }) + val status = JSONObject().put("relayEnrollment", JSONObject().put("state", "unenrolled").put("automaticConfigured", true)) + SentryPush.enrollIfRequested(context, api, status) + SentryPush.enrollIfRequested(context, api, status) + assertEquals(1, requests) + assertFalse(prefs.getBoolean("sentryEnrollRequested", false)) + prefs.edit().clear().commit() + } +} diff --git a/push-relay/README.md b/push-relay/README.md index c5958f6..1d1a46b 100644 --- a/push-relay/README.md +++ b/push-relay/README.md @@ -1,10 +1,14 @@ # Galaxy sentry relay +Deploy `worker.js` with `wrangler.jsonc` for a shared Cloudflare relay. See [setup](SETUP.md) for the operator handoff, Android Firebase setup and direct Apple APNs setup. Install the StarPilot enrollment PR, configure the hosted URL once for the release, and users simply enable alerts. The normal flow needs no invitation or URL entry. The old shared-secret v1 pilot remains optional. + +Native iOS tokens prefixed `apns.production.` or `apns.sandbox.` go directly to Apple. Android and legacy Firebase tokens use FCM. Apple credentials are optional for Android-only deployments. + See [SETUP.md](SETUP.md) for setup and operation. -`POST /v1/sentry` accepts only a token, allowlisted app ID and event kind, bounded event ID and label, and integer event time. It sends a fixed data-only HIGH priority FCM message with a ten-minute TTL. No token or event payload is logged or persisted. Only hashed-token hourly counters are kept, in memory with a two-hour expiry; OAuth access tokens are cached in memory. +The provider adapter behind both versions accepts only a token, allowlisted app ID and event kind, bounded event ID and label, and integer event time. It sends a fixed data-only HIGH priority FCM message with a ten-minute TTL. Raw phone tokens and event bodies are not persisted. v2 stores per-installation credential digests, destination fingerprints and delivery receipts in SQLite-backed Durable Object storage; receipt IDs expire after a day and are pruned on subsequent sends. Invitations are pruned by an hourly alarm. Provider authorization tokens are cached in memory. -The limit is 30 requests per token per hour per relay process. It is an abuse deterrent, not an exact quota, and it resets on restart. Duplicate delivery is handled on the phone using the event ID. +v2 enforces 60 attempts per installation per hour transactionally and scopes destinations to that installation. The optional legacy v1 limit is approximately 30 requests per token per hour. It is an abuse deterrent, not an exact quota; KV is eventually consistent, and VPS memory counters reset on restart. Duplicate delivery is handled on the phone using the event ID. `relay.js` holds the logic and `server.js` is the small Node HTTP wrapper (`npm start`). Run `npm test`. Tests use a fake FCM transport and never send notifications. An FCM-specific `UNREGISTERED` or `INVALID_ARGUMENT` maps to 410; general payload/configuration errors remain 502 so configuration failures cannot delete all phone registrations. diff --git a/push-relay/SETUP.md b/push-relay/SETUP.md index 2f2af86..4e9b9ab 100644 --- a/push-relay/SETUP.md +++ b/push-relay/SETUP.md @@ -6,21 +6,67 @@ The app works without Firebase or a relay: enabled alerts use polling while conn 1. Create a Firebase project on the Spark plan, without Analytics. 2. Register both Android IDs: `link.firestar.companion` and `com.starpilot.galaxy`. -3. Download the project's `google-services.json` with both clients and put it in `app/`. This config is not a secret; commit it. Both flavors automatically include Messaging when the file exists. Without it they compile the polling implementation. +3. Download the project's `google-services.json` with both clients and put it in `app/`. This config is not a secret; commit it. Both flavors automatically include Messaging when the file exists. Without it they compile the polling implementation. For a push-enabled release, add `-PrequireSentryPush=true` to the Gradle build; it fails early if this file is absent. Register both package IDs in the same Firebase project used by the relay. 4. Enable Firebase Cloud Messaging API (V1) in Google Cloud. 5. Create a service account named `galaxy-push-relay` with **Firebase Cloud Messaging API Admin**. Create a JSON key. Keep that key private: never commit it or share it. -## iOS push (optional) +## iOS push without Firebase -Firebase delivers to iPhones through Apple's push service, using the same relay. +Nate's iOS app uses APNs directly. No Firebase SDK or Google configuration is needed on iOS. -1. Join the Apple Developer Program. In Certificates, Identifiers & Profiles, enable the Push Notifications capability on the iOS app's identifier and create an APNs Auth Key (.p8). Note its Key ID and your Team ID. -2. In Firebase, Project settings, Cloud Messaging, upload that key under the iOS app configuration. Register the iOS bundle ID as an iOS app in the project. -3. Set the relay's `IOS_APP_IDS` (comma-separated bundle IDs) in `galaxy-push-relay.service`. The default is the bundle ID currently in the iOS project, `link.galaxy.bluetooth.natehuby76`; change it to the published one. An empty value turns iOS off. The same IDs must be in `APP_IDS` in StarPilot's `sentry_mode/notifications.py`, or comma will reject the subscription. -4. The relay sends iOS pushes as a visible alert (title, body, `thread-id` sentry), not a silent data message, because iOS delays or drops silent pushes. Android keeps the data-only message. -5. The iOS app must add the Firebase Messaging SDK, ask for notification permission, and send its FCM token with the iOS bundle ID to Galaxy's `appSubscribe` action, like the Android app. That app work is separate and not done yet. The sentry photo needs an iOS notification service extension, since the `mutable-content` flag only allows one. +1. Enable Push Notifications for `link.galaxy.bluetooth.natehuby76` in the Apple developer account and regenerate the signing profile. +2. Create an APNs `.p8` signing key that permits this app and the needed environments. Give its Key ID, Team ID and private key securely to the Worker operator. The key never belongs in either app or on comma. +3. Keep `IOS_APP_IDS` equal to the app's bundle ID. This ID and both Android IDs above are already accepted by the current StarPilot comma code. Changing to another bundle ID may require a comma change. +4. Build the iOS PR. Debug uses development/sandbox APNs; Release uses production APNs (including TestFlight). `APS_ENVIRONMENT` sets both the entitlement and token routing. Verify the final signed entitlement matches the app's `SentryAPNsEnvironment` before distributing a custom build. +5. Enable phone alerts in the app's Settings or Galaxy's Sentry page. The app registers an `apns.production.` or `apns.sandbox.` token with comma. The Worker sends a visible alert; tapping it opens Sentry events after connecting. This version does not attach camera photos to iOS notifications. -Test on a physical iPhone; the simulator cannot receive remote pushes. +## Cloudflare Worker with per-comma enrollment + +**Must complete before phone push works:** create Firebase configuration for Android, obtain Nate's APNs key for iOS, deploy this Worker, install the StarPilot enrollment PR, and set the hosted URL once in the StarPilot release. Users then enable alerts with one action. These account credentials and live deployment are not included in the repository. Nate can use his existing Apple developer membership; no additional Apple membership is needed for his app. + +The public Worker URL contains no secret. On the user’s Enable alerts action, each comma generates and privately stores a separate sending credential without an invitation. Phones retain their provider tokens; neither phone receives the comma credential. The Worker stores credential digests, destination fingerprints and bounded delivery receipts in a SQLite-backed Durable Object. Camera media remains on comma. The operator can revoke one comma without disrupting others. + +1. Use Node 20+ and Cloudflare's Workers Free plan. From this directory run `npx wrangler@4 login`. The SQLite Durable Object binding and migration are already configured. Run `npx wrangler@4 kv namespace create RATE` and replace the RATE namespace placeholder in `wrangler.jsonc` (KV is used only by the optional legacy pilot). +2. Replace `FCM_PROJECT_ID`, `APNS_TEAM_ID`, `APNS_KEY_ID`, and `IOS_APP_IDS` in `wrangler.jsonc`. Keep the existing bundle ID for Nate's app. For Android-only operation set `IOS_APP_IDS` to an empty string; iOS-only operation needs no Firebase credentials. +3. Put provider keys in Worker secrets, never source, either app, or comma: + + ```sh + npx wrangler@4 secret put FCM_SERVICE_ACCOUNT < /secure/service-account.json + npx wrangler@4 secret put APNS_PRIVATE_KEY < /secure/AuthKey.p8 + openssl rand -hex 32 + npx wrangler@4 secret put ENROLLMENT_ADMIN_SECRET + ``` + + Paste the generated 64-character admin secret into Wrangler's prompt and retain it in the operator password manager. Skip provider secrets for unused platforms. The admin secret is exclusively for the operator; do not give it to users. Leave `RELAY_PATH_SECRET` unset unless deliberately maintaining an old private pilot. +4. Run `npm test` and `npx wrangler@4 deploy`. Keep observability disabled. The migration creates SQLite storage automatically. Retain the resulting `https://galaxy-sentry-relay..workers.dev` address; no custom domain is required. +5. Set `DEFAULT_RELAY_URL` in the StarPilot PR's `openpilot/starpilot/sentry_mode/relay_config.py` to the deployed public HTTPS origin, then ship that StarPilot release. This is a one-time maintainer setting; users never type it. No production URL is guessed while hosting is undeployed. Both apps discover readiness from comma, so neither app needs its own URL. Keep `AUTO_ENROLL` set to `"true"` in Wrangler. +6. Users connect to comma and tap **Enable alerts** in Galaxy, or enable Sentry phone alerts in the native app. The phone requests notification permission. Comma enrolls itself, saves its private credential, registers the phone destination and retries interrupted work. There are no invitation codes, accounts, copied tokens or endpoint fields in the normal flow. A release without a configured host reports that hosting is not ready. Existing enabled phones require an explicit enable action to opt into automatic enrollment. +7. Install Firebase-enabled Android and APNs-enabled iOS builds. Allow notifications, enable alerts on both phones, and send tests. Wait for relay enrollment before expecting delivery. Each comma supports eight registered destinations; event sending is limited to 60 attempts/hour/installation. Ingress limiting is approximate; installation authorization and quotas use transactional storage. +8. Verify each platform with the app backgrounded and the phone away from comma's Wi-Fi, while comma has internet. Verify wrong credentials cannot send, revoke one of two enrolled commas, and confirm only the revoked device stops. A successful provider response is acceptance, not proof of notification display. + +Automatic enrollment is public, not hardware attestation or proof of ownership. Enforced defaults are 5 new installations per IP/day, 100 per relay/day, 1,000 total automatic installations (including revoked installations), and 1,000 send attempts per relay/day, plus 60/hour per installation. Configure these Wrangler variables for capacity; they fail closed at the limit. Daily hashed IP records expire and are pruned. Attackers can consume capacity or enroll new identities; accounts or attestation would be needed to prevent that. Keep the deployment on Free and monitor capacity. Revoked IDs remain tombstoned so retries cannot reactivate them. + +For a private deployment, set `AUTO_ENROLL` to `"false"` and use `node operator.mjs invite` with `RELAY_URL` and `ENROLLMENT_ADMIN_SECRET` in the operator environment. Enter that code through Galaxy's Custom relay controls. Normal users of the public deployment do not need this tool. + +Use Galaxy's **Rotate credential** control for an individual comma. Rotation retains the old credential for at most ten minutes and retires it on confirmation. **Revoke relay access** stops sending locally immediately and retries remote revocation if offline. **Forget locally** is not remote revocation; use the operator tool if credentials have already been lost: + +```sh +export RELAY_URL=https://galaxy-sentry-relay.YOUR-ACCOUNT.workers.dev +read -r -s ENROLLMENT_ADMIN_SECRET +export ENROLLMENT_ADMIN_SECRET +node operator.mjs revoke INSTALLATION_ID_FROM_GALAXY +unset ENROLLMENT_ADMIN_SECRET +``` + +Provider key rotation is separate: replace the Worker secret, test delivery, then retire the old Apple/Google key. Do not delete the Durable Object namespace to update code. Deploy new code with the same binding/class and migration history to retain enrolled devices. Disabling the Worker stops delivery; restoring it preserves credentials. Never downgrade enrolled devices to anonymous sending. + +### Cost and limits + +[FCM is a no-cost Firebase product](https://firebase.google.com/pricing). Use Spark; no Analytics, database, Cloud Functions, or paid Firebase extension is needed. [Cloudflare supports SQLite Durable Objects on Workers Free](https://developers.cloudflare.com/durable-objects/platform/pricing/); stay on Free and monitor its request, CPU and storage limits. Free limits can stop service; free hosting is not unlimited delivery. A workers.dev address avoids domain registration costs. Nate supplies the existing Apple developer membership and signing assets. Play distribution uses the existing Play developer account. No provider account or deployment is created automatically by these PRs. + +### Legacy private pilot + +Old comma builds can still use the original v1 secret-path Worker when the operator explicitly sets a 64-hex `RELAY_PATH_SECRET`. Its base URL is `https:///`, with `/v1/sentry` appended by old comma code. Never publish that URL as a default. Migrate each comma to enrollment, then delete the shared secret. The VPS section below is legacy v1 only and does not implement enrollment. ## Relay on a VPS @@ -29,7 +75,7 @@ Any Linux server with Node 20+ and HTTPS works; the existing `galaxy.firestar.li 1. Copy the `push-relay` folder to `/opt/galaxy-push-relay` and create `/etc/galaxy-push-relay/service-account.json` (root-owned, mode 600) holding the private service-account JSON. 2. Install `galaxy-push-relay.service` into `/etc/systemd/system/`, set `FCM_PROJECT_ID` in it to the Firebase project ID, then run `systemctl daemon-reload && systemctl enable --now galaxy-push-relay`. It listens on `127.0.0.1:8787` only. The unit runs as a throwaway user and reads the key through systemd credentials. 3. Put HTTPS in front of it. `Caddyfile.example` exposes only `/v1/sentry` at `https://push.firestar.link`; Caddy gets the certificate itself. With nginx, proxy `/v1/sentry` to `127.0.0.1:8787` the same way. Never expose port 8787 directly. -4. Set `APP_PUSH_RELAY_URL` in the StarPilot source to `https://push.firestar.link/v1/sentry`. Sync the app using `companion/sync.sh`. The normal StarPilot branch update ships everything to comma; no SSH, environment variables, Params or hand-placed device files are needed. +4. Save `https://push.firestar.link` in Galaxy’s HTTPS push relay URL setting. Comma appends `/v1/sentry`; no source change is required. This legacy VPS entry point does not implement the Worker’s secret path. Use the Worker deployment above for the shared hosted service. Run it by hand to try it: `FCM_PROJECT_ID=... FCM_SERVICE_ACCOUNT_FILE=key.json node server.js`. diff --git a/push-relay/apns.js b/push-relay/apns.js new file mode 100644 index 0000000..3f926eb --- /dev/null +++ b/push-relay/apns.js @@ -0,0 +1,36 @@ +const encode = value => new TextEncoder().encode(value) +const b64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') +const json = (status, error) => Response.json(error ? { error } : { ok: true }, { status, headers: { 'Cache-Control': 'no-store' } }) +let cached + +export async function apnsAuthorization(env) { + const now = Math.floor(Date.now() / 1000) + if (cached?.key === env.APNS_PRIVATE_KEY && cached.team === env.APNS_TEAM_ID && cached.id === env.APNS_KEY_ID && cached.until > now) return cached.jwt + if (!/^[A-Z0-9]{10}$/.test(env.APNS_TEAM_ID) || !/^[A-Z0-9]{10}$/.test(env.APNS_KEY_ID)) throw new Error('APNs configuration') + const raw = Uint8Array.from(atob(env.APNS_PRIVATE_KEY.replace(/-----[^-]+-----|\s/g, '')), c => c.charCodeAt(0)) + const key = await crypto.subtle.importKey('pkcs8', raw, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']) + const unsigned = `${b64(encode(JSON.stringify({ alg: 'ES256', kid: env.APNS_KEY_ID })))}.${b64(encode(JSON.stringify({ iss: env.APNS_TEAM_ID, iat: now })))}` + const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, encode(unsigned)) + const jwt = `${unsigned}.${b64(signature)}` + cached = { key: env.APNS_PRIVATE_KEY, team: env.APNS_TEAM_ID, id: env.APNS_KEY_ID, jwt, until: now + 3000 } + return jwt +} + +export async function sendAPNs(body, env, fetcher = fetch, authorize = apnsAuthorization) { + const token = /^apns\.(production|sandbox)\.([a-f0-9]{32,512})$/.exec(body.token) + if (!token || token[2].length % 2) return json(400, 'Invalid Apple token') + const host = token[1] === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com' + const title = { alarm: 'Sentry alarm', warning: 'Sentry: motion detected', test: 'Galaxy test alert' }[body.kind] + const response = await fetcher(`https://${host}/3/device/${token[2]}`, { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), + headers: { authorization: `bearer ${await authorize(env)}`, 'content-type': 'application/json', + 'apns-topic': body.appId, 'apns-push-type': 'alert', 'apns-priority': '10', + 'apns-expiration': String(Math.floor(Date.now() / 1000) + 600), 'apns-collapse-id': body.eventId }, + body: JSON.stringify({ aps: { alert: { title, body: body.label || 'Parked motion detected' }, sound: 'default', 'thread-id': 'sentry' }, + type: 'sentry', kind: body.kind, eventId: body.eventId, wallTimeNs: String(body.wallTimeNs) }), + }) + if (response.ok) return json(200) + const failure = await response.json().catch(() => ({})) + if (response.status === 410 && failure.reason === 'Unregistered') return json(410, 'Token expired') + return json(response.status === 429 ? 429 : 502, 'APNs unavailable') +} diff --git a/push-relay/apns.test.js b/push-relay/apns.test.js new file mode 100644 index 0000000..c6feae5 --- /dev/null +++ b/push-relay/apns.test.js @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { apnsAuthorization, sendAPNs } from './apns.js' +import { handle } from './relay.js' +const body = { token: `apns.production.${'ab'.repeat(32)}`, appId: 'link.galaxy.bluetooth.natehuby76', kind: 'alarm', eventId: 'abc123', wallTimeNs: Date.now() * 1e6, label: 'iPhone' } +const authorized = async () => 'jwt' + +test('APNs sends a visible alert with bounded expiry, correct topic and environment', async () => { + for (const [environment, host] of [['production', 'api.push.apple.com'], ['sandbox', 'api.sandbox.push.apple.com']]) { + const response = await sendAPNs({ ...body, token: `apns.${environment}.${'ab'.repeat(32)}` }, {}, async (url, request) => { + assert.equal(url, `https://${host}/3/device/${'ab'.repeat(32)}`) + assert.equal(request.headers['apns-topic'], body.appId) + assert.equal(request.headers['apns-push-type'], 'alert') + assert.equal(request.headers['apns-collapse-id'], body.eventId) + assert.equal(request.redirect, 'error') + const payload = JSON.parse(request.body) + assert.equal(payload.aps.alert.title, 'Sentry alarm') + assert.equal(payload.type, 'sentry') + assert.equal(payload.eventId, body.eventId) + assert.equal(payload.token, undefined) + assert.ok(Number(request.headers['apns-expiration']) <= Date.now() / 1000 + 601) + return new Response(null, { status: 200 }) + }, authorized) + assert.equal(response.status, 200) + } +}) + +test('only unregistered Apple tokens remove subscriptions; setup errors remain retryable', async () => { + for (const [status, reason, expected] of [[410, 'Unregistered', 410], [400, 'BadDeviceToken', 502], [400, 'DeviceTokenNotForTopic', 502], [403, 'InvalidProviderToken', 502], [429, 'TooManyRequests', 429], [500, 'InternalServerError', 502]]) { + const response = await sendAPNs(body, {}, async () => Response.json({ reason }, { status }), authorized) + assert.equal(response.status, expected) + } + assert.equal((await sendAPNs({ ...body, token: 'apns.production.invalid' }, {}, () => assert.fail('must not send'), authorized)).status, 400) +}) + +test('ES256 authorization signs a verifiable JWT and refreshes when credentials change', async () => { + const key = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) + const raw = Buffer.from(await crypto.subtle.exportKey('pkcs8', key.privateKey)).toString('base64') + const env = { APNS_PRIVATE_KEY: `-----BEGIN PRIVATE KEY-----\n${raw}\n-----END PRIVATE KEY-----`, APNS_TEAM_ID: 'ABCDEFGHIJ', APNS_KEY_ID: '1234567890' } + const jwt = await apnsAuthorization(env) + const [header, payload, signature] = jwt.split('.') + assert.equal(JSON.parse(Buffer.from(header, 'base64url')).alg, 'ES256') + assert.equal(JSON.parse(Buffer.from(payload, 'base64url')).iss, env.APNS_TEAM_ID) + assert.equal(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key.publicKey, Buffer.from(signature, 'base64url'), new TextEncoder().encode(`${header}.${payload}`)), true) + assert.equal(await apnsAuthorization(env), jwt) + const changed = await apnsAuthorization({ ...env, APNS_KEY_ID: 'ABCDEFGHIJ' }) + assert.notEqual(changed, jwt) + let count = 0 + const response = await handle(new Request('https://relay.test/v1/sentry', { method: 'POST', body: JSON.stringify(body) }), { + ...env, RATE: { get: async () => 0, put: async () => {} }, + }, async url => { assert.ok(url.startsWith('https://api.push.apple.com/')); count++; return new Response(null, { status: 200 }) }, () => assert.fail('APNs must not require Firebase')) + assert.equal(response.status, 200) + assert.equal(count, 1) +}) diff --git a/push-relay/enrollment.js b/push-relay/enrollment.js new file mode 100644 index 0000000..3a5721c --- /dev/null +++ b/push-relay/enrollment.js @@ -0,0 +1,186 @@ +import { handle, valid, iosApps } from './relay.js' + +const hex = { test: value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) } +const idPattern = { test: value => typeof value === 'string' && /^[a-f0-9]{32}$/.test(value) } +const limit = (value, fallback) => /^\d+$/.test(String(value)) ? Math.max(1, Math.min(100000, Number(value))) : fallback +const reply = (status, error) => Response.json(typeof error === 'string' ? { error } : error, { status, headers: { 'Cache-Control': 'no-store' } }) +export const digest = async value => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))), n => n.toString(16).padStart(2, '0')).join('') +export function equal(a, b) { + if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false + let result = 0 + for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i) + return result === 0 +} +const fields = (value, names) => value && !Array.isArray(value) && Object.keys(value).sort().join(',') === names.split(',').sort().join(',') +async function body(request) { + const reader = request.body?.getReader() + if (!reader) throw Error('body') + let size = 0; const chunks = [] + for (;;) { + const { done, value } = await reader.read() + if (done) break + size += value.length + if (size > 8192) { await reader.cancel(); throw Error('body') } + chunks.push(value) + } + const bytes = new Uint8Array(size); let offset = 0 + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length } + return JSON.parse(new TextDecoder().decode(bytes)) +} + +export class EnrollmentRegistry { + constructor(state, env) { this.storage = state.storage; this.env = env } + + async alarm() { + for (const prefix of ['invite:', 'auto-ip:']) { + let startAfter + for (;;) { + const rows = await this.storage.list({ prefix, limit: 128, ...(startAfter ? { startAfter } : {}) }) + if (!rows.size) break + const expired = [...rows].filter(([, value]) => value.expires < Date.now()).map(([key]) => key) + if (expired.length) await this.storage.delete(expired) + startAfter = [...rows.keys()].at(-1) + if (rows.size < 128) break + } + } + await this.storage.setAlarm(Date.now() + 3600000) + } + + async fetch(request) { + try { return await this.route(request) } catch { return reply(503, 'Relay unavailable') } + } + + async route(request) { + const url = new URL(request.url), path = url.pathname, now = Date.now() + if (url.search) return reply(400, 'Query parameters are not supported') + let data = {} + if (['POST', 'PUT'].includes(request.method)) { + try { data = await body(request) } catch { return reply(400, 'Invalid body') } + } + if (path.startsWith('/v2/admin/')) { + const secret = this.env.ENROLLMENT_ADMIN_SECRET + if (!hex.test(secret || '') || !equal(request.headers.get('Authorization'), `Bearer ${secret}`)) return reply(403, 'Admin authorization required') + if (path === '/v2/admin/invitations' && request.method === 'POST') { + if (!fields(data, 'invitation') || !hex.test(data.invitation)) return reply(400, 'Invalid invitation') + const key = `invite:${await digest(data.invitation)}` + if (this.storage.setAlarm && !(await this.storage.getAlarm())) await this.storage.setAlarm(now + 3600000) + return this.storage.transaction(async tx => { + if (await tx.get(key)) return reply(409, 'Invitation already exists') + await tx.put(key, { expires: now + 900000 }) + return reply(201, { expiresAt: now + 900000 }) + }) + } + const match = path.match(/^\/v2\/admin\/installations\/([a-f0-9]{32})$/) + if (match && request.method === 'DELETE') { + await this.storage.put(`device:${match[1]}`, { revoked: true }) + return reply(200, { revoked: true }) + } + return reply(404, 'Not found') + } + if (path === '/v2/enroll' && request.method === 'POST') { + if (!fields(data, 'invitation,installationId,secretDigest,requestId') || !(data.invitation === '' || hex.test(data.invitation)) || !hex.test(data.secretDigest) || !idPattern.test(data.installationId) || !idPattern.test(data.requestId)) return reply(400, 'Invalid enrollment') + if (data.invitation === '') { + if (this.env.AUTO_ENROLL !== 'true' || !hex.test(this.env.ENROLLMENT_ADMIN_SECRET)) return reply(503, 'Automatic enrollment is not configured') + const day = Math.floor(now / 86400000) + const ipKey = `auto-ip:${await digest(day + ':' + (request.headers.get('CF-Connecting-IP') || 'unknown'))}` + if (this.storage.setAlarm && !(await this.storage.getAlarm())) await this.storage.setAlarm(now + 3600000) + return this.storage.transaction(async tx => { + const deviceKey = `device:${data.installationId}` + const existing = await tx.get(deviceKey) + if (existing) return !existing.revoked && equal(existing.digest, data.secretDigest) && existing.enrollmentRequest === data.requestId + ? reply(200, { enrolled: true }) : reply(409, 'Installation already exists or is revoked') + const stats = await tx.get('auto-stats') || { day, count: 0, total: 0 } + if (stats.day !== day) { stats.day = day; stats.count = 0 } + const ip = await tx.get(ipKey) || { count: 0, expires: (day + 2) * 86400000 } + if (stats.total >= limit(this.env.MAX_INSTALLATIONS, 1000) || stats.count >= limit(this.env.AUTO_ENROLL_DAILY_LIMIT, 100) || ip.count >= limit(this.env.AUTO_ENROLL_IP_DAILY_LIMIT, 5)) return reply(429, 'Enrollment capacity reached; retry later') + stats.count++; stats.total++; ip.count++ + await tx.put('auto-stats', stats); await tx.put(ipKey, ip) + await tx.put(deviceKey, { digest: data.secretDigest, enrollmentRequest: data.requestId, subscriptions: {}, hour: 0, count: 0, receipts: {} }) + return reply(201, { enrolled: true }) + }) + } + const key = `invite:${await digest(data.invitation)}` + return this.storage.transaction(async tx => { + const invitation = await tx.get(key) + if (!invitation || invitation.expires < now) return reply(403, 'Invitation expired or unavailable') + const deviceKey = `device:${data.installationId}` + if (invitation.used) { + const current = await tx.get(deviceKey) + return invitation.used === data.requestId && invitation.installationId === data.installationId && equal(current?.digest, data.secretDigest) + ? reply(200, { enrolled: true }) : reply(403, 'Invitation already used') + } + if (await tx.get(deviceKey)) return reply(409, 'Installation already exists') + await tx.put(deviceKey, { digest: data.secretDigest, subscriptions: {}, hour: 0, count: 0, receipts: {} }) + await tx.put(key, { used: data.requestId, installationId: data.installationId, expires: now + 86400000 }) + return reply(201, { enrolled: true }) + }) + } + const auth = request.headers.get('Authorization')?.match(/^Bearer ([a-f0-9]{32})\.([a-f0-9]{64})$/) + if (!auth) return reply(401, 'Credential required') + const key = `device:${auth[1]}`, hash = await digest(auth[2]) + const result = await this.storage.transaction(async tx => { + const device = await tx.get(key) + if (!device || !(equal(device.digest, hash) || (device.previousUntil > now && equal(device.previous, hash)))) return reply(401, 'Credential invalid or revoked') + if (path === '/v2/installation' && request.method === 'GET') return reply(200, { enrolled: true, current: equal(device.digest, hash) }) + if (path === '/v2/installation' && request.method === 'DELETE') { await tx.put(key, { revoked: true }); return reply(200, { revoked: true }) } + if (path === '/v2/credentials/rotate' && request.method === 'POST') { + if (!fields(data, 'secretDigest,requestId') || !hex.test(data.secretDigest) || !idPattern.test(data.requestId)) return reply(400, 'Invalid rotation') + if (device.rotation === data.requestId) return equal(device.digest, data.secretDigest) ? reply(200, { rotated: true }) : reply(409, 'Rotation mismatch') + if (!equal(device.digest, hash)) return reply(403, 'Current credential required') + device.previous = device.digest; device.previousUntil = now + 600000 + device.digest = data.secretDigest; device.rotation = data.requestId + await tx.put(key, device) + return reply(200, { rotated: true }) + } + if (path === '/v2/credentials/confirm' && request.method === 'POST') { + if (!fields(data, '') || !equal(device.digest, hash)) return reply(403, 'Current credential required') + delete device.previous; delete device.previousUntil + await tx.put(key, device) + return reply(200, { confirmed: true }) + } + const sub = path.match(/^\/v2\/subscriptions\/([a-f0-9]{12,32})$/) + if (sub && request.method === 'DELETE') { + delete device.subscriptions[sub[1]]; await tx.put(key, device) + return reply(200, { removed: true }) + } + if (sub && request.method === 'PUT') { + if (!fields(data, 'token,appId') || !valid({ ...data, kind: 'test', eventId: 'test', wallTimeNs: now * 1e6, label: '' }, iosApps(this.env))) return reply(400, 'Invalid destination') + if (!device.subscriptions[sub[1]] && Object.keys(device.subscriptions).length >= 8) return reply(409, 'Destination limit') + device.subscriptions[sub[1]] = await digest(`${data.appId}:${data.token}`) + await tx.put(key, device) + return reply(200, { registered: true }) + } + if (path !== '/v2/sentry' || request.method !== 'POST') return reply(404, 'Not found') + if (!valid(data, iosApps(this.env)) || Math.abs(now - data.wallTimeNs / 1e6) > 600000) return reply(400, 'Invalid or stale event') + const fingerprint = await digest(`${data.appId}:${data.token}`) + if (!Object.values(device.subscriptions).includes(fingerprint)) return reply(409, 'Destination not registered') + const receipt = `${fingerprint}:${data.eventId}:${data.kind}` + for (const [id, value] of Object.entries(device.receipts)) if (value.until < now) delete device.receipts[id] + if (device.receipts[receipt]) return device.receipts[receipt].done ? reply(200, { duplicate: true }) : reply(429, 'Delivery in progress') + const hour = Math.floor(now / 3600000) + if (device.hour !== hour) { device.hour = hour; device.count = 0 } + if (device.count >= 60) return reply(429, 'Installation quota exceeded') + const day = Math.floor(now / 86400000) + const global = await tx.get('send-budget') || { day, count: 0 } + if (global.day !== day) { global.day = day; global.count = 0 } + if (global.count >= limit(this.env.DAILY_SEND_LIMIT, 1000)) return reply(429, 'Daily relay capacity reached') + global.count++; await tx.put('send-budget', global) + device.count++ + device.receipts[receipt] = { until: now + 30000, done: false } + await tx.put(key, device) + return { receipt, fingerprint } + }) + if (result instanceof Response) return result + const provider = this.env.SEND || handle + const response = await provider(new Request('https://relay.internal/v1/sentry', { method: 'POST', body: JSON.stringify(data) }), { ...this.env, RATE: { get: async () => null, put: async () => {} } }) + await this.storage.transaction(async tx => { + const device = await tx.get(key) + if (!device || device.revoked) return + if (response.ok) device.receipts[result.receipt] = { until: now + 86400000, done: true } + else delete device.receipts[result.receipt] + if (response.status === 410) for (const [id, value] of Object.entries(device.subscriptions)) if (value === result.fingerprint) delete device.subscriptions[id] + await tx.put(key, device) + }) + return response + } +} diff --git a/push-relay/enrollment.test.js b/push-relay/enrollment.test.js new file mode 100644 index 0000000..7b51a7a --- /dev/null +++ b/push-relay/enrollment.test.js @@ -0,0 +1,115 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { EnrollmentRegistry, digest } from './enrollment.js' + +function setup(overrides = {}) { + const records = new Map(); let tail = Promise.resolve(); let sends = 0 + const storage = { + get: async key => structuredClone(records.get(key)), + put: async (key, value) => records.set(key, structuredClone(value)), + delete: async key => records.delete(key), + transaction(fn) { const next = tail.then(() => fn(storage)); tail = next.catch(() => {}); return next }, + } + const admin = 'a'.repeat(64), invitation = 'b'.repeat(64), secret = 'c'.repeat(64), id = 'd'.repeat(32) + const registry = new EnrollmentRegistry({ storage }, { ENROLLMENT_ADMIN_SECRET: admin, SEND: async () => { sends++; return Response.json({ ok: true }) }, ...overrides }) + const request = (path, method = 'GET', data, credential = `${id}.${secret}`) => registry.fetch(new Request(`https://test/v2/${path}`, { + method, headers: { Authorization: `Bearer ${credential}` }, ...(data ? { body: JSON.stringify(data) } : {}), + })) + const enroll = async () => { + assert.equal((await request('admin/invitations', 'POST', { invitation }, admin)).status, 201) + const data = { invitation, installationId: id, secretDigest: await digest(secret), requestId: 'e'.repeat(32) } + assert.equal((await request('enroll', 'POST', data)).status, 201) + return data + } + return { records, request, enroll, id, secret, sends: () => sends } +} + +test('invitation enrollment recovers lost response and rejects reuse and forged credentials', async () => { + const s = setup(), data = await s.enroll() + assert.equal((await s.request('enroll', 'POST', data)).status, 200) + assert.equal((await s.request('enroll', 'POST', { ...data, installationId: 'f'.repeat(32) })).status, 403) + assert.equal((await s.request('installation')).status, 200) + assert.equal((await s.request('installation', 'GET', null, `${s.id}.${'f'.repeat(64)}`)).status, 401) + assert.equal((await s.request('installation', 'DELETE')).status, 200) + assert.equal((await s.request('enroll', 'POST', data)).status, 403) + assert.equal((await s.request('installation')).status, 401) +}) + +test('destination ownership, deduplication and removal gate provider calls', async () => { + const s = setup(); await s.enroll() + const event = { token: 'fcm-token', appId: 'com.starpilot.galaxy', kind: 'test', eventId: 'abc', wallTimeNs: Date.now() * 1e6, label: '' } + assert.equal((await s.request('sentry', 'POST', event)).status, 409) + assert.equal((await s.request('subscriptions/123456789abc', 'PUT', { token: event.token, appId: event.appId })).status, 200) + assert.equal((await s.request('sentry', 'POST', event)).status, 200) + assert.equal((await s.request('sentry', 'POST', event)).status, 200) + assert.equal(s.sends(), 1) + assert.equal((await s.request('subscriptions/123456789abc', 'DELETE')).status, 200) + assert.equal((await s.request('sentry', 'POST', { ...event, eventId: 'def' })).status, 409) + assert.equal(s.sends(), 1) +}) + +test('rotation recovers retries and confirmation retires the old credential', async () => { + const s = setup(); await s.enroll() + const replacement = 'f'.repeat(64), data = { secretDigest: await digest(replacement), requestId: '1'.repeat(32) } + assert.equal((await s.request('credentials/rotate', 'POST', data)).status, 200) + assert.equal((await s.request('credentials/rotate', 'POST', data)).status, 200) + assert.equal((await s.request('credentials/confirm', 'POST', {}, `${s.id}.${replacement}`)).status, 200) + assert.equal((await s.request('installation')).status, 401) + assert.equal((await s.request('installation', 'GET', null, `${s.id}.${replacement}`)).status, 200) +}) + +test('expired invitations and oversized enrollment requests fail closed', async () => { + const s = setup() + s.records.set(`invite:${await digest('b'.repeat(64))}`, { expires: 1 }) + assert.equal((await s.request('enroll', 'POST', { invitation: 'b'.repeat(64), installationId: s.id, secretDigest: await digest(s.secret), requestId: 'e'.repeat(32) })).status, 403) + assert.equal((await s.request('enroll', 'POST', { oversized: 'x'.repeat(9000) })).status, 400) +}) + +test('concurrent invitation redemption authorizes only one installation', async () => { + const s = setup() + s.records.set(`invite:${await digest('b'.repeat(64))}`, { expires: Date.now() + 900000 }) + const attempts = await Promise.all(Array.from({ length: 8 }, async (_, i) => s.request('enroll', 'POST', { + invitation: 'b'.repeat(64), secretDigest: await digest(s.secret), requestId: i.toString(16).repeat(32), installationId: i.toString(16).repeat(32), + }))) + assert.equal(attempts.filter(response => response.status === 201).length, 1) + assert.equal(attempts.filter(response => response.status === 403).length, 7) + assert.equal([...s.records.keys()].filter(key => key.startsWith('device:')).length, 1) +}) + +test('installation quotas are enforced and unrelated installations cannot send', async () => { + const s = setup(); await s.enroll() + const event = { token: 'quota-phone', appId: 'com.starpilot.galaxy', kind: 'test', eventId: 'quota', wallTimeNs: Date.now() * 1e6, label: '' } + await s.request('subscriptions/123456789abc', 'PUT', { token: event.token, appId: event.appId }) + for (let i = 0; i < 60; i++) assert.equal((await s.request('sentry', 'POST', { ...event, eventId: 'quota' + i })).status, 200) + assert.equal((await s.request('sentry', 'POST', { ...event, eventId: 'overflow' })).status, 429) + assert.equal(s.sends(), 60) + const otherId = '2'.repeat(32), otherSecret = '3'.repeat(64) + s.records.set(`device:${otherId}`, { digest: await digest(otherSecret), subscriptions: {}, receipts: {}, hour: 0, count: 0 }) + assert.equal((await s.request('sentry', 'POST', event, `${otherId}.${otherSecret}`)).status, 409) + assert.equal((await s.request('admin/installations/' + s.id, 'DELETE', null, 'a'.repeat(64))).status, 200) + assert.equal((await s.request('sentry', 'POST', event)).status, 401) + assert.equal((await s.request('installation', 'GET', null, `${otherId}.${otherSecret}`)).status, 200) +}) + +test('automatic enrollment needs no invitation and retries cannot reactivate revoked credentials', async () => { + const s = setup({ AUTO_ENROLL: 'true' }) + const data = { invitation: '', installationId: s.id, secretDigest: await digest(s.secret), requestId: 'e'.repeat(32) } + assert.equal((await s.request('enroll', 'POST', data)).status, 201) + assert.equal((await s.request('enroll', 'POST', data)).status, 200) + assert.equal((await s.request('installation')).status, 200) + assert.equal((await s.request('installation', 'DELETE')).status, 200) + assert.equal((await s.request('enroll', 'POST', data)).status, 409) + assert.equal((await s.request('installation')).status, 401) +}) + +test('automatic enrollment fails closed and enforces global and IP signup limits', async () => { + const data = { invitation: '', installationId: 'd'.repeat(32), secretDigest: await digest('c'.repeat(64)), requestId: 'e'.repeat(32) } + assert.equal((await setup().request('enroll', 'POST', data)).status, 503) + assert.equal((await setup({ AUTO_ENROLL: 'true', ENROLLMENT_ADMIN_SECRET: '' }).request('enroll', 'POST', data)).status, 503) + for (const overrides of [{ AUTO_ENROLL_IP_DAILY_LIMIT: '1' }, { AUTO_ENROLL_DAILY_LIMIT: '1' }, { MAX_INSTALLATIONS: '1' }]) { + const s = setup({ AUTO_ENROLL: 'true', ...overrides }) + assert.equal((await s.request('enroll', 'POST', data)).status, 201) + assert.equal((await s.request('enroll', 'POST', { ...data, installationId: 'f'.repeat(32) })).status, 429) + assert.equal((await s.request('enroll', 'POST', data)).status, 200) + } +}) diff --git a/push-relay/operator.mjs b/push-relay/operator.mjs new file mode 100644 index 0000000..5e3e9d6 --- /dev/null +++ b/push-relay/operator.mjs @@ -0,0 +1,21 @@ +import { randomBytes } from 'node:crypto' + +const [command, installationId] = process.argv.slice(2) +const base = process.env.RELAY_URL +const admin = process.env.ENROLLMENT_ADMIN_SECRET +if (!base || !/^https:\/\/[^/?#@]+\/?$/.test(base) || !/^[a-f0-9]{64}$/.test(admin || '') || + !['invite', 'revoke'].includes(command) || (command === 'revoke' && !/^[a-f0-9]{32}$/.test(installationId || ''))) { + console.error('Set RELAY_URL and ENROLLMENT_ADMIN_SECRET, then run: node operator.mjs invite | revoke ') + process.exit(1) +} +const invitation = randomBytes(32).toString('hex') +try { + const response = await fetch(base.replace(/\/$/, '') + '/v2/admin/' + (command === 'invite' ? 'invitations' : 'installations/' + installationId), { + method: command === 'invite' ? 'POST' : 'DELETE', redirect: 'error', signal: AbortSignal.timeout(15000), + headers: { Authorization: 'Bearer ' + admin, 'Content-Type': 'application/json' }, + ...(command === 'invite' ? { body: JSON.stringify({ invitation }) } : {}), + }) + if (!response.ok) throw Error(String(response.status)) + if (command === 'invite') console.log('Single-use code (expires in 15 minutes):\n' + invitation) + else console.log('Installation revoked.') +} catch { console.error('Operator request failed. Check configuration and relay availability.'); process.exitCode = 1 } diff --git a/push-relay/package.json b/push-relay/package.json index 12b3de6..1568267 100644 --- a/push-relay/package.json +++ b/push-relay/package.json @@ -1 +1 @@ -{"private":true,"type":"module","engines":{"node":">=20"},"scripts":{"start":"node server.js","test":"node --test relay.test.js server.test.js"}} +{"private":true,"type":"module","engines":{"node":">=20"},"scripts":{"start":"node server.js","test":"node --test *.test.js"}} diff --git a/push-relay/relay.js b/push-relay/relay.js index 650d3b5..8c38c5f 100644 --- a/push-relay/relay.js +++ b/push-relay/relay.js @@ -1,3 +1,4 @@ +import { sendAPNs } from './apns.js' const androidApps = new Set(['link.firestar.companion', 'com.starpilot.galaxy']) const defaultIosApps = 'link.galaxy.bluetooth.natehuby76' export const iosApps = env => new Set(String(env?.IOS_APP_IDS ?? defaultIosApps).split(',').map(id => id.trim()).filter(Boolean)) @@ -72,6 +73,7 @@ export async function handle(request, env, fetcher = fetch, authorize = accessTo const count = Number(await env.RATE.get(key)) || 0 if (count >= 30) return json(429, { error: 'Rate limited' }) await env.RATE.put(key, String(count + 1), { expirationTtl: 7200 }) + if (iosApps(env).has(body.appId) && body.token.startsWith('apns.')) return await sendAPNs(body, env, fetcher) if (!/^[a-z0-9][a-z0-9-]{4,61}[a-z0-9]$/.test(env.FCM_PROJECT_ID)) throw new Error('Invalid project') const token = await authorize(env, fetcher) const response = await fetcher(`https://fcm.googleapis.com/v1/projects/${env.FCM_PROJECT_ID}/messages:send`, { diff --git a/push-relay/worker.js b/push-relay/worker.js new file mode 100644 index 0000000..4118a0e --- /dev/null +++ b/push-relay/worker.js @@ -0,0 +1,25 @@ +import { handle } from './relay.js' +export { EnrollmentRegistry } from './enrollment.js' + +export async function workerFetch(request, env, relay = handle) { + const route = new URL(request.url) + if (route.pathname.startsWith('/v2/')) { + if (route.protocol !== 'https:' || route.search) return new Response('Invalid URL', { status: 400 }) + try { + if (!(await env.INGRESS.limit({ key: request.headers.get('CF-Connecting-IP') || 'unknown' })).success) return new Response('Rate limited', { status: 429 }) + return await env.ENROLLMENT.get(env.ENROLLMENT.idFromName('registry')).fetch(request) + } catch { return new Response('Relay unavailable', { status: 503 }) } + } + const secret = env.RELAY_PATH_SECRET + if (typeof secret !== 'string' || !/^[a-f0-9]{64}$/.test(secret)) return new Response('Relay not configured', { status: 503 }) + const url = new URL(request.url) + if (request.method !== 'POST' || url.pathname !== `/${secret}/v1/sentry` || url.search) return new Response('Not found', { status: 404 }) + try { + const ip = request.headers.get('CF-Connecting-IP') || 'unknown' + if (!(await env.INGRESS.limit({ key: ip })).success) return new Response('Rate limited', { status: 429 }) + url.pathname = '/v1/sentry' + return await relay(new Request(url, request), env) + } catch { return new Response('Relay unavailable', { status: 502 }) } +} + +export default { fetch: (request, env) => workerFetch(request, env) } diff --git a/push-relay/worker.test.js b/push-relay/worker.test.js new file mode 100644 index 0000000..e17c164 --- /dev/null +++ b/push-relay/worker.test.js @@ -0,0 +1,26 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import worker, { workerFetch } from './worker.js' +const secret = 'a'.repeat(64) +const env = { RELAY_PATH_SECRET: secret, INGRESS: { limit: async () => ({ success: true }) } } +const request = (path = `/${secret}/v1/sentry`, method = 'POST') => new Request(`https://worker.test${path}`, { method, ...(method === 'POST' ? { body: '{"kind":"test"}' } : {}) }) + +test('Worker requires secret path and forwards an unchanged body to existing relay contract', async () => { + const response = await workerFetch(request(), env, async (req, bindings) => { + assert.equal(new URL(req.url).pathname, '/v1/sentry') + assert.equal(await req.text(), '{"kind":"test"}') + assert.equal(bindings, env) + return new Response(null, { status: 200 }) + }) + assert.equal(response.status, 200) + for (const req of [request('/v1/sentry'), request(`/${'b'.repeat(64)}/v1/sentry`), request(`/${secret}/v1/sentry?token=x`), request(`/${secret}/v1/sentry`, 'GET')]) { + assert.equal((await workerFetch(req, env, () => assert.fail('must reject'))).status, 404) + } +}) + +test('Worker fails closed for missing configuration and rate limits before processing tokens', async () => { + assert.equal((await workerFetch(request(), {})).status, 503) + assert.equal((await workerFetch(request(), { ...env, INGRESS: { limit: async () => ({ success: false }) } }, () => assert.fail('must rate limit'))).status, 429) + assert.equal((await workerFetch(request(), { RELAY_PATH_SECRET: secret })).status, 502) + assert.equal((await worker.fetch(request(), env, { waitUntil() {} })).status, 400) +}) diff --git a/push-relay/wrangler.jsonc b/push-relay/wrangler.jsonc new file mode 100644 index 0000000..7d7c962 --- /dev/null +++ b/push-relay/wrangler.jsonc @@ -0,0 +1,23 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "galaxy-sentry-relay", + "main": "worker.js", + "compatibility_date": "2026-01-01", + "workers_dev": true, + "observability": { "enabled": false }, + "vars": { + "AUTO_ENROLL": "true", + "AUTO_ENROLL_DAILY_LIMIT": "100", + "AUTO_ENROLL_IP_DAILY_LIMIT": "5", + "MAX_INSTALLATIONS": "1000", + "DAILY_SEND_LIMIT": "1000", + "FCM_PROJECT_ID": "replace-with-firebase-project-id", + "IOS_APP_IDS": "link.galaxy.bluetooth.natehuby76", + "APNS_TEAM_ID": "REPLACE_ME", + "APNS_KEY_ID": "REPLACE_ME" + }, + "durable_objects": { "bindings": [{ "name": "ENROLLMENT", "class_name": "EnrollmentRegistry" }] }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["EnrollmentRegistry"] }], + "kv_namespaces": [{ "binding": "RATE", "id": "replace-with-kv-namespace-id" }], + "ratelimits": [{ "name": "INGRESS", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }] +} From 044c9cf33ca8aa40ca6d3a9cf1ac01027b1ebaaa Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:06:32 -0800 Subject: [PATCH 2/9] Sentry: Fix Worker sends on Cloudflare and add a step-by-step hosting guide --- .github/workflows/release.yaml | 6 +- push-relay/HOSTING.md | 94 ++++++++++++++++++++++++++++ push-relay/README.md | 15 ++--- push-relay/SETUP.md | 109 ++++++++------------------------- push-relay/apns.js | 2 +- push-relay/apns.test.js | 2 +- push-relay/operator.mjs | 57 +++++++++++++---- push-relay/relay.js | 4 +- push-relay/relay.test.js | 5 +- push-relay/wrangler.jsonc | 17 +++-- 10 files changed, 188 insertions(+), 123 deletions(-) create mode 100644 push-relay/HOSTING.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a182020..d09c32f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -64,7 +64,7 @@ jobs: - name: Test run: | ./gradlew --max-workers=1 testDirectDebugUnitTest testPlayDebugUnitTest - node --test push-relay/relay.test.js push-relay/server.test.js + node --test push-relay/*.test.js - name: Restore Direct signing key if: steps.release.outputs.channel == 'direct' env: @@ -96,9 +96,13 @@ jobs: COMPANION_UPLOAD_KEYSTORE: ${{ runner.temp }}/upload.jks COMPANION_UPLOAD_KEY_PASSWORD: ${{ secrets.COMPANION_UPLOAD_KEY_PASSWORD }} AA_COMPANION_CERT_SHA256: ${{ secrets.AA_COMPANION_CERT_SHA256 }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + ORG_GRADLE_PROJECT_requireSentryPush: 'true' run: | test -n "$COMPANION_UPLOAD_KEY_PASSWORD" test -n "$AA_COMPANION_CERT_SHA256" + test -n "$GOOGLE_SERVICES_JSON" + printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json ./build_play.sh - name: Remove keys if: always() diff --git a/push-relay/HOSTING.md b/push-relay/HOSTING.md new file mode 100644 index 0000000..4fc2247 --- /dev/null +++ b/push-relay/HOSTING.md @@ -0,0 +1,94 @@ +# Hosting the Sentry push relay + +This Worker forwards parked Sentry alerts from comma devices to phones: Android through Firebase, iPhone directly through Apple. It stores no photos, video or locations. It is sized for about 500 commas. + +## What you receive + +Get these through a password manager or another private channel, never chat or email: + +- This `push-relay` folder. +- The Firebase service-account key: `starpilot-sentry-….json`. +- For iPhone: Nate's Apple **Team ID**, **Key ID** and `AuthKey_.p8`. If they aren't ready yet, set them up later (step 5). Android works without them. + +## Set up (one time) + +1. **Install Node.js 20 or newer.** Check with `node --version`. + +2. **Run the tests.** Unzip the folder, open a terminal in `push-relay`, and run: + + ```sh + npm test + ``` + + Every test should pass. Nothing is sent anywhere. + +3. **Sign in to Cloudflare.** Create a free account at dash.cloudflare.com if you don't have one. We recommend the Workers Paid plan ($5/month) at this size. Free also works, but it has daily request and storage-write caps that a busy day of alerts could reach. Then run: + + ```sh + npx wrangler@4 login + ``` + +4. **Deploy:** + + ```sh + npx wrangler@4 deploy + ``` + + Copy the address it prints, for example `https://galaxy-sentry-relay..workers.dev`. That is the **relay URL**. + +5. **Add the secrets.** Run each command from `push-relay`. Each one reads a file or asks you to paste a value. + + ```sh + npx wrangler@4 secret put FCM_SERVICE_ACCOUNT < /path/to/starpilot-sentry-….json + openssl rand -hex 32 # copy the output, then paste it at the next prompt + npx wrangler@4 secret put ENROLLMENT_ADMIN_SECRET + ``` + + For iPhone alerts, also run: + + ```sh + npx wrangler@4 secret put APNS_PRIVATE_KEY < /path/to/AuthKey_.p8 + npx wrangler@4 secret put APNS_TEAM_ID # paste the 10-character Team ID + npx wrangler@4 secret put APNS_KEY_ID # paste the 10-character Key ID + ``` + + Save the admin secret in your password manager. You need it for step 6 and for revoking a comma later. Don't give it to users. + +6. **Check the deployment:** + + ```sh + export RELAY_URL=https://galaxy-sentry-relay..workers.dev + read -r -s ENROLLMENT_ADMIN_SECRET && export ENROLLMENT_ADMIN_SECRET # paste the admin secret + node operator.mjs check + ``` + + You should see five `PASS` lines and `Relay check passed.` The check enrolls a temporary test device, makes sure Firebase accepts your key, then deletes the test device. If the Firebase line fails, the service-account key or `FCM_PROJECT_ID` in `wrangler.jsonc` is wrong. + +7. **Send the relay URL to Danny.** He sets it in the StarPilot release, and comma devices then enroll themselves when users tap **Enable alerts**. Nobody types the URL into an app. Also share the admin secret with Danny privately, so either of you can revoke a comma. + +8. **Delete your local copies** of the `.json` and `.p8` key files. Cloudflare keeps the only copy it needs. + +iPhone delivery has no automatic check. Once a StarPilot release has the URL, enable alerts on an iPhone and use **Send test** in Galaxy → Sentry. + +## Day to day + +- **Update the code:** replace the folder's files with the new version and run `npx wrangler@4 deploy` again. Never rename the Worker, change `class_name` or delete the Durable Object. Enrolled commas are stored there and would all stop working. +- **Revoke one comma:** ask the user for the installation ID shown in Galaxy → Sentry, then run `node operator.mjs revoke ` with `RELAY_URL` and `ENROLLMENT_ADMIN_SECRET` set as in step 6. +- **Replace a key:** run the same `secret put` command with the new file, run `node operator.mjs check`, then delete the old key in Google Cloud or the Apple developer account. +- **Watch usage:** Cloudflare dashboard → Workers & Pages → `galaxy-sentry-relay` → Metrics. The relay never logs phone tokens or alert contents. Keep it that way. +- **Turn it off:** in the dashboard, disable the Worker. Phones fall back to checking for alerts while connected. Turning it back on keeps every enrollment. + +## Limits + +Set in `wrangler.jsonc` under `vars`. Raise one and redeploy if users hit it. A comma that hits a limit retries on its own. + +| Setting | Value | Meaning | +| --- | --- | --- | +| `AUTO_ENROLL_DAILY_LIMIT` | 500 | New commas per day across all users | +| `AUTO_ENROLL_IP_DAILY_LIMIT` | 20 | New commas per day from one network address (carrier networks share addresses) | +| `MAX_INSTALLATIONS` | 5000 | Lifetime enrollments. Resets and re-enrollments count, so this leaves headroom for 500 commas | +| `DAILY_SEND_LIMIT` | 20000 | Alerts per day across all users | +| Per comma | 60/hour | Built in. Alerts per comma per hour | +| `INGRESS` rate limit | 300/minute | Requests per network address | + +Anyone can enroll a device automatically. These limits cap the damage a bad actor could do; they don't prove ownership. diff --git a/push-relay/README.md b/push-relay/README.md index 1d1a46b..9f3c7a2 100644 --- a/push-relay/README.md +++ b/push-relay/README.md @@ -1,15 +1,10 @@ # Galaxy sentry relay -Deploy `worker.js` with `wrangler.jsonc` for a shared Cloudflare relay. See [setup](SETUP.md) for the operator handoff, Android Firebase setup and direct Apple APNs setup. Install the StarPilot enrollment PR, configure the hosted URL once for the release, and users simply enable alerts. The normal flow needs no invitation or URL entry. The old shared-secret v1 pilot remains optional. +A Cloudflare Worker that forwards parked Sentry alerts from comma to phones: Android through Firebase, iPhone directly through Apple. -Native iOS tokens prefixed `apns.production.` or `apns.sandbox.` go directly to Apple. Android and legacy Firebase tokens use FCM. Apple credentials are optional for Android-only deployments. +- **Hosting it:** [HOSTING.md](HOSTING.md), step by step. +- **Firebase, Apple, release and testing:** [SETUP.md](SETUP.md). -See [SETUP.md](SETUP.md) for setup and operation. +`worker.js` routes requests, `enrollment.js` holds per-comma credentials in a SQLite Durable Object, `relay.js` sends to Firebase and `apns.js` sends to Apple. `operator.mjs` checks a deployment and revokes a comma. `server.js` is the legacy v1 Node relay. Run `npm test`. The tests use fake providers and never send anything. -The provider adapter behind both versions accepts only a token, allowlisted app ID and event kind, bounded event ID and label, and integer event time. It sends a fixed data-only HIGH priority FCM message with a ten-minute TTL. Raw phone tokens and event bodies are not persisted. v2 stores per-installation credential digests, destination fingerprints and delivery receipts in SQLite-backed Durable Object storage; receipt IDs expire after a day and are pruned on subsequent sends. Invitations are pruned by an hourly alarm. Provider authorization tokens are cached in memory. - -v2 enforces 60 attempts per installation per hour transactionally and scopes destinations to that installation. The optional legacy v1 limit is approximately 30 requests per token per hour. It is an abuse deterrent, not an exact quota; KV is eventually consistent, and VPS memory counters reset on restart. Duplicate delivery is handled on the phone using the event ID. - -`relay.js` holds the logic and `server.js` is the small Node HTTP wrapper (`npm start`). Run `npm test`. Tests use a fake FCM transport and never send notifications. An FCM-specific `UNREGISTERED` or `INVALID_ARGUMENT` maps to 410; general payload/configuration errors remain 502 so configuration failures cannot delete all phone registrations. - -Android app IDs are built in. iOS bundle IDs come from `IOS_APP_IDS` and get a visible APNs alert instead of the data-only Android message. +The relay accepts only an allowlisted app ID, event kind, bounded event ID and label, and an integer event time. It never stores raw phone tokens or alert contents. A dead phone token returns 410 and removes that phone; configuration errors return 502, so a broken key can't delete every registration. diff --git a/push-relay/SETUP.md b/push-relay/SETUP.md index 4e9b9ab..c8ef3b7 100644 --- a/push-relay/SETUP.md +++ b/push-relay/SETUP.md @@ -2,109 +2,50 @@ The app works without Firebase or a relay: enabled alerts use polling while connected and a 15-minute Android job. Android schedules background work opportunistically, so this does not promise immediate delivery. -## Firebase +## Launch checklist -1. Create a Firebase project on the Spark plan, without Analytics. -2. Register both Android IDs: `link.firestar.companion` and `com.starpilot.galaxy`. -3. Download the project's `google-services.json` with both clients and put it in `app/`. This config is not a secret; commit it. Both flavors automatically include Messaging when the file exists. Without it they compile the polling implementation. For a push-enabled release, add `-PrequireSentryPush=true` to the Gradle build; it fails early if this file is absent. Register both package IDs in the same Firebase project used by the relay. -4. Enable Firebase Cloud Messaging API (V1) in Google Cloud. -5. Create a service account named `galaxy-push-relay` with **Firebase Cloud Messaging API Admin**. Create a JSON key. Keep that key private: never commit it or share it. +1. **Firebase (done):** project `starpilot-sentry` on the Spark plan, Android app `com.starpilot.galaxy`, Cloud Messaging API (V1) enabled, and service account `galaxy-push-relay` with **Firebase Cloud Messaging API Admin**. +2. **Apple:** Nate enables Push Notifications for `link.galaxy.bluetooth.natehuby76`, creates an APNs `.p8` key, and sends the key, Key ID and Team ID privately to the host. +3. **Host:** follows [HOSTING.md](HOSTING.md) and sends back the relay URL. +4. **StarPilot:** set `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` to that URL (origin only, no path) and ship the release. +5. **Android release:** store the client config once with `gh secret set GOOGLE_SERVICES_JSON --repo dirwin31/StarPilotAndroid --env android-release < app/google-services.json`. The release workflow writes it before building the Play bundle and fails if it is missing. Keep the file out of git: the Direct flavor's ID isn't in it, so committing it breaks the Direct unit tests. +6. **iOS release:** build the iOS PR. Debug uses sandbox APNs; Release (TestFlight and App Store) uses production. +7. **Test:** see [Test](#test). -## iOS push without Firebase +Users then tap **Enable alerts** in Galaxy → Sentry or in the app. The phone asks for notification permission, and comma enrolls itself and registers the phone. There are no codes or URLs to type. -Nate's iOS app uses APNs directly. No Firebase SDK or Google configuration is needed on iOS. +## How it fits together -1. Enable Push Notifications for `link.galaxy.bluetooth.natehuby76` in the Apple developer account and regenerate the signing profile. -2. Create an APNs `.p8` signing key that permits this app and the needed environments. Give its Key ID, Team ID and private key securely to the Worker operator. The key never belongs in either app or on comma. -3. Keep `IOS_APP_IDS` equal to the app's bundle ID. This ID and both Android IDs above are already accepted by the current StarPilot comma code. Changing to another bundle ID may require a comma change. -4. Build the iOS PR. Debug uses development/sandbox APNs; Release uses production APNs (including TestFlight). `APS_ENVIRONMENT` sets both the entitlement and token routing. Verify the final signed entitlement matches the app's `SentryAPNsEnvironment` before distributing a custom build. -5. Enable phone alerts in the app's Settings or Galaxy's Sentry page. The app registers an `apns.production.` or `apns.sandbox.` token with comma. The Worker sends a visible alert; tapping it opens Sentry events after connecting. This version does not attach camera photos to iOS notifications. +Each comma generates its own sending credential on enrollment and keeps it private. Phones keep their own push tokens. The Worker stores only credential digests, phone-token fingerprints and short-lived delivery receipts. Camera media stays on comma. Android gets a data-only Firebase message; iPhone gets a visible Apple alert without photos. Tapping either opens Sentry events. -## Cloudflare Worker with per-comma enrollment +If the relay rejects a phone (for example, an app ID it doesn't know), comma skips that phone and keeps alerting the others. The phone retries when it next reconnects. -**Must complete before phone push works:** create Firebase configuration for Android, obtain Nate's APNs key for iOS, deploy this Worker, install the StarPilot enrollment PR, and set the hosted URL once in the StarPilot release. Users then enable alerts with one action. These account credentials and live deployment are not included in the repository. Nate can use his existing Apple developer membership; no additional Apple membership is needed for his app. +Comma controls in Galaxy → Sentry: **Rotate credential** replaces the comma's credential (the old one keeps working for up to ten minutes). **Revoke relay access** stops sending at once and retries the remote revoke if offline. **Forget locally** does not revoke remotely; use `node operator.mjs revoke` for that. -The public Worker URL contains no secret. On the user’s Enable alerts action, each comma generates and privately stores a separate sending credential without an invitation. Phones retain their provider tokens; neither phone receives the comma credential. The Worker stores credential digests, destination fingerprints and bounded delivery receipts in a SQLite-backed Durable Object. Camera media remains on comma. The operator can revoke one comma without disrupting others. +For a private deployment, set `AUTO_ENROLL` to `"false"`, then create single-use codes with `node operator.mjs invite` and enter them under Galaxy's Custom relay. -1. Use Node 20+ and Cloudflare's Workers Free plan. From this directory run `npx wrangler@4 login`. The SQLite Durable Object binding and migration are already configured. Run `npx wrangler@4 kv namespace create RATE` and replace the RATE namespace placeholder in `wrangler.jsonc` (KV is used only by the optional legacy pilot). -2. Replace `FCM_PROJECT_ID`, `APNS_TEAM_ID`, `APNS_KEY_ID`, and `IOS_APP_IDS` in `wrangler.jsonc`. Keep the existing bundle ID for Nate's app. For Android-only operation set `IOS_APP_IDS` to an empty string; iOS-only operation needs no Firebase credentials. -3. Put provider keys in Worker secrets, never source, either app, or comma: +## Legacy private pilot - ```sh - npx wrangler@4 secret put FCM_SERVICE_ACCOUNT < /secure/service-account.json - npx wrangler@4 secret put APNS_PRIVATE_KEY < /secure/AuthKey.p8 - openssl rand -hex 32 - npx wrangler@4 secret put ENROLLMENT_ADMIN_SECRET - ``` - - Paste the generated 64-character admin secret into Wrangler's prompt and retain it in the operator password manager. Skip provider secrets for unused platforms. The admin secret is exclusively for the operator; do not give it to users. Leave `RELAY_PATH_SECRET` unset unless deliberately maintaining an old private pilot. -4. Run `npm test` and `npx wrangler@4 deploy`. Keep observability disabled. The migration creates SQLite storage automatically. Retain the resulting `https://galaxy-sentry-relay..workers.dev` address; no custom domain is required. -5. Set `DEFAULT_RELAY_URL` in the StarPilot PR's `openpilot/starpilot/sentry_mode/relay_config.py` to the deployed public HTTPS origin, then ship that StarPilot release. This is a one-time maintainer setting; users never type it. No production URL is guessed while hosting is undeployed. Both apps discover readiness from comma, so neither app needs its own URL. Keep `AUTO_ENROLL` set to `"true"` in Wrangler. -6. Users connect to comma and tap **Enable alerts** in Galaxy, or enable Sentry phone alerts in the native app. The phone requests notification permission. Comma enrolls itself, saves its private credential, registers the phone destination and retries interrupted work. There are no invitation codes, accounts, copied tokens or endpoint fields in the normal flow. A release without a configured host reports that hosting is not ready. Existing enabled phones require an explicit enable action to opt into automatic enrollment. -7. Install Firebase-enabled Android and APNs-enabled iOS builds. Allow notifications, enable alerts on both phones, and send tests. Wait for relay enrollment before expecting delivery. Each comma supports eight registered destinations; event sending is limited to 60 attempts/hour/installation. Ingress limiting is approximate; installation authorization and quotas use transactional storage. -8. Verify each platform with the app backgrounded and the phone away from comma's Wi-Fi, while comma has internet. Verify wrong credentials cannot send, revoke one of two enrolled commas, and confirm only the revoked device stops. A successful provider response is acceptance, not proof of notification display. - -Automatic enrollment is public, not hardware attestation or proof of ownership. Enforced defaults are 5 new installations per IP/day, 100 per relay/day, 1,000 total automatic installations (including revoked installations), and 1,000 send attempts per relay/day, plus 60/hour per installation. Configure these Wrangler variables for capacity; they fail closed at the limit. Daily hashed IP records expire and are pruned. Attackers can consume capacity or enroll new identities; accounts or attestation would be needed to prevent that. Keep the deployment on Free and monitor capacity. Revoked IDs remain tombstoned so retries cannot reactivate them. - -For a private deployment, set `AUTO_ENROLL` to `"false"` and use `node operator.mjs invite` with `RELAY_URL` and `ENROLLMENT_ADMIN_SECRET` in the operator environment. Enter that code through Galaxy's Custom relay controls. Normal users of the public deployment do not need this tool. - -Use Galaxy's **Rotate credential** control for an individual comma. Rotation retains the old credential for at most ten minutes and retires it on confirmation. **Revoke relay access** stops sending locally immediately and retries remote revocation if offline. **Forget locally** is not remote revocation; use the operator tool if credentials have already been lost: - -```sh -export RELAY_URL=https://galaxy-sentry-relay.YOUR-ACCOUNT.workers.dev -read -r -s ENROLLMENT_ADMIN_SECRET -export ENROLLMENT_ADMIN_SECRET -node operator.mjs revoke INSTALLATION_ID_FROM_GALAXY -unset ENROLLMENT_ADMIN_SECRET -``` - -Provider key rotation is separate: replace the Worker secret, test delivery, then retire the old Apple/Google key. Do not delete the Durable Object namespace to update code. Deploy new code with the same binding/class and migration history to retain enrolled devices. Disabling the Worker stops delivery; restoring it preserves credentials. Never downgrade enrolled devices to anonymous sending. - -### Cost and limits - -[FCM is a no-cost Firebase product](https://firebase.google.com/pricing). Use Spark; no Analytics, database, Cloud Functions, or paid Firebase extension is needed. [Cloudflare supports SQLite Durable Objects on Workers Free](https://developers.cloudflare.com/durable-objects/platform/pricing/); stay on Free and monitor its request, CPU and storage limits. Free limits can stop service; free hosting is not unlimited delivery. A workers.dev address avoids domain registration costs. Nate supplies the existing Apple developer membership and signing assets. Play distribution uses the existing Play developer account. No provider account or deployment is created automatically by these PRs. - -### Legacy private pilot - -Old comma builds can still use the original v1 secret-path Worker when the operator explicitly sets a 64-hex `RELAY_PATH_SECRET`. Its base URL is `https:///`, with `/v1/sentry` appended by old comma code. Never publish that URL as a default. Migrate each comma to enrollment, then delete the shared secret. The VPS section below is legacy v1 only and does not implement enrollment. +Old comma builds can still use the v1 secret-path Worker if the operator sets a 64-hex `RELAY_PATH_SECRET` and adds a KV namespace bound as `RATE`. Its base URL is `https:///`, and old comma code appends `/v1/sentry`. Never publish that URL. Move each comma to enrollment, then delete the secret. The VPS relay below is also v1 only. ## Relay on a VPS -Any Linux server with Node 20+ and HTTPS works; the existing `galaxy.firestar.link` server is the natural home. The relay holds no data and has no dependencies. +Any Linux server with Node 20+ and HTTPS works. The relay holds no data and has no dependencies. 1. Copy the `push-relay` folder to `/opt/galaxy-push-relay` and create `/etc/galaxy-push-relay/service-account.json` (root-owned, mode 600) holding the private service-account JSON. -2. Install `galaxy-push-relay.service` into `/etc/systemd/system/`, set `FCM_PROJECT_ID` in it to the Firebase project ID, then run `systemctl daemon-reload && systemctl enable --now galaxy-push-relay`. It listens on `127.0.0.1:8787` only. The unit runs as a throwaway user and reads the key through systemd credentials. -3. Put HTTPS in front of it. `Caddyfile.example` exposes only `/v1/sentry` at `https://push.firestar.link`; Caddy gets the certificate itself. With nginx, proxy `/v1/sentry` to `127.0.0.1:8787` the same way. Never expose port 8787 directly. -4. Save `https://push.firestar.link` in Galaxy’s HTTPS push relay URL setting. Comma appends `/v1/sentry`; no source change is required. This legacy VPS entry point does not implement the Worker’s secret path. Use the Worker deployment above for the shared hosted service. +2. Install `galaxy-push-relay.service` into `/etc/systemd/system/`, set `FCM_PROJECT_ID` in it, then run `systemctl daemon-reload && systemctl enable --now galaxy-push-relay`. It listens on `127.0.0.1:8787` only. +3. Put HTTPS in front of it. `Caddyfile.example` exposes only `/v1/sentry`. Never expose port 8787 directly. +4. Save the HTTPS address in Galaxy's legacy private relay setting. Comma appends `/v1/sentry`. -Run it by hand to try it: `FCM_PROJECT_ID=... FCM_SERVICE_ACCOUNT_FILE=key.json node server.js`. +Rotate the key by replacing the file, running `systemctl restart galaxy-push-relay`, then deleting the old key. Read failures with `journalctl -u galaxy-push-relay`. The relay never logs tokens or bodies. ## Test -Install a Firebase-enabled build, enable Sentry alerts, and connect to Galaxy. Connections → Notification details shows acknowledgement and lets you copy this phone's token. Keep it private. - -From a terminal, read the token without putting it in shell history, then send a test: - -```sh -read -r FCM_TEST_TOKEN -export FCM_TEST_TOKEN -python3 - <<'PY' | curl --fail-with-body -H 'Content-Type: application/json' --data-binary @- https://push.firestar.link/v1/sentry -import json, os, time, uuid -print(json.dumps({'token': os.environ['FCM_TEST_TOKEN'], 'appId': 'com.starpilot.galaxy', 'kind': 'test', 'eventId': uuid.uuid4().hex, 'wallTimeNs': time.time_ns(), 'label': 'Relay test'})) -PY -unset FCM_TEST_TOKEN -``` - -Expect 200 on acceptance, 410 for a dead/invalid FCM registration, 429 when rate limited, and 502 for a relay/FCM configuration failure. A 200 means FCM accepted the message; it does not prove the phone displayed it. - -In Galaxy → Sentry, enable this phone, use Test, and check the delivery result. Repeat over LAN, Remote and Bluetooth. With the relay unavailable, use Simulate event while parked and check polling. Do not force-stop the Android app to test ordinary background delivery: Android blocks a force-stopped package until it is opened again. - -## Operate +- **Relay alone:** `node operator.mjs check` (see HOSTING.md step 6). To push to a real Android phone, copy its token from Connections → Notification details and run `read -r -s RELAY_TEST_TOKEN && export RELAY_TEST_TOKEN` before the check. +- **End to end:** on a release with `DEFAULT_RELAY_URL` set, enable alerts on each phone, then use **Send test** in Galaxy → Sentry. Repeat with the app in the background and the phone off comma's Wi-Fi while comma has internet. A 200 from the relay means Firebase or Apple accepted the message, not that the phone showed it. +- **Without the relay:** use Simulate event while parked and check polling. -- Rotate credentials by creating a new service-account key, replacing `/etc/galaxy-push-relay/service-account.json`, running `systemctl restart galaxy-push-relay`, then deleting the old key. -- Inspect failures with `journalctl -u galaxy-push-relay`. The relay never logs tokens or bodies; do not add that. -- Rate limits live in memory, so a restart resets them. That is acceptable for an abuse deterrent. -- Update by copying new files and restarting. Run `systemctl disable --now galaxy-push-relay` to turn it off. Polling remains available. +Do not force-stop the Android app to test background delivery. Android blocks a force-stopped app until it is opened again. ## Play release diff --git a/push-relay/apns.js b/push-relay/apns.js index 3f926eb..314fc16 100644 --- a/push-relay/apns.js +++ b/push-relay/apns.js @@ -22,7 +22,7 @@ export async function sendAPNs(body, env, fetcher = fetch, authorize = apnsAutho const host = token[1] === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com' const title = { alarm: 'Sentry alarm', warning: 'Sentry: motion detected', test: 'Galaxy test alert' }[body.kind] const response = await fetcher(`https://${host}/3/device/${token[2]}`, { - method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), + method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(5000), headers: { authorization: `bearer ${await authorize(env)}`, 'content-type': 'application/json', 'apns-topic': body.appId, 'apns-push-type': 'alert', 'apns-priority': '10', 'apns-expiration': String(Math.floor(Date.now() / 1000) + 600), 'apns-collapse-id': body.eventId }, diff --git a/push-relay/apns.test.js b/push-relay/apns.test.js index c6feae5..101ef63 100644 --- a/push-relay/apns.test.js +++ b/push-relay/apns.test.js @@ -12,7 +12,7 @@ test('APNs sends a visible alert with bounded expiry, correct topic and environm assert.equal(request.headers['apns-topic'], body.appId) assert.equal(request.headers['apns-push-type'], 'alert') assert.equal(request.headers['apns-collapse-id'], body.eventId) - assert.equal(request.redirect, 'error') + assert.equal(request.redirect, 'manual') const payload = JSON.parse(request.body) assert.equal(payload.aps.alert.title, 'Sentry alarm') assert.equal(payload.type, 'sentry') diff --git a/push-relay/operator.mjs b/push-relay/operator.mjs index 5e3e9d6..548cd81 100644 --- a/push-relay/operator.mjs +++ b/push-relay/operator.mjs @@ -1,21 +1,54 @@ -import { randomBytes } from 'node:crypto' +import { randomBytes, createHash } from 'node:crypto' const [command, installationId] = process.argv.slice(2) const base = process.env.RELAY_URL const admin = process.env.ENROLLMENT_ADMIN_SECRET if (!base || !/^https:\/\/[^/?#@]+\/?$/.test(base) || !/^[a-f0-9]{64}$/.test(admin || '') || - !['invite', 'revoke'].includes(command) || (command === 'revoke' && !/^[a-f0-9]{32}$/.test(installationId || ''))) { - console.error('Set RELAY_URL and ENROLLMENT_ADMIN_SECRET, then run: node operator.mjs invite | revoke ') + !['invite', 'revoke', 'check'].includes(command) || (command === 'revoke' && !/^[a-f0-9]{32}$/.test(installationId || ''))) { + console.error('Set RELAY_URL and ENROLLMENT_ADMIN_SECRET, then run: node operator.mjs check | invite | revoke ') process.exit(1) } -const invitation = randomBytes(32).toString('hex') +const hex = bytes => randomBytes(bytes).toString('hex') +const call = (path, method, body, auth = admin) => fetch(base.replace(/\/$/, '') + '/v2/' + path, { + method, redirect: 'error', signal: AbortSignal.timeout(15000), + headers: { Authorization: 'Bearer ' + auth, 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), +}) + +async function check() { + const invitation = hex(32), id = hex(16), secret = hex(32) + const step = async (label, response, ok) => { + const status = (await response).status + console.log(`${ok.includes(status) ? 'PASS' : 'FAIL'} ${label} (HTTP ${status})`) + if (!ok.includes(status)) throw Error(label) + return status + } + await step('Worker reachable and admin secret accepted', call('admin/invitations', 'POST', { invitation }), [201]) + await step('Enrollment storage works', call('enroll', 'POST', { invitation, installationId: id, + secretDigest: createHash('sha256').update(secret).digest('hex'), requestId: hex(16) }), [201]) + const token = process.env.RELAY_TEST_TOKEN || 'galaxy-relay-check' + const destination = { token, appId: 'com.starpilot.galaxy' } + try { + await step('Phone destination accepted', call('subscriptions/' + hex(6), 'PUT', destination, `${id}.${secret}`), [200]) + const sent = await step(process.env.RELAY_TEST_TOKEN ? 'Firebase delivered to your phone' : 'Firebase credentials work (fake phone rejected as expected)', + call('sentry', 'POST', { ...destination, kind: 'test', eventId: hex(16), wallTimeNs: Date.now() * 1e6, label: 'Relay check' }, `${id}.${secret}`), + process.env.RELAY_TEST_TOKEN ? [200] : [410]) + if (sent === 200) console.log('Look for a "Galaxy test alert" notification on the phone.') + } finally { + await step('Test installation removed', call('admin/installations/' + id, 'DELETE'), [200]) + } + console.log('Relay check passed.') +} + try { - const response = await fetch(base.replace(/\/$/, '') + '/v2/admin/' + (command === 'invite' ? 'invitations' : 'installations/' + installationId), { - method: command === 'invite' ? 'POST' : 'DELETE', redirect: 'error', signal: AbortSignal.timeout(15000), - headers: { Authorization: 'Bearer ' + admin, 'Content-Type': 'application/json' }, - ...(command === 'invite' ? { body: JSON.stringify({ invitation }) } : {}), - }) - if (!response.ok) throw Error(String(response.status)) - if (command === 'invite') console.log('Single-use code (expires in 15 minutes):\n' + invitation) - else console.log('Installation revoked.') + if (command === 'check') await check() + else if (command === 'invite') { + const invitation = hex(32) + const response = await call('admin/invitations', 'POST', { invitation }) + if (!response.ok) throw Error(String(response.status)) + console.log('Single-use code (expires in 15 minutes):\n' + invitation) + } else { + const response = await call('admin/installations/' + installationId, 'DELETE') + if (!response.ok) throw Error(String(response.status)) + console.log('Installation revoked.') + } } catch { console.error('Operator request failed. Check configuration and relay availability.'); process.exitCode = 1 } diff --git a/push-relay/relay.js b/push-relay/relay.js index 8c38c5f..1f11bef 100644 --- a/push-relay/relay.js +++ b/push-relay/relay.js @@ -1,5 +1,5 @@ import { sendAPNs } from './apns.js' -const androidApps = new Set(['link.firestar.companion', 'com.starpilot.galaxy']) +const androidApps = new Set(['com.starpilot.galaxy']) const defaultIosApps = 'link.galaxy.bluetooth.natehuby76' export const iosApps = env => new Set(String(env?.IOS_APP_IDS ?? defaultIosApps).split(',').map(id => id.trim()).filter(Boolean)) const kinds = new Set(['warning', 'alarm', 'test']) @@ -78,7 +78,7 @@ export async function handle(request, env, fetcher = fetch, authorize = accessTo const token = await authorize(env, fetcher) const response = await fetcher(`https://fcm.googleapis.com/v1/projects/${env.FCM_PROJECT_ID}/messages:send`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(fcmMessage(body, iosApps(env).has(body.appId))), signal: AbortSignal.timeout(5000), redirect: 'error', + body: JSON.stringify(fcmMessage(body, iosApps(env).has(body.appId))), signal: AbortSignal.timeout(5000), redirect: 'manual', }) if (response.ok) return json(200, { ok: true }) const failure = await response.json().catch(() => ({})) diff --git a/push-relay/relay.test.js b/push-relay/relay.test.js index dd23280..4a7b6fb 100644 --- a/push-relay/relay.test.js +++ b/push-relay/relay.test.js @@ -9,7 +9,7 @@ function environment() { } test('strict schema and templated data-only payload', () => { assert.ok(valid(body)) - for (const value of [{ ...body, appId: 'evil.app' }, { ...body, kind: 'arbitrary' }, { ...body, extra: true }, { ...body, token: 'a'.repeat(4097) }]) assert.equal(valid(value), false) + for (const value of [{ ...body, appId: 'evil.app' }, { ...body, appId: 'link.firestar.companion' }, { ...body, kind: 'arbitrary' }, { ...body, extra: true }, { ...body, token: 'a'.repeat(4097) }]) assert.equal(valid(value), false) const message = fcmMessage(body).message assert.equal(message.notification, undefined) assert.equal(message.android.priority, 'HIGH') @@ -17,7 +17,8 @@ test('strict schema and templated data-only payload', () => { }) test('rate limiting and response mapping', async () => { const env = environment() - for (let i = 0; i < 30; i++) assert.equal((await handle(request(), env, async () => Response.json({}), async () => 'oauth')).status, 200) + // Cloudflare's fetch rejects redirect: 'error'. + for (let i = 0; i < 30; i++) assert.equal((await handle(request(), env, async (url, init) => { assert.equal(init.redirect, 'manual'); return Response.json({}) }, async () => 'oauth')).status, 200) assert.equal((await handle(request(), env)).status, 429) for (const code of ['UNREGISTERED', 'INVALID_ARGUMENT']) { const failure = { error: { details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: code }] } } diff --git a/push-relay/wrangler.jsonc b/push-relay/wrangler.jsonc index 7d7c962..dbfd444 100644 --- a/push-relay/wrangler.jsonc +++ b/push-relay/wrangler.jsonc @@ -7,17 +7,14 @@ "observability": { "enabled": false }, "vars": { "AUTO_ENROLL": "true", - "AUTO_ENROLL_DAILY_LIMIT": "100", - "AUTO_ENROLL_IP_DAILY_LIMIT": "5", - "MAX_INSTALLATIONS": "1000", - "DAILY_SEND_LIMIT": "1000", - "FCM_PROJECT_ID": "replace-with-firebase-project-id", - "IOS_APP_IDS": "link.galaxy.bluetooth.natehuby76", - "APNS_TEAM_ID": "REPLACE_ME", - "APNS_KEY_ID": "REPLACE_ME" + "AUTO_ENROLL_DAILY_LIMIT": "500", + "AUTO_ENROLL_IP_DAILY_LIMIT": "20", + "MAX_INSTALLATIONS": "5000", + "DAILY_SEND_LIMIT": "20000", + "FCM_PROJECT_ID": "starpilot-sentry", + "IOS_APP_IDS": "link.galaxy.bluetooth.natehuby76" }, "durable_objects": { "bindings": [{ "name": "ENROLLMENT", "class_name": "EnrollmentRegistry" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["EnrollmentRegistry"] }], - "kv_namespaces": [{ "binding": "RATE", "id": "replace-with-kv-namespace-id" }], - "ratelimits": [{ "name": "INGRESS", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }] + "ratelimits": [{ "name": "INGRESS", "namespace_id": "1001", "simple": { "limit": 300, "period": 60 } }] } From c32d5afb322cc2737f5cec6f95d92b646698da34 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:09:10 -0800 Subject: [PATCH 3/9] Sentry: Hold iPhone relay keys until the APNs route is settled --- push-relay/HOSTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/push-relay/HOSTING.md b/push-relay/HOSTING.md index 4fc2247..166150c 100644 --- a/push-relay/HOSTING.md +++ b/push-relay/HOSTING.md @@ -8,7 +8,7 @@ Get these through a password manager or another private channel, never chat or e - This `push-relay` folder. - The Firebase service-account key: `starpilot-sentry-….json`. -- For iPhone: Nate's Apple **Team ID**, **Key ID** and `AuthKey_.p8`. If they aren't ready yet, set them up later (step 5). Android works without them. +- iPhone keys are **on hold**. Cloudflare Workers can't open the HTTP/2 connection Apple requires, so the iPhone route is being changed. Skip the Apple secrets in step 5 until Danny confirms. Android doesn't need them. ## Set up (one time) @@ -44,7 +44,7 @@ Get these through a password manager or another private channel, never chat or e npx wrangler@4 secret put ENROLLMENT_ADMIN_SECRET ``` - For iPhone alerts, also run: + Only if Danny tells you the iPhone route needs them, also run: ```sh npx wrangler@4 secret put APNS_PRIVATE_KEY < /path/to/AuthKey_.p8 @@ -68,7 +68,7 @@ Get these through a password manager or another private channel, never chat or e 8. **Delete your local copies** of the `.json` and `.p8` key files. Cloudflare keeps the only copy it needs. -iPhone delivery has no automatic check. Once a StarPilot release has the URL, enable alerts on an iPhone and use **Send test** in Galaxy → Sentry. +Before Danny ships the URL to everyone, try one comma first: run `node operator.mjs invite`, enter the code and relay URL under Galaxy → Sentry → Custom relay, then use **Send test** to an Android phone. ## Day to day From ec748bc8d2fd27b611086044a94630b7b120a89e Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:21:54 -0800 Subject: [PATCH 4/9] Sentry: Deliver iPhone alerts through Firebase --- push-relay/HOSTING.md | 83 +++++++++++----------------------------- push-relay/README.md | 6 +-- push-relay/SETUP.md | 12 +++--- push-relay/apns.js | 36 ----------------- push-relay/apns.test.js | 54 -------------------------- push-relay/relay.js | 29 ++++++++++++-- push-relay/relay.test.js | 22 +++++++++++ 7 files changed, 78 insertions(+), 164 deletions(-) delete mode 100644 push-relay/apns.js delete mode 100644 push-relay/apns.test.js diff --git a/push-relay/HOSTING.md b/push-relay/HOSTING.md index 166150c..a5d2999 100644 --- a/push-relay/HOSTING.md +++ b/push-relay/HOSTING.md @@ -1,42 +1,14 @@ -# Hosting the Sentry push relay +# Running the Sentry push relay -This Worker forwards parked Sentry alerts from comma devices to phones: Android through Firebase, iPhone directly through Apple. It stores no photos, video or locations. It is sized for about 500 commas. - -## What you receive - -Get these through a password manager or another private channel, never chat or email: - -- This `push-relay` folder. -- The Firebase service-account key: `starpilot-sentry-….json`. -- iPhone keys are **on hold**. Cloudflare Workers can't open the HTTP/2 connection Apple requires, so the iPhone route is being changed. Skip the Apple secrets in step 5 until Danny confirms. Android doesn't need them. +This Worker forwards parked Sentry alerts from comma devices to phones through Firebase: Android directly, and iPhone by converting the phone's Apple token to a Firebase token. It stores no photos, video or locations. It is sized for about 500 commas. ## Set up (one time) 1. **Install Node.js 20 or newer.** Check with `node --version`. - -2. **Run the tests.** Unzip the folder, open a terminal in `push-relay`, and run: - - ```sh - npm test - ``` - - Every test should pass. Nothing is sent anywhere. - -3. **Sign in to Cloudflare.** Create a free account at dash.cloudflare.com if you don't have one. We recommend the Workers Paid plan ($5/month) at this size. Free also works, but it has daily request and storage-write caps that a busy day of alerts could reach. Then run: - - ```sh - npx wrangler@4 login - ``` - -4. **Deploy:** - - ```sh - npx wrangler@4 deploy - ``` - - Copy the address it prints, for example `https://galaxy-sentry-relay..workers.dev`. That is the **relay URL**. - -5. **Add the secrets.** Run each command from `push-relay`. Each one reads a file or asks you to paste a value. +2. **Run the tests** from `push-relay`: `npm test`. Nothing is sent anywhere. +3. **Sign in to Cloudflare:** `npx wrangler@4 login`. Workers Paid ($5/month) is recommended at this size; Free works but has daily request and storage-write caps. +4. **Deploy:** `npx wrangler@4 deploy`. The printed `https://galaxy-sentry-relay..workers.dev` address is the **relay URL**. +5. **Add the secrets:** ```sh npx wrangler@4 secret put FCM_SERVICE_ACCOUNT < /path/to/starpilot-sentry-….json @@ -44,39 +16,28 @@ Get these through a password manager or another private channel, never chat or e npx wrangler@4 secret put ENROLLMENT_ADMIN_SECRET ``` - Only if Danny tells you the iPhone route needs them, also run: - - ```sh - npx wrangler@4 secret put APNS_PRIVATE_KEY < /path/to/AuthKey_.p8 - npx wrangler@4 secret put APNS_TEAM_ID # paste the 10-character Team ID - npx wrangler@4 secret put APNS_KEY_ID # paste the 10-character Key ID - ``` - - Save the admin secret in your password manager. You need it for step 6 and for revoking a comma later. Don't give it to users. - -6. **Check the deployment:** + Keep the admin secret in a password manager. It is for revoking commas, never for users. +6. **Check it:** ```sh - export RELAY_URL=https://galaxy-sentry-relay..workers.dev - read -r -s ENROLLMENT_ADMIN_SECRET && export ENROLLMENT_ADMIN_SECRET # paste the admin secret + export RELAY_URL=https://galaxy-sentry-relay..workers.dev + read -r -s ENROLLMENT_ADMIN_SECRET && export ENROLLMENT_ADMIN_SECRET node operator.mjs check ``` - You should see five `PASS` lines and `Relay check passed.` The check enrolls a temporary test device, makes sure Firebase accepts your key, then deletes the test device. If the Firebase line fails, the service-account key or `FCM_PROJECT_ID` in `wrangler.jsonc` is wrong. - -7. **Send the relay URL to Danny.** He sets it in the StarPilot release, and comma devices then enroll themselves when users tap **Enable alerts**. Nobody types the URL into an app. Also share the admin secret with Danny privately, so either of you can revoke a comma. - -8. **Delete your local copies** of the `.json` and `.p8` key files. Cloudflare keeps the only copy it needs. + Expect five `PASS` lines and `Relay check passed.` +7. **Try one comma** before shipping: run `node operator.mjs invite`, enter the code and relay URL in Galaxy → Sentry → Custom relay, then **Send test** to an Android phone and an iPhone. +8. **Ship it:** set `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` to the relay URL (no path). Users then just tap **Enable alerts**. -Before Danny ships the URL to everyone, try one comma first: run `node operator.mjs invite`, enter the code and relay URL under Galaxy → Sentry → Custom relay, then use **Send test** to an Android phone. +iPhone delivery also needs the iOS app registered in the Firebase project with Nate's APNs key uploaded. See [SETUP.md](SETUP.md). ## Day to day -- **Update the code:** replace the folder's files with the new version and run `npx wrangler@4 deploy` again. Never rename the Worker, change `class_name` or delete the Durable Object. Enrolled commas are stored there and would all stop working. -- **Revoke one comma:** ask the user for the installation ID shown in Galaxy → Sentry, then run `node operator.mjs revoke ` with `RELAY_URL` and `ENROLLMENT_ADMIN_SECRET` set as in step 6. -- **Replace a key:** run the same `secret put` command with the new file, run `node operator.mjs check`, then delete the old key in Google Cloud or the Apple developer account. -- **Watch usage:** Cloudflare dashboard → Workers & Pages → `galaxy-sentry-relay` → Metrics. The relay never logs phone tokens or alert contents. Keep it that way. -- **Turn it off:** in the dashboard, disable the Worker. Phones fall back to checking for alerts while connected. Turning it back on keeps every enrollment. +- **Update the code:** `npx wrangler@4 deploy` from this folder. Never rename the Worker, change `class_name` or delete the Durable Object; enrolled commas live there. +- **Revoke one comma:** `node operator.mjs revoke ` (the ID is shown in Galaxy → Sentry), with the variables from step 6 set. +- **Replace the Firebase key:** run the `secret put FCM_SERVICE_ACCOUNT` command with the new file, run `node operator.mjs check`, then delete the old key in Google Cloud. +- **Watch usage:** Cloudflare dashboard → Workers & Pages → `galaxy-sentry-relay` → Metrics. The relay never logs phone tokens or alert contents. +- **Turn it off:** disable the Worker in the dashboard. Phones fall back to checking while connected, and turning it back on keeps every enrollment. ## Limits @@ -86,9 +47,9 @@ Set in `wrangler.jsonc` under `vars`. Raise one and redeploy if users hit it. A | --- | --- | --- | | `AUTO_ENROLL_DAILY_LIMIT` | 500 | New commas per day across all users | | `AUTO_ENROLL_IP_DAILY_LIMIT` | 20 | New commas per day from one network address (carrier networks share addresses) | -| `MAX_INSTALLATIONS` | 5000 | Lifetime enrollments. Resets and re-enrollments count, so this leaves headroom for 500 commas | +| `MAX_INSTALLATIONS` | 5000 | Lifetime enrollments, including resets and re-enrollments | | `DAILY_SEND_LIMIT` | 20000 | Alerts per day across all users | -| Per comma | 60/hour | Built in. Alerts per comma per hour | +| Per comma | 60/hour | Built in | | `INGRESS` rate limit | 300/minute | Requests per network address | -Anyone can enroll a device automatically. These limits cap the damage a bad actor could do; they don't prove ownership. +Anyone can enroll a device automatically. These limits cap abuse; they don't prove ownership. diff --git a/push-relay/README.md b/push-relay/README.md index 9f3c7a2..c2bc2eb 100644 --- a/push-relay/README.md +++ b/push-relay/README.md @@ -1,10 +1,10 @@ # Galaxy sentry relay -A Cloudflare Worker that forwards parked Sentry alerts from comma to phones: Android through Firebase, iPhone directly through Apple. +A Cloudflare Worker that forwards parked Sentry alerts from comma to phones through Firebase. iPhone Apple tokens are converted to Firebase tokens, so the iOS app needs no Firebase SDK. -- **Hosting it:** [HOSTING.md](HOSTING.md), step by step. +- **Running it:** [HOSTING.md](HOSTING.md), step by step. - **Firebase, Apple, release and testing:** [SETUP.md](SETUP.md). -`worker.js` routes requests, `enrollment.js` holds per-comma credentials in a SQLite Durable Object, `relay.js` sends to Firebase and `apns.js` sends to Apple. `operator.mjs` checks a deployment and revokes a comma. `server.js` is the legacy v1 Node relay. Run `npm test`. The tests use fake providers and never send anything. +`worker.js` routes requests, `enrollment.js` holds per-comma credentials in a SQLite Durable Object and `relay.js` sends to Firebase. `operator.mjs` checks a deployment and revokes a comma. `server.js` is the legacy v1 Node relay. Run `npm test`. The tests use fake providers and never send anything. The relay accepts only an allowlisted app ID, event kind, bounded event ID and label, and an integer event time. It never stores raw phone tokens or alert contents. A dead phone token returns 410 and removes that phone; configuration errors return 502, so a broken key can't delete every registration. diff --git a/push-relay/SETUP.md b/push-relay/SETUP.md index c8ef3b7..2697fd7 100644 --- a/push-relay/SETUP.md +++ b/push-relay/SETUP.md @@ -5,18 +5,18 @@ The app works without Firebase or a relay: enabled alerts use polling while conn ## Launch checklist 1. **Firebase (done):** project `starpilot-sentry` on the Spark plan, Android app `com.starpilot.galaxy`, Cloud Messaging API (V1) enabled, and service account `galaxy-push-relay` with **Firebase Cloud Messaging API Admin**. -2. **Apple:** Nate enables Push Notifications for `link.galaxy.bluetooth.natehuby76`, creates an APNs `.p8` key, and sends the key, Key ID and Team ID privately to the host. -3. **Host:** follows [HOSTING.md](HOSTING.md) and sends back the relay URL. -4. **StarPilot:** set `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` to that URL (origin only, no path) and ship the release. +2. **iPhone in Firebase:** in the Firebase console, add an Apple app with bundle ID `link.galaxy.bluetooth.natehuby76` (skip the SDK and plist steps). Then, under Project settings → Cloud Messaging → Apple app configuration, upload Nate's APNs auth key (`.p8`, Key ID and Team ID). To keep the key with Nate, add him to the Firebase project and let him upload it. The iOS app itself needs no Firebase SDK. +3. **Relay:** deploy and check it with [HOSTING.md](HOSTING.md). +4. **StarPilot:** set `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` to the relay URL (origin only, no path) and ship the release. 5. **Android release:** store the client config once with `gh secret set GOOGLE_SERVICES_JSON --repo dirwin31/StarPilotAndroid --env android-release < app/google-services.json`. The release workflow writes it before building the Play bundle and fails if it is missing. Keep the file out of git: the Direct flavor's ID isn't in it, so committing it breaks the Direct unit tests. -6. **iOS release:** build the iOS PR. Debug uses sandbox APNs; Release (TestFlight and App Store) uses production. +6. **iOS release:** Nate enables Push Notifications for the bundle ID and builds the iOS PR. Debug uses sandbox APNs; Release (TestFlight and App Store) uses production. 7. **Test:** see [Test](#test). Users then tap **Enable alerts** in Galaxy → Sentry or in the app. The phone asks for notification permission, and comma enrolls itself and registers the phone. There are no codes or URLs to type. ## How it fits together -Each comma generates its own sending credential on enrollment and keeps it private. Phones keep their own push tokens. The Worker stores only credential digests, phone-token fingerprints and short-lived delivery receipts. Camera media stays on comma. Android gets a data-only Firebase message; iPhone gets a visible Apple alert without photos. Tapping either opens Sentry events. +Each comma generates its own sending credential on enrollment and keeps it private. Phones keep their own push tokens. The Worker stores only credential digests, phone-token fingerprints and short-lived delivery receipts. Camera media stays on comma. Android gets a data-only Firebase message. The iPhone app registers its Apple token; the Worker converts it to a Firebase token and Firebase delivers a visible alert without photos through Apple. Tapping either opens Sentry events. If the relay rejects a phone (for example, an app ID it doesn't know), comma skips that phone and keeps alerting the others. The phone retries when it next reconnects. @@ -42,7 +42,7 @@ Rotate the key by replacing the file, running `systemctl restart galaxy-push-rel ## Test - **Relay alone:** `node operator.mjs check` (see HOSTING.md step 6). To push to a real Android phone, copy its token from Connections → Notification details and run `read -r -s RELAY_TEST_TOKEN && export RELAY_TEST_TOKEN` before the check. -- **End to end:** on a release with `DEFAULT_RELAY_URL` set, enable alerts on each phone, then use **Send test** in Galaxy → Sentry. Repeat with the app in the background and the phone off comma's Wi-Fi while comma has internet. A 200 from the relay means Firebase or Apple accepted the message, not that the phone showed it. +- **End to end:** on a release with `DEFAULT_RELAY_URL` set, enable alerts on each phone, then use **Send test** in Galaxy → Sentry. Repeat with the app in the background and the phone off comma's Wi-Fi while comma has internet. A 200 from the relay means Firebase accepted the message, not that the phone showed it. - **Without the relay:** use Simulate event while parked and check polling. Do not force-stop the Android app to test background delivery. Android blocks a force-stopped app until it is opened again. diff --git a/push-relay/apns.js b/push-relay/apns.js deleted file mode 100644 index 314fc16..0000000 --- a/push-relay/apns.js +++ /dev/null @@ -1,36 +0,0 @@ -const encode = value => new TextEncoder().encode(value) -const b64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') -const json = (status, error) => Response.json(error ? { error } : { ok: true }, { status, headers: { 'Cache-Control': 'no-store' } }) -let cached - -export async function apnsAuthorization(env) { - const now = Math.floor(Date.now() / 1000) - if (cached?.key === env.APNS_PRIVATE_KEY && cached.team === env.APNS_TEAM_ID && cached.id === env.APNS_KEY_ID && cached.until > now) return cached.jwt - if (!/^[A-Z0-9]{10}$/.test(env.APNS_TEAM_ID) || !/^[A-Z0-9]{10}$/.test(env.APNS_KEY_ID)) throw new Error('APNs configuration') - const raw = Uint8Array.from(atob(env.APNS_PRIVATE_KEY.replace(/-----[^-]+-----|\s/g, '')), c => c.charCodeAt(0)) - const key = await crypto.subtle.importKey('pkcs8', raw, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']) - const unsigned = `${b64(encode(JSON.stringify({ alg: 'ES256', kid: env.APNS_KEY_ID })))}.${b64(encode(JSON.stringify({ iss: env.APNS_TEAM_ID, iat: now })))}` - const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, encode(unsigned)) - const jwt = `${unsigned}.${b64(signature)}` - cached = { key: env.APNS_PRIVATE_KEY, team: env.APNS_TEAM_ID, id: env.APNS_KEY_ID, jwt, until: now + 3000 } - return jwt -} - -export async function sendAPNs(body, env, fetcher = fetch, authorize = apnsAuthorization) { - const token = /^apns\.(production|sandbox)\.([a-f0-9]{32,512})$/.exec(body.token) - if (!token || token[2].length % 2) return json(400, 'Invalid Apple token') - const host = token[1] === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com' - const title = { alarm: 'Sentry alarm', warning: 'Sentry: motion detected', test: 'Galaxy test alert' }[body.kind] - const response = await fetcher(`https://${host}/3/device/${token[2]}`, { - method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(5000), - headers: { authorization: `bearer ${await authorize(env)}`, 'content-type': 'application/json', - 'apns-topic': body.appId, 'apns-push-type': 'alert', 'apns-priority': '10', - 'apns-expiration': String(Math.floor(Date.now() / 1000) + 600), 'apns-collapse-id': body.eventId }, - body: JSON.stringify({ aps: { alert: { title, body: body.label || 'Parked motion detected' }, sound: 'default', 'thread-id': 'sentry' }, - type: 'sentry', kind: body.kind, eventId: body.eventId, wallTimeNs: String(body.wallTimeNs) }), - }) - if (response.ok) return json(200) - const failure = await response.json().catch(() => ({})) - if (response.status === 410 && failure.reason === 'Unregistered') return json(410, 'Token expired') - return json(response.status === 429 ? 429 : 502, 'APNs unavailable') -} diff --git a/push-relay/apns.test.js b/push-relay/apns.test.js deleted file mode 100644 index 101ef63..0000000 --- a/push-relay/apns.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { apnsAuthorization, sendAPNs } from './apns.js' -import { handle } from './relay.js' -const body = { token: `apns.production.${'ab'.repeat(32)}`, appId: 'link.galaxy.bluetooth.natehuby76', kind: 'alarm', eventId: 'abc123', wallTimeNs: Date.now() * 1e6, label: 'iPhone' } -const authorized = async () => 'jwt' - -test('APNs sends a visible alert with bounded expiry, correct topic and environment', async () => { - for (const [environment, host] of [['production', 'api.push.apple.com'], ['sandbox', 'api.sandbox.push.apple.com']]) { - const response = await sendAPNs({ ...body, token: `apns.${environment}.${'ab'.repeat(32)}` }, {}, async (url, request) => { - assert.equal(url, `https://${host}/3/device/${'ab'.repeat(32)}`) - assert.equal(request.headers['apns-topic'], body.appId) - assert.equal(request.headers['apns-push-type'], 'alert') - assert.equal(request.headers['apns-collapse-id'], body.eventId) - assert.equal(request.redirect, 'manual') - const payload = JSON.parse(request.body) - assert.equal(payload.aps.alert.title, 'Sentry alarm') - assert.equal(payload.type, 'sentry') - assert.equal(payload.eventId, body.eventId) - assert.equal(payload.token, undefined) - assert.ok(Number(request.headers['apns-expiration']) <= Date.now() / 1000 + 601) - return new Response(null, { status: 200 }) - }, authorized) - assert.equal(response.status, 200) - } -}) - -test('only unregistered Apple tokens remove subscriptions; setup errors remain retryable', async () => { - for (const [status, reason, expected] of [[410, 'Unregistered', 410], [400, 'BadDeviceToken', 502], [400, 'DeviceTokenNotForTopic', 502], [403, 'InvalidProviderToken', 502], [429, 'TooManyRequests', 429], [500, 'InternalServerError', 502]]) { - const response = await sendAPNs(body, {}, async () => Response.json({ reason }, { status }), authorized) - assert.equal(response.status, expected) - } - assert.equal((await sendAPNs({ ...body, token: 'apns.production.invalid' }, {}, () => assert.fail('must not send'), authorized)).status, 400) -}) - -test('ES256 authorization signs a verifiable JWT and refreshes when credentials change', async () => { - const key = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) - const raw = Buffer.from(await crypto.subtle.exportKey('pkcs8', key.privateKey)).toString('base64') - const env = { APNS_PRIVATE_KEY: `-----BEGIN PRIVATE KEY-----\n${raw}\n-----END PRIVATE KEY-----`, APNS_TEAM_ID: 'ABCDEFGHIJ', APNS_KEY_ID: '1234567890' } - const jwt = await apnsAuthorization(env) - const [header, payload, signature] = jwt.split('.') - assert.equal(JSON.parse(Buffer.from(header, 'base64url')).alg, 'ES256') - assert.equal(JSON.parse(Buffer.from(payload, 'base64url')).iss, env.APNS_TEAM_ID) - assert.equal(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key.publicKey, Buffer.from(signature, 'base64url'), new TextEncoder().encode(`${header}.${payload}`)), true) - assert.equal(await apnsAuthorization(env), jwt) - const changed = await apnsAuthorization({ ...env, APNS_KEY_ID: 'ABCDEFGHIJ' }) - assert.notEqual(changed, jwt) - let count = 0 - const response = await handle(new Request('https://relay.test/v1/sentry', { method: 'POST', body: JSON.stringify(body) }), { - ...env, RATE: { get: async () => 0, put: async () => {} }, - }, async url => { assert.ok(url.startsWith('https://api.push.apple.com/')); count++; return new Response(null, { status: 200 }) }, () => assert.fail('APNs must not require Firebase')) - assert.equal(response.status, 200) - assert.equal(count, 1) -}) diff --git a/push-relay/relay.js b/push-relay/relay.js index 1f11bef..37601ad 100644 --- a/push-relay/relay.js +++ b/push-relay/relay.js @@ -1,4 +1,3 @@ -import { sendAPNs } from './apns.js' const androidApps = new Set(['com.starpilot.galaxy']) const defaultIosApps = 'link.galaxy.bluetooth.natehuby76' export const iosApps = env => new Set(String(env?.IOS_APP_IDS ?? defaultIosApps).split(',').map(id => id.trim()).filter(Boolean)) @@ -7,6 +6,23 @@ const encode = value => new TextEncoder().encode(value) const b64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') const json = (status, value) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } }) let oauth = null +const imported = new Map() + +async function importApns(body, token, fetcher) { + const apns = /^apns\.(production|sandbox)\.([a-f0-9]{32,512})$/.exec(body.token) + if (!apns || apns[2].length % 2) return null + const key = `${body.appId}:${body.token}` + if (imported.has(key)) return imported.get(key) + const response = await fetcher('https://iid.googleapis.com/iid/v1:batchImport', { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(5000), + headers: { Authorization: `Bearer ${token}`, access_token_auth: 'true', 'Content-Type': 'application/json' }, + body: JSON.stringify({ application: body.appId, sandbox: apns[1] === 'sandbox', apns_tokens: [apns[2]] }) }) + if (!response.ok) throw new Error('Token import unavailable') + const result = (await response.json()).results?.[0] + if (result?.status !== 'OK' || typeof result.registration_token !== 'string') throw new Error('Token import failed') + if (imported.size >= 1000) imported.clear() + imported.set(key, result.registration_token) + return result.registration_token +} export function valid(body, ios = new Set()) { return body !== null && typeof body === 'object' && !Array.isArray(body) && @@ -73,17 +89,22 @@ export async function handle(request, env, fetcher = fetch, authorize = accessTo const count = Number(await env.RATE.get(key)) || 0 if (count >= 30) return json(429, { error: 'Rate limited' }) await env.RATE.put(key, String(count + 1), { expirationTtl: 7200 }) - if (iosApps(env).has(body.appId) && body.token.startsWith('apns.')) return await sendAPNs(body, env, fetcher) if (!/^[a-z0-9][a-z0-9-]{4,61}[a-z0-9]$/.test(env.FCM_PROJECT_ID)) throw new Error('Invalid project') + const ios = iosApps(env).has(body.appId) const token = await authorize(env, fetcher) + let destination = body.token + if (ios && body.token.startsWith('apns.')) { + destination = await importApns(body, token, fetcher) + if (!destination) return json(400, { error: 'Invalid Apple token' }) + } const response = await fetcher(`https://fcm.googleapis.com/v1/projects/${env.FCM_PROJECT_ID}/messages:send`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(fcmMessage(body, iosApps(env).has(body.appId))), signal: AbortSignal.timeout(5000), redirect: 'manual', + body: JSON.stringify(fcmMessage({ ...body, token: destination }, ios)), signal: AbortSignal.timeout(5000), redirect: 'manual', }) if (response.ok) return json(200, { ok: true }) const failure = await response.json().catch(() => ({})) const codes = (failure.error?.details || []).filter(detail => detail['@type'] === 'type.googleapis.com/google.firebase.fcm.v1.FcmError').map(detail => detail.errorCode) - if (codes.includes('UNREGISTERED') || codes.includes('INVALID_ARGUMENT')) return json(410, { error: 'Token expired' }) + if (codes.includes('UNREGISTERED') || codes.includes('INVALID_ARGUMENT')) { imported.delete(`${body.appId}:${body.token}`); return json(410, { error: 'Token expired' }) } return json(response.status === 429 ? 429 : 502, { error: 'FCM unavailable' }) } catch { return json(502, { error: 'Relay unavailable' }) } } diff --git a/push-relay/relay.test.js b/push-relay/relay.test.js index 4a7b6fb..81c3739 100644 --- a/push-relay/relay.test.js +++ b/push-relay/relay.test.js @@ -47,3 +47,25 @@ test('iOS IDs are configurable and can be disabled', () => { assert.equal(iosApps({ IOS_APP_IDS: '' }).size, 0) assert.deepEqual([...iosApps({ IOS_APP_IDS: 'a.b, c.d' })], ['a.b', 'c.d']) }) +test('Apple tokens are converted through Firebase and sent as visible alerts', async () => { + const env = { ...environment(), IOS_APP_IDS: 'link.galaxy.bluetooth.natehuby76' } + const phone = { ...body, appId: 'link.galaxy.bluetooth.natehuby76', token: `apns.sandbox.${'cd'.repeat(32)}` } + const calls = [] + const fetcher = async (url, init) => { + calls.push(url) + assert.equal(init.redirect, 'manual') + const sent = JSON.parse(init.body) + if (url.startsWith('https://iid.googleapis.com/')) { + assert.deepEqual(sent, { application: phone.appId, sandbox: true, apns_tokens: ['cd'.repeat(32)] }) + return Response.json({ results: [{ status: 'OK', registration_token: 'fcm-from-apns' }] }) + } + assert.equal(sent.message.token, 'fcm-from-apns') + assert.equal(sent.message.notification.title, 'Sentry motion') + return Response.json({}) + } + for (let i = 0; i < 2; i++) assert.equal((await handle(request(phone), env, fetcher, async () => 'oauth')).status, 200) + assert.equal(calls.filter(url => url.startsWith('https://iid.googleapis.com/')).length, 1) + const failed = async url => url.startsWith('https://iid.') ? Response.json({ results: [{ status: 'INTERNAL' }] }) : assert.fail('must not send') + assert.equal((await handle(request({ ...phone, token: `apns.production.${'ef'.repeat(32)}` }), env, failed, async () => 'oauth')).status, 502) + assert.equal((await handle(request({ ...phone, token: 'apns.production.xyz' }), env, () => assert.fail('must not send'), async () => 'oauth')).status, 400) +}) From e3ec093552ecbf676c5153665216c2f7972c84b6 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:32:17 -0800 Subject: [PATCH 5/9] Sentry: Mark relay setup steps that are done --- .../.wrangler/cache/wrangler-account.json | 6 ++++++ push-relay/HOSTING.md | 2 ++ push-relay/SETUP.md | 21 +++++++++++-------- 3 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 push-relay/.wrangler/cache/wrangler-account.json diff --git a/push-relay/.wrangler/cache/wrangler-account.json b/push-relay/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..41e7823 --- /dev/null +++ b/push-relay/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "d0f6d4fa130228e02387f0915ba4d352", + "name": "Dannyirwin31@yahoo.com's Account" + } +} \ No newline at end of file diff --git a/push-relay/HOSTING.md b/push-relay/HOSTING.md index a5d2999..1a10841 100644 --- a/push-relay/HOSTING.md +++ b/push-relay/HOSTING.md @@ -4,6 +4,8 @@ This Worker forwards parked Sentry alerts from comma devices to phones through F ## Set up (one time) +Already done: the relay runs at `https://galaxy-sentry-relay.dannyirwin31.workers.dev` on Danny's Cloudflare account, and the admin secret is in his Keychain as `galaxy-relay-admin`. These steps are for rebuilding it. + 1. **Install Node.js 20 or newer.** Check with `node --version`. 2. **Run the tests** from `push-relay`: `npm test`. Nothing is sent anywhere. 3. **Sign in to Cloudflare:** `npx wrangler@4 login`. Workers Paid ($5/month) is recommended at this size; Free works but has daily request and storage-write caps. diff --git a/push-relay/SETUP.md b/push-relay/SETUP.md index 2697fd7..6a194f2 100644 --- a/push-relay/SETUP.md +++ b/push-relay/SETUP.md @@ -2,15 +2,18 @@ The app works without Firebase or a relay: enabled alerts use polling while connected and a 15-minute Android job. Android schedules background work opportunistically, so this does not promise immediate delivery. -## Launch checklist +## Status -1. **Firebase (done):** project `starpilot-sentry` on the Spark plan, Android app `com.starpilot.galaxy`, Cloud Messaging API (V1) enabled, and service account `galaxy-push-relay` with **Firebase Cloud Messaging API Admin**. -2. **iPhone in Firebase:** in the Firebase console, add an Apple app with bundle ID `link.galaxy.bluetooth.natehuby76` (skip the SDK and plist steps). Then, under Project settings → Cloud Messaging → Apple app configuration, upload Nate's APNs auth key (`.p8`, Key ID and Team ID). To keep the key with Nate, add him to the Firebase project and let him upload it. The iOS app itself needs no Firebase SDK. -3. **Relay:** deploy and check it with [HOSTING.md](HOSTING.md). -4. **StarPilot:** set `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` to the relay URL (origin only, no path) and ship the release. -5. **Android release:** store the client config once with `gh secret set GOOGLE_SERVICES_JSON --repo dirwin31/StarPilotAndroid --env android-release < app/google-services.json`. The release workflow writes it before building the Play bundle and fails if it is missing. Keep the file out of git: the Direct flavor's ID isn't in it, so committing it breaks the Direct unit tests. -6. **iOS release:** Nate enables Push Notifications for the bundle ID and builds the iOS PR. Debug uses sandbox APNs; Release (TestFlight and App Store) uses production. -7. **Test:** see [Test](#test). +Done: +- Firebase project `starpilot-sentry`: Android app `com.starpilot.galaxy`, Cloud Messaging API (V1) and the `galaxy-push-relay` service account. +- Relay deployed at `https://galaxy-sentry-relay.dannyirwin31.workers.dev` with its live check passing ([HOSTING.md](HOSTING.md)). +- `DEFAULT_RELAY_URL` set in StarPilot. +- `GOOGLE_SERVICES_JSON` stored for Play releases. The release workflow writes it before building the Play bundle. Keep the file out of git: the Direct flavor's ID isn't in it, so committing it breaks the Direct unit tests. + +Remaining: +1. **iPhone in Firebase:** in the Firebase console, add an Apple app with bundle ID `link.galaxy.bluetooth.natehuby76` (skip the SDK and plist steps). Then, under Project settings → Cloud Messaging → Apple app configuration, upload Nate's APNs auth key (`.p8`, Key ID and Team ID). Adding Nate to the Firebase project lets him upload it himself. +2. **iOS release:** Nate enables Push Notifications for the bundle ID and builds the iOS PR. Debug uses sandbox APNs; Release (TestFlight and App Store) uses production. +3. **Test** on real phones: see [Test](#test). Users then tap **Enable alerts** in Galaxy → Sentry or in the app. The phone asks for notification permission, and comma enrolls itself and registers the phone. There are no codes or URLs to type. @@ -42,7 +45,7 @@ Rotate the key by replacing the file, running `systemctl restart galaxy-push-rel ## Test - **Relay alone:** `node operator.mjs check` (see HOSTING.md step 6). To push to a real Android phone, copy its token from Connections → Notification details and run `read -r -s RELAY_TEST_TOKEN && export RELAY_TEST_TOKEN` before the check. -- **End to end:** on a release with `DEFAULT_RELAY_URL` set, enable alerts on each phone, then use **Send test** in Galaxy → Sentry. Repeat with the app in the background and the phone off comma's Wi-Fi while comma has internet. A 200 from the relay means Firebase accepted the message, not that the phone showed it. +- **End to end:** on a comma running the StarPilot PR, enable alerts on each phone, then use **Send test** in Galaxy → Sentry. Repeat with the app in the background and the phone off comma's Wi-Fi while comma has internet. A 200 from the relay means Firebase accepted the message, not that the phone showed it. - **Without the relay:** use Simulate event while parked and check polling. Do not force-stop the Android app to test background delivery. Android blocks a force-stopped app until it is opened again. From 8d9042326c21d4849faa16ac8a88b73cc212ba92 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 09:13:08 -0800 Subject: [PATCH 6/9] Sentry: Show the cabin picture beside phone alerts --- .../com/starpilot/galaxy/CabinWhiteBalance.kt | 45 +++++++++++++++++++ .../java/com/starpilot/galaxy/SentryApi.kt | 4 +- .../java/com/starpilot/galaxy/SentryPush.kt | 29 +++++++++--- .../starpilot/galaxy/CabinWhiteBalanceTest.kt | 23 ++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/starpilot/galaxy/CabinWhiteBalance.kt create mode 100644 app/src/test/java/com/starpilot/galaxy/CabinWhiteBalanceTest.kt diff --git a/app/src/main/java/com/starpilot/galaxy/CabinWhiteBalance.kt b/app/src/main/java/com/starpilot/galaxy/CabinWhiteBalance.kt new file mode 100644 index 0000000..6ec445d --- /dev/null +++ b/app/src/main/java/com/starpilot/galaxy/CabinWhiteBalance.kt @@ -0,0 +1,45 @@ +package com.starpilot.galaxy + +import android.graphics.Bitmap +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** Same correction as Galaxy's white-balance.js: cancels the cabin camera's IR magenta cast. */ +object CabinWhiteBalance { + fun gains(pixels: IntArray, width: Int, height: Int): FloatArray { + var r = 0.0; var g = 0.0; var b = 0.0; var count = 0 + for (y in 0 until height step 8) for (x in 0 until width step 8) { + val c = pixels[y * width + x] + r += (c shr 16) and 0xFF; g += (c shr 8) and 0xFF; b += c and 0xFF; count++ + } + if (count == 0) return floatArrayOf(1f, 1f, 1f) + val mean = doubleArrayOf(r / count / 255, g / count / 255, b / count / 255) + val luma = 0.299 * mean[0] + 0.587 * mean[1] + 0.114 * mean[2] + if (luma < 0.02) return floatArrayOf(1f, 1f, 1f) + return FloatArray(3) { min(1.6, max(0.6, luma / max(mean[it], 1e-3))).toFloat() } + } + + fun apply(pixels: IntArray, width: Int, height: Int) { + val gains = gains(pixels, width, height) + val rgb = FloatArray(3) + for (i in pixels.indices) { + val c = pixels[i] + rgb[0] = ((c shr 16) and 0xFF) / 255f; rgb[1] = ((c shr 8) and 0xFF) / 255f; rgb[2] = (c and 0xFF) / 255f + val t = min(1f, max(0f, (max(rgb[0], max(rgb[1], rgb[2])) - 0.95f) / 0.05f)) + for (k in 0..2) rgb[k] = rgb[k] * gains[k] * (1 - t) + rgb[k] * t + val gray = 0.299f * rgb[0] + 0.587f * rgb[1] + 0.114f * rgb[2] + val out = IntArray(3) { (min(1f, max(0f, gray + (rgb[it] - gray) * 0.75f)) * 255).roundToInt() } + pixels[i] = (c and 0xFF000000.toInt()) or (out[0] shl 16) or (out[1] shl 8) or out[2] + } + } + + fun apply(bitmap: Bitmap): Bitmap { + val copy = bitmap.copy(Bitmap.Config.ARGB_8888, true) + val pixels = IntArray(copy.width * copy.height) + copy.getPixels(pixels, 0, copy.width, 0, 0, copy.width, copy.height) + apply(pixels, copy.width, copy.height) + copy.setPixels(pixels, 0, copy.width, 0, 0, copy.width, copy.height) + return copy + } +} diff --git a/app/src/main/java/com/starpilot/galaxy/SentryApi.kt b/app/src/main/java/com/starpilot/galaxy/SentryApi.kt index 7752c42..f5a5fdc 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryApi.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryApi.kt @@ -10,12 +10,12 @@ class SentryApi(val origin: String, private val cookie: String, private val netw private val bridge: ((String, JSONObject?) -> JSONObject)? = null, private val cookieProvider: (() -> String)? = null) { fun request(path: String, body: JSONObject? = null): JSONObject = bridge?.invoke(path, body) ?: JSONObject(String(bytes(path, body))) - fun bytes(path: String, body: JSONObject? = null): ByteArray { + fun bytes(path: String, body: JSONObject? = null, readTimeoutMs: Int = 3000): ByteArray { check(!Thread.currentThread().isInterrupted) require(path.startsWith("/api/") && !path.contains("..")) val connection = (network?.openConnection(URL(origin + path)) ?: URL(origin + path).openConnection()) as HttpURLConnection try { - connection.connectTimeout = 2000; connection.readTimeout = 3000; connection.instanceFollowRedirects = false + connection.connectTimeout = 2000; connection.readTimeout = readTimeoutMs; connection.instanceFollowRedirects = false connection.setRequestProperty("Cookie", cookieProvider?.invoke() ?: cookie) if (body != null) { connection.requestMethod = "POST"; connection.doOutput = true diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt index fb87bd4..9bec112 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt @@ -5,6 +5,7 @@ import android.app.* import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import org.json.JSONArray @@ -107,6 +108,8 @@ object SentryPush { else -> if (result.optBoolean("appPushAvailable")) "Registered; send a test from Galaxy → Sentry" else "Polling; relay not configured" } } + private fun decode(bytes: ByteArray, sample: Int): Bitmap? = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) @Synchronized fun receive(ctx: Context, data: Map, fromPush: Boolean = true, demo: Boolean = false): Boolean { val p = prefs(ctx) if (!demo && suspended) return false @@ -129,16 +132,28 @@ object SentryPush { p.edit().putString("sentrySeenIds", JSONArray(dedupe.ids()).toString()) .putLong("sentryLastSeenNs", maxOf(p.getLong("sentryLastSeenNs", 0), (data["wallTimeNs"]?.toLongOrNull() ?: 0).coerceIn(0, System.currentTimeMillis() * 1000000))) .apply { if (fromPush) putLong("sentryLastPushAt", System.currentTimeMillis()) }.commit() + val test = data["kind"] == "test" Thread { - val image = java.util.concurrent.FutureTask { - val current = api ?: SentryApi.best(ctx) - current?.bytes("/api/sentry/image/$id/wide")?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } + val images = java.util.concurrent.FutureTask { + val current = api ?: SentryApi.best(ctx) ?: return@FutureTask null + val wide = if (test) null else runCatching { decode(current.bytes("/api/sentry/image/$id/wide"), 1) }.getOrNull() + val cabin = runCatching { + decode(if (test) current.bytes("/api/cameras/snapshot", JSONObject().put("camera", "cabin"), 30000) + else current.bytes("/api/sentry/image/$id/cabin"), 4) + }.getOrNull()?.let { bitmap -> + val balanced = runCatching { current.request("/api/cameras/white-balance", JSONObject()).optBoolean("enabled") }.getOrDefault(false) + if (balanced) CabinWhiteBalance.apply(bitmap) else bitmap + } + wide to cabin } - Thread(image).apply { isDaemon = true; start() } + Thread(images).apply { isDaemon = true; start() } try { - val bitmap = image.get(5, java.util.concurrent.TimeUnit.SECONDS) - if (bitmap != null && !suspended) manager.notify(notificationId, builder.setStyle(Notification.BigPictureStyle().bigPicture(bitmap)).build()) - } catch (_: Exception) {} finally { image.cancel(true) } + val (wide, cabin) = images.get(if (test) 35L else 8L, java.util.concurrent.TimeUnit.SECONDS) ?: return@Thread + if (suspended || (wide == null && cabin == null)) return@Thread + cabin?.let { builder.setLargeIcon(it) } + wide?.let { builder.setStyle(Notification.BigPictureStyle().bigPicture(it)) } + manager.notify(notificationId, builder.build()) + } catch (_: Exception) {} finally { images.cancel(true) } }.start() } return true diff --git a/app/src/test/java/com/starpilot/galaxy/CabinWhiteBalanceTest.kt b/app/src/test/java/com/starpilot/galaxy/CabinWhiteBalanceTest.kt new file mode 100644 index 0000000..2656119 --- /dev/null +++ b/app/src/test/java/com/starpilot/galaxy/CabinWhiteBalanceTest.kt @@ -0,0 +1,23 @@ +package com.starpilot.galaxy + +import org.junit.Assert.* +import org.junit.Test + +class CabinWhiteBalanceTest { + private fun rgb(r: Int, g: Int, b: Int) = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + private fun channels(c: Int) = intArrayOf((c shr 16) and 0xFF, (c shr 8) and 0xFF, c and 0xFF) + + @Test fun magentaCastIsNeutralized() { + val pixels = IntArray(16 * 16) { rgb(170, 110, 160) } + CabinWhiteBalance.apply(pixels, 16, 16) + val (r, g, b) = channels(pixels[0]).toList() + assertTrue(maxOf(r, g, b) - minOf(r, g, b) < 10) + assertEquals(0xFF, pixels[0] ushr 24) + } + @Test fun darkFramesAndClippedHighlightsKeepTheirGains() { + assertArrayEquals(floatArrayOf(1f, 1f, 1f), CabinWhiteBalance.gains(IntArray(64) { rgb(2, 1, 2) }, 8, 8), 0f) + val pixels = IntArray(64) { if (it == 0) rgb(255, 255, 255) else rgb(170, 110, 160) } + CabinWhiteBalance.apply(pixels, 8, 8) + assertArrayEquals(intArrayOf(255, 255, 255), channels(pixels[0])) + } +} From 5c282a5d3a94327bcb148648c276b636ce6d2006 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 09:19:09 -0800 Subject: [PATCH 7/9] Sentry: Show the cabin picture in the first alert --- .../java/com/starpilot/galaxy/SentryPush.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt index 9bec112..29612fd 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt @@ -126,9 +126,10 @@ object SentryPush { val title = when (data["kind"]) { "alarm" -> "Sentry alarm"; "test" -> "Galaxy test alert"; else -> "Sentry: motion detected" } val builder = Notification.Builder(ctx, Notifications.SENTRY_CHANNEL).setSmallIcon(android.R.drawable.ic_dialog_alert) .setContentTitle(title).setContentText(data["label"].orEmpty().take(64)).setAutoCancel(true).setContentIntent(open) + .setOnlyAlertOnce(true) if (data["kind"] == "alarm") builder.setCategory(Notification.CATEGORY_ALARM) - manager.notify(notificationId, builder.build()) - if (!demo) { + if (demo) manager.notify(notificationId, builder.build()) + else { p.edit().putString("sentrySeenIds", JSONArray(dedupe.ids()).toString()) .putLong("sentryLastSeenNs", maxOf(p.getLong("sentryLastSeenNs", 0), (data["wallTimeNs"]?.toLongOrNull() ?: 0).coerceIn(0, System.currentTimeMillis() * 1000000))) .apply { if (fromPush) putLong("sentryLastPushAt", System.currentTimeMillis()) }.commit() @@ -147,13 +148,15 @@ object SentryPush { wide to cabin } Thread(images).apply { isDaemon = true; start() } - try { - val (wide, cabin) = images.get(if (test) 35L else 8L, java.util.concurrent.TimeUnit.SECONDS) ?: return@Thread - if (suspended || (wide == null && cabin == null)) return@Thread - cabin?.let { builder.setLargeIcon(it) } - wide?.let { builder.setStyle(Notification.BigPictureStyle().bigPicture(it)) } - manager.notify(notificationId, builder.build()) - } catch (_: Exception) {} finally { images.cancel(true) } + fun post(result: Pair?) { + result?.first?.let { builder.setStyle(Notification.BigPictureStyle().bigPicture(it)) } + result?.second?.let { builder.setLargeIcon(it) } + if (!suspended) manager.notify(notificationId, builder.build()) + } + val unit = java.util.concurrent.TimeUnit.SECONDS + try { post(images.get(if (test) 12L else 5L, unit)); return@Thread } catch (_: Exception) { post(null) } + try { images.get(if (test) 23L else 3L, unit)?.takeIf { it.first != null || it.second != null }?.let(::post) } + catch (_: Exception) {} finally { images.cancel(true) } }.start() } return true From 5ebf999116d483b95b994401c763c6a946ddef76 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 09:22:41 -0800 Subject: [PATCH 8/9] Sentry: Wait for the cabin picture on test alerts --- app/src/main/java/com/starpilot/galaxy/SentryPush.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt index 29612fd..71a219d 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt @@ -154,8 +154,8 @@ object SentryPush { if (!suspended) manager.notify(notificationId, builder.build()) } val unit = java.util.concurrent.TimeUnit.SECONDS - try { post(images.get(if (test) 12L else 5L, unit)); return@Thread } catch (_: Exception) { post(null) } - try { images.get(if (test) 23L else 3L, unit)?.takeIf { it.first != null || it.second != null }?.let(::post) } + try { post(images.get(if (test) 20L else 5L, unit)); return@Thread } catch (_: Exception) { post(null) } + try { images.get(if (test) 15L else 3L, unit)?.takeIf { it.first != null || it.second != null }?.let(::post) } catch (_: Exception) {} finally { images.cancel(true) } }.start() } From 81c972e2e66ed8a967a9da6300d52ffc740d99ab Mon Sep 17 00:00:00 2001 From: Danny Date: Sun, 8 Nov 2026 11:22:02 -0800 Subject: [PATCH 9/9] Sentry: Low battery alerts --- .../java/com/starpilot/galaxy/SentryPoller.kt | 7 ++++++- .../java/com/starpilot/galaxy/SentryPush.kt | 7 ++++--- push-relay/relay.js | 12 +++++++----- push-relay/relay.test.js | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPoller.kt b/app/src/main/java/com/starpilot/galaxy/SentryPoller.kt index 8680506..aea0796 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPoller.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPoller.kt @@ -15,9 +15,14 @@ object SentryPoller { (0 until events.length()).map { events.getJSONObject(it) }.sortedBy { it.optLong("systemTimeMs") }.forEach { event -> val ns = event.optLong("wallTimeNs", event.optLong("systemTimeMs") * 1000000) if (ns > since) SentryPush.receive(ctx, mapOf("type" to "sentry", "eventId" to event.optString("eventId"), - "kind" to event.optString("kind"), "wallTimeNs" to ns.toString(), "label" to "Galaxy"), fromPush = false) + "kind" to event.optString("kind"), "wallTimeNs" to ns.toString(), "label" to "Galaxy") + + batteryDetail(event.optInt("voltage"), event.optInt("cutoff")), fromPush = false) } p.edit().putLong("sentryLastPollAt", System.currentTimeMillis()).apply() } finally { busy.set(false) } } + fun batteryDetail(voltage: Int, cutoff: Int): Map = + if (voltage in 1..999 && cutoff in 1..999) + mapOf("detail" to "Car battery is low (%.1f V). The device and Sentry turn off at %.1f V.".format(java.util.Locale.US, voltage / 10.0, cutoff / 10.0)) + else emptyMap() } diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt index 71a219d..2e4b43d 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt @@ -113,7 +113,7 @@ object SentryPush { @Synchronized fun receive(ctx: Context, data: Map, fromPush: Boolean = true, demo: Boolean = false): Boolean { val p = prefs(ctx) if (!demo && suspended) return false - if (!demo && !p.getBoolean("sentryPushEnabled", false) || data["type"] != "sentry" || data["kind"] !in setOf("warning", "alarm", "test") || !permitted(ctx)) return false + if (!demo && !p.getBoolean("sentryPushEnabled", false) || data["type"] != "sentry" || data["kind"] !in setOf("warning", "alarm", "battery", "test") || !permitted(ctx)) return false val id = data["eventId"]?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return false val seen = runCatching { JSONArray(p.getString("sentrySeenIds", "[]")) }.getOrDefault(JSONArray()) val dedupe = SentryDedupe((0 until seen.length()).map { seen.getString(it) }) @@ -123,9 +123,9 @@ object SentryPush { val notificationId = Notifications.SENTRY_BASE + (id.hashCode() and 0x3FF) val intent = Intent(ctx, MainActivity::class.java).putExtra("openRoute", "/cameras/events").putExtra("demo", demo) val open = PendingIntent.getActivity(ctx, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val title = when (data["kind"]) { "alarm" -> "Sentry alarm"; "test" -> "Galaxy test alert"; else -> "Sentry: motion detected" } + val title = when (data["kind"]) { "alarm" -> "Sentry alarm"; "battery" -> "Sentry: battery low"; "test" -> "Galaxy test alert"; else -> "Sentry: motion detected" } val builder = Notification.Builder(ctx, Notifications.SENTRY_CHANNEL).setSmallIcon(android.R.drawable.ic_dialog_alert) - .setContentTitle(title).setContentText(data["label"].orEmpty().take(64)).setAutoCancel(true).setContentIntent(open) + .setContentTitle(title).setContentText(data["detail"]?.take(160) ?: data["label"].orEmpty().take(64)).setAutoCancel(true).setContentIntent(open) .setOnlyAlertOnce(true) if (data["kind"] == "alarm") builder.setCategory(Notification.CATEGORY_ALARM) if (demo) manager.notify(notificationId, builder.build()) @@ -136,6 +136,7 @@ object SentryPush { val test = data["kind"] == "test" Thread { val images = java.util.concurrent.FutureTask { + if (data["kind"] == "battery") return@FutureTask null val current = api ?: SentryApi.best(ctx) ?: return@FutureTask null val wide = if (test) null else runCatching { decode(current.bytes("/api/sentry/image/$id/wide"), 1) }.getOrNull() val cabin = runCatching { diff --git a/push-relay/relay.js b/push-relay/relay.js index 37601ad..2ad83ba 100644 --- a/push-relay/relay.js +++ b/push-relay/relay.js @@ -1,7 +1,7 @@ const androidApps = new Set(['com.starpilot.galaxy']) const defaultIosApps = 'link.galaxy.bluetooth.natehuby76' export const iosApps = env => new Set(String(env?.IOS_APP_IDS ?? defaultIosApps).split(',').map(id => id.trim()).filter(Boolean)) -const kinds = new Set(['warning', 'alarm', 'test']) +const kinds = new Set(['warning', 'alarm', 'battery', 'test']) const encode = value => new TextEncoder().encode(value) const b64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') const json = (status, value) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } }) @@ -26,7 +26,8 @@ async function importApns(body, token, fetcher) { export function valid(body, ios = new Set()) { return body !== null && typeof body === 'object' && !Array.isArray(body) && - Object.keys(body).sort().join(',') === 'appId,eventId,kind,label,token,wallTimeNs' && + Object.keys(body).sort().join(',') === (body.kind === 'battery' ? 'appId,detail,eventId,kind,label,token,wallTimeNs' : 'appId,eventId,kind,label,token,wallTimeNs') && + (body.kind !== 'battery' || typeof body.detail === 'string' && body.detail.length > 0 && body.detail.length <= 160 && !/[\x00-\x1f\x7f]/.test(body.detail)) && typeof body.token === 'string' && /^[^\s\x00-\x1f\x7f]{1,4096}$/.test(body.token) && (androidApps.has(body.appId) || ios.has(body.appId)) && kinds.has(body.kind) && typeof body.eventId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.eventId) && typeof body.wallTimeNs === 'number' && Number.isInteger(body.wallTimeNs) && body.wallTimeNs > 0 && body.wallTimeNs < 2 ** 63 && @@ -52,13 +53,14 @@ async function accessToken(env, fetcher) { return oauth.token } -const titles = { alarm: 'Sentry alarm', warning: 'Sentry motion', test: 'Galaxy test alert' } +const titles = { alarm: 'Sentry alarm', warning: 'Sentry motion', battery: 'Sentry: battery low', test: 'Galaxy test alert' } export function fcmMessage(body, ios = false) { - const data = { type: 'sentry', kind: body.kind, eventId: body.eventId, wallTimeNs: String(body.wallTimeNs), label: body.label } + const data = { type: 'sentry', kind: body.kind, eventId: body.eventId, wallTimeNs: String(body.wallTimeNs), label: body.label, + ...(body.detail ? { detail: body.detail } : {}) } if (!ios) return { message: { token: body.token, android: { priority: 'HIGH', ttl: '600s', restricted_package_name: body.appId }, data } } return { message: { token: body.token, data, - notification: { title: titles[body.kind], body: body.label || 'Parked motion detected' }, + notification: { title: titles[body.kind], body: body.detail || body.label || 'Parked motion detected' }, apns: { headers: { 'apns-priority': '10', 'apns-push-type': 'alert', 'apns-expiration': String(Math.floor(Date.now() / 1000) + 600), 'apns-collapse-id': body.eventId }, payload: { aps: { sound: 'default', 'thread-id': 'sentry', 'mutable-content': 1 } } } } } } diff --git a/push-relay/relay.test.js b/push-relay/relay.test.js index 81c3739..77cb5f8 100644 --- a/push-relay/relay.test.js +++ b/push-relay/relay.test.js @@ -69,3 +69,22 @@ test('Apple tokens are converted through Firebase and sent as visible alerts', a assert.equal((await handle(request({ ...phone, token: `apns.production.${'ef'.repeat(32)}` }), env, failed, async () => 'oauth')).status, 502) assert.equal((await handle(request({ ...phone, token: 'apns.production.xyz' }), env, () => assert.fail('must not send'), async () => 'oauth')).status, 400) }) +test('battery alerts carry their voltage text to Android data and the iPhone body', () => { + const battery = { ...body, kind: 'battery', detail: 'Car battery is low (12.0 V). The device and Sentry turn off at 11.8 V.' } + assert.ok(valid(battery)) + for (const value of [{ ...body, kind: 'battery' }, { ...body, detail: battery.detail }, { ...battery, detail: '' }, { ...battery, detail: 'a\nb' }, { ...battery, detail: 'a'.repeat(161) }]) + assert.equal(valid(value), false) + assert.equal(fcmMessage(battery).message.data.detail, battery.detail) + const phone = fcmMessage({ ...battery, appId: 'link.galaxy.bluetooth.natehuby76' }, true).message + assert.equal(phone.notification.title, 'Sentry: battery low') + assert.equal(phone.notification.body, battery.detail) + assert.equal(fcmMessage(body).message.data.detail, undefined) +}) +test('battery detail reaches the outgoing FCM request through the handler', async () => { + const battery = { ...body, kind: 'battery', detail: 'Car battery is low (12.0 V). The device and Sentry turn off at 11.8 V.' } + const sent = [] + const response = await handle(request(battery), environment(), async (url, init) => { sent.push(JSON.parse(init.body)); return Response.json({}) }, async () => 'oauth') + assert.equal(response.status, 200) + assert.equal(sent[0].message.data.kind, 'battery') + assert.equal(sent[0].message.data.detail, battery.detail) +})