Add ADB management and live car telemetry

This commit is contained in:
2026-07-31 22:23:12 +02:00
parent 34b31c4caf
commit 0e4ce931ec
12 changed files with 911 additions and 23 deletions
+154 -1
View File
@@ -28,7 +28,14 @@ import {
requestHotspotConfiguration,
requestNetworkAction
} from "./network.js";
import { collectAdbStatus } from "./adb.js";
import {
collectAdbStatus,
collectCarTelemetry,
installAdbApk,
launchAdbTarget,
listInstalledAdbPackages,
MAX_APK_BYTES
} from "./adb.js";
import { openShell } from "./shell.js";
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
import "./types.js";
@@ -54,6 +61,28 @@ const setupCredentialsSchema = loginCredentialsSchema.extend({
.regex(/[0-9]/, "Password must contain at least one number")
});
const adbPackageNameSchema = z.string().trim().max(240).regex(
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/,
"Enter a valid Android package name"
);
const adbComponentSchema = z.string().trim().max(240).regex(
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*\/(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/,
"Enter a valid Android component"
);
const adbTargetSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("component"), value: adbComponentSchema }),
z.object({ type: z.literal("package"), value: adbPackageNameSchema })
]);
const adbShortcutSchema = z.object({ name: z.string().trim().min(1).max(48), type: z.enum(["component", "package"]), value: z.string() })
.transform((value, context) => {
const target = adbTargetSchema.safeParse({ type: value.type, value: value.value });
if (!target.success) {
context.addIssue({ code: "custom", message: target.error.issues[0]?.message ?? "Enter a valid Android target" });
return z.NEVER;
}
return { name: value.name, ...target.data };
});
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
type SessionUserRow = { id: number; username: string; role: "admin" };
@@ -118,6 +147,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
await app.register(cookie);
await app.register(rateLimit, { global: false });
await app.register(websocket);
app.addContentTypeParser(
["application/vnd.android.package-archive", "application/octet-stream"],
{ parseAs: "buffer" },
(_request, body, done) => done(null, body)
);
app.addHook("onClose", async () => {
jobRunner.close();
@@ -335,6 +369,125 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return readNetworkActivity(config.network.activityPath);
});
app.get("/api/adb/packages", async (request, reply) => {
if (!requireUser(request, reply)) return;
try {
return { packages: await listInstalledAdbPackages(config.adb) };
} catch {
return reply.code(503).send({ error: { code: "ADB_UNAVAILABLE", message: "The infotainment unit is not available over ADB" } });
}
});
app.post(
"/api/adb/install",
{ config: { rateLimit: { max: 4, timeWindow: "10 minutes" } }, bodyLimit: MAX_APK_BYTES },
async (request, reply) => {
if (!requireUser(request, reply)) return;
if (!Buffer.isBuffer(request.body)) {
return reply.code(400).send({ error: { code: "INVALID_APK", message: "Choose a valid APK file" } });
}
try {
await installAdbApk(config.adb, request.body);
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.install-apk",
result: "success",
ipAddress: clientAddress(request),
details: { bytes: request.body.byteLength }
});
return reply.code(201).send({ installed: true });
} catch (error) {
const invalid = error instanceof Error && error.message === "INVALID_APK";
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.install-apk",
result: "failure",
ipAddress: clientAddress(request),
details: { reason: invalid ? "invalid_apk" : "install_failed" }
});
return reply.code(invalid ? 400 : 503).send({
error: {
code: invalid ? "INVALID_APK" : "APK_INSTALL_FAILED",
message: invalid ? "Choose a valid APK file" : "The APK could not be installed on the infotainment unit"
}
});
}
}
);
app.post("/api/adb/launch", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = adbTargetSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: { code: "INVALID_ADB_TARGET", message: "Enter a valid package or activity" } });
}
try {
await launchAdbTarget(config.adb, parsed.data);
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.launch",
result: "success",
ipAddress: clientAddress(request),
details: { targetType: parsed.data.type, target: parsed.data.value }
});
return { launched: true };
} catch {
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.launch",
result: "failure",
ipAddress: clientAddress(request),
details: { targetType: parsed.data.type, target: parsed.data.value }
});
return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The target could not be opened on the infotainment unit" } });
}
});
app.get("/api/adb/shortcuts", async (request, reply) => {
if (!requireUser(request, reply)) return;
const shortcuts = database
.prepare("SELECT id, name, target_type AS type, target_value AS value, created_at AS createdAt FROM adb_shortcuts ORDER BY created_at, id")
.all();
return { shortcuts };
});
app.post("/api/adb/shortcuts", async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = adbShortcutSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "Enter a name and a valid target" } });
}
try {
const createdAt = new Date().toISOString();
const result = database
.prepare("INSERT INTO adb_shortcuts (name, target_type, target_value, created_at) VALUES (?, ?, ?, ?)")
.run(parsed.data.name, parsed.data.type, parsed.data.value, createdAt);
return reply.code(201).send({ id: Number(result.lastInsertRowid), ...parsed.data, createdAt });
} catch {
return reply.code(409).send({ error: { code: "ADB_SHORTCUT_EXISTS", message: "A shortcut for that target already exists" } });
}
});
app.delete<{ Params: { id: string } }>("/api/adb/shortcuts/:id", async (request, reply) => {
if (!requireUser(request, reply)) return;
const id = Number(request.params.id);
if (!Number.isInteger(id) || id < 1) {
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "That shortcut is invalid" } });
}
const result = database.prepare("DELETE FROM adb_shortcuts WHERE id = ?").run(id);
if (result.changes === 0) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
return reply.code(204).send();
});
app.get("/api/car/telemetry", async (request, reply) => {
if (!requireUser(request, reply)) return;
try {
return await collectCarTelemetry(config.adb);
} catch {
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
}
});
app.post(
"/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },