Add live hotspot network diagnostics

This commit is contained in:
2026-07-31 21:01:02 +02:00
parent d1239d66af
commit 37e2cb154e
19 changed files with 490 additions and 11 deletions
+1
View File
@@ -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
+1
View File
@@ -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
+13 -1
View File
@@ -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
@@ -0,0 +1,2 @@
# Query metadata only. Replies, packet bodies, and client payloads are not logged.
log-queries=extra
+8 -1
View File
@@ -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" <<EOF
@@ -140,6 +143,7 @@ NETWORK_MANAGER_ENABLED=false
NETWORK_STATUS_PATH=${DATA_DIR}/network-status.json
NETWORK_COMMAND_PATH=${DATA_DIR}/network-command.json
NETWORK_CONFIGURATION_REQUEST_PATH=${DATA_DIR}/network-configuration-request.json
NETWORK_ACTIVITY_PATH=${DATA_DIR}/network-activity.json
NETWORK_HOTSPOT_INTERFACE=wlan0
NETWORK_UPSTREAM_INTERFACE=wlan1
NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot
@@ -160,6 +164,7 @@ ensure_env_default NETWORK_MANAGER_ENABLED false
ensure_env_default NETWORK_STATUS_PATH "${DATA_DIR}/network-status.json"
ensure_env_default NETWORK_COMMAND_PATH "${DATA_DIR}/network-command.json"
ensure_env_default NETWORK_CONFIGURATION_REQUEST_PATH "${DATA_DIR}/network-configuration-request.json"
ensure_env_default NETWORK_ACTIVITY_PATH "${DATA_DIR}/network-activity.json"
ensure_env_default NETWORK_HOTSPOT_INTERFACE wlan0
ensure_env_default NETWORK_UPSTREAM_INTERFACE wlan1
ensure_env_default NETWORK_HOTSPOT_CONNECTION pi-car-hotspot
@@ -179,10 +184,12 @@ echo "[6/7] Enabling start-on-boot services"
systemctl daemon-reload
systemctl enable --now avahi-daemon
systemctl enable pi-car-companion.service
systemctl enable pi-car-companion-network-observer.service
if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then
systemctl enable --now pi-car-companion-network.service
fi
systemctl restart pi-car-companion.service
systemctl restart pi-car-companion-network-observer.service
echo "[7/7] Verifying the service"
for _attempt in {1..20}; do
+4
View File
@@ -61,7 +61,10 @@ install_integration_files() {
install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
install -m 0644 "${source_release}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf
}
cleanup() {
@@ -182,3 +185,4 @@ fi
write_status "success" "Update installed and the companion service is healthy."
systemctl try-restart pi-car-companion-network.service || true
systemctl enable --now pi-car-companion-network-observer.service || true
+6
View File
@@ -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" } } },
+4 -1
View File
@@ -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,
+58
View File
@@ -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."]
};
}
}
+173
View File
@@ -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);
}
+6 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
});
});
});
+37
View File
@@ -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: [] });
});
});
+2 -1
View File
@@ -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 }
};
+2 -1
View File
@@ -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 }
};
@@ -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
+90 -4
View File
@@ -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<void> }) {
const [page, setPage] = useState<"home" | "settings">("home");
const [page, setPage] = useState<"home" | "network" | "settings">("home");
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState("");
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
@@ -194,6 +196,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
<nav aria-label="Primary navigation">
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => setPage("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
</nav>
@@ -202,8 +205,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<main className="dashboard">
<header className="dashboard-header">
<div>
<p className="eyebrow">{page === "home" ? "System overview" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}</h1>
<p className="eyebrow">{page === "home" ? "System overview" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "network" ? "Network activity" : "Settings"}</h1>
</div>
<div className="header-actions">
<div className={`connection-state ${connection}`}>
@@ -218,12 +221,95 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
{page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : <SettingsPage csrfToken={auth.csrfToken} />}
</> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</main>
</div>
);
}
function NetworkActivityPage() {
const [activity, setActivity] = useState<NetworkActivity | null>(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 (
<div className="activity-layout">
<section className="activity-summary">
<div><span>Connected clients</span><strong>{connected}</strong></div>
<div><span>Recent DNS queries</span><strong>{activity?.dnsQueries.length ?? 0}</strong></div>
<div><span>Active flows</span><strong>{activity?.flows.length ?? 0}</strong></div>
<div><span>Snapshot</span><strong>{activity?.supported ? new Date(activity.collectedAt).toLocaleTimeString() : "Unavailable"}</strong></div>
<button className="secondary-button text-button" onClick={() => setPaused((value) => !value)}>{paused ? "Resume live view" : "Pause live view"}</button>
</section>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
{activity?.messages.map((message) => <div className="activity-message" key={message}><WarningCircle size={18} />{message}</div>)}
<section className="activity-clients activity-panel">
<div className="activity-heading"><div><h2>Hotspot clients</h2><p>DHCP leases and current neighbor reachability.</p></div></div>
<div className="client-grid">
{activity?.clients.length ? activity.clients.map((client) => (
<div className="client-row" key={`${client.ipAddress}-${client.macAddress}`}>
<span className={`client-dot ${client.connected ? "online" : ""}`} aria-hidden="true" />
<div><strong>{client.hostname ?? client.ipAddress}</strong><small>{client.hostname ? client.ipAddress : client.macAddress}</small></div>
<code>{client.hostname ? client.macAddress : client.state}</code>
<span>{client.connected ? "Connected" : client.state}</span>
</div>
)) : <ActivityEmpty text="No hotspot clients observed." />}
</div>
</section>
<div className="activity-columns">
<section className="activity-panel">
<div className="activity-heading"><div><h2>DNS requests</h2><p>Query metadata from clients during the last 15 minutes.</p></div><span>{activity?.dnsAvailable ? "Live" : "Unavailable"}</span></div>
<div className="activity-table-wrap">
<table className="activity-table"><thead><tr><th>Time</th><th>Client</th><th>Type</th><th>Name</th></tr></thead><tbody>
{activity?.dnsQueries.map((query, index) => <tr key={`${query.timestamp}-${query.clientAddress}-${query.name}-${index}`}><td>{new Date(query.timestamp).toLocaleTimeString()}</td><td><code>{query.clientAddress}</code></td><td>{query.type}</td><td className="request-name">{query.name}</td></tr>)}
</tbody></table>
{!activity?.dnsQueries.length && <ActivityEmpty text="No DNS requests captured yet." />}
</div>
</section>
<section className="activity-panel">
<div className="activity-heading"><div><h2>Active connections</h2><p>Destination metadata only; encrypted content remains private.</p></div><span>{activity?.flowsAvailable ? "Live" : "Unavailable"}</span></div>
<div className="activity-table-wrap">
<table className="activity-table"><thead><tr><th>Client</th><th>Protocol</th><th>Destination</th><th>State</th></tr></thead><tbody>
{activity?.flows.map((flow, index) => <tr key={`${flow.sourceAddress}-${flow.sourcePort}-${flow.destinationAddress}-${flow.destinationPort}-${index}`}><td><code>{flow.sourceAddress}</code></td><td>{flow.protocol.toUpperCase()}</td><td><code>{flow.destinationAddress}:{flow.destinationPort}</code><small>{portLabel(flow.destinationPort)}</small></td><td>{flow.state}</td></tr>)}
</tbody></table>
{!activity?.flows.length && <ActivityEmpty text="No active client connections observed." />}
</div>
</section>
</div>
<p className="activity-privacy">This page records bounded connection metadata only. It does not capture payloads, passwords, cookies, or HTTPS paths.</p>
</div>
);
}
function ActivityEmpty({ text }: { text: string }) {
return <div className="activity-empty">{text}</div>;
}
function portLabel(port: number): string {
return ({ 53: "DNS", 80: "HTTP", 123: "NTP", 443: "HTTPS", 853: "DNS over TLS" } as Record<number, string>)[port] ?? "";
}
function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [network, setNetwork] = useState<NetworkStatus | null>(null);
+15
View File
@@ -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<UpdateStatus> {
export async function getNetworkStatus(): Promise<NetworkStatus> {
return parseResponse<NetworkStatus>(await fetch("/api/network", { credentials: "same-origin" }));
}
export async function getNetworkActivity(): Promise<NetworkActivity> {
return parseResponse<NetworkActivity>(await fetch("/api/network/activity", { credentials: "same-origin" }));
}
+37
View File
@@ -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; }