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/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/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/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 9dcf0a0..2e4b43d 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 @@ -22,13 +23,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,21 +51,69 @@ 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" + } + } + 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 - 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) }) @@ -72,25 +123,41 @@ 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) - 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() + 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 { + 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 { + 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(images).apply { isDaemon = true; start() } + 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()) } - Thread(image).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 unit = java.util.concurrent.TimeUnit.SECONDS + 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() } 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])) + } +} 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/.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 new file mode 100644 index 0000000..1a10841 --- /dev/null +++ b/push-relay/HOSTING.md @@ -0,0 +1,57 @@ +# Running the Sentry push relay + +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) + +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. +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 + openssl rand -hex 32 # copy the output, then paste it at the next prompt + npx wrangler@4 secret put ENROLLMENT_ADMIN_SECRET + ``` + + 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 + node operator.mjs check + ``` + + 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**. + +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:** `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 + +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, including resets and re-enrollments | +| `DAILY_SEND_LIMIT` | 20000 | Alerts per day across all users | +| Per comma | 60/hour | Built in | +| `INGRESS` rate limit | 300/minute | Requests per network address | + +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 c5958f6..c2bc2eb 100644 --- a/push-relay/README.md +++ b/push-relay/README.md @@ -1,11 +1,10 @@ # Galaxy sentry relay -See [SETUP.md](SETUP.md) for setup and operation. +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. -`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. +- **Running it:** [HOSTING.md](HOSTING.md), step by step. +- **Firebase, Apple, release and testing:** [SETUP.md](SETUP.md). -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. +`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. -`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 2f2af86..6a194f2 100644 --- a/push-relay/SETUP.md +++ b/push-relay/SETUP.md @@ -2,63 +2,53 @@ 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 +## Status -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. -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. +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. -## iOS push (optional) +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). -Firebase delivers to iPhones through Apple's push service, using the same relay. +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. -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. +## How it fits together -Test on a physical iPhone; the simulator cannot receive remote pushes. +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. -## Relay on a VPS +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. -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. +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. -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. +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. -Run it by hand to try it: `FCM_PROJECT_ID=... FCM_SERVICE_ACCOUNT_FILE=key.json node server.js`. +## Legacy private pilot -## Test +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. -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. +## Relay on a VPS -From a terminal, read the token without putting it in shell history, then send a test: +Any Linux server with Node 20+ and HTTPS works. The relay holds no data and has no dependencies. -```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 -``` +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, 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`. -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. +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. -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. +## Test -## 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 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. -- 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/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..548cd81 --- /dev/null +++ b/push-relay/operator.mjs @@ -0,0 +1,54 @@ +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', '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 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 { + 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/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..2ad83ba 100644 --- a/push-relay/relay.js +++ b/push-relay/relay.js @@ -1,15 +1,33 @@ -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']) +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' } }) 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) && - 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 && @@ -35,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 } } } } } } @@ -73,15 +92,21 @@ export async function handle(request, env, fetcher = fetch, authorize = accessTo if (count >= 30) return json(429, { error: 'Rate limited' }) await env.RATE.put(key, String(count + 1), { expirationTtl: 7200 }) 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: 'error', + 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 dd23280..77cb5f8 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 }] } } @@ -46,3 +47,44 @@ 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) +}) +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) +}) 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..dbfd444 --- /dev/null +++ b/push-relay/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "$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": "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"] }], + "ratelimits": [{ "name": "INGRESS", "namespace_id": "1001", "simple": { "limit": 300, "period": 60 } }] +}