Add ADB management and live car telemetry
This commit is contained in:
+154
-1
@@ -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" } } },
|
||||
|
||||
Reference in New Issue
Block a user