Add live hotspot network diagnostics
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
SESSION_COOKIE
|
||||
} from "./security.js";
|
||||
import { collectSystemStatus } from "./status.js";
|
||||
import { readNetworkActivity } from "./network-activity.js";
|
||||
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
||||
import {
|
||||
hotspotConfigurationSchema,
|
||||
@@ -325,6 +326,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
return readNetworkStatus(config);
|
||||
});
|
||||
|
||||
app.get("/api/network/activity", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
return readNetworkActivity(config.network.activityPath);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/network/configuration",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||
|
||||
@@ -21,6 +21,7 @@ const schema = z.object({
|
||||
NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"),
|
||||
NETWORK_COMMAND_PATH: z.string().default("/var/lib/pi-car-companion/network-command.json"),
|
||||
NETWORK_CONFIGURATION_REQUEST_PATH: z.string().default("/var/lib/pi-car-companion/network-configuration-request.json"),
|
||||
NETWORK_ACTIVITY_PATH: z.string().default("/var/lib/pi-car-companion/network-activity.json"),
|
||||
ADB_ENABLED: booleanFromString,
|
||||
ADB_PATH: z.string().min(1).default("adb"),
|
||||
ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""),
|
||||
@@ -42,6 +43,7 @@ export type AppConfig = {
|
||||
statusPath: string;
|
||||
commandPath: string;
|
||||
configurationRequestPath: string;
|
||||
activityPath: string;
|
||||
};
|
||||
adb: AdbConfig;
|
||||
};
|
||||
@@ -74,7 +76,8 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
|
||||
enabled: parsed.NETWORK_MANAGER_ENABLED,
|
||||
statusPath: parsed.NETWORK_STATUS_PATH,
|
||||
commandPath: parsed.NETWORK_COMMAND_PATH,
|
||||
configurationRequestPath: parsed.NETWORK_CONFIGURATION_REQUEST_PATH
|
||||
configurationRequestPath: parsed.NETWORK_CONFIGURATION_REQUEST_PATH,
|
||||
activityPath: parsed.NETWORK_ACTIVITY_PATH
|
||||
},
|
||||
adb: {
|
||||
enabled: parsed.ADB_ENABLED,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
const clientSchema = z.object({
|
||||
ipAddress: z.string().max(64),
|
||||
macAddress: z.string().max(32),
|
||||
hostname: z.string().max(128).nullable(),
|
||||
leaseExpiresAt: z.string().nullable(),
|
||||
state: z.string().max(32),
|
||||
connected: z.boolean()
|
||||
});
|
||||
|
||||
const dnsQuerySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
clientAddress: z.string().max(64),
|
||||
type: z.string().max(16),
|
||||
name: z.string().max(253)
|
||||
});
|
||||
|
||||
const flowSchema = z.object({
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
state: z.string().max(32),
|
||||
sourceAddress: z.string().max(64),
|
||||
sourcePort: z.number().int().min(0).max(65_535),
|
||||
destinationAddress: z.string().max(64),
|
||||
destinationPort: z.number().int().min(0).max(65_535),
|
||||
packets: z.number().int().nonnegative().nullable(),
|
||||
bytes: z.number().int().nonnegative().nullable()
|
||||
});
|
||||
|
||||
export const networkActivitySchema = z.object({
|
||||
collectedAt: z.string(),
|
||||
clients: z.array(clientSchema).max(256),
|
||||
dnsQueries: z.array(dnsQuerySchema).max(250),
|
||||
flows: z.array(flowSchema).max(150),
|
||||
dnsAvailable: z.boolean(),
|
||||
flowsAvailable: z.boolean(),
|
||||
messages: z.array(z.string().max(300)).max(8)
|
||||
});
|
||||
|
||||
export type NetworkActivity = z.infer<typeof networkActivitySchema> & { supported: boolean };
|
||||
|
||||
export async function readNetworkActivity(path: string): Promise<NetworkActivity> {
|
||||
try {
|
||||
return { ...networkActivitySchema.parse(JSON.parse(await readFile(path, "utf8"))), supported: true };
|
||||
} catch {
|
||||
return {
|
||||
supported: false,
|
||||
collectedAt: new Date(0).toISOString(),
|
||||
clients: [],
|
||||
dnsQueries: [],
|
||||
flows: [],
|
||||
dnsAvailable: false,
|
||||
flowsAvailable: false,
|
||||
messages: ["Network activity is available after the observer service is installed and started."]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { networkActivitySchema } from "./network-activity.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const config = z
|
||||
.object({
|
||||
NETWORK_ACTIVITY_PATH: z.string().default("/var/lib/pi-car-companion/network-activity.json"),
|
||||
NETWORK_HOTSPOT_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan0"),
|
||||
NETWORK_OBSERVER_INTERVAL_MS: z.coerce.number().int().min(1_000).max(30_000).default(2_000)
|
||||
})
|
||||
.parse(process.env);
|
||||
|
||||
type Activity = z.infer<typeof networkActivitySchema>;
|
||||
type Client = Activity["clients"][number];
|
||||
type DnsQuery = Activity["dnsQueries"][number];
|
||||
type Flow = Activity["flows"][number];
|
||||
let stopped = false;
|
||||
|
||||
async function execute(file: string, args: string[], maxBuffer = 256 * 1024): Promise<string> {
|
||||
const { stdout } = await execFileAsync(file, args, { timeout: 10_000, maxBuffer });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function clients(): Promise<Client[]> {
|
||||
const leasePath = `/var/lib/NetworkManager/dnsmasq-${config.NETWORK_HOTSPOT_INTERFACE}.leases`;
|
||||
const [leases, neighbors] = await Promise.all([
|
||||
readFile(leasePath, "utf8").catch(() => ""),
|
||||
execute("/usr/sbin/ip", ["neigh", "show", "dev", config.NETWORK_HOTSPOT_INTERFACE]).catch(() => "")
|
||||
]);
|
||||
const states = new Map<string, { mac: string | null; state: string }>();
|
||||
for (const line of neighbors.trim().split("\n")) {
|
||||
const match = line.match(/^(\S+)(?:\s+lladdr\s+(\S+))?\s+(\S+)$/);
|
||||
if (match?.[1] && match[3]) states.set(match[1], { mac: match[2] ?? null, state: match[3] });
|
||||
}
|
||||
const result = new Map<string, Client>();
|
||||
for (const line of leases.trim().split("\n")) {
|
||||
const [expiry, mac, ip, hostname] = line.trim().split(/\s+/);
|
||||
if (!expiry || !mac || !ip) continue;
|
||||
const neighbor = states.get(ip);
|
||||
result.set(ip, {
|
||||
ipAddress: ip,
|
||||
macAddress: mac,
|
||||
hostname: hostname && hostname !== "*" ? hostname.slice(0, 128) : null,
|
||||
leaseExpiresAt: Number.isFinite(Number(expiry)) ? new Date(Number(expiry) * 1000).toISOString() : null,
|
||||
state: neighbor?.state ?? "LEASED",
|
||||
connected: Boolean(neighbor && !["FAILED", "INCOMPLETE"].includes(neighbor.state))
|
||||
});
|
||||
}
|
||||
for (const [ip, neighbor] of states) {
|
||||
if (!result.has(ip)) {
|
||||
result.set(ip, {
|
||||
ipAddress: ip,
|
||||
macAddress: neighbor.mac ?? "unknown",
|
||||
hostname: null,
|
||||
leaseExpiresAt: null,
|
||||
state: neighbor.state,
|
||||
connected: !["FAILED", "INCOMPLETE"].includes(neighbor.state)
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...result.values()].sort((left, right) => Number(right.connected) - Number(left.connected) || left.ipAddress.localeCompare(right.ipAddress));
|
||||
}
|
||||
|
||||
async function dnsQueries(): Promise<{ available: boolean; queries: DnsQuery[] }> {
|
||||
try {
|
||||
const output = await execute(
|
||||
"/usr/bin/journalctl",
|
||||
["--unit", "NetworkManager.service", "--since", "-15 minutes", "--output", "json", "--no-pager"],
|
||||
1024 * 1024
|
||||
);
|
||||
const queries: DnsQuery[] = [];
|
||||
for (const line of output.trim().split("\n")) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as { MESSAGE?: unknown; __REALTIME_TIMESTAMP?: unknown };
|
||||
if (typeof entry.MESSAGE !== "string") continue;
|
||||
const match = entry.MESSAGE.match(/query\[([^\]]+)]\s+(\S+)\s+from\s+(10\.42\.0\.\d+)/);
|
||||
if (!match?.[1] || !match[2] || !match[3]) continue;
|
||||
const microseconds = Number(entry.__REALTIME_TIMESTAMP);
|
||||
queries.push({
|
||||
timestamp: Number.isFinite(microseconds) ? new Date(Math.floor(microseconds / 1000)).toISOString() : new Date().toISOString(),
|
||||
clientAddress: match[3],
|
||||
type: match[1].slice(0, 16),
|
||||
name: match[2].slice(0, 253)
|
||||
});
|
||||
} catch {
|
||||
// Ignore unrelated or malformed journal records.
|
||||
}
|
||||
}
|
||||
return { available: true, queries: queries.slice(-250).reverse() };
|
||||
} catch {
|
||||
return { available: false, queries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function value(line: string, name: string): string | null {
|
||||
return line.match(new RegExp(`(?:^|\\s)${name}=([^\\s]+)`))?.[1] ?? null;
|
||||
}
|
||||
|
||||
function numericValue(line: string, name: string): number | null {
|
||||
const raw = value(line, name);
|
||||
if (raw === null) return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function flows(): Promise<{ available: boolean; flows: Flow[] }> {
|
||||
try {
|
||||
const output = await execute("/usr/sbin/conntrack", ["--list"], 1024 * 1024);
|
||||
const parsed: Flow[] = [];
|
||||
for (const line of output.trim().split("\n")) {
|
||||
const protocol = line.match(/^(tcp|udp)\s/)?.[1] as "tcp" | "udp" | undefined;
|
||||
const sourceAddress = value(line, "src");
|
||||
const destinationAddress = value(line, "dst");
|
||||
const sourcePort = numericValue(line, "sport");
|
||||
const destinationPort = numericValue(line, "dport");
|
||||
if (!protocol || !sourceAddress?.startsWith("10.42.0.") || !destinationAddress || sourcePort === null || destinationPort === null) continue;
|
||||
const state = protocol === "tcp" ? line.match(/^tcp\s+\d+\s+\d+\s+(\S+)/)?.[1] ?? "UNKNOWN" : "ACTIVE";
|
||||
const packets = numericValue(line, "packets");
|
||||
const bytes = numericValue(line, "bytes");
|
||||
parsed.push({
|
||||
protocol,
|
||||
state,
|
||||
sourceAddress,
|
||||
sourcePort,
|
||||
destinationAddress,
|
||||
destinationPort,
|
||||
packets,
|
||||
bytes
|
||||
});
|
||||
}
|
||||
return { available: true, flows: parsed.slice(0, 150) };
|
||||
} catch {
|
||||
return { available: false, flows: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(): Promise<Activity> {
|
||||
const [clientList, dns, activeFlows] = await Promise.all([clients(), dnsQueries(), flows()]);
|
||||
const messages: string[] = [];
|
||||
if (!dns.available) messages.push("DNS query metadata is unavailable; restart the hotspot after installing its dnsmasq configuration.");
|
||||
if (!activeFlows.available) messages.push("Active flows are unavailable because conntrack is not installed or accessible.");
|
||||
return networkActivitySchema.parse({
|
||||
collectedAt: new Date().toISOString(),
|
||||
clients: clientList,
|
||||
dnsQueries: dns.queries,
|
||||
flows: activeFlows.flows,
|
||||
dnsAvailable: dns.available,
|
||||
flowsAvailable: activeFlows.available,
|
||||
messages
|
||||
});
|
||||
}
|
||||
|
||||
async function publish(activity: Activity): Promise<void> {
|
||||
const temporary = `${config.NETWORK_ACTIVITY_PATH}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(activity)}\n`, { mode: 0o644 });
|
||||
await chmod(temporary, 0o644);
|
||||
await rename(temporary, config.NETWORK_ACTIVITY_PATH);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => { stopped = true; });
|
||||
process.on("SIGTERM", () => { stopped = true; });
|
||||
while (!stopped) {
|
||||
try {
|
||||
await publish(await collect());
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : "Network activity collection failed");
|
||||
}
|
||||
await sleep(config.NETWORK_OBSERVER_INTERVAL_MS);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ function testConfig(): AppConfig {
|
||||
enabled: false,
|
||||
statusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-network-status.json`,
|
||||
commandPath: `/tmp/pi-car-companion-test-${process.pid}-network-command.json`,
|
||||
configurationRequestPath: `/tmp/pi-car-companion-test-${process.pid}-network-configuration.json`
|
||||
configurationRequestPath: `/tmp/pi-car-companion-test-${process.pid}-network-configuration.json`,
|
||||
activityPath: `/tmp/pi-car-companion-test-${process.pid}-network-activity.json`
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
@@ -189,6 +190,7 @@ describe("authentication boundary", () => {
|
||||
it("keeps network status and controls behind authentication and explicit configuration", async () => {
|
||||
const app = await createApp();
|
||||
expect((await app.inject({ method: "GET", url: "/api/network" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/network/activity" })).statusCode).toBe(401);
|
||||
|
||||
const token = await csrf(app);
|
||||
await setup(app, token);
|
||||
@@ -203,6 +205,9 @@ describe("authentication boundary", () => {
|
||||
const status = await app.inject({ method: "GET", url: "/api/network", headers: { cookie } });
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toMatchObject({ supported: false, enabled: false, mode: "disabled" });
|
||||
const activity = await app.inject({ method: "GET", url: "/api/network/activity", headers: { cookie } });
|
||||
expect(activity.statusCode).toBe(200);
|
||||
expect(activity.json()).toMatchObject({ supported: false, clients: [], dnsQueries: [], flows: [] });
|
||||
|
||||
const action = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -27,7 +27,8 @@ describe("ADB configuration", () => {
|
||||
enabled: false,
|
||||
statusPath: "/var/lib/pi-car-companion/network-status.json",
|
||||
commandPath: "/var/lib/pi-car-companion/network-command.json",
|
||||
configurationRequestPath: "/var/lib/pi-car-companion/network-configuration-request.json"
|
||||
configurationRequestPath: "/var/lib/pi-car-companion/network-configuration-request.json",
|
||||
activityPath: "/var/lib/pi-car-companion/network-activity.json"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readNetworkActivity } from "../src/network-activity.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("network activity snapshot", () => {
|
||||
it("accepts only bounded sanitized observer output", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-activity-test-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "activity.json");
|
||||
await writeFile(path, JSON.stringify({
|
||||
collectedAt: "2026-07-31T20:00:00.000Z",
|
||||
clients: [{ ipAddress: "10.42.0.20", macAddress: "aa:bb:cc:dd:ee:ff", hostname: "head-unit", leaseExpiresAt: null, state: "REACHABLE", connected: true }],
|
||||
dnsQueries: [{ timestamp: "2026-07-31T20:00:00.000Z", clientAddress: "10.42.0.20", type: "A", name: "example.com" }],
|
||||
flows: [{ protocol: "tcp", state: "ESTABLISHED", sourceAddress: "10.42.0.20", sourcePort: 50000, destinationAddress: "1.1.1.1", destinationPort: 443, packets: 4, bytes: 1000 }],
|
||||
dnsAvailable: true,
|
||||
flowsAvailable: true,
|
||||
messages: []
|
||||
}));
|
||||
await expect(readNetworkActivity(path)).resolves.toMatchObject({ supported: true, clients: [{ hostname: "head-unit" }] });
|
||||
});
|
||||
|
||||
it("does not expose malformed observer files", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-activity-test-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "activity.json");
|
||||
await writeFile(path, '{"clients":"not-an-array"}');
|
||||
await expect(readNetworkActivity(path)).resolves.toMatchObject({ supported: false, clients: [] });
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ function config(directory: string, enabled = true): AppConfig {
|
||||
enabled,
|
||||
statusPath: join(directory, "status.json"),
|
||||
commandPath: join(directory, "command.json"),
|
||||
configurationRequestPath: join(directory, "configuration.json")
|
||||
configurationRequestPath: join(directory, "configuration.json"),
|
||||
activityPath: join(directory, "activity.json")
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
|
||||
@@ -22,7 +22,8 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
|
||||
enabled: false,
|
||||
statusPath: join(tmpdir(), "missing-network-status.json"),
|
||||
commandPath: join(tmpdir(), "network-command.json"),
|
||||
configurationRequestPath: join(tmpdir(), "network-configuration.json")
|
||||
configurationRequestPath: join(tmpdir(), "network-configuration.json"),
|
||||
activityPath: join(tmpdir(), "network-activity.json")
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user