diff --git a/OTA.md b/OTA.md index 240b6c7..cb2de3e 100644 --- a/OTA.md +++ b/OTA.md @@ -1,164 +1,93 @@ -# Publishing an OTA release +# Updating the car app over the air -How to ship an update the car will install by itself. Written for whoever does this -next — probably me, having forgotten the details. +How a new car build reaches the head unit without the Bluetooth-settings detour, and what to +check when it does not. -## First upgrade from 2.2.3 or older +``` +build-car ─▶ publish-car ─▶ forge pre-release (private repo, git.stinnesbeck.com/nils/mg4) + │ read with FORGE_TOKEN (read-only, server Secret) + ▼ + car server /v1/app/releases, /v1/app/assets/… + │ the car's own paired token + ▼ + app: refresh button → check → SHA-256 → install +``` -Install the platform-signed 2.2.7 APK manually once if the car still runs 2.2.3 or older. -Those builds use the old `pm install` path; publishing a newer APK does not repair the -installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions. -Keep the unstable package ID, system shared UID and signing certificate when upgrading. +The repository stays private and the forge token stays on the server: the car asks its own +server, with its own token, and never holds a forge credential. The server only relays +bytes. Every check that decides whether code gets installed is in the app. -The 2.2.5 build removes window automation. It also distinguishes failed release checks -from a successful check with no newer release. On 2026-09-29, GitHub still published only -2.2.3 at the last live check. The local 2.2.7 build includes these fixes and the redesigned -UI; it becomes available to OTA only after publication as a pre-release asset named -`FatihsMG4-unstable-2.2.7.apk`, together with -`FatihsMG4-unstable-2.2.7.apk.sha256`. +## Publishing a release -The vehicle artifact uses `com.stinnesbeck.nilsmg4.unstable` (upstream: `com.evsuite.abrp.unstable`), -`android.uid.system`, and the -DriveHub_Kamera platform key. Its certificate SHA-256 must be -`c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8`. -The stable build used for a Samsung UI preview is a separate package without OTA or the -vehicle's shared UID; do not use that preview APK for the MG4 upgrade. +1. **Raise the version** in `app/build.gradle` (`versionCode` must increase; `versionName` + is three-part, e.g. `2.16.0`). The car build reports it as `2.16.0-unstable`. +2. **Build:** `nix develop`, then `build-car`. It signs with the AOSP platform key the head + unit trusts, checks the signer, and writes `build/car/NilsMG4-unstable-.apk` and + its `.sha256`. +3. **Publish:** `publish-car`. It creates the pre-release `car-` on the current + commit and attaches the APK and the `.sha256`. It needs a forge token that may write the + repository's releases, in `~/.config/nilsmg4/forge-token` (or `$FORGE_TOKEN`); the token is + only sent in a header, never printed. It refuses a version that is already published. -## What the updater actually looks for +By hand, the same: in the forge's web UI create a release with tag `car-`, tick +**pre-release**, and attach both files with exactly their names. -> **Switched off in Nils' MG4.** `RELEASES_API` points at `updates.invalid`, so every check -> fails. To re-enable it, point it at this repository's releases and add the host to -> `ALLOWED_HOSTS` (see `SPECS.md`). The steps below are upstream's GitHub process. +## What the updater accepts -`OtaUpdater.check()` reads the `RELEASES_API` URL (upstream: -`https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases`) and walks **every** entry, keeping the highest version rather than the first match. A -release is a candidate only if all of these hold: +The app asks the car server for the releases (`GET /v1/app/releases`) and walks every one, +keeping the highest version: -| Requirement | Where it is enforced | What happens otherwise | +| Requirement | Where it is enforced | Otherwise | |---|---|---| -| Marked **pre-release** | `release.optBoolean("prerelease")` | Skipped entirely | -| Asset name contains `unstable` | `name.contains("unstable")` | Asset ignored | -| Asset name ends `-.apk` | `ASSET_VERSION` regex | No version, asset ignored | -| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Up to date" | -| Download host on the allowlist | `isAllowedUrl()` | Refused and logged | -| APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform | -| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/refused | +| Marked **pre-release**, not a draft | `selectUpdate` | Skipped | +| Asset name contains `unstable` and ends `-.apk` | `ASSET_VERSION` regex | Ignored | +| Version strictly higher than installed | `isNewer()` | "Up to date" | +| Every URL https and **exactly** the configured server's host | `isAllowedUrl()` | Refused | +| A matching `.sha256` (or `SHA256SUMS`) asset, and the APK's bytes match it | `fetchExpectedSha256()`, `download()` | Refused, file deleted | +| The archive is this app's package | `archiveIsThisPackage()` | Refused | +| Signed with the **same certificate** as the installed app | Android's installer | Not installed | -The last one is the one that bites. The car runs a platform-signed build; an APK signed -with a debug key — or anyone else's key — is downloaded, rejected and deleted. Sign every -release with the same platform key or the update silently never lands. +The version comes from the **asset file name**, not the tag. -The version comes from the **asset filename**, not the git tag. The tag can stay -`unstable` forever and be moved; the filename is what identifies a build. +## Installing -## Release checklist +Press the refresh button in the app's top bar. The app checks, downloads the APK into +**Downloads** (with progress), verifies its SHA-256, and hands it to a `PackageInstaller` +session. As the system user it may install without asking; if the platform wants a +confirmation, the confirmation screen opens. When a session cannot start at all, the file +manager opens in Downloads (or, where the head unit has none, open the APK there with the +Files app). The verified APK stays in Downloads either way. -1. **Raise the version.** `app/build.gradle`, `defaultConfig`: - - ```gradle - versionCode 24 // must increase — Android orders updates by this, not by name - versionName "2.2.7" - ``` - - The unstable flavour appends its own suffix, so `2.2.7` becomes `2.2.7-unstable`. - Check what the installed build reports before picking a number: the app's About - dialog shows it, and `isNewer` compares numerically segment by segment. - -2. **Build.** - - ```bash - ./gradlew :app:assembleUnstableDebug - ``` - -3. **Sign with the platform key.** Options come *before* the input APK — apksigner - rejects the other order. - - ```bash - BT=~/Android/Sdk/build-tools/35.0.0 - # Local copies from DriveHub_Kamera/tools; private and gitignored. - KEYS="$PWD/tools" - - "$BT/zipalign" -f 4 \ - app/build/outputs/apk/unstable/debug/app-unstable-debug.apk aligned.apk - - "$BT/apksigner" sign \ - --key "$KEYS/platform.pk8" \ - --cert "$KEYS/platform.x509.pem" \ - --out NilsMG4-unstable-2.2.7.apk \ - aligned.apk - - rm aligned.apk - ``` - - The output filename **is** the version the updater reads. Name it - `NilsMG4-unstable-.apk` (`build-car` does steps 2 to 4 and names it so), then create its required integrity sidecar: - - ```bash - shasum -a 256 NilsMG4-unstable-2.2.7.apk \ - > NilsMG4-unstable-2.2.7.apk.sha256 - ``` - -4. **Verify the signer** before uploading. It must say `CN=Android`: - - ```bash - "$BT/apksigner" verify --print-certs NilsMG4-unstable-2.2.7.apk - ``` - -5. **Publish.** On github.com/fatihdonmezdev/FatihsMG4 → Releases → Draft a new release: - - tag: `unstable` (reuse it; the rolling tag is the design) - - **tick "Set as a pre-release"** ← the whole thing is inert without this - - attach the signed APK and its `.sha256` sidecar - -6. **Install it.** Open the app on the head unit and press the refresh button in the top - bar. The dialog shows download progress, verifies SHA-256, then reports that the - PackageInstaller session has started. +At start the app only checks, and shows "Update available" when there is one. It never +downloads or installs on its own, and the upload service never checks: installing restarts +the app, which must not happen mid-drive on its own. ## When it says something else | Message | Meaning | |---|---| -| `Up to date (2.14.2-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. | -| `Update refused: no SHA-256 verification` | The APK has no matching sidecar, or the sidecar is invalid or unreachable. | -| `Download or SHA-256 verification failed (…)` | The download failed, or its bytes do not match the published hash. | -| `Update check failed` | The check threw. `adb logcat -s UpdateHook OtaUpdater` if a cable is available; otherwise the in-app Logs tab. | -| `Update check already in progress` | A check is still running; wait for its result. | +| `Up to date (2.16.0-unstable)` | No published pre-release is newer. Check the pre-release tick and the asset name. | +| `Set up the server first (Server tab): updates come through it` | The app has no server URL and token. | +| `Update check failed` | The server could not be reached, or answered with an error: 404 when updates are off on the server (no `FORGE_TOKEN`), 502 when the forge refused the server's token. | +| `Update refused: no SHA-256 verification` | The release has no matching `.sha256`, or it is unreadable. | +| `Download or SHA-256 verification failed (…)` | The download broke off, or the bytes do not match the published hash. | +| `Update refused: the downloaded file is not this app` | The archive does not parse, or it is another package. | +| `Installing 2.16.0… the app restarts when it is done` | The session was committed; the result follows as its own toast. | +| `Installation failed: INSTALL_FAILED_… The APK is in Downloads.` | The package manager's own words. A signature error means the APK was not signed by `build-car`. | +| `… is in Downloads — tap the file to install` | A session could not start; install from Downloads by hand. | -## How the install works, and why it is not `pm install` +On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`. -`OtaUpdater.install()` used to shell out to `/system/bin/pm install -r`. On this head unit -that fails every time, and the reason is worth writing down because the symptom pointed -somewhere else entirely. +## Why a PackageInstaller session, not `pm install` -The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself -— it passes the path to the package manager service, which opens it from *its* process and -SELinux context, where an app's private cache is not readable. The install failed, and -because `install()` returned a bare boolean, the caller reported the only failure it knew -how to name: `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)"). A correctly signed build, refused with -a signature error it had not earned. The `pm` output that would have said so was captured -and thrown away. - -It now uses the platform `PackageInstaller`: we open the archive ourselves and stream it -into a session, so no second process ever has to read our cache. The approach is taken from +Upstream first shelled out to `/system/bin/pm install -r` with the APK in the app's private +cache. That fails on this head unit: `pm` hands the path to the package manager service, +which opens it from its own process and SELinux context, where an app's private cache is not +readable — and the failure surfaced as a signature error it never was. A session takes no +path: the app opens the archive itself and streams it in. The approach comes from [merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit -the same wall on the same hardware. +the same wall on the same hardware. Upstream later fell back to "download to Downloads and +tap it"; this fork tries the session first and keeps that as the fallback. -Two consequences for anyone reading a failure: - -- **Committing a session is not installing.** `commit()` is asynchronous. The refresh button - can only report that an install *started*; the verdict arrives at - `OtaInstallResultReceiver` and appears as its own toast. -- **`STATUS_PENDING_USER_ACTION` is not an error.** The platform is asking the driver to - confirm. The receiver launches the confirmation activity the platform hands it. - -Still unverified on the car: whether this build installs silently or raises that -confirmation. Either way it installs, which the old path did not. - -## Why installation is manual - -`UpdateHook.checkInBackground(this)` performs an availability-only check at app start and -shows a notification toast when a complete APK + hash pair exists. It never downloads or -installs. The refresh button is the only trigger for download, verification and installation. - -The upload service does **not** check for updates on its tick. Installing a new APK -restarts the process, and doing that mid-drive would interrupt the telemetry the service -exists to send. +**Not yet tried on this car:** whether the session installs silently as the system user, or +asks for a confirmation. diff --git a/README.md b/README.md index fdf9000..7e45237 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,9 @@ adb shell "pm install -r -f -d -t /data/local/tmp/update.apk" > ⚠️ Do this **parked**, and only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md). -Self-updating (OTA) is switched off: the updater points at an unresolvable host until it is -pointed at this repository's releases (see `SPECS.md`). +After that first install, the app updates itself: publish a build with `publish-car`, then +press the refresh button in the app's top bar (`OTA.md`). The car fetches it through your car +server, so the repository stays private. ## Setup diff --git a/SPECS.md b/SPECS.md index 80820fe..c03218d 100644 --- a/SPECS.md +++ b/SPECS.md @@ -70,12 +70,6 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * ## To do -- [ ] **Re-enable OTA updates against our own releases.** `RELEASES_API` in - `app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java` points at `updates.invalid` on purpose, - so checks fail. The updater speaks the GitHub releases API and only allows GitHub hosts - (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the - response parsing checked against that server's releases API. Release assets must be named - `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). - [ ] **Grafana high availability needs another database.** Grafana keeps its own state (users, sessions, preferences, provisioned-dashboard records, alerting state) in SQLite at `/var/lib/grafana/grafana.db` on the `grafana-data` PVC. That is fine for one replica, and is @@ -115,6 +109,13 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.16.0, server 2.6.0: over-the-air updates from this repository. `publish-car` + creates the pre-release `car-` on the private forge with the APK and its `.sha256`; + the server relays releases and attachments to the car (`/v1/app/…`, read with a read-only + `FORGE_TOKEN`); the app checks them through its server with its own token, allows exactly the + server's host, downloads itself (no DownloadManager, so the token stays in the app), verifies + SHA-256 and package, and tries a PackageInstaller session before falling back to Downloads. + `OTA.md` rewritten for this flow. Not yet tried on the car. - 2026-10-18 — App 2.15.0: unsent live samples survive a restart. The queue is saved to `files/telemetry_queue.json` (AtomicFile) at most every 5 minutes and only after a change, and at once when the service stops; it is restored, in order and within its 2000-sample cap, when diff --git a/app/build.gradle b/app/build.gradle index c85119b..97cbc52 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 83 - versionName "2.15.0" + versionCode 84 + versionName "2.16.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 288bf29..d17bf14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,7 +198,7 @@ ABRP Uploader Stop Update installed — restart the app - Installation failed: %1$s + Installation failed: %1$s. The APK is in Downloads. Check for updates Checking for a new OTA version… Software update diff --git a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java index e318cfd..89839e4 100644 --- a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java +++ b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java @@ -24,46 +24,55 @@ import java.nio.charset.StandardCharsets; */ public class OtaUpdaterTest { - // ---- URL allowlist ---- + // ---- URL allowlist: exactly the configured car server ---- + + private static final String HOST = "car.example.com"; @Test - public void httpsFromAnAllowedHostIsAccepted() { - assertTrue(OtaUpdater.isAllowedUrl( - "https://github.com/malys/EVABRPUploader/releases/download/v1.2.0/app.apk")); - assertTrue(OtaUpdater.isAllowedUrl( - "https://objects.githubusercontent.com/github-production-release-asset/x.apk")); + public void httpsFromTheServerIsAccepted() { + assertTrue(OtaUpdater.isAllowedUrl("https://car.example.com/v1/app/assets/7/11/app.apk", HOST)); } @Test - public void httpIsRejectedEvenOnAnAllowedHost() { - assertFalse(OtaUpdater.isAllowedUrl("http://github.com/malys/app.apk")); + public void httpIsRejectedEvenFromTheServer() { + assertFalse(OtaUpdater.isAllowedUrl("http://car.example.com/v1/app/assets/7/11/app.apk", HOST)); } @Test public void foreignHostsAreRejected() { - assertFalse(OtaUpdater.isAllowedUrl("https://evil.example.com/app.apk")); + assertFalse(OtaUpdater.isAllowedUrl("https://evil.example.com/app.apk", HOST)); + assertFalse(OtaUpdater.isAllowedUrl("https://github.com/fatihdonmezdev/FatihsMG4/app.apk", HOST)); } @Test public void lookalikeHostsAreRejected() { // Exact match, never a suffix test. - assertFalse(OtaUpdater.isAllowedUrl("https://github.com.attacker.net/app.apk")); - assertFalse(OtaUpdater.isAllowedUrl("https://evil-github.com/app.apk")); - assertFalse(OtaUpdater.isAllowedUrl("https://notgithub.com/app.apk")); + assertFalse(OtaUpdater.isAllowedUrl("https://car.example.com.attacker.net/app.apk", HOST)); + assertFalse(OtaUpdater.isAllowedUrl("https://evil-car.example.com/app.apk", HOST)); + assertFalse(OtaUpdater.isAllowedUrl("https://example.com/app.apk", HOST)); } @Test public void unparsableOrNonHttpUrlsAreRejected() { - assertFalse(OtaUpdater.isAllowedUrl("")); - assertFalse(OtaUpdater.isAllowedUrl("not a url")); - assertFalse(OtaUpdater.isAllowedUrl("ftp://github.com/app.apk")); - assertFalse(OtaUpdater.isAllowedUrl("file:///sdcard/Download/app.apk")); + assertFalse(OtaUpdater.isAllowedUrl("", HOST)); + assertFalse(OtaUpdater.isAllowedUrl(null, HOST)); + assertFalse(OtaUpdater.isAllowedUrl("not a url", HOST)); + assertFalse(OtaUpdater.isAllowedUrl("ftp://car.example.com/app.apk", HOST)); + assertFalse(OtaUpdater.isAllowedUrl("file:///sdcard/Download/app.apk", HOST)); + assertFalse(OtaUpdater.isAllowedUrl("https://car.example.com/app.apk", null)); // no server set up } @Test public void hostMatchingIgnoresCase() { - assertTrue(OtaUpdater.isAllowedUrl("https://GitHub.com/malys/app.apk")); - assertTrue(OtaUpdater.isAllowedUrl("HTTPS://github.com/malys/app.apk")); + assertTrue(OtaUpdater.isAllowedUrl("https://Car.Example.com/app.apk", HOST)); + assertTrue(OtaUpdater.isAllowedUrl("HTTPS://car.example.com/app.apk", HOST)); + } + + @Test + public void theAllowedHostIsTheConfiguredServers() { + assertEquals("car.example.com", OtaUpdater.allowedHost(new ServerConfig("https://Car.Example.com", "t", "i"))); + assertEquals("https://car.example.com/v1/app/releases", + OtaUpdater.releasesUrl(new ServerConfig("https://car.example.com", "t", "i"))); } // ---- Version comparison ---- @@ -138,7 +147,7 @@ public class OtaUpdaterTest { @Test public void updateWithoutHashSidecarIsReportedButCannotBeVerified() throws Exception { JSONArray releases = new JSONArray().put(releaseWithoutHashes(true, asset("2.2.7"))); - OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.6.0-unstable"); + OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.6.0-unstable", HOST); assertEquals("2.2.7", update.versionName); assertNull(update.hashUrl); } @@ -148,7 +157,7 @@ public class OtaUpdaterTest { JSONArray releases = new JSONArray().put(release(true, asset("2.2.3"), asset("2.2.6"), asset("2.2.4"))) .put(release(true, asset("2.2.5"))); - OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.2.0-unstable"); + OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.2.0-unstable", HOST); assertEquals("2.2.6", update.versionName); assertTrue(update.apkUrl.endsWith("NilsMG4-unstable-2.2.6.apk")); } @@ -162,14 +171,14 @@ public class OtaUpdaterTest { .put(release(false, asset("9.0.0"))) .put(release(true, asset("9.0.0")).put("draft", true)) .put(release(true, foreign, unversioned, stableName, asset("2.2.5"))); - assertEquals("2.2.5", OtaUpdater.selectUpdate(releases, "2.2.4.0-unstable").versionName); + assertEquals("2.2.5", OtaUpdater.selectUpdate(releases, "2.2.4.0-unstable", HOST).versionName); } @Test public void successfulCheckFindsUpdateAndDisconnects() throws Exception { Response response = new Response(200, new JSONArray().put(release(true, asset("2.2.5"))).toString()); - assertEquals("2.2.5", OtaUpdater.check("2.2.4.0-unstable", response).versionName); + assertEquals("2.2.5", OtaUpdater.check("2.2.4.0-unstable", response, HOST).versionName); assertTrue(response.disconnected); } @@ -177,7 +186,7 @@ public class OtaUpdaterTest { public void successfulCheckWithOnlyOlderOrEqualVersionsIsCurrent() throws Exception { Response response = new Response(200, new JSONArray().put(release(true, asset("2.2.3"), asset("2.2.5"))).toString()); - assertNull(OtaUpdater.check("2.2.5.0-unstable", response)); + assertNull(OtaUpdater.check("2.2.5.0-unstable", response, HOST)); assertTrue(response.disconnected); } @@ -185,7 +194,7 @@ public class OtaUpdaterTest { public void rateLimitMustNotBeReportedAsCurrent() throws Exception { Response response = new Response(403, "{}"); try { - OtaUpdater.check("2.2.4.0-unstable", response); + OtaUpdater.check("2.2.4.0-unstable", response, HOST); } finally { assertTrue(response.disconnected); } @@ -195,7 +204,7 @@ public class OtaUpdaterTest { public void malformedResponseMustNotBeReportedAsCurrent() throws Exception { Response response = new Response(200, "unavailable"); try { - OtaUpdater.check("2.2.4.0-unstable", response); + OtaUpdater.check("2.2.4.0-unstable", response, HOST); } finally { assertTrue(response.disconnected); } @@ -209,7 +218,7 @@ public class OtaUpdaterTest { } }; try { - OtaUpdater.check("2.2.4.0-unstable", response); + OtaUpdater.check("2.2.4.0-unstable", response, HOST); } finally { assertTrue(response.disconnected); } @@ -218,7 +227,7 @@ public class OtaUpdaterTest { private static JSONObject asset(String version) throws Exception { String name = "NilsMG4-unstable-" + version + ".apk"; return new JSONObject().put("name", name).put("browser_download_url", - "https://github.com/fatihdonmezdev/FatihsMG4/releases/download/unstable/" + name); + "https://car.example.com/v1/app/assets/7/11/" + name); } private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception { @@ -249,7 +258,7 @@ public class OtaUpdaterTest { boolean disconnected; Response(int status, String body) throws Exception { - super(new URL("https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases")); + super(new URL("https://car.example.com/v1/app/releases")); this.status = status; this.body = body; } @@ -262,8 +271,6 @@ public class OtaUpdaterTest { @Override public void connect() { } } - // The `pm install -r` exit-code test that stood here is gone with the shell-out it - // covered. Installation is now a PackageInstaller session, whose every step needs a real - // PackageManager — there is no pure part of it left to exercise on the JVM. What used to - // be asserted here is now reported by OtaInstallResultReceiver, on the car. + // Installation is a PackageInstaller session, whose every step needs a real PackageManager + // — nothing of it can be exercised on the JVM. OtaInstallResultReceiver reports it on the car. } diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index 276f60f..528b61f 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -1,17 +1,15 @@ package com.evsuite.abrp; -import android.app.PendingIntent; import android.app.DownloadManager; +import android.app.PendingIntent; import android.content.ClipData; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInstaller; import android.content.pm.ResolveInfo; -import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.os.Environment; -import android.os.ParcelFileDescriptor; import android.provider.Settings; import android.util.Log; @@ -43,43 +41,34 @@ import java.security.MessageDigest; * stable build. Unstable testers get updates without manual work, and accept that channel's * risk. * - * Security posture is the one EVProfile settled on in its own OTA work: - * - the APK URL comes from a remote JSON document and is never trusted: https only, - * exact-match host allowlist, checked again before it reaches the system downloader; - * - the downloaded APK must be signed by the same certificate as the running app, or it - * is deleted rather than offered for install; - * - both checks fail closed. + * Releases come from the car server (server/releases.go), which relays them from the private + * forge repository: the car asks with its own paired token and never holds a forge + * credential. The server only carries bytes; the checks are all here: + * - every URL in the release list is untrusted: https only, and exactly the configured + * server's host (no suffix match), checked again before anything is fetched; + * - the APK must match the SHA-256 published next to it, or it is deleted; + * - it must be this app's package, and the installer refuses any other signing key; + * - every check fails closed. */ final class OtaUpdater { private static final String TAG = "OtaUpdater"; private static final String CACHE_PREFIX = "NilsMG4-ota-"; - /** - * Pre-releases live here; the unstable channel tracks them. - * - * This fork's own repository, deliberately: an update pulled from upstream would be - * signed with a different key, and Android refuses to install over a platform-signed - * build with anything but the same signature. Pointing here means the check finds - * nothing until this repository publishes a release, which is the intended quiet. - * - * Disabled for now: the .invalid TLD (RFC 2606) never resolves, so every check fails - * with "Update check failed" instead of offering upstream's builds over this one. To - * re-enable, point this at a fork's releases API, e.g. - * "https://api.github.com/repos///releases". - */ - private static final String RELEASES_API = - "https://updates.invalid/repos/fatihdonmezdev/FatihsMG4/releases"; + /** The release list on the car server, for the server configured in the app. */ + static String releasesUrl(ServerConfig config) { + return config.url + "/v1/app/releases"; + } - /** - * Hosts an update may come from. The githubusercontent entries are the CDNs GitHub - * redirects release-asset downloads to; without them the download fails. - */ - private static final List ALLOWED_HOSTS = Arrays.asList( - "api.github.com", - "github.com", - "objects.githubusercontent.com", - "release-assets.githubusercontent.com"); + /** The one host updates may come from: the configured server's. Null if unusable. */ + static String allowedHost(ServerConfig config) { + try { + String host = new URI(config.url).getHost(); + return host == null ? null : host.toLowerCase(Locale.US); + } catch (Exception e) { + return null; + } + } private static final java.util.regex.Pattern ASSET_VERSION = java.util.regex.Pattern.compile("-(\\d[0-9.]*?)\\.apk$", @@ -87,6 +76,7 @@ final class OtaUpdater { private static final int TIMEOUT_MS = 10_000; private static final int MAX_HASH_BYTES = 16 * 1024; + private static final long MAX_APK_BYTES = 64L * 1024 * 1024; private OtaUpdater() { } @@ -119,13 +109,14 @@ final class OtaUpdater { } /** - * True if [url] is https and points at an allowed host. + * True if [url] is https and points at exactly [allowedHost]. * - * Rejects http (including an https -> http downgrade), unknown hosts, unparsable - * URLs, and lookalikes such as "github.com.attacker.net" — the host match is exact, - * never a suffix test. + * Rejects http (including an https -> http downgrade), other hosts, unparsable URLs, and + * lookalikes such as "car.example.com.attacker.net" — the host match is exact, never a + * suffix test. */ - static boolean isAllowedUrl(String url) { + static boolean isAllowedUrl(String url, String allowedHost) { + if (url == null || allowedHost == null) return false; URI uri; try { uri = new URI(url); @@ -134,7 +125,7 @@ final class OtaUpdater { } if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("https")) return false; String host = uri.getHost(); - return host != null && ALLOWED_HOSTS.contains(host.toLowerCase(java.util.Locale.US)); + return host != null && host.toLowerCase(Locale.US).equals(allowedHost.toLowerCase(Locale.US)); } /** @@ -191,18 +182,21 @@ final class OtaUpdater { } /** - * Asks GitHub for the newest pre-release and returns it if it beats [currentVersion]. - * Runs on the caller's thread — never call from the main thread. + * Asks the car server for the newest pre-release and returns it if it beats + * [currentVersion]. Runs on the caller's thread — never call from the main thread. */ - static Update check(String currentVersion) throws IOException { - return check(currentVersion, (HttpURLConnection) new URL(RELEASES_API).openConnection()); + static Update check(ServerConfig config, String currentVersion) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(releasesUrl(config)).openConnection(); + // The car's own token, in a header; never in a URL or a log line. + conn.setRequestProperty("Authorization", "Bearer " + config.token); + return check(currentVersion, conn, allowedHost(config)); } /** Connection seam for exercising real response handling without a device or network. */ - static Update check(String currentVersion, HttpURLConnection conn) throws IOException { + static Update check(String currentVersion, HttpURLConnection conn, String allowedHost) throws IOException { try { conn.setInstanceFollowRedirects(false); - conn.setRequestProperty("Accept", "application/vnd.github.v3+json"); + conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("User-Agent", "NilsMG4-Android"); conn.setConnectTimeout(TIMEOUT_MS); conn.setReadTimeout(TIMEOUT_MS); @@ -217,7 +211,7 @@ final class OtaUpdater { while ((line = br.readLine()) != null) body.append(line); } - return selectUpdate(new JSONArray(body.toString()), currentVersion); + return selectUpdate(new JSONArray(body.toString()), currentVersion, allowedHost); } catch (JSONException e) { throw new IOException("Invalid release response", e); } finally { @@ -226,7 +220,7 @@ final class OtaUpdater { } /** Scan every asset of every pre-release; asset ordering does not imply version order. */ - static Update selectUpdate(JSONArray releases, String currentVersion) throws JSONException { + static Update selectUpdate(JSONArray releases, String currentVersion, String allowedHost) throws JSONException { Update best = null; for (int i = 0; i < releases.length(); i++) { JSONObject release = releases.getJSONObject(i); @@ -241,24 +235,24 @@ final class OtaUpdater { if (version == null || !isNewer(version, currentVersion)) continue; if (best != null && !isNewer(version, best.versionName)) continue; String url = asset.optString("browser_download_url", ""); - String hashUrl = findHashUrl(assets, name); + String hashUrl = findHashUrl(assets, name, allowedHost); // Keep the candidate even when the sidecar is absent so the UI can report // a broken release instead of incorrectly saying the app is current. // fetchExpectedSha256 still fails closed before any APK is downloaded. - if (isAllowedUrl(url)) best = new Update(version, url, name, hashUrl); + if (isAllowedUrl(url, allowedHost)) best = new Update(version, url, name, hashUrl); } } return best; } - private static String findHashUrl(JSONArray assets, String apkName) throws JSONException { + private static String findHashUrl(JSONArray assets, String apkName, String allowedHost) throws JSONException { String exact = apkName + ".sha256"; String sums = null; for (int i = 0; i < assets.length(); i++) { JSONObject asset = assets.getJSONObject(i); String name = asset.optString("name", ""); String url = asset.optString("browser_download_url", ""); - if (!isAllowedUrl(url)) continue; + if (!isAllowedUrl(url, allowedHost)) continue; if (exact.equalsIgnoreCase(name)) return url; if ("SHA256SUMS".equalsIgnoreCase(name)) sums = url; } @@ -285,20 +279,21 @@ final class OtaUpdater { return value != null && value.matches("(?i)[a-f0-9]{64}"); } - static String fetchExpectedSha256(Update update) throws IOException { - if (update == null || !isAllowedUrl(update.hashUrl)) + static String fetchExpectedSha256(Update update, ServerConfig config) throws IOException { + if (update == null || !isAllowedUrl(update.hashUrl, allowedHost(config))) throw new IOException("Missing SHA-256 sidecar"); - String body = readSmallText(update.hashUrl); + String body = readSmallText(update.hashUrl, config); String hash = parseExpectedSha256(body, update.apkName); if (hash == null) throw new IOException("Invalid SHA-256 sidecar"); return hash; } - private static String readSmallText(String initialUrl) throws IOException { + private static String readSmallText(String initialUrl, ServerConfig config) throws IOException { URL current = new URL(initialUrl); for (int redirects = 0; redirects <= 5; redirects++) { - if (!isAllowedUrl(current.toString())) throw new IOException("Hash URL refused"); + if (!isAllowedUrl(current.toString(), allowedHost(config))) throw new IOException("Hash URL refused"); HttpURLConnection connection = (HttpURLConnection) current.openConnection(); + connection.setRequestProperty("Authorization", "Bearer " + config.token); connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS); @@ -346,64 +341,64 @@ final class OtaUpdater { } /** - * Downloads into private cache. Every redirect URL is validated before it is followed. + * Downloads the APK from the car server into the public Downloads folder, hashing it on + * the way. Done here rather than with the system DownloadManager, so the car's token stays + * in this app instead of another app's download database. A wrong hash deletes the file. + * No redirects: the server streams the file itself. */ - static File download(Context context, Update update, String expectedSha256, + static File download(Update update, String expectedSha256, ServerConfig config, ProgressListener listener) { - if (!isAllowedUrl(update.apkUrl)) { - Log.w(TAG, "Refusing to download from " + update.apkUrl); + if (!isAllowedUrl(update.apkUrl, allowedHost(config))) { + Log.w(TAG, "Refusing to download from an unexpected host"); return null; } - DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - if (manager == null) return null; - String publicName = downloadFileName(update.versionName); - File publicFile = new File(Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS), publicName); - if (publicFile.exists() && !publicFile.delete()) return null; - long downloadId = -1L; + File downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (downloads == null || (!downloads.isDirectory() && !downloads.mkdirs())) return null; + File target = new File(downloads, downloadFileName(update.versionName)); + HttpURLConnection connection = null; + boolean ok = false; try { - DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl)) - .setTitle("Nils' MG4 " + update.versionName) - .setDescription("Downloading update") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName) - .setAllowedOverMetered(true) - .setAllowedOverRoaming(true); - downloadId = manager.enqueue(request); - while (true) { - try (Cursor cursor = manager.query(new DownloadManager.Query().setFilterById(downloadId))) { - if (cursor == null || !cursor.moveToFirst()) return null; - int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); - long received = cursor.getLong(cursor.getColumnIndexOrThrow( - DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); - long total = cursor.getLong(cursor.getColumnIndexOrThrow( - DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); - if (listener != null) listener.onProgress(total > 0 - ? (int) Math.min(100, received * 100 / total) : -1); - if (status == DownloadManager.STATUS_SUCCESSFUL) break; - if (status == DownloadManager.STATUS_FAILED) return null; - } - Thread.sleep(250L); - } - - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - try (ParcelFileDescriptor descriptor = manager.openDownloadedFile(downloadId); - java.io.InputStream input = new ParcelFileDescriptor.AutoCloseInputStream(descriptor)) { - byte[] buffer = new byte[64 * 1024]; - int count; - while ((count = input.read(buffer)) != -1) { - digest.update(buffer, 0, count); - } - } - if (!hex(digest.digest()).equalsIgnoreCase(expectedSha256)) { - if (!publicFile.delete()) Log.w(TAG, "Could not remove invalid OTA file"); + connection = (HttpURLConnection) new URL(update.apkUrl).openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(30_000); + connection.setRequestProperty("Authorization", "Bearer " + config.token); + connection.setRequestProperty("User-Agent", "NilsMG4-Android"); + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "Update download returned " + connection.getResponseCode()); return null; } - return publicFile; + long total = connection.getContentLengthLong(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + long received = 0; + int lastPercent = -2; + try (java.io.InputStream in = connection.getInputStream(); + FileOutputStream out = new FileOutputStream(target, false)) { + byte[] buffer = new byte[64 * 1024]; + int count; + while ((count = in.read(buffer)) != -1) { + out.write(buffer, 0, count); + digest.update(buffer, 0, count); + received += count; + if (received > MAX_APK_BYTES) throw new IOException("APK too large"); + int percent = total > 0 ? (int) Math.min(100, received * 100 / total) : -1; + if (listener != null && percent != lastPercent) { + listener.onProgress(percent); + lastPercent = percent; + } + } + out.getFD().sync(); + } + ok = hex(digest.digest()).equalsIgnoreCase(expectedSha256); + if (!ok) Log.w(TAG, "Update APK does not match its SHA-256"); + return ok ? target : null; } catch (Exception e) { - Log.w(TAG, "Update download failed", e); + Log.w(TAG, "Update download failed: " + e.getClass().getSimpleName()); + return null; + } finally { + if (connection != null) connection.disconnect(); + if (!ok && target.exists() && !target.delete()) Log.w(TAG, "Could not remove a bad update file"); } - return null; } private static String hex(byte[] bytes) { diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java index a335a1e..e57833c 100644 --- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java @@ -10,9 +10,9 @@ import java.util.concurrent.atomic.AtomicBoolean; /** * Drives {@link OtaUpdater} — UNSTABLE BUILDS ONLY. * - * Upstream keeps this seam inert pending a safety and legal audit of the published suite. - * This fork publishes its own builds from its own repository, signed with the platform key - * the head unit already trusts, so that audit does not govern here and the seam is live. + * Releases are this fork's own car builds, published as pre-releases on the private forge and + * relayed by the car server (OtaUpdater); they are signed with the platform key the head unit + * already trusts. * * Installation goes through the platform {@link android.content.pm.PackageInstaller}, not * {@code pm install -r}: that shelled out with a path inside our private cache, which the @@ -21,8 +21,11 @@ import java.util.concurrent.atomic.AtomicBoolean; * authority for signing-certificate compatibility; the app verifies SHA-256 and package name * before handing it the archive. * - * Committing a session is asynchronous, so the message this returns says an install has been - * started, never that one finished. {@link OtaInstallResultReceiver} has the verdict. + * The verified APK stays in Downloads either way. The app first offers it to a PackageInstaller + * session — as the system user it may install without asking — and only if that cannot start + * does it open the file manager there, to tap the file. Committing a session is asynchronous, + * so the message says an install has started, never that one finished; + * {@link OtaInstallResultReceiver} has the verdict. */ final class UpdateHook { @@ -44,9 +47,11 @@ final class UpdateHook { Context app = context.getApplicationContext(); new Thread(() -> { try { + ServerConfig config = ServerConfig.get(app); + if (!config.isConfigured()) return; String current = app.getPackageManager() .getPackageInfo(app.getPackageName(), 0).versionName; - OtaUpdater.Update update = OtaUpdater.check(current); + OtaUpdater.Update update = OtaUpdater.check(config, current); if (update != null && update.hashUrl != null) new android.os.Handler(android.os.Looper.getMainLooper()) .post(() -> android.widget.Toast.makeText(app, @@ -100,25 +105,37 @@ final class UpdateHook { return "Could not read version"; } - OtaUpdater.Update update = OtaUpdater.check(current); + ServerConfig config = ServerConfig.get(app); + if (!config.isConfigured()) return "Set up the server first (Server tab): updates come through it"; + OtaUpdater.Update update = OtaUpdater.check(config, current); if (update == null) return "Up to date (" + current + ")"; Log.i(TAG, "Update available: " + update.versionName); String expectedHash; try { - expectedHash = OtaUpdater.fetchExpectedSha256(update); + expectedHash = OtaUpdater.fetchExpectedSha256(update, config); } catch (IOException e) { - Log.w(TAG, "OTA hash unavailable", e); + Log.w(TAG, "OTA hash unavailable: " + e.getMessage()); return "Update refused: no SHA-256 verification"; } - File apk = OtaUpdater.download(app, update, expectedHash, percent -> { + File apk = OtaUpdater.download(update, expectedHash, config, percent -> { if (listener != null) main.post(() -> listener.onProgress(percent)); }); if (apk == null) return "Download or SHA-256 verification failed (" + update.versionName + ")"; + switch (OtaUpdater.install(app, apk)) { + case SESSION_STARTED: + return "Installing " + update.versionName + "… the app restarts when it is done"; + case INSTALL_UI_STARTED: + return "Confirm the installation of " + update.versionName + " on the screen"; + case UNREADABLE_ARCHIVE: + return "Update refused: the downloaded file is not this app"; + default: + break; + } if (!OtaUpdater.openDownloads(app)) - return "APK downloaded to Downloads; could not open the file manager"; - return update.versionName + ".apk downloaded to Downloads — tap the file to install"; + return update.versionName + " is in Downloads; open it with the Files app to install"; + return update.versionName + ".apk is in Downloads — tap the file to install"; } } diff --git a/flake.nix b/flake.nix index a371adf..1b7b9d5 100644 --- a/flake.nix +++ b/flake.nix @@ -109,10 +109,62 @@ echo " adb shell \"pm install -r -f -d -t /data/local/tmp/update.apk\"" ''; }; + + # Publishes the current car build as a pre-release on the forge, where the car + # server relays it to the in-app updater (server/releases.go, OTA.md). Needs a forge + # token with write access to the repository's releases, from $FORGE_TOKEN or + # ~/.config/nilsmg4/forge-token; it is sent in a header and never printed. + publishCar = pkgs.writeShellApplication { + name = "publish-car"; + runtimeInputs = [ pkgs.curl pkgs.jq pkgs.coreutils pkgs.git ]; + text = '' + forge=''${FORGE_URL:-https://git.stinnesbeck.com} + repo=''${FORGE_REPO:-nils/mg4} + token=''${FORGE_TOKEN:-} + if [ -z "$token" ] && [ -f "$HOME/.config/nilsmg4/forge-token" ]; then + token=$(tr -d '[:space:]' < "$HOME/.config/nilsmg4/forge-token") + fi + if [ -z "$token" ]; then + echo "publish-car: no token. Put one in ~/.config/nilsmg4/forge-token (or \$FORGE_TOKEN)." >&2 + exit 1 + fi + root=$(git rev-parse --show-toplevel) + cd "$root" + version=$(grep -m1 'versionName "' app/build.gradle | sed 's/.*versionName "\(.*\)".*/\1/') + apk="build/car/NilsMG4-unstable-$version.apk" + if [ ! -f "$apk" ] || [ ! -f "$apk.sha256" ]; then + echo "publish-car: $apk (and .sha256) not found; run build-car first" >&2 + exit 1 + fi + if [ "$(sha256sum "$apk" | cut -d' ' -f1)" != "$(cat "$apk.sha256")" ]; then + echo "publish-car: $apk does not match its .sha256" >&2 + exit 1 + fi + tag="car-$version" + commit=$(git rev-parse HEAD) + api="$forge/api/v1/repos/$repo" + auth=(-H "Authorization: token $token") + + if curl -sSf "''${auth[@]}" "$api/releases/tags/$tag" >/dev/null 2>&1; then + echo "publish-car: $tag already exists; bump the version before publishing again" >&2 + exit 1 + fi + body=$(jq -n --arg tag "$tag" --arg commit "$commit" --arg name "Car build $version" \ + '{tag_name: $tag, target_commitish: $commit, name: $name, prerelease: true, + body: "Platform-signed car build, installed by the in-app updater."}') + id=$(curl -sSf "''${auth[@]}" -H "Content-Type: application/json" -d "$body" \ + "$api/releases" | jq -r .id) + for f in "$apk" "$apk.sha256"; do + curl -sSf "''${auth[@]}" -F "attachment=@$f" \ + "$api/releases/$id/assets?name=$(basename "$f")" >/dev/null + done + echo "Published $tag ($(basename "$apk") and .sha256) at $forge/$repo/releases/tag/$tag" + ''; + }; in { default = pkgs.mkShell { - packages = [ jdk android.androidsdk buildCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ]; + packages = [ jdk android.androidsdk buildCar publishCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ]; ANDROID_HOME = sdk; ANDROID_SDK_ROOT = sdk; @@ -126,6 +178,7 @@ echo " ./gradlew assembleStableDebug # debug APK" echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)" echo " build-car # unstable build signed for the car" + echo " publish-car # publish it for the in-app updater" ''; }; }); diff --git a/k8s/README.md b/k8s/README.md index 15c87ed..fbad856 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -60,6 +60,14 @@ Until the Secret exists, Grafana does not start (the container waits for the mis On a fresh install, apply everything once without Grafana working, create the token, then apply the Secret. +## App updates + +The server relays the app's releases from the private repository to the car's updater +(`OTA.md`). It reads them with `FORGE_TOKEN` from the `mg4-server` Secret: create a token in +the forge that can only **read** the `nils/mg4` repository, put it there, apply the Secret and +restart the server. Without it, updates are off (the car's updater gets a 404). `FORGE_URL` +and `FORGE_REPO` are set in `server.yaml`. + ## Notes - InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later diff --git a/k8s/secrets/secrets.example.yaml b/k8s/secrets/secrets.example.yaml index 9a9e853..b47a58d 100644 --- a/k8s/secrets/secrets.example.yaml +++ b/k8s/secrets/secrets.example.yaml @@ -26,6 +26,10 @@ stringData: # Approves a car on your phone when it pairs by QR code (at least 12 characters). Leave it # empty to switch pairing off; then only the INGEST_TOKEN above works. APPROVAL_PASSWORD: CHANGE-ME-at-least-12-characters + # A forge token that can only READ the nils/mg4 repository, for app updates (the server + # relays the releases to the car; k8s/README.md, "App updates"). Leave it empty to switch + # updates off. + FORGE_TOKEN: CHANGE-ME-read-only-forge-token --- apiVersion: v1 kind: Secret diff --git a/k8s/server.yaml b/k8s/server.yaml index b848271..e9912a6 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -29,8 +29,8 @@ spec: type: RuntimeDefault containers: - name: server - # 2.5.0, pinned by digest so a re-pushed tag cannot change what runs. - image: git.stinnesbeck.com/nils/mg4-server@sha256:93b82b24a649c7b8b6fb7c0d4ef0bfda5694689808991a211e22ddeb257a39e2 + # 2.6.0, pinned by digest so a re-pushed tag cannot change what runs. + image: git.stinnesbeck.com/nils/mg4-server@sha256:35ff5973836620c1e69f06b00163cf95183f938cb9830355452d4161c6c46e33 ports: - name: http containerPort: 8080 @@ -44,12 +44,18 @@ spec: # Where a phone reaches the server; the pairing QR code points here. - name: PUBLIC_URL value: https://car.k8s.stinnesbeck.com + # App updates: the releases of this repository, relayed to the car. Read with + # FORGE_TOKEN from the mg4-server Secret; leave that empty to switch updates off. + - name: FORGE_URL + value: https://git.stinnesbeck.com + - name: FORGE_REPO + value: nils/mg4 - name: INFLUX_TOKEN valueFrom: secretKeyRef: name: influxdb-auth key: admin-token - # INGEST_TOKEN, APPROVAL_PASSWORD. + # INGEST_TOKEN, APPROVAL_PASSWORD, FORGE_TOKEN. envFrom: - secretRef: name: mg4-server diff --git a/server/README.md b/server/README.md index 5eecc00..ba23ee7 100644 --- a/server/README.md +++ b/server/README.md @@ -22,6 +22,7 @@ Environment only; the server refuses to start when a required value is missing. | `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` | | `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) | | `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty | +| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository | | `LISTEN_ADDR` | default `:8080` | ## Endpoints @@ -43,6 +44,14 @@ with (403 otherwise); the shared token works for any. A malformed request is a 400 naming the field; a database failure is a 500 without detail. +## App updates + +`GET /v1/app/releases` and `GET /v1/app/assets/{release}/{asset}/{name}`, with the car's token: +the repository's releases, read from the forge with `FORGE_TOKEN`, in GitHub's field names +with download URLs pointing back at this server, and their attachments streamed through. The +repository stays private and the forge token never reaches the car; redirects away from the +forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset. + ## Settings from a phone The ABRP credentials are long to type on the car. With pairing on, the car (with its own token) diff --git a/server/api.go b/server/api.go index 0fcdf89..a9d380e 100644 --- a/server/api.go +++ b/server/api.go @@ -19,6 +19,7 @@ import ( // GET /v1/consumption/history/{installationId} the car reads its own totals back // GET /v1/health unauthenticated liveness probe // /v1/settings/…, /settings settings typed on a phone (handoff_http.go) +// GET /v1/app/… app updates from the forge (releases.go) // // A car authenticates with the shared INGEST_TOKEN or with its own token from pairing // (pairing_http.go). @@ -28,6 +29,8 @@ type API struct { Pairing *Pairing // Handoff carries settings typed on a phone to the car (handoff.go). Handoff *Handoff + // Releases relays the app's releases from the private forge to the car (releases.go). + Releases *Releases // PublicURL is the server's address as a phone reaches it, for the pairing QR code; // derived from the request when empty. PublicURL string @@ -46,6 +49,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history)) a.pairingRoutes(mux) a.handoffRoutes(mux) + a.releaseRoutes(mux) return mux } diff --git a/server/main.go b/server/main.go index be65222..521d7a4 100644 --- a/server/main.go +++ b/server/main.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" ) @@ -30,6 +31,9 @@ type config struct { ingestToken string approvalPass string publicURL string + forgeURL string + forgeRepo string + forgeToken string } // loadConfig reads the environment and fails closed: a missing InfluxDB setting or a @@ -44,6 +48,9 @@ func loadConfig(getenv func(string) string) (config, error) { ingestToken: getenv("INGEST_TOKEN"), approvalPass: getenv("APPROVAL_PASSWORD"), publicURL: getenv("PUBLIC_URL"), + forgeURL: getenv("FORGE_URL"), + forgeRepo: getenv("FORGE_REPO"), + forgeToken: getenv("FORGE_TOKEN"), } if c.listenAddr == "" { c.listenAddr = ":8080" @@ -61,6 +68,11 @@ func loadConfig(getenv func(string) string) (config, error) { } // Pairing is optional; when it is on, its password guards token issuance and must not // be guessable. + // App updates are optional; when the forge is named it must be https, so the token + // never crosses the network in the clear. + if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") { + return config{}, errors.New("FORGE_URL must be an https URL") + } if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength { return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength) } @@ -87,8 +99,9 @@ func main() { os.Exit(1) } pairing := NewPairing(cfg.approvalPass, devices) + releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken} api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(), - PublicURL: cfg.publicURL} + Releases: releases, PublicURL: cfg.publicURL} srv := &http.Server{ Addr: cfg.listenAddr, @@ -103,7 +116,7 @@ func main() { defer stop() go func() { slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, - "pairing", pairing.Enabled(), "pairedDevices", len(devices)) + "pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled()) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) diff --git a/server/releases.go b/server/releases.go new file mode 100644 index 0000000..ce3d356 --- /dev/null +++ b/server/releases.go @@ -0,0 +1,209 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Releases relays the app's own releases from the private forge repository to the car, for +// the in-app updater. The repository stays private and the forge token stays here: the car +// asks this server with its own token and never holds a forge credential. +// +// GET /v1/app/releases the repo's releases, GitHub-shaped +// GET /v1/app/assets/{release}/{asset}/{name} one attachment, streamed through +// +// The car still checks everything itself (SHA-256 sidecar, package name, signature by the +// installer); this only carries bytes. Off unless FORGE_URL, FORGE_REPO and FORGE_TOKEN are set. +type Releases struct { + ForgeURL string // e.g. https://git.stinnesbeck.com + Repo string // owner/name + Token string // read-only on the repository + Client *http.Client +} + +const ( + maxReleaseListBytes = 1 << 20 + maxAssetBytes = 64 << 20 + // An APK over a slow mobile link takes longer than the server's general write timeout. + assetWriteTimeout = 10 * time.Minute +) + +func (rel *Releases) enabled() bool { + return rel != nil && rel.ForgeURL != "" && rel.Repo != "" && rel.Token != "" +} + +func (rel *Releases) client() *http.Client { + if rel.Client != nil { + return rel.Client + } + return &http.Client{Timeout: 5 * time.Minute, CheckRedirect: rel.sameHostOnly} +} + +// sameHostOnly keeps the token on the forge: a redirect elsewhere is refused, not followed +// with the Authorization header. +func (rel *Releases) sameHostOnly(req *http.Request, via []*http.Request) error { + forge, err := url.Parse(rel.ForgeURL) + if err != nil || req.URL.Host != forge.Host || req.URL.Scheme != forge.Scheme { + return errors.New("redirect away from the forge refused") + } + if len(via) > 5 { + return errors.New("too many redirects") + } + return nil +} + +func (rel *Releases) get(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(rel.ForgeURL, "/")+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+rel.Token) + req.Header.Set("Accept", "application/json") + return rel.client().Do(req) +} + +type forgeAsset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` +} + +type forgeRelease struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Assets []forgeAsset `json:"assets"` +} + +// carRelease is what the car's updater reads: GitHub's field names, with download URLs that +// point back at this server. +type carRelease struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Assets []carAsset `json:"assets"` +} + +type carAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (a *API) releaseRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/app/releases", a.ingest(a.appReleases)) + mux.HandleFunc("GET /v1/app/assets/{release}/{asset}/{name}", a.ingest(a.appAsset)) +} + +func (a *API) releasesOff(w http.ResponseWriter) bool { + if a.Releases.enabled() { + return false + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "app updates are not enabled"}) + return true +} + +func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { + if a.releasesOff(w) { + return nil + } + resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20") + if err != nil { + return fmt.Errorf("forge releases: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn("forge refused the release list", "status", resp.StatusCode) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) + return nil + } + var releases []forgeRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseListBytes)).Decode(&releases); err != nil { + return fmt.Errorf("forge releases: %w", err) + } + base := a.publicURL(r) + "/v1/app/assets/" + out := make([]carRelease, 0, len(releases)) + for _, rel := range releases { + cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}} + for _, as := range rel.Assets { + cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size, + BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) + + "/" + url.PathEscape(as.Name)}) + } + out = append(out, cr) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +// appAsset streams one release attachment. The forge's own metadata names the file, so a car +// cannot ask for anything but an attachment of this repository's releases. +func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { + if a.releasesOff(w) { + return nil + } + releaseID, err1 := strconv.ParseInt(r.PathValue("release"), 10, 64) + assetID, err2 := strconv.ParseInt(r.PathValue("asset"), 10, 64) + if err1 != nil || err2 != nil || releaseID <= 0 || assetID <= 0 { + return invalid("release or asset is invalid") + } + meta, err := a.Releases.get(r.Context(), fmt.Sprintf("/api/v1/repos/%s/releases/%d/assets/%d", + a.Releases.Repo, releaseID, assetID)) + if err != nil { + return fmt.Errorf("forge asset: %w", err) + } + var asset struct { + Name string `json:"name"` + UUID string `json:"uuid"` + } + decodeErr := json.NewDecoder(io.LimitReader(meta.Body, 64<<10)).Decode(&asset) + meta.Body.Close() + if meta.StatusCode == http.StatusNotFound { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"}) + return nil + } + if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" { + slog.Warn("forge refused the asset", "status", meta.StatusCode) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) + return nil + } + if asset.Name != r.PathValue("name") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"}) + return nil + } + file, err := a.Releases.get(r.Context(), "/attachments/"+url.PathEscape(asset.UUID)) + if err != nil { + return fmt.Errorf("forge download: %w", err) + } + defer file.Body.Close() + if file.StatusCode != http.StatusOK { + slog.Warn("forge refused the download", "status", file.StatusCode) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) + return nil + } + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(assetWriteTimeout)); err != nil { + slog.Warn("asset write deadline not extended", "error", err) + } + h := w.Header() + h.Set("Content-Type", "application/octet-stream") + h.Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(asset.Name, `"`, "")+`"`) + h.Set("Cache-Control", "no-store") + if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes { + h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10)) + } + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil { + slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err) + } + return nil +} diff --git a/server/releases_test.go b/server/releases_test.go new file mode 100644 index 0000000..4c0c7e0 --- /dev/null +++ b/server/releases_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testForgeToken = "forge-read-token-0123456789" + +// fakeForge answers like Gitea for one pre-release with an APK and its sidecar, and only to +// the right token. +func fakeForge(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + auth := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token "+testForgeToken { + http.NotFound(w, r) // a private repository looks absent without a login + return + } + h(w, r) + } + } + mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases", auth(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"id":7,"tag_name":"car-2.16.0","prerelease":true,"draft":false,"assets":[ + {"id":11,"name":"NilsMG4-unstable-2.16.0.apk","size":4}, + {"id":12,"name":"NilsMG4-unstable-2.16.0.apk.sha256","size":64}]}]`)) + })) + mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases/7/assets/11", auth(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":11,"name":"NilsMG4-unstable-2.16.0.apk","uuid":"abc-uuid"}`)) + })) + mux.HandleFunc("GET /attachments/abc-uuid", auth(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("APK!")) + })) + return httptest.NewTLSServer(mux) +} + +func releasesAPI(t *testing.T, forge *httptest.Server) http.Handler { + t.Helper() + api, _ := newTestAPI() + api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: testForgeToken, Client: forge.Client()} + return api.Routes() +} + +func TestReleasesAreRelayedWithDownloadsThroughTheServer(t *testing.T) { + forge := fakeForge(t) + defer forge.Close() + h := releasesAPI(t, forge) + if rec := do(t, h, "GET", "/v1/app/releases", "", ""); rec.Code != 401 { + t.Fatalf("without the car's token: %d", rec.Code) + } + rec := do(t, h, "GET", "/v1/app/releases", testToken, "") + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var got []carRelease + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || !got[0].Prerelease || len(got[0].Assets) != 2 || + got[0].Assets[0].BrowserDownloadURL != "https://car.example.com/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk" { + t.Fatalf("releases %+v", got) + } + if strings.Contains(rec.Body.String(), testForgeToken) || strings.Contains(rec.Body.String(), forge.URL) { + t.Fatal("the forge or its token leaked to the car") + } + file := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, "") + if file.Code != 200 || file.Body.String() != "APK!" { + t.Fatalf("download %d %q", file.Code, file.Body) + } + // The name must be the asset's own: no asking for one file under another's name. + if rec := do(t, h, "GET", "/v1/app/assets/7/11/other.apk", testToken, ""); rec.Code != 404 { + t.Fatalf("wrong name: %d", rec.Code) + } +} + +func TestAWrongForgeTokenIsABadGatewayNotALeak(t *testing.T) { + forge := fakeForge(t) + defer forge.Close() + api, _ := newTestAPI() + api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: "wrong", Client: forge.Client()} + if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 502 { + t.Fatalf("%d", rec.Code) + } +} + +func TestAppUpdatesAreOffWithoutAForge(t *testing.T) { + api, _ := newTestAPI() + if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 404 { + t.Fatalf("%d", rec.Code) + } +} + +func TestRedirectsAwayFromTheForgeAreRefused(t *testing.T) { + rel := &Releases{ForgeURL: "https://git.example.com"} + away, _ := http.NewRequest("GET", "https://elsewhere.example.com/x", nil) + if rel.sameHostOnly(away, nil) == nil { + t.Fatal("redirect to another host followed") + } + same, _ := http.NewRequest("GET", "https://git.example.com/attachments/x", nil) + if err := rel.sameHostOnly(same, nil); err != nil { + t.Fatal(err) + } +} + +func TestForgeURLMustBeHTTPS(t *testing.T) { + env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", + "INGEST_TOKEN": "0123456789abcdef01234567", "FORGE_URL": "http://git.example.com"} + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil { + t.Fatal("plain http forge accepted") + } +}