From 37e2cb154e96e0f743b9a21a39d5115d61084311 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 31 Jul 2026 21:01:02 +0200 Subject: [PATCH] Add live hotspot network diagnostics --- .env.example | 1 + PLAN.md | 1 + README.md | 14 +- config/dnsmasq-shared-pi-car-companion.conf | 2 + install.sh | 9 +- scripts/pi-car-companion-update | 4 + server/src/app.ts | 6 + server/src/config.ts | 5 +- server/src/network-activity.ts | 58 ++++++ server/src/network-observer.ts | 173 ++++++++++++++++++ server/tests/app.test.ts | 7 +- server/tests/config.test.ts | 3 +- server/tests/network-activity.test.ts | 37 ++++ server/tests/network.test.ts | 3 +- server/tests/update.test.ts | 3 +- .../pi-car-companion-network-observer.service | 29 +++ web/src/App.tsx | 94 +++++++++- web/src/api.ts | 15 ++ web/src/styles.css | 37 ++++ 19 files changed, 490 insertions(+), 11 deletions(-) create mode 100644 config/dnsmasq-shared-pi-car-companion.conf create mode 100644 server/src/network-activity.ts create mode 100644 server/src/network-observer.ts create mode 100644 server/tests/network-activity.test.ts create mode 100644 systemd/pi-car-companion-network-observer.service diff --git a/.env.example b/.env.example index 0a81385..244a60e 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ NETWORK_MANAGER_ENABLED=false NETWORK_STATUS_PATH=/var/lib/pi-car-companion/network-status.json NETWORK_COMMAND_PATH=/var/lib/pi-car-companion/network-command.json NETWORK_CONFIGURATION_REQUEST_PATH=/var/lib/pi-car-companion/network-configuration-request.json +NETWORK_ACTIVITY_PATH=/var/lib/pi-car-companion/network-activity.json NETWORK_HOTSPOT_INTERFACE=wlan0 NETWORK_UPSTREAM_INTERFACE=wlan1 NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot diff --git a/PLAN.md b/PLAN.md index bc075ec..8c7f0a6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -54,6 +54,7 @@ Core operation must work without cloud access. Optional remote access is a later - Companion service restart job - Reboot and shutdown actions with strong confirmation - Audit filtering and diagnostic export improvements +- Live hotspot client, DNS-query metadata, and active-flow diagnostics - Optional PWA installation support ### Later, only after real-world validation diff --git a/README.md b/README.md index c99ac63..ac71be3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The current MVP slice includes: - an idempotent Raspberry Pi installer with systemd start-on-boot support; - atomic, dashboard-triggered Git updates with automatic verification and rollback safety; - optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot; +- authenticated live network diagnostics for hotspot clients, DNS query metadata, and active destination flows; - automated API security tests and reproducible quality commands. The ADB transport, job UI, artifacts, Android companion, and remaining operations work are tracked in [PLAN.md](./PLAN.md). @@ -157,6 +158,7 @@ Important values: - `NETWORK_MANAGER_ENABLED`: enables the separately supervised NetworkManager controller after explicit setup - `NETWORK_STATUS_PATH` and `NETWORK_COMMAND_PATH`: validated state and fixed-action request files - `NETWORK_CONFIGURATION_REQUEST_PATH`: mode-0600 one-time hotspot configuration request consumed by the root service +- `NETWORK_ACTIVITY_PATH`: bounded observer snapshot consumed by the authenticated dashboard - `NETWORK_HOTSPOT_INTERFACE` / `NETWORK_UPSTREAM_INTERFACE`: default to built-in `wlan0` and USB `wlan1` - `NETWORK_PROBE_TIMEOUT_SECONDS`: maximum built-in upstream attempt before restoring the offline hotspot @@ -187,7 +189,17 @@ Behavior by mode: - no upstream after 60 seconds: `wlan0` returns to the offline hotspot automatically. - `wlan1` returns: it must connect and remain stable before `wlan0` returns to hotspot mode. -Add phone hotspots as additional saved `wlan1` profiles. Dashboard actions only select existing NetworkManager profiles; Wi-Fi credentials are not accepted through the API. +Add phone hotspots as additional saved `wlan1` profiles. Upstream actions only select existing NetworkManager profiles; the dashboard credential form configures the isolated car hotspot, not arbitrary upstream networks. + +### Network activity diagnostics + +The authenticated **Network** page refreshes every two seconds and reports: + +- current and recently leased hotspot clients from NetworkManager DHCP leases and the neighbor table; +- up to 250 DNS query names/types from the previous 15 minutes; +- up to 150 active TCP/UDP destination IP, port, state, packet, and byte counters from conntrack. + +The observer runs read-only as a separate root service and publishes a validated snapshot to the unprivileged application. It does not capture packet payloads, passwords, cookies, HTTP bodies, or HTTPS paths. DNS metadata comes from the system journal and follows its configured retention; the dashboard snapshot is overwritten rather than appended. ### Read-only ADB setup diff --git a/config/dnsmasq-shared-pi-car-companion.conf b/config/dnsmasq-shared-pi-car-companion.conf new file mode 100644 index 0000000..57de942 --- /dev/null +++ b/config/dnsmasq-shared-pi-car-companion.conf @@ -0,0 +1,2 @@ +# Query metadata only. Replies, packet bodies, and client payloads are not logged. +log-queries=extra diff --git a/install.sh b/install.sh index e5146cd..b32e77f 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,7 @@ fi echo "[1/7] Installing operating-system packages" export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential network-manager wpasupplicant +apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential network-manager wpasupplicant conntrack node_major="" if command -v node >/dev/null 2>&1; then @@ -116,10 +116,13 @@ install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/sy install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service +install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null install -d -m 0755 /etc/avahi/services install -m 0644 "${SCRIPT_DIR}/config/pi-car-companion.service.xml" /etc/avahi/services/pi-car-companion.service +install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d +install -m 0644 "${SCRIPT_DIR}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf if [[ ! -f "${CONFIG_DIR}/companion.env" ]]; then cat > "${CONFIG_DIR}/companion.env" < { 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" } } }, diff --git a/server/src/config.ts b/server/src/config.ts index f15dbf6..1321694 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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, diff --git a/server/src/network-activity.ts b/server/src/network-activity.ts new file mode 100644 index 0000000..11506be --- /dev/null +++ b/server/src/network-activity.ts @@ -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 & { supported: boolean }; + +export async function readNetworkActivity(path: string): Promise { + 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."] + }; + } +} diff --git a/server/src/network-observer.ts b/server/src/network-observer.ts new file mode 100644 index 0000000..e83de9b --- /dev/null +++ b/server/src/network-observer.ts @@ -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; +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 { + const { stdout } = await execFileAsync(file, args, { timeout: 10_000, maxBuffer }); + return stdout; +} + +async function clients(): Promise { + 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(); + 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(); + 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 { + 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 { + 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); +} diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index f5677a5..58ce402 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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", diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 9f7f017..f4eeb54 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -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" }); }); }); diff --git a/server/tests/network-activity.test.ts b/server/tests/network-activity.test.ts new file mode 100644 index 0000000..9199d34 --- /dev/null +++ b/server/tests/network-activity.test.ts @@ -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: [] }); + }); +}); diff --git a/server/tests/network.test.ts b/server/tests/network.test.ts index 84cce88..d67c074 100644 --- a/server/tests/network.test.ts +++ b/server/tests/network.test.ts @@ -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 } }; diff --git a/server/tests/update.test.ts b/server/tests/update.test.ts index 2c89379..7721244 100644 --- a/server/tests/update.test.ts +++ b/server/tests/update.test.ts @@ -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 } }; diff --git a/systemd/pi-car-companion-network-observer.service b/systemd/pi-car-companion-network-observer.service new file mode 100644 index 0000000..a37c61f --- /dev/null +++ b/systemd/pi-car-companion-network-observer.service @@ -0,0 +1,29 @@ +[Unit] +Description=Pi Car Companion network activity observer +After=NetworkManager.service pi-car-companion-network.service +Wants=NetworkManager.service + +[Service] +Type=simple +EnvironmentFile=/etc/pi-car-companion/companion.env +Environment=HOME=/var/lib/pi-car-companion +WorkingDirectory=/opt/pi-car-companion/current +ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/network-observer.js +Restart=on-failure +RestartSec=5s +TimeoutStopSec=15s +UMask=0027 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +LockPersonality=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +ReadWritePaths=/var/lib/pi-car-companion + +[Install] +WantedBy=multi-user.target diff --git a/web/src/App.tsx b/web/src/App.tsx index 482c96d..812c135 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,11 +19,13 @@ import { getAuthState, getStatus, getNetworkStatus, + getNetworkActivity, getUpdateStatus, post, type AuthState, type SystemStatus, type NetworkStatus, + type NetworkActivity, type UpdateStatus } from "./api"; @@ -152,7 +154,7 @@ function AccountScreen({ } function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise }) { - const [page, setPage] = useState<"home" | "settings">("home"); + const [page, setPage] = useState<"home" | "network" | "settings">("home"); const [status, setStatus] = useState(null); const [error, setError] = useState(""); const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting"); @@ -194,6 +196,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
Pi Companion
@@ -202,8 +205,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
-

{page === "home" ? "System overview" : "Companion settings"}

-

{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}

+

{page === "home" ? "System overview" : page === "network" ? "Live diagnostics" : "Companion settings"}

+

{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "network" ? "Network activity" : "Settings"}

@@ -218,12 +221,95 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => {page === "home" ? <> {error &&
{error}
} {!status ? : } - : } + : page === "network" ? : }
); } +function NetworkActivityPage() { + const [activity, setActivity] = useState(null); + const [error, setError] = useState(""); + const [paused, setPaused] = useState(false); + + const refresh = useCallback(async () => { + try { + setActivity(await getNetworkActivity()); + setError(""); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Network activity is unavailable"); + } + }, []); + + useEffect(() => { + void refresh(); + if (paused) return; + const interval = window.setInterval(() => void refresh(), 2_000); + return () => window.clearInterval(interval); + }, [paused, refresh]); + + const connected = activity?.clients.filter((client) => client.connected).length ?? 0; + return ( +
+
+
Connected clients{connected}
+
Recent DNS queries{activity?.dnsQueries.length ?? 0}
+
Active flows{activity?.flows.length ?? 0}
+
Snapshot{activity?.supported ? new Date(activity.collectedAt).toLocaleTimeString() : "Unavailable"}
+ +
+ + {error &&
{error}
} + {activity?.messages.map((message) =>
{message}
)} + +
+

Hotspot clients

DHCP leases and current neighbor reachability.

+
+ {activity?.clients.length ? activity.clients.map((client) => ( +
+
+ )) : } +
+
+ +
+
+

DNS requests

Query metadata from clients during the last 15 minutes.

{activity?.dnsAvailable ? "Live" : "Unavailable"}
+
+ + {activity?.dnsQueries.map((query, index) => )} +
TimeClientTypeName
{new Date(query.timestamp).toLocaleTimeString()}{query.clientAddress}{query.type}{query.name}
+ {!activity?.dnsQueries.length && } +
+
+ +
+

Active connections

Destination metadata only; encrypted content remains private.

{activity?.flowsAvailable ? "Live" : "Unavailable"}
+
+ + {activity?.flows.map((flow, index) => )} +
ClientProtocolDestinationState
{flow.sourceAddress}{flow.protocol.toUpperCase()}{flow.destinationAddress}:{flow.destinationPort}{portLabel(flow.destinationPort)}{flow.state}
+ {!activity?.flows.length && } +
+
+
+

This page records bounded connection metadata only. It does not capture payloads, passwords, cookies, or HTTPS paths.

+
+ ); +} + +function ActivityEmpty({ text }: { text: string }) { + return
{text}
; +} + +function portLabel(port: number): string { + return ({ 53: "DNS", 80: "HTTP", 123: "NTP", 443: "HTTPS", 853: "DNS over TLS" } as Record)[port] ?? ""; +} + function SettingsPage({ csrfToken }: { csrfToken: string }) { const [update, setUpdate] = useState(null); const [network, setNetwork] = useState(null); diff --git a/web/src/api.ts b/web/src/api.ts index 434f8a2..5420903 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -89,6 +89,17 @@ export type NetworkAdapterStatus = { address: string | null; }; +export type NetworkActivity = { + supported: boolean; + collectedAt: string; + clients: Array<{ ipAddress: string; macAddress: string; hostname: string | null; leaseExpiresAt: string | null; state: string; connected: boolean }>; + dnsQueries: Array<{ timestamp: string; clientAddress: string; type: string; name: string }>; + flows: Array<{ protocol: "tcp" | "udp"; state: string; sourceAddress: string; sourcePort: number; destinationAddress: string; destinationPort: number; packets: number | null; bytes: number | null }>; + dnsAvailable: boolean; + flowsAvailable: boolean; + messages: string[]; +}; + type ErrorResponse = { error?: { code?: string; message?: string } }; export class ApiError extends Error { @@ -135,3 +146,7 @@ export async function getUpdateStatus(): Promise { export async function getNetworkStatus(): Promise { return parseResponse(await fetch("/api/network", { credentials: "same-origin" })); } + +export async function getNetworkActivity(): Promise { + return parseResponse(await fetch("/api/network/activity", { credentials: "same-origin" })); +} diff --git a/web/src/styles.css b/web/src/styles.css index c3b8c14..6eedf62 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -103,6 +103,38 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .system-strip div { min-width: 0; display: grid; } .system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; } +.activity-layout { display: grid; gap: 18px; } +.activity-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: center; gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); } +.activity-summary > div { min-height: 82px; display: grid; align-content: center; gap: 7px; padding: 15px 20px; background: var(--surface); } +.activity-summary span { color: var(--muted); font-size: 0.76rem; } +.activity-summary strong { font: 750 1.25rem/1 ui-monospace, "Cascadia Code", monospace; } +.activity-summary button { margin: 0 16px; } +.activity-message { display: flex; align-items: center; gap: 9px; padding: 11px 14px; color: #ffd5cf; background: #321d19; border: 1px solid #6b332a; border-radius: 9px; font-size: 0.8rem; } +.activity-panel { min-width: 0; padding: 21px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } +.activity-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; } +.activity-heading h2 { margin: 0 0 5px; font-size: 1.12rem; } +.activity-heading p { margin: 0; color: var(--muted); font-size: 0.78rem; } +.activity-heading > span { color: var(--accent); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; text-transform: uppercase; } +.client-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.client-row { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 11px; min-height: 55px; padding: 9px 13px; background: #121610; border-radius: 8px; } +.client-row div { min-width: 0; display: grid; gap: 3px; } +.client-row strong, .client-row small, .client-row code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client-row strong { font-size: 0.86rem; } +.client-row small, .client-row > span:last-child { color: var(--muted); font-size: 0.7rem; } +.client-row code { color: #cbd3c5; font-size: 0.72rem; } +.client-dot { width: 8px; height: 8px; border-radius: 50%; background: #777f72; } +.client-dot.online { background: var(--accent); box-shadow: 0 0 0 4px rgba(185, 231, 105, 0.09); } +.activity-columns { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; } +.activity-table-wrap { max-height: 285px; overflow: auto; border: 1px solid var(--line); border-radius: 9px; } +.activity-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; } +.activity-table th { position: sticky; top: 0; z-index: 1; padding: 10px 11px; color: var(--muted); background: #151914; font-size: 0.67rem; letter-spacing: 0.06em; text-align: left; text-transform: uppercase; } +.activity-table td { max-width: 280px; padding: 10px 11px; border-top: 1px solid var(--line); white-space: nowrap; } +.activity-table td.request-name { overflow: hidden; text-overflow: ellipsis; } +.activity-table td small { display: block; margin-top: 3px; color: var(--muted); } +.activity-table code { color: #d7ded2; font-size: 0.72rem; } +.activity-empty { min-height: 74px; display: grid; place-items: center; padding: 18px; color: var(--muted); font-size: 0.8rem; text-align: center; } +.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; } + .settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; } .settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } .network-panel { grid-column: 1 / -1; } @@ -167,6 +199,9 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .settings-layout { grid-template-columns: 1fr; } .network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .activity-summary button { margin: 12px; } + .activity-columns { grid-template-columns: 1fr; } } @media (max-width: 767px) { @@ -178,6 +213,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .sidebar { position: fixed; top: auto; bottom: 0; z-index: 10; width: 100%; height: 72px; flex-direction: row; padding: 8px 12px; border-top: 1px solid var(--line); border-right: 0; } .brand-compact { display: none; } .sidebar nav { display: flex; flex: 1; } + .sidebar nav .nav-item:disabled { display: none; } .nav-item { width: 72px; min-height: 56px; } .nav-item.logout { margin: 0 0 0 auto; } .dashboard { padding: 26px 18px; } @@ -192,6 +228,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .update-facts { grid-template-columns: 1fr; } .network-overview { grid-template-columns: 1fr; } .hotspot-fields { grid-template-columns: 1fr; } + .activity-summary, .client-grid { grid-template-columns: 1fr; } .settings-actions, .dialog-actions { flex-direction: column; } .settings-actions button, .dialog-actions button { width: 100%; } .skeleton-grid { grid-template-columns: 1fr; }