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:
@@ -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, "<html>unavailable</html>");
|
||||
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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user