App 2.16.0, server 2.6.0: over-the-air updates from our own repository
The car updates itself again, from this fork's own releases, while the repository stays private: - publish-car (flake) creates the pre-release car-<version> on the forge and attaches the APK and its .sha256. It needs a forge token that may write releases, from ~/.config/nilsmg4/forge-token or $FORGE_TOKEN; it is sent in a header and never printed. - The server relays releases and attachments (/v1/app/releases, /v1/app/assets/...), read with a read-only FORGE_TOKEN from its Secret, GitHub-shaped with download URLs back at itself. Redirects away from the forge are not followed; off (404) without a token. - The app asks its server with its own paired token, allows exactly the server's host, downloads the APK itself instead of through the DownloadManager (so the token stays in the app), verifies SHA-256 and package name, and tries a PackageInstaller session (it may install silently as the system user) before falling back to Downloads. OTA.md is rewritten for this flow; the k8s and server READMEs explain the new token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -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
|
How a new car build reaches the head unit without the Bluetooth-settings detour, and what to
|
||||||
next — probably me, having forgotten the details.
|
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.
|
The repository stays private and the forge token stays on the server: the car asks its own
|
||||||
Those builds use the old `pm install` path; publishing a newer APK does not repair the
|
server, with its own token, and never holds a forge credential. The server only relays
|
||||||
installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions.
|
bytes. Every check that decides whether code gets installed is in the app.
|
||||||
Keep the unstable package ID, system shared UID and signing certificate when upgrading.
|
|
||||||
|
|
||||||
The 2.2.5 build removes window automation. It also distinguishes failed release checks
|
## Publishing a release
|
||||||
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`.
|
|
||||||
|
|
||||||
The vehicle artifact uses `com.stinnesbeck.nilsmg4.unstable` (upstream: `com.evsuite.abrp.unstable`),
|
1. **Raise the version** in `app/build.gradle` (`versionCode` must increase; `versionName`
|
||||||
`android.uid.system`, and the
|
is three-part, e.g. `2.16.0`). The car build reports it as `2.16.0-unstable`.
|
||||||
DriveHub_Kamera platform key. Its certificate SHA-256 must be
|
2. **Build:** `nix develop`, then `build-car`. It signs with the AOSP platform key the head
|
||||||
`c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8`.
|
unit trusts, checks the signer, and writes `build/car/NilsMG4-unstable-<version>.apk` and
|
||||||
The stable build used for a Samsung UI preview is a separate package without OTA or the
|
its `.sha256`.
|
||||||
vehicle's shared UID; do not use that preview APK for the MG4 upgrade.
|
3. **Publish:** `publish-car`. It creates the pre-release `car-<version>` 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-<version>`, 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
|
## What the updater accepts
|
||||||
> 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.
|
|
||||||
|
|
||||||
`OtaUpdater.check()` reads the `RELEASES_API` URL (upstream:
|
The app asks the car server for the releases (`GET /v1/app/releases`) and walks every one,
|
||||||
`https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases`) and walks **every** entry, keeping the highest version rather than the first match. A
|
keeping the highest version:
|
||||||
release is a candidate only if all of these hold:
|
|
||||||
|
|
||||||
| Requirement | Where it is enforced | What happens otherwise |
|
| Requirement | Where it is enforced | Otherwise |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Marked **pre-release** | `release.optBoolean("prerelease")` | Skipped entirely |
|
| Marked **pre-release**, not a draft | `selectUpdate` | Skipped |
|
||||||
| Asset name contains `unstable` | `name.contains("unstable")` | Asset ignored |
|
| Asset name contains `unstable` and ends `-<version>.apk` | `ASSET_VERSION` regex | Ignored |
|
||||||
| Asset name ends `-<version>.apk` | `ASSET_VERSION` regex | No version, asset ignored |
|
| Version strictly higher than installed | `isNewer()` | "Up to date" |
|
||||||
| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Up to date" |
|
| Every URL https and **exactly** the configured server's host | `isAllowedUrl()` | Refused |
|
||||||
| Download host on the allowlist | `isAllowedUrl()` | Refused and logged |
|
| A matching `.sha256` (or `SHA256SUMS`) asset, and the APK's bytes match it | `fetchExpectedSha256()`, `download()` | Refused, file deleted |
|
||||||
| APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform |
|
| The archive is this app's package | `archiveIsThisPackage()` | Refused |
|
||||||
| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/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
|
The version comes from the **asset file name**, not the tag.
|
||||||
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 filename**, not the git tag. The tag can stay
|
## Installing
|
||||||
`unstable` forever and be moved; the filename is what identifies a build.
|
|
||||||
|
|
||||||
## 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`:
|
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
|
||||||
```gradle
|
the app, which must not happen mid-drive on its own.
|
||||||
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-<version>.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.
|
|
||||||
|
|
||||||
## When it says something else
|
## When it says something else
|
||||||
|
|
||||||
| Message | Meaning |
|
| Message | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Up to date (2.14.2-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
|
| `Up to date (2.16.0-unstable)` | No published pre-release is newer. Check the pre-release tick and the asset name. |
|
||||||
| `Update refused: no SHA-256 verification` | The APK has no matching sidecar, or the sidecar is invalid or unreachable. |
|
| `Set up the server first (Server tab): updates come through it` | The app has no server URL and token. |
|
||||||
| `Download or SHA-256 verification failed (…)` | The download failed, or its bytes do not match the published hash. |
|
| `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 check failed` | The check threw. `adb logcat -s UpdateHook OtaUpdater` if a cable is available; otherwise the in-app Logs tab. |
|
| `Update refused: no SHA-256 verification` | The release has no matching `.sha256`, or it is unreadable. |
|
||||||
| `Update check already in progress` | A check is still running; wait for its result. |
|
| `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
|
## Why a PackageInstaller session, not `pm install`
|
||||||
that fails every time, and the reason is worth writing down because the symptom pointed
|
|
||||||
somewhere else entirely.
|
|
||||||
|
|
||||||
The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself
|
Upstream first shelled out to `/system/bin/pm install -r` with the APK in the app's private
|
||||||
— it passes the path to the package manager service, which opens it from *its* process and
|
cache. That fails on this head unit: `pm` hands the path to the package manager service,
|
||||||
SELinux context, where an app's private cache is not readable. The install failed, and
|
which opens it from its own process and SELinux context, where an app's private cache is not
|
||||||
because `install()` returned a bare boolean, the caller reported the only failure it knew
|
readable — and the failure surfaced as a signature error it never was. A session takes no
|
||||||
how to name: `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)"). A correctly signed build, refused with
|
path: the app opens the archive itself and streams it in. The approach comes from
|
||||||
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
|
|
||||||
[merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit
|
[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:
|
**Not yet tried on this car:** whether the session installs silently as the system user, or
|
||||||
|
asks for a confirmation.
|
||||||
- **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.
|
|
||||||
|
|||||||
@@ -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).
|
> ⚠️ 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
|
After that first install, the app updates itself: publish a build with `publish-car`, then
|
||||||
pointed at this repository's releases (see `SPECS.md`).
|
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
|
## Setup
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,6 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
|
|
||||||
## To do
|
## 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-<version>.apk` with a `.sha256` sidecar (`build-car` writes both).
|
|
||||||
- [ ] **Grafana high availability needs another database.** Grafana keeps its own state (users,
|
- [ ] **Grafana high availability needs another database.** Grafana keeps its own state (users,
|
||||||
sessions, preferences, provisioned-dashboard records, alerting state) in SQLite at
|
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
|
`/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
|
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
|
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.
|
`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-<version>` 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
|
- 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
|
`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
|
at once when the service stops; it is restored, in order and within its 2000-sample cap, when
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 83
|
versionCode 84
|
||||||
versionName "2.15.0"
|
versionName "2.16.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// 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.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
<string name="notif_title">ABRP Uploader</string>
|
<string name="notif_title">ABRP Uploader</string>
|
||||||
<string name="notif_action_stop">Stop</string>
|
<string name="notif_action_stop">Stop</string>
|
||||||
<string name="ota_install_success">Update installed — restart the app</string>
|
<string name="ota_install_success">Update installed — restart the app</string>
|
||||||
<string name="ota_install_failed">Installation failed: %1$s</string>
|
<string name="ota_install_failed">Installation failed: %1$s. The APK is in Downloads.</string>
|
||||||
<string name="update_check">Check for updates</string>
|
<string name="update_check">Check for updates</string>
|
||||||
<string name="update_checking">Checking for a new OTA version…</string>
|
<string name="update_checking">Checking for a new OTA version…</string>
|
||||||
<string name="update_dialog_title">Software update</string>
|
<string name="update_dialog_title">Software update</string>
|
||||||
|
|||||||
@@ -24,46 +24,55 @@ import java.nio.charset.StandardCharsets;
|
|||||||
*/
|
*/
|
||||||
public class OtaUpdaterTest {
|
public class OtaUpdaterTest {
|
||||||
|
|
||||||
// ---- URL allowlist ----
|
// ---- URL allowlist: exactly the configured car server ----
|
||||||
|
|
||||||
|
private static final String HOST = "car.example.com";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void httpsFromAnAllowedHostIsAccepted() {
|
public void httpsFromTheServerIsAccepted() {
|
||||||
assertTrue(OtaUpdater.isAllowedUrl(
|
assertTrue(OtaUpdater.isAllowedUrl("https://car.example.com/v1/app/assets/7/11/app.apk", HOST));
|
||||||
"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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void httpIsRejectedEvenOnAnAllowedHost() {
|
public void httpIsRejectedEvenFromTheServer() {
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("http://github.com/malys/app.apk"));
|
assertFalse(OtaUpdater.isAllowedUrl("http://car.example.com/v1/app/assets/7/11/app.apk", HOST));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void foreignHostsAreRejected() {
|
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
|
@Test
|
||||||
public void lookalikeHostsAreRejected() {
|
public void lookalikeHostsAreRejected() {
|
||||||
// Exact match, never a suffix test.
|
// Exact match, never a suffix test.
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("https://github.com.attacker.net/app.apk"));
|
assertFalse(OtaUpdater.isAllowedUrl("https://car.example.com.attacker.net/app.apk", HOST));
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("https://evil-github.com/app.apk"));
|
assertFalse(OtaUpdater.isAllowedUrl("https://evil-car.example.com/app.apk", HOST));
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("https://notgithub.com/app.apk"));
|
assertFalse(OtaUpdater.isAllowedUrl("https://example.com/app.apk", HOST));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void unparsableOrNonHttpUrlsAreRejected() {
|
public void unparsableOrNonHttpUrlsAreRejected() {
|
||||||
assertFalse(OtaUpdater.isAllowedUrl(""));
|
assertFalse(OtaUpdater.isAllowedUrl("", HOST));
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("not a url"));
|
assertFalse(OtaUpdater.isAllowedUrl(null, HOST));
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("ftp://github.com/app.apk"));
|
assertFalse(OtaUpdater.isAllowedUrl("not a url", HOST));
|
||||||
assertFalse(OtaUpdater.isAllowedUrl("file:///sdcard/Download/app.apk"));
|
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
|
@Test
|
||||||
public void hostMatchingIgnoresCase() {
|
public void hostMatchingIgnoresCase() {
|
||||||
assertTrue(OtaUpdater.isAllowedUrl("https://GitHub.com/malys/app.apk"));
|
assertTrue(OtaUpdater.isAllowedUrl("https://Car.Example.com/app.apk", HOST));
|
||||||
assertTrue(OtaUpdater.isAllowedUrl("HTTPS://github.com/malys/app.apk"));
|
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 ----
|
// ---- Version comparison ----
|
||||||
@@ -138,7 +147,7 @@ public class OtaUpdaterTest {
|
|||||||
@Test
|
@Test
|
||||||
public void updateWithoutHashSidecarIsReportedButCannotBeVerified() throws Exception {
|
public void updateWithoutHashSidecarIsReportedButCannotBeVerified() throws Exception {
|
||||||
JSONArray releases = new JSONArray().put(releaseWithoutHashes(true, asset("2.2.7")));
|
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);
|
assertEquals("2.2.7", update.versionName);
|
||||||
assertNull(update.hashUrl);
|
assertNull(update.hashUrl);
|
||||||
}
|
}
|
||||||
@@ -148,7 +157,7 @@ public class OtaUpdaterTest {
|
|||||||
JSONArray releases = new JSONArray().put(release(true,
|
JSONArray releases = new JSONArray().put(release(true,
|
||||||
asset("2.2.3"), asset("2.2.6"), asset("2.2.4")))
|
asset("2.2.3"), asset("2.2.6"), asset("2.2.4")))
|
||||||
.put(release(true, asset("2.2.5")));
|
.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);
|
assertEquals("2.2.6", update.versionName);
|
||||||
assertTrue(update.apkUrl.endsWith("NilsMG4-unstable-2.2.6.apk"));
|
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(false, asset("9.0.0")))
|
||||||
.put(release(true, asset("9.0.0")).put("draft", true))
|
.put(release(true, asset("9.0.0")).put("draft", true))
|
||||||
.put(release(true, foreign, unversioned, stableName, asset("2.2.5")));
|
.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
|
@Test
|
||||||
public void successfulCheckFindsUpdateAndDisconnects() throws Exception {
|
public void successfulCheckFindsUpdateAndDisconnects() throws Exception {
|
||||||
Response response = new Response(200,
|
Response response = new Response(200,
|
||||||
new JSONArray().put(release(true, asset("2.2.5"))).toString());
|
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);
|
assertTrue(response.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +186,7 @@ public class OtaUpdaterTest {
|
|||||||
public void successfulCheckWithOnlyOlderOrEqualVersionsIsCurrent() throws Exception {
|
public void successfulCheckWithOnlyOlderOrEqualVersionsIsCurrent() throws Exception {
|
||||||
Response response = new Response(200,
|
Response response = new Response(200,
|
||||||
new JSONArray().put(release(true, asset("2.2.3"), asset("2.2.5"))).toString());
|
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);
|
assertTrue(response.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +194,7 @@ public class OtaUpdaterTest {
|
|||||||
public void rateLimitMustNotBeReportedAsCurrent() throws Exception {
|
public void rateLimitMustNotBeReportedAsCurrent() throws Exception {
|
||||||
Response response = new Response(403, "{}");
|
Response response = new Response(403, "{}");
|
||||||
try {
|
try {
|
||||||
OtaUpdater.check("2.2.4.0-unstable", response);
|
OtaUpdater.check("2.2.4.0-unstable", response, HOST);
|
||||||
} finally {
|
} finally {
|
||||||
assertTrue(response.disconnected);
|
assertTrue(response.disconnected);
|
||||||
}
|
}
|
||||||
@@ -195,7 +204,7 @@ public class OtaUpdaterTest {
|
|||||||
public void malformedResponseMustNotBeReportedAsCurrent() throws Exception {
|
public void malformedResponseMustNotBeReportedAsCurrent() throws Exception {
|
||||||
Response response = new Response(200, "<html>unavailable</html>");
|
Response response = new Response(200, "<html>unavailable</html>");
|
||||||
try {
|
try {
|
||||||
OtaUpdater.check("2.2.4.0-unstable", response);
|
OtaUpdater.check("2.2.4.0-unstable", response, HOST);
|
||||||
} finally {
|
} finally {
|
||||||
assertTrue(response.disconnected);
|
assertTrue(response.disconnected);
|
||||||
}
|
}
|
||||||
@@ -209,7 +218,7 @@ public class OtaUpdaterTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
OtaUpdater.check("2.2.4.0-unstable", response);
|
OtaUpdater.check("2.2.4.0-unstable", response, HOST);
|
||||||
} finally {
|
} finally {
|
||||||
assertTrue(response.disconnected);
|
assertTrue(response.disconnected);
|
||||||
}
|
}
|
||||||
@@ -218,7 +227,7 @@ public class OtaUpdaterTest {
|
|||||||
private static JSONObject asset(String version) throws Exception {
|
private static JSONObject asset(String version) throws Exception {
|
||||||
String name = "NilsMG4-unstable-" + version + ".apk";
|
String name = "NilsMG4-unstable-" + version + ".apk";
|
||||||
return new JSONObject().put("name", name).put("browser_download_url",
|
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 {
|
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
|
||||||
@@ -249,7 +258,7 @@ public class OtaUpdaterTest {
|
|||||||
boolean disconnected;
|
boolean disconnected;
|
||||||
|
|
||||||
Response(int status, String body) throws Exception {
|
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.status = status;
|
||||||
this.body = body;
|
this.body = body;
|
||||||
}
|
}
|
||||||
@@ -262,8 +271,6 @@ public class OtaUpdaterTest {
|
|||||||
@Override public void connect() { }
|
@Override public void connect() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `pm install -r` exit-code test that stood here is gone with the shell-out it
|
// Installation is a PackageInstaller session, whose every step needs a real PackageManager
|
||||||
// covered. Installation is now a PackageInstaller session, whose every step needs a real
|
// — nothing of it can be exercised on the JVM. OtaInstallResultReceiver reports it on the car.
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package com.evsuite.abrp;
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.app.DownloadManager;
|
import android.app.DownloadManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
import android.content.ClipData;
|
import android.content.ClipData;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.pm.PackageInstaller;
|
import android.content.pm.PackageInstaller;
|
||||||
import android.content.pm.ResolveInfo;
|
import android.content.pm.ResolveInfo;
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
import android.os.ParcelFileDescriptor;
|
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.util.Log;
|
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
|
* stable build. Unstable testers get updates without manual work, and accept that channel's
|
||||||
* risk.
|
* risk.
|
||||||
*
|
*
|
||||||
* Security posture is the one EVProfile settled on in its own OTA work:
|
* Releases come from the car server (server/releases.go), which relays them from the private
|
||||||
* - the APK URL comes from a remote JSON document and is never trusted: https only,
|
* forge repository: the car asks with its own paired token and never holds a forge
|
||||||
* exact-match host allowlist, checked again before it reaches the system downloader;
|
* credential. The server only carries bytes; the checks are all here:
|
||||||
* - the downloaded APK must be signed by the same certificate as the running app, or it
|
* - every URL in the release list is untrusted: https only, and exactly the configured
|
||||||
* is deleted rather than offered for install;
|
* server's host (no suffix match), checked again before anything is fetched;
|
||||||
* - both checks fail closed.
|
* - 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 {
|
final class OtaUpdater {
|
||||||
|
|
||||||
private static final String TAG = "OtaUpdater";
|
private static final String TAG = "OtaUpdater";
|
||||||
private static final String CACHE_PREFIX = "NilsMG4-ota-";
|
private static final String CACHE_PREFIX = "NilsMG4-ota-";
|
||||||
|
|
||||||
/**
|
/** The release list on the car server, for the server configured in the app. */
|
||||||
* Pre-releases live here; the unstable channel tracks them.
|
static String releasesUrl(ServerConfig config) {
|
||||||
*
|
return config.url + "/v1/app/releases";
|
||||||
* 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/<owner>/<repo>/releases".
|
|
||||||
*/
|
|
||||||
private static final String RELEASES_API =
|
|
||||||
"https://updates.invalid/repos/fatihdonmezdev/FatihsMG4/releases";
|
|
||||||
|
|
||||||
/**
|
/** The one host updates may come from: the configured server's. Null if unusable. */
|
||||||
* Hosts an update may come from. The githubusercontent entries are the CDNs GitHub
|
static String allowedHost(ServerConfig config) {
|
||||||
* redirects release-asset downloads to; without them the download fails.
|
try {
|
||||||
*/
|
String host = new URI(config.url).getHost();
|
||||||
private static final List<String> ALLOWED_HOSTS = Arrays.asList(
|
return host == null ? null : host.toLowerCase(Locale.US);
|
||||||
"api.github.com",
|
} catch (Exception e) {
|
||||||
"github.com",
|
return null;
|
||||||
"objects.githubusercontent.com",
|
}
|
||||||
"release-assets.githubusercontent.com");
|
}
|
||||||
|
|
||||||
private static final java.util.regex.Pattern ASSET_VERSION =
|
private static final java.util.regex.Pattern ASSET_VERSION =
|
||||||
java.util.regex.Pattern.compile("-(\\d[0-9.]*?)\\.apk$",
|
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 TIMEOUT_MS = 10_000;
|
||||||
private static final int MAX_HASH_BYTES = 16 * 1024;
|
private static final int MAX_HASH_BYTES = 16 * 1024;
|
||||||
|
private static final long MAX_APK_BYTES = 64L * 1024 * 1024;
|
||||||
|
|
||||||
private OtaUpdater() { }
|
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
|
* Rejects http (including an https -> http downgrade), other hosts, unparsable URLs, and
|
||||||
* URLs, and lookalikes such as "github.com.attacker.net" — the host match is exact,
|
* lookalikes such as "car.example.com.attacker.net" — the host match is exact, never a
|
||||||
* never a suffix test.
|
* suffix test.
|
||||||
*/
|
*/
|
||||||
static boolean isAllowedUrl(String url) {
|
static boolean isAllowedUrl(String url, String allowedHost) {
|
||||||
|
if (url == null || allowedHost == null) return false;
|
||||||
URI uri;
|
URI uri;
|
||||||
try {
|
try {
|
||||||
uri = new URI(url);
|
uri = new URI(url);
|
||||||
@@ -134,7 +125,7 @@ final class OtaUpdater {
|
|||||||
}
|
}
|
||||||
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("https")) return false;
|
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("https")) return false;
|
||||||
String host = uri.getHost();
|
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].
|
* Asks the car server for the newest pre-release and returns it if it beats
|
||||||
* Runs on the caller's thread — never call from the main thread.
|
* [currentVersion]. Runs on the caller's thread — never call from the main thread.
|
||||||
*/
|
*/
|
||||||
static Update check(String currentVersion) throws IOException {
|
static Update check(ServerConfig config, String currentVersion) throws IOException {
|
||||||
return check(currentVersion, (HttpURLConnection) new URL(RELEASES_API).openConnection());
|
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. */
|
/** 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 {
|
try {
|
||||||
conn.setInstanceFollowRedirects(false);
|
conn.setInstanceFollowRedirects(false);
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
|
conn.setRequestProperty("Accept", "application/json");
|
||||||
conn.setRequestProperty("User-Agent", "NilsMG4-Android");
|
conn.setRequestProperty("User-Agent", "NilsMG4-Android");
|
||||||
conn.setConnectTimeout(TIMEOUT_MS);
|
conn.setConnectTimeout(TIMEOUT_MS);
|
||||||
conn.setReadTimeout(TIMEOUT_MS);
|
conn.setReadTimeout(TIMEOUT_MS);
|
||||||
@@ -217,7 +211,7 @@ final class OtaUpdater {
|
|||||||
while ((line = br.readLine()) != null) body.append(line);
|
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) {
|
} catch (JSONException e) {
|
||||||
throw new IOException("Invalid release response", e);
|
throw new IOException("Invalid release response", e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -226,7 +220,7 @@ final class OtaUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Scan every asset of every pre-release; asset ordering does not imply version order. */
|
/** 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;
|
Update best = null;
|
||||||
for (int i = 0; i < releases.length(); i++) {
|
for (int i = 0; i < releases.length(); i++) {
|
||||||
JSONObject release = releases.getJSONObject(i);
|
JSONObject release = releases.getJSONObject(i);
|
||||||
@@ -241,24 +235,24 @@ final class OtaUpdater {
|
|||||||
if (version == null || !isNewer(version, currentVersion)) continue;
|
if (version == null || !isNewer(version, currentVersion)) continue;
|
||||||
if (best != null && !isNewer(version, best.versionName)) continue;
|
if (best != null && !isNewer(version, best.versionName)) continue;
|
||||||
String url = asset.optString("browser_download_url", "");
|
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
|
// 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.
|
// a broken release instead of incorrectly saying the app is current.
|
||||||
// fetchExpectedSha256 still fails closed before any APK is downloaded.
|
// 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;
|
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 exact = apkName + ".sha256";
|
||||||
String sums = null;
|
String sums = null;
|
||||||
for (int i = 0; i < assets.length(); i++) {
|
for (int i = 0; i < assets.length(); i++) {
|
||||||
JSONObject asset = assets.getJSONObject(i);
|
JSONObject asset = assets.getJSONObject(i);
|
||||||
String name = asset.optString("name", "");
|
String name = asset.optString("name", "");
|
||||||
String url = asset.optString("browser_download_url", "");
|
String url = asset.optString("browser_download_url", "");
|
||||||
if (!isAllowedUrl(url)) continue;
|
if (!isAllowedUrl(url, allowedHost)) continue;
|
||||||
if (exact.equalsIgnoreCase(name)) return url;
|
if (exact.equalsIgnoreCase(name)) return url;
|
||||||
if ("SHA256SUMS".equalsIgnoreCase(name)) sums = url;
|
if ("SHA256SUMS".equalsIgnoreCase(name)) sums = url;
|
||||||
}
|
}
|
||||||
@@ -285,20 +279,21 @@ final class OtaUpdater {
|
|||||||
return value != null && value.matches("(?i)[a-f0-9]{64}");
|
return value != null && value.matches("(?i)[a-f0-9]{64}");
|
||||||
}
|
}
|
||||||
|
|
||||||
static String fetchExpectedSha256(Update update) throws IOException {
|
static String fetchExpectedSha256(Update update, ServerConfig config) throws IOException {
|
||||||
if (update == null || !isAllowedUrl(update.hashUrl))
|
if (update == null || !isAllowedUrl(update.hashUrl, allowedHost(config)))
|
||||||
throw new IOException("Missing SHA-256 sidecar");
|
throw new IOException("Missing SHA-256 sidecar");
|
||||||
String body = readSmallText(update.hashUrl);
|
String body = readSmallText(update.hashUrl, config);
|
||||||
String hash = parseExpectedSha256(body, update.apkName);
|
String hash = parseExpectedSha256(body, update.apkName);
|
||||||
if (hash == null) throw new IOException("Invalid SHA-256 sidecar");
|
if (hash == null) throw new IOException("Invalid SHA-256 sidecar");
|
||||||
return hash;
|
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);
|
URL current = new URL(initialUrl);
|
||||||
for (int redirects = 0; redirects <= 5; redirects++) {
|
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();
|
HttpURLConnection connection = (HttpURLConnection) current.openConnection();
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + config.token);
|
||||||
connection.setInstanceFollowRedirects(false);
|
connection.setInstanceFollowRedirects(false);
|
||||||
connection.setConnectTimeout(TIMEOUT_MS);
|
connection.setConnectTimeout(TIMEOUT_MS);
|
||||||
connection.setReadTimeout(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) {
|
ProgressListener listener) {
|
||||||
if (!isAllowedUrl(update.apkUrl)) {
|
if (!isAllowedUrl(update.apkUrl, allowedHost(config))) {
|
||||||
Log.w(TAG, "Refusing to download from " + update.apkUrl);
|
Log.w(TAG, "Refusing to download from an unexpected host");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
|
File downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||||
if (manager == null) return null;
|
if (downloads == null || (!downloads.isDirectory() && !downloads.mkdirs())) return null;
|
||||||
String publicName = downloadFileName(update.versionName);
|
File target = new File(downloads, downloadFileName(update.versionName));
|
||||||
File publicFile = new File(Environment.getExternalStoragePublicDirectory(
|
HttpURLConnection connection = null;
|
||||||
Environment.DIRECTORY_DOWNLOADS), publicName);
|
boolean ok = false;
|
||||||
if (publicFile.exists() && !publicFile.delete()) return null;
|
|
||||||
long downloadId = -1L;
|
|
||||||
try {
|
try {
|
||||||
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl))
|
connection = (HttpURLConnection) new URL(update.apkUrl).openConnection();
|
||||||
.setTitle("Nils' MG4 " + update.versionName)
|
connection.setInstanceFollowRedirects(false);
|
||||||
.setDescription("Downloading update")
|
connection.setConnectTimeout(TIMEOUT_MS);
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
connection.setReadTimeout(30_000);
|
||||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName)
|
connection.setRequestProperty("Authorization", "Bearer " + config.token);
|
||||||
.setAllowedOverMetered(true)
|
connection.setRequestProperty("User-Agent", "NilsMG4-Android");
|
||||||
.setAllowedOverRoaming(true);
|
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
||||||
downloadId = manager.enqueue(request);
|
Log.w(TAG, "Update download returned " + connection.getResponseCode());
|
||||||
while (true) {
|
return null;
|
||||||
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);
|
long total = connection.getContentLengthLong();
|
||||||
}
|
|
||||||
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
try (ParcelFileDescriptor descriptor = manager.openDownloadedFile(downloadId);
|
long received = 0;
|
||||||
java.io.InputStream input = new ParcelFileDescriptor.AutoCloseInputStream(descriptor)) {
|
int lastPercent = -2;
|
||||||
|
try (java.io.InputStream in = connection.getInputStream();
|
||||||
|
FileOutputStream out = new FileOutputStream(target, false)) {
|
||||||
byte[] buffer = new byte[64 * 1024];
|
byte[] buffer = new byte[64 * 1024];
|
||||||
int count;
|
int count;
|
||||||
while ((count = input.read(buffer)) != -1) {
|
while ((count = in.read(buffer)) != -1) {
|
||||||
|
out.write(buffer, 0, count);
|
||||||
digest.update(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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hex(digest.digest()).equalsIgnoreCase(expectedSha256)) {
|
out.getFD().sync();
|
||||||
if (!publicFile.delete()) Log.w(TAG, "Could not remove invalid OTA file");
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
return publicFile;
|
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) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Update download failed", e);
|
Log.w(TAG, "Update download failed: " + e.getClass().getSimpleName());
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
if (!ok && target.exists() && !target.delete()) Log.w(TAG, "Could not remove a bad update file");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String hex(byte[] bytes) {
|
private static String hex(byte[] bytes) {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
/**
|
/**
|
||||||
* Drives {@link OtaUpdater} — UNSTABLE BUILDS ONLY.
|
* Drives {@link OtaUpdater} — UNSTABLE BUILDS ONLY.
|
||||||
*
|
*
|
||||||
* Upstream keeps this seam inert pending a safety and legal audit of the published suite.
|
* Releases are this fork's own car builds, published as pre-releases on the private forge and
|
||||||
* This fork publishes its own builds from its own repository, signed with the platform key
|
* relayed by the car server (OtaUpdater); they are signed with the platform key the head unit
|
||||||
* the head unit already trusts, so that audit does not govern here and the seam is live.
|
* already trusts.
|
||||||
*
|
*
|
||||||
* Installation goes through the platform {@link android.content.pm.PackageInstaller}, not
|
* 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
|
* {@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
|
* authority for signing-certificate compatibility; the app verifies SHA-256 and package name
|
||||||
* before handing it the archive.
|
* before handing it the archive.
|
||||||
*
|
*
|
||||||
* Committing a session is asynchronous, so the message this returns says an install has been
|
* The verified APK stays in Downloads either way. The app first offers it to a PackageInstaller
|
||||||
* started, never that one finished. {@link OtaInstallResultReceiver} has the verdict.
|
* 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 {
|
final class UpdateHook {
|
||||||
|
|
||||||
@@ -44,9 +47,11 @@ final class UpdateHook {
|
|||||||
Context app = context.getApplicationContext();
|
Context app = context.getApplicationContext();
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
|
ServerConfig config = ServerConfig.get(app);
|
||||||
|
if (!config.isConfigured()) return;
|
||||||
String current = app.getPackageManager()
|
String current = app.getPackageManager()
|
||||||
.getPackageInfo(app.getPackageName(), 0).versionName;
|
.getPackageInfo(app.getPackageName(), 0).versionName;
|
||||||
OtaUpdater.Update update = OtaUpdater.check(current);
|
OtaUpdater.Update update = OtaUpdater.check(config, current);
|
||||||
if (update != null && update.hashUrl != null)
|
if (update != null && update.hashUrl != null)
|
||||||
new android.os.Handler(android.os.Looper.getMainLooper())
|
new android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
.post(() -> android.widget.Toast.makeText(app,
|
.post(() -> android.widget.Toast.makeText(app,
|
||||||
@@ -100,25 +105,37 @@ final class UpdateHook {
|
|||||||
return "Could not read version";
|
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 + ")";
|
if (update == null) return "Up to date (" + current + ")";
|
||||||
|
|
||||||
Log.i(TAG, "Update available: " + update.versionName);
|
Log.i(TAG, "Update available: " + update.versionName);
|
||||||
String expectedHash;
|
String expectedHash;
|
||||||
try {
|
try {
|
||||||
expectedHash = OtaUpdater.fetchExpectedSha256(update);
|
expectedHash = OtaUpdater.fetchExpectedSha256(update, config);
|
||||||
} catch (IOException e) {
|
} 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";
|
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 (listener != null) main.post(() -> listener.onProgress(percent));
|
||||||
});
|
});
|
||||||
if (apk == null) return "Download or SHA-256 verification failed ("
|
if (apk == null) return "Download or SHA-256 verification failed ("
|
||||||
+ update.versionName + ")";
|
+ 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))
|
if (!OtaUpdater.openDownloads(app))
|
||||||
return "APK downloaded to Downloads; could not open the file manager";
|
return update.versionName + " is in Downloads; open it with the Files app to install";
|
||||||
return update.versionName + ".apk downloaded to Downloads — tap the file to install";
|
return update.versionName + ".apk is in Downloads — tap the file to install";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,10 +109,62 @@
|
|||||||
echo " adb shell \"pm install -r -f -d -t /data/local/tmp/update.apk\""
|
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
|
in
|
||||||
{
|
{
|
||||||
default = pkgs.mkShell {
|
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_HOME = sdk;
|
||||||
ANDROID_SDK_ROOT = sdk;
|
ANDROID_SDK_ROOT = sdk;
|
||||||
@@ -126,6 +178,7 @@
|
|||||||
echo " ./gradlew assembleStableDebug # debug APK"
|
echo " ./gradlew assembleStableDebug # debug APK"
|
||||||
echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)"
|
echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)"
|
||||||
echo " build-car # unstable build signed for the car"
|
echo " build-car # unstable build signed for the car"
|
||||||
|
echo " publish-car # publish it for the in-app updater"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
On a fresh install, apply everything once without Grafana working, create the token, then
|
||||||
apply the Secret.
|
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
|
## Notes
|
||||||
|
|
||||||
- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later
|
- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ stringData:
|
|||||||
# Approves a car on your phone when it pairs by QR code (at least 12 characters). Leave it
|
# 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.
|
# empty to switch pairing off; then only the INGEST_TOKEN above works.
|
||||||
APPROVAL_PASSWORD: CHANGE-ME-at-least-12-characters
|
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
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
|
|||||||
+9
-3
@@ -29,8 +29,8 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# 2.5.0, pinned by digest so a re-pushed tag cannot change what runs.
|
# 2.6.0, pinned by digest so a re-pushed tag cannot change what runs.
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:93b82b24a649c7b8b6fb7c0d4ef0bfda5694689808991a211e22ddeb257a39e2
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:35ff5973836620c1e69f06b00163cf95183f938cb9830355452d4161c6c46e33
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
@@ -44,12 +44,18 @@ spec:
|
|||||||
# Where a phone reaches the server; the pairing QR code points here.
|
# Where a phone reaches the server; the pairing QR code points here.
|
||||||
- name: PUBLIC_URL
|
- name: PUBLIC_URL
|
||||||
value: https://car.k8s.stinnesbeck.com
|
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
|
- name: INFLUX_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: influxdb-auth
|
name: influxdb-auth
|
||||||
key: admin-token
|
key: admin-token
|
||||||
# INGEST_TOKEN, APPROVAL_PASSWORD.
|
# INGEST_TOKEN, APPROVAL_PASSWORD, FORGE_TOKEN.
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: mg4-server
|
name: mg4-server
|
||||||
|
|||||||
@@ -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` |
|
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
|
||||||
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
| `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 |
|
| `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` |
|
| `LISTEN_ADDR` | default `:8080` |
|
||||||
|
|
||||||
## Endpoints
|
## 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.
|
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
|
## Settings from a phone
|
||||||
|
|
||||||
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
||||||
// GET /v1/health unauthenticated liveness probe
|
// GET /v1/health unauthenticated liveness probe
|
||||||
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
|
// /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
|
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
|
||||||
// (pairing_http.go).
|
// (pairing_http.go).
|
||||||
@@ -28,6 +29,8 @@ type API struct {
|
|||||||
Pairing *Pairing
|
Pairing *Pairing
|
||||||
// Handoff carries settings typed on a phone to the car (handoff.go).
|
// Handoff carries settings typed on a phone to the car (handoff.go).
|
||||||
Handoff *Handoff
|
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;
|
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
||||||
// derived from the request when empty.
|
// derived from the request when empty.
|
||||||
PublicURL string
|
PublicURL string
|
||||||
@@ -46,6 +49,7 @@ func (a *API) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
||||||
a.pairingRoutes(mux)
|
a.pairingRoutes(mux)
|
||||||
a.handoffRoutes(mux)
|
a.handoffRoutes(mux)
|
||||||
|
a.releaseRoutes(mux)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,9 @@ type config struct {
|
|||||||
ingestToken string
|
ingestToken string
|
||||||
approvalPass string
|
approvalPass string
|
||||||
publicURL string
|
publicURL string
|
||||||
|
forgeURL string
|
||||||
|
forgeRepo string
|
||||||
|
forgeToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
// 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"),
|
ingestToken: getenv("INGEST_TOKEN"),
|
||||||
approvalPass: getenv("APPROVAL_PASSWORD"),
|
approvalPass: getenv("APPROVAL_PASSWORD"),
|
||||||
publicURL: getenv("PUBLIC_URL"),
|
publicURL: getenv("PUBLIC_URL"),
|
||||||
|
forgeURL: getenv("FORGE_URL"),
|
||||||
|
forgeRepo: getenv("FORGE_REPO"),
|
||||||
|
forgeToken: getenv("FORGE_TOKEN"),
|
||||||
}
|
}
|
||||||
if c.listenAddr == "" {
|
if c.listenAddr == "" {
|
||||||
c.listenAddr = ":8080"
|
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
|
// Pairing is optional; when it is on, its password guards token issuance and must not
|
||||||
// be guessable.
|
// 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 {
|
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
||||||
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
|
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
|
||||||
}
|
}
|
||||||
@@ -87,8 +99,9 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
pairing := NewPairing(cfg.approvalPass, devices)
|
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(),
|
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
|
||||||
PublicURL: cfg.publicURL}
|
Releases: releases, PublicURL: cfg.publicURL}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.listenAddr,
|
Addr: cfg.listenAddr,
|
||||||
@@ -103,7 +116,7 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
|
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) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
slog.Error("server failed", "error", err)
|
slog.Error("server failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user