287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { getServers } from "node:dns";
|
|
import { hostname, networkInterfaces } from "node:os";
|
|
import type { CompanionDatabase } from "./database.js";
|
|
import { collectSystemStatus } from "./status.js";
|
|
import { DISABLED_ADB_CONFIG, type AdbConfig } from "./adb.js";
|
|
|
|
const MAX_OUTPUT_BYTES = 32 * 1024;
|
|
const SECRET_KEY = /authorization|cookie|csrf|password|secret|token|credential/i;
|
|
const SECRET_VALUE = /(bearer\s+\S+|(?:password|secret|token|authorization|cookie)=\S+)/gi;
|
|
|
|
export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "timed_out" | "interrupted";
|
|
|
|
export type JobDescriptor = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
timeoutMs: number;
|
|
riskLevel: "low" | "medium" | "high";
|
|
confirmationRequired: boolean;
|
|
};
|
|
|
|
export type JobDefinition = JobDescriptor & {
|
|
run: (signal: AbortSignal) => Promise<unknown>;
|
|
};
|
|
|
|
export type JobRun = {
|
|
id: string;
|
|
jobId: string;
|
|
status: JobStatus;
|
|
output: unknown | null;
|
|
errorCode: string | null;
|
|
createdAt: string;
|
|
startedAt: string | null;
|
|
finishedAt: string | null;
|
|
};
|
|
|
|
function networkDiagnostics() {
|
|
let localHostname: { available: true; value: string } | { available: false; reason: string };
|
|
let dnsServers: { available: true; value: string[] } | { available: false; reason: string };
|
|
let interfaces:
|
|
| { available: true; value: Array<{ name: string; address: string; family: string; internal: boolean; netmask: string }> }
|
|
| { available: false; reason: string };
|
|
try {
|
|
localHostname = { available: true, value: hostname() };
|
|
} catch {
|
|
localHostname = { available: false, reason: "The host name is not exposed by this system" };
|
|
}
|
|
try {
|
|
dnsServers = { available: true, value: getServers() };
|
|
} catch {
|
|
dnsServers = { available: false, reason: "Resolver configuration is not exposed by this system" };
|
|
}
|
|
try {
|
|
interfaces = {
|
|
available: true,
|
|
value: Object.entries(networkInterfaces()).flatMap(([name, addresses]) =>
|
|
(addresses ?? []).map(({ address, family, internal, netmask }) => ({ name, address, family, internal, netmask }))
|
|
)
|
|
};
|
|
} catch {
|
|
interfaces = { available: false, reason: "Network interfaces are not exposed by this system" };
|
|
}
|
|
return {
|
|
hostname: localHostname,
|
|
dnsServers,
|
|
interfaces
|
|
};
|
|
}
|
|
|
|
function supportBundle(database: CompanionDatabase, generatedAt: string, status: unknown) {
|
|
const tableCounts = Object.fromEntries(
|
|
["users", "sessions", "audit_events", "job_runs"].map((table) => {
|
|
const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
|
return [table, row.count];
|
|
})
|
|
);
|
|
const jobStates = database
|
|
.prepare("SELECT status, COUNT(*) AS count FROM job_runs GROUP BY status ORDER BY status")
|
|
.all() as Array<{ status: JobStatus; count: number }>;
|
|
|
|
return {
|
|
manifest: {
|
|
format: "pi-car-companion-support-bundle",
|
|
version: 1,
|
|
generatedAt,
|
|
entries: [
|
|
{ name: "system-status", description: "Current host and companion health", classification: "diagnostic" },
|
|
{ name: "network", description: "Local interface and resolver state", classification: "sensitive-local" },
|
|
{ name: "database-summary", description: "Record counts only; no account or session values", classification: "diagnostic" }
|
|
],
|
|
exclusions: ["password hashes", "session identifiers", "CSRF values", "cookies", "environment variables", "file contents"]
|
|
},
|
|
diagnostics: {
|
|
systemStatus: status,
|
|
network: networkDiagnostics(),
|
|
database: { tableCounts, jobStates }
|
|
}
|
|
};
|
|
}
|
|
|
|
function defaultDefinitions(database: CompanionDatabase, adbConfig: AdbConfig): readonly JobDefinition[] {
|
|
const safeJob = (definition: Omit<JobDefinition, "riskLevel" | "confirmationRequired">): JobDefinition => ({
|
|
...definition,
|
|
riskLevel: "low",
|
|
confirmationRequired: false
|
|
});
|
|
return [
|
|
safeJob({
|
|
id: "refresh-health",
|
|
title: "Refresh system health",
|
|
description: "Collect a fresh system health snapshot.",
|
|
timeoutMs: 15_000,
|
|
run: async () => collectSystemStatus(adbConfig)
|
|
}),
|
|
safeJob({
|
|
id: "network-diagnostics",
|
|
title: "Network diagnostics",
|
|
description: "Inspect local interfaces and resolver configuration without contacting the internet.",
|
|
timeoutMs: 10_000,
|
|
run: async () => networkDiagnostics()
|
|
}),
|
|
safeJob({
|
|
id: "support-bundle",
|
|
title: "Generate support bundle",
|
|
description: "Collect a bounded, sanitized diagnostic manifest without secrets or file contents.",
|
|
timeoutMs: 20_000,
|
|
run: async () => {
|
|
const generatedAt = new Date().toISOString();
|
|
return supportBundle(database, generatedAt, await collectSystemStatus(adbConfig));
|
|
}
|
|
})
|
|
];
|
|
}
|
|
|
|
function sanitize(value: unknown, key = ""): unknown {
|
|
if (SECRET_KEY.test(key)) return "[REDACTED]";
|
|
if (typeof value === "string") return value.replace(SECRET_VALUE, "[REDACTED]");
|
|
if (Array.isArray(value)) return value.map((item) => sanitize(item));
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, sanitize(child, childKey)]));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function boundedJson(value: unknown): string {
|
|
const json = JSON.stringify(sanitize(value));
|
|
if (Buffer.byteLength(json) <= MAX_OUTPUT_BYTES) return json;
|
|
return JSON.stringify({ truncated: true, message: "Job output exceeded the 32 KiB storage limit." });
|
|
}
|
|
|
|
function rowToRun(row: Record<string, unknown>): JobRun {
|
|
return {
|
|
id: String(row.id),
|
|
jobId: String(row.job_id),
|
|
status: row.status as JobStatus,
|
|
output: typeof row.output_json === "string" ? JSON.parse(row.output_json) : null,
|
|
errorCode: typeof row.error_code === "string" ? row.error_code : null,
|
|
createdAt: String(row.created_at),
|
|
startedAt: typeof row.started_at === "string" ? row.started_at : null,
|
|
finishedAt: typeof row.finished_at === "string" ? row.finished_at : null
|
|
};
|
|
}
|
|
|
|
export class JobRunner {
|
|
readonly #jobs: Map<string, JobDefinition>;
|
|
readonly #listeners = new Set<(run: JobRun) => void>();
|
|
readonly #controllers = new Set<AbortController>();
|
|
#closed = false;
|
|
|
|
constructor(
|
|
private readonly database: CompanionDatabase,
|
|
jobs?: readonly JobDefinition[],
|
|
adbConfig: AdbConfig = DISABLED_ADB_CONFIG
|
|
) {
|
|
this.#jobs = new Map((jobs ?? defaultDefinitions(database, adbConfig)).map((job) => [job.id, job]));
|
|
}
|
|
|
|
listJobs(): JobDescriptor[] {
|
|
return [...this.#jobs.values()].map((job) => ({
|
|
id: job.id,
|
|
title: job.title,
|
|
description: job.description,
|
|
timeoutMs: job.timeoutMs,
|
|
riskLevel: job.riskLevel,
|
|
confirmationRequired: job.confirmationRequired
|
|
}));
|
|
}
|
|
|
|
has(jobId: string): boolean {
|
|
return this.#jobs.has(jobId);
|
|
}
|
|
|
|
onRun(listener: (run: JobRun) => void): () => void {
|
|
this.#listeners.add(listener);
|
|
return () => this.#listeners.delete(listener);
|
|
}
|
|
|
|
close(): void {
|
|
this.#closed = true;
|
|
for (const controller of this.#controllers) controller.abort();
|
|
this.database
|
|
.prepare(
|
|
`UPDATE job_runs SET status = 'interrupted', error_code = 'PROCESS_STOPPED', finished_at = ?
|
|
WHERE status IN ('queued', 'running')`
|
|
)
|
|
.run(new Date().toISOString());
|
|
this.#listeners.clear();
|
|
}
|
|
|
|
enqueue(jobId: string, actorUserId: number): JobRun {
|
|
if (this.#closed) throw new Error("RUNNER_CLOSED");
|
|
const job = this.#jobs.get(jobId);
|
|
if (!job) throw new Error("UNKNOWN_JOB");
|
|
const id = randomUUID();
|
|
const createdAt = new Date().toISOString();
|
|
this.database
|
|
.prepare(
|
|
`INSERT INTO job_runs (id, job_id, actor_user_id, status, created_at)
|
|
VALUES (?, ?, ?, 'queued', ?)`
|
|
)
|
|
.run(id, jobId, actorUserId, createdAt);
|
|
this.#emit(id);
|
|
queueMicrotask(() => void this.#execute(id, job));
|
|
return this.getRun(id)!;
|
|
}
|
|
|
|
getRun(id: string): JobRun | null {
|
|
const row = this.database.prepare("SELECT * FROM job_runs WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
return row ? rowToRun(row) : null;
|
|
}
|
|
|
|
listRuns(limit = 50): JobRun[] {
|
|
const rows = this.database
|
|
.prepare("SELECT * FROM job_runs ORDER BY created_at DESC LIMIT ?")
|
|
.all(Math.min(Math.max(limit, 1), 100)) as Record<string, unknown>[];
|
|
return rows.map(rowToRun);
|
|
}
|
|
|
|
async #execute(id: string, job: JobDefinition): Promise<void> {
|
|
if (this.#closed) return;
|
|
const startedAt = new Date().toISOString();
|
|
this.database.prepare("UPDATE job_runs SET status = 'running', started_at = ? WHERE id = ?").run(startedAt, id);
|
|
this.#emit(id);
|
|
const controller = new AbortController();
|
|
this.#controllers.add(controller);
|
|
let timer: NodeJS.Timeout | undefined;
|
|
try {
|
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
controller.abort();
|
|
reject(new Error("JOB_TIMEOUT"));
|
|
}, job.timeoutMs);
|
|
timer.unref();
|
|
});
|
|
const output = await Promise.race([job.run(controller.signal), timeout]);
|
|
if (this.#closed) return;
|
|
this.database
|
|
.prepare("UPDATE job_runs SET status = 'succeeded', output_json = ?, finished_at = ? WHERE id = ?")
|
|
.run(boundedJson(output), new Date().toISOString(), id);
|
|
this.#emit(id);
|
|
} catch (error) {
|
|
if (this.#closed) return;
|
|
const timedOut = error instanceof Error && error.message === "JOB_TIMEOUT";
|
|
this.database
|
|
.prepare("UPDATE job_runs SET status = ?, error_code = ?, finished_at = ? WHERE id = ?")
|
|
.run(timedOut ? "timed_out" : "failed", timedOut ? "JOB_TIMEOUT" : "JOB_FAILED", new Date().toISOString(), id);
|
|
this.#emit(id);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
this.#controllers.delete(controller);
|
|
}
|
|
}
|
|
|
|
#emit(id: string): void {
|
|
const run = this.getRun(id);
|
|
if (!run) return;
|
|
for (const listener of this.#listeners) {
|
|
try {
|
|
listener(run);
|
|
} catch {
|
|
// A disconnected event consumer must not change the persisted job result.
|
|
}
|
|
}
|
|
}
|
|
}
|