ccdeck 3.39.1 → 3.39.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -14
- package/dist/web/assets/{BrowserWatchModal-ByzpjRVo.js → BrowserWatchModal-DvLtvWy6.js} +1 -1
- package/dist/web/assets/TrafficRadar-MfnZJeq1.js +1 -0
- package/dist/web/assets/{UsageHistoryModal-C5K8FVaU.js → UsageHistoryModal-X4UF2sFE.js} +1 -1
- package/dist/web/assets/index-CxIdVCPz.js +305 -0
- package/dist/web/assets/index-TDFtmv7q.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +13 -2
- package/src/server/desktop-update-routes.mjs +14 -1
- package/src/server/index.mjs +5 -1
- package/src/server/lan-beacon.mjs +5 -1
- package/src/server/lan-engine.mjs +6 -1
- package/src/server/traffic-radar-capture.mjs +67 -35
- package/src/server/traffic-radar-config.mjs +9 -6
- package/src/server/traffic-radar-helper.mjs +31 -7
- package/src/server/traffic-radar-monitor.mjs +77 -0
- package/src/server/traffic-radar-platform.mjs +80 -0
- package/src/server/traffic-radar-routes.mjs +3 -2
- package/src/server/traffic-radar.mjs +12 -18
- package/dist/web/assets/TrafficRadar-CyYzkKvE.js +0 -1
- package/dist/web/assets/index-DGMP4S3t.js +0 -305
- package/dist/web/assets/index-WHHCRAB3.css +0 -1
package/dist/web/index.html
CHANGED
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
|
|
67
67
|
})();
|
|
68
68
|
</script>
|
|
69
|
-
<script type="module" crossorigin src="/assets/index-
|
|
70
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
69
|
+
<script type="module" crossorigin src="/assets/index-CxIdVCPz.js"></script>
|
|
70
|
+
<link rel="stylesheet" crossorigin href="/assets/index-TDFtmv7q.css">
|
|
71
71
|
</head>
|
|
72
72
|
<body>
|
|
73
73
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.39.
|
|
3
|
+
"version": "3.39.4",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -73,7 +73,18 @@
|
|
|
73
73
|
"3.38.0",
|
|
74
74
|
"3.38.1",
|
|
75
75
|
"3.38.2",
|
|
76
|
-
"3.38.6"
|
|
76
|
+
"3.38.6",
|
|
77
|
+
"3.39.4"
|
|
78
|
+
],
|
|
79
|
+
"3.39.3": [
|
|
80
|
+
{
|
|
81
|
+
"title": "A cleaner desktop window",
|
|
82
|
+
"body": "The desktop app now has one compact toolbar that follows your theme, with native window controls and an application menu. Wider side rails give icons more room; the Local network footer and unread badges are more compact."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"title": "Clearer account usage and Telemetry Radar",
|
|
86
|
+
"body": "Account usage shows reset times together with capacity. Telemetry Radar has a clearer monitoring workspace and keeps history for up to 24 hours. Radar remains Beta."
|
|
87
|
+
}
|
|
77
88
|
],
|
|
78
89
|
"3.39.1": [
|
|
79
90
|
{
|
|
@@ -116,7 +127,7 @@
|
|
|
116
127
|
"3.38.4": [
|
|
117
128
|
{
|
|
118
129
|
"title": "🔔 A notification when Auto-switch changes your account",
|
|
119
|
-
"body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work
|
|
130
|
+
"body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work · personal@example.com reached 90%\". You can turn it off in the sound settings, where two more wait, off until you turn them on: one when a quota window of the Claude or Codex account you are on reaches 90% and again at 100%, and one when a window that had reached 90% is available again. Each is said once per window and a restart does not repeat it. With either quota notification on, the deck reads quota about every two minutes even while no page is open."
|
|
120
131
|
}
|
|
121
132
|
],
|
|
122
133
|
"3.38.3": [
|
|
@@ -22,7 +22,7 @@ function cleanDesktopUpdate(value) {
|
|
|
22
22
|
? value.version.trim().slice(0, 80)
|
|
23
23
|
: null;
|
|
24
24
|
if (value.status === "ready" && !version) return null;
|
|
25
|
-
return { status: value.status, version };
|
|
25
|
+
return { status: value.status, version, ...(value.canCheck === true ? { canCheck: true } : {}) };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function broadcastDesktopUpdate() {
|
|
@@ -70,6 +70,19 @@ async function handleDesktopUpdateRequest(req, res, event) {
|
|
|
70
70
|
send(res, 202, { ok: true });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Checking is routed to the same native updater as the tray menu. The existing
|
|
74
|
+
// mutation gate protects the request; only authenticated tray subscribers receive it.
|
|
75
|
+
let lastCheckAt = 0;
|
|
76
|
+
export function handleDesktopUpdateCheck(_req, res) {
|
|
77
|
+
if (trayClients.size === 0) return send(res, 409, { ok: false, reason: 'app_disconnected' });
|
|
78
|
+
if (!desktopUpdateState.canCheck) return send(res, 409, { ok: false, reason: 'app_update_required' });
|
|
79
|
+
if (Date.now() - lastCheckAt >= 1000) {
|
|
80
|
+
lastCheckAt = Date.now();
|
|
81
|
+
for (const client of trayClients) writeSse(client, 'event: desktop-update-check\ndata: {}\n\n');
|
|
82
|
+
}
|
|
83
|
+
send(res, 202, { ok: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
// The four routes index.mjs dispatches to these. Listed rather than marked at
|
|
74
87
|
// each declaration, so the declarations read as they did where they came from.
|
|
75
88
|
export { handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest };
|
package/src/server/index.mjs
CHANGED
|
@@ -20,6 +20,7 @@ export { challengeDeck, challengeProof, isProcessAlive };
|
|
|
20
20
|
// The gates in front of the route table — see src/server/request-gates.mjs,
|
|
21
21
|
// which also holds the per-process token the strictest of them checks.
|
|
22
22
|
import { GUARDED_READS, OPEN_MUTATIONS, isAuthorizedDataRead, isAuthorizedMutation, isTrustedMutation, isTrustedRead } from "./request-gates.mjs";
|
|
23
|
+
import { trafficCapture } from "./traffic-radar-capture.mjs";
|
|
23
24
|
import { trafficRadar } from "./traffic-radar.mjs";
|
|
24
25
|
import { handleTrafficCapture, isAuthorizedTrafficIngest } from "./traffic-radar-routes.mjs";
|
|
25
26
|
// How much the event ring may hold and what one event is charged against it —
|
|
@@ -71,7 +72,7 @@ import { handleFeature, noteRouteFeature } from "./feature-use.mjs";
|
|
|
71
72
|
export { HARD_TRACKED_SESSIONS } from "./session-tracking.mjs";
|
|
72
73
|
// The desktop app's update as its window sees it — the state the app reports
|
|
73
74
|
// and the window's answers relayed back to it. See desktop-update-routes.mjs.
|
|
74
|
-
import { handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest } from "./desktop-update-routes.mjs";
|
|
75
|
+
import { handleDesktopUpdateCheck, handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest } from "./desktop-update-routes.mjs";
|
|
75
76
|
// The one spelling of `--workspace`, and of a rollout's cwd — see
|
|
76
77
|
// canonical-path.mjs. Both were exported from this file before they moved,
|
|
77
78
|
// and still are.
|
|
@@ -308,6 +309,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
308
309
|
if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
|
|
309
310
|
if (req.method === "GET" && url.pathname === "/api/desktop-update") return handleDesktopUpdateRead(req, res);
|
|
310
311
|
if (req.method === "POST" && url.pathname === "/api/desktop-update") return guard(handleDesktopUpdateReport(req, res), res);
|
|
312
|
+
if (req.method === "POST" && url.pathname === "/api/desktop-update/check") return guard(handleDesktopUpdateCheck(req, res), res);
|
|
311
313
|
if (req.method === "POST" && url.pathname === "/api/desktop-update/restart") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-restart"), res);
|
|
312
314
|
if (req.method === "POST" && url.pathname === "/api/desktop-update/seen") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-seen"), res);
|
|
313
315
|
if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
|
|
@@ -497,6 +499,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
497
499
|
// and that will differ on the next boot. Every test in this suite boots
|
|
498
500
|
// that way. Comparing the candidate would hand those decks a manifest.
|
|
499
501
|
boundPort = server.address()?.port ?? candidate;
|
|
502
|
+
void trafficCapture.resume(boundPort).catch(() => {});
|
|
503
|
+
server.once("close", () => trafficCapture.dispose());
|
|
500
504
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
501
505
|
if (codex) startCodexWatcher(workspace);
|
|
502
506
|
// What a session is producing between its tool calls. Claude only — it
|
|
@@ -243,7 +243,11 @@ export function createBeacon({
|
|
|
243
243
|
// broadcast is filtered, and it is what Syncthing sends — and every
|
|
244
244
|
// interface's own directed broadcast goes out beside it. A duplicate packet
|
|
245
245
|
// costs one datagram; a missing one costs the whole feature.
|
|
246
|
-
|
|
246
|
+
// Adapter enumeration may fail transiently on Windows. The limited
|
|
247
|
+
// broadcast still works without an interface map; retry next announce.
|
|
248
|
+
let faces;
|
|
249
|
+
try { faces = ifaces(); } catch { faces = {}; }
|
|
250
|
+
const plan = broadcastPlan(faces);
|
|
247
251
|
// Asked behind the send, and read from what was last answered: the first
|
|
248
252
|
// beacon after a start goes as it always did, and the ones after it know.
|
|
249
253
|
void routes?.want?.(plan.map(p => p.to));
|
|
@@ -119,7 +119,12 @@ export function defaultName() {
|
|
|
119
119
|
* `internal` is node's word for loopback, and a link-local 169.254 address is a
|
|
120
120
|
* machine whose DHCP failed — reachable by nobody worth telling about.
|
|
121
121
|
*/
|
|
122
|
-
export function localAddresses(faces
|
|
122
|
+
export function localAddresses(faces) {
|
|
123
|
+
if (faces === undefined) {
|
|
124
|
+
// Windows can temporarily fail to enumerate adapters. Keep status and
|
|
125
|
+
// invite reads usable, and ask the OS again on the next read.
|
|
126
|
+
try { faces = networkInterfaces(); } catch { return []; }
|
|
127
|
+
}
|
|
123
128
|
const out = [];
|
|
124
129
|
for (const list of Object.values(faces ?? {})) {
|
|
125
130
|
for (const n of list ?? []) {
|
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
3
2
|
import { isIP } from "node:net";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { captureInterface, captureInterfaceValid, captureTool, CaptureSetupError } from './traffic-radar-platform.mjs';
|
|
5
|
+
import { monitorDestination, capturePreferences } from "./traffic-radar-monitor.mjs";
|
|
5
6
|
import { createWireCapture } from "./traffic-radar-wire.mjs";
|
|
6
7
|
|
|
7
|
-
const WINDOW =
|
|
8
|
-
const
|
|
9
|
-
|
|
8
|
+
const WINDOW = 24 * 60 * 60 * 1000;
|
|
9
|
+
const MAX_EXPORTS = 2000;
|
|
10
|
+
|
|
11
|
+
const MAX_CHARS = 32 * 1024 * 1024;
|
|
10
12
|
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
13
|
+
const psQuote = value => `'${String(value).replaceAll("'", "''")}'`;
|
|
11
14
|
export function captureDestination(value) {
|
|
12
15
|
const match = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/.exec(String(value));
|
|
13
16
|
if (!match || isIP(match[1]) !== 4 || +match[2] < 1 || +match[2] > 65535) throw new Error('Choose an IPv4 address and TCP port.');
|
|
14
17
|
return { host: match[1], port: +match[2] };
|
|
15
18
|
}
|
|
16
|
-
async function networkInterface(host) {
|
|
17
|
-
return new Promise(resolve => execFile('/sbin/route', ['-n', 'get', host], { timeout: 2000, maxBuffer: 16_384 }, (error, stdout) => {
|
|
18
|
-
const name = !error && /interface:\s+([a-zA-Z0-9_.:-]{1,32})/.exec(stdout)?.[1];
|
|
19
|
-
resolve(name || null);
|
|
20
|
-
}));
|
|
21
|
-
}
|
|
22
19
|
export function exportSummary(payload, signal) {
|
|
23
20
|
const group = signal === 'traces' ? 'Spans' : signal === 'logs' ? 'Logs' : 'Metrics';
|
|
24
21
|
const item = signal === 'traces' ? 'spans' : signal === 'logs' ? 'logRecords' : 'metrics';
|
|
@@ -45,60 +42,93 @@ export function exportSummary(payload, signal) {
|
|
|
45
42
|
}
|
|
46
43
|
return { count: records.length, name: String(records[0]?.eventName || records[0]?.name || signal).slice(0, 200), content: [...fields], sessionIds: [...sessionIds] };
|
|
47
44
|
}
|
|
48
|
-
export function createTrafficCapture({ now = Date.now, platform = process.platform, findInterface =
|
|
49
|
-
let session = null, parsers = [], timer = null, nextId = 1, chars = 0;
|
|
45
|
+
export function createTrafficCapture({ now = Date.now, platform = process.platform, findInterface = host => captureInterface(host, platform), findTool = () => captureTool(platform), wire = createWireCapture, execPath = process.execPath, electron = !!process.versions.electron, managed = false, preferences = null, monitor = monitorDestination } = {}) {
|
|
46
|
+
let session = null, parsers = [], timer = null, nextId = 1, chars = 0, evictedExports = 0;
|
|
50
47
|
const events = new Map();
|
|
51
48
|
let observations = [];
|
|
49
|
+
let monitors = [], localPort = null, generation = 0;
|
|
50
|
+
const persist = enabled => preferences && localPort ? preferences.write(localPort, { enabled, destinations: session?.sources.map(s => s.destination) ?? [] }) : Promise.resolve();
|
|
52
51
|
function prune() {
|
|
53
52
|
observations = observations.filter(entry => now() - entry.observedAt <= WINDOW);
|
|
54
53
|
for (const [id, event] of events) if (now() - event.observedAt > WINDOW) { events.delete(id); chars -= event.chars; }
|
|
55
|
-
if (session?.token &&
|
|
56
|
-
|
|
57
|
-
session.sources.filter(s => s.lastInputAt !== null).every(s => now() - s.lastInputAt > 5000)) stop('interrupted');
|
|
54
|
+
if (session?.token && session.sources.some(s => s.lastInputAt !== null) &&
|
|
55
|
+
session.sources.filter(s => s.lastInputAt !== null).every(s => now() - s.lastInputAt > 5000)) session.state = 'interrupted';
|
|
58
56
|
if (!session?.token && !events.size && !observations.length && timer) { clearInterval(timer); timer = null; }
|
|
59
57
|
}
|
|
60
|
-
function stop(state = 'stopped') {
|
|
58
|
+
function stop(state = 'stopped', save = true) {
|
|
59
|
+
generation++;
|
|
60
|
+
for (const cancel of monitors) cancel(); monitors = [];
|
|
61
|
+
const saved = save ? persist(false) : Promise.resolve();
|
|
61
62
|
if (session) { session.state = state; session.token = null; for (const source of session.sources) source.command = null; }
|
|
62
63
|
for (const parser of parsers) parser.close(); parsers = [];
|
|
64
|
+
return saved;
|
|
63
65
|
}
|
|
64
66
|
function addExport(value) {
|
|
65
67
|
const text = JSON.stringify(value.payload);
|
|
66
|
-
const cost = text
|
|
68
|
+
const cost = Buffer.byteLength(text, 'utf8') + Buffer.byteLength(value.protobufBase64, 'utf8');
|
|
67
69
|
if (cost > 1_000_000) { session.issues.payload_limit = (session.issues.payload_limit ?? 0) + 1; return null; }
|
|
68
70
|
const id = nextId++;
|
|
69
|
-
while (events.size >=
|
|
71
|
+
while (events.size >= MAX_EXPORTS || (chars + cost > MAX_CHARS && events.size)) {
|
|
70
72
|
const first = events.keys().next().value; chars -= events.get(first).chars; events.delete(first);
|
|
73
|
+
evictedExports++;
|
|
71
74
|
}
|
|
72
75
|
events.set(id, { ...value, id, observedAt: now(), chars: cost, ...exportSummary(value.payload, value.signal), outcome: 'unconfirmed', response: null });
|
|
73
76
|
chars += cost;
|
|
74
77
|
return id;
|
|
75
78
|
}
|
|
76
|
-
|
|
77
|
-
async prepare(destination,
|
|
78
|
-
|
|
79
|
+
const api = {
|
|
80
|
+
async prepare(destination, requestedPort) {
|
|
81
|
+
const operation = ++generation;
|
|
82
|
+
if (!['darwin', 'linux', 'win32'].includes(platform)) throw new CaptureSetupError('Live capture is available on macOS, Linux and Windows.');
|
|
79
83
|
const targets = [...new Set((Array.isArray(destination) ? destination : [destination]).map(String))];
|
|
80
84
|
if (!targets.length || targets.length > 8) throw new Error('Choose between one and eight destinations.');
|
|
81
85
|
const addresses = targets.map(captureDestination);
|
|
82
|
-
if (!Number.isInteger(
|
|
86
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) throw new Error('Local ccdeck port unavailable.');
|
|
87
|
+
const tool = await findTool();
|
|
83
88
|
const names = await Promise.all(addresses.map(({ host }) => findInterface(host)));
|
|
84
|
-
if (names.some(name => !name
|
|
85
|
-
|
|
89
|
+
if (names.some(name => !captureInterfaceValid(name, platform))) throw new Error('Network interface unavailable. Check the destination and retry.');
|
|
90
|
+
if (operation !== generation) throw new CaptureSetupError("Monitoring setup was cancelled.");
|
|
91
|
+
stop("stopped", false); const activeGeneration = generation; localPort = requestedPort;
|
|
92
|
+
prune();
|
|
86
93
|
const token = randomBytes(32).toString('hex');
|
|
87
94
|
const helper = fileURLToPath(new URL('./traffic-radar-helper.mjs', import.meta.url));
|
|
88
95
|
const sources = addresses.map(({ host, port }, index) => {
|
|
89
96
|
const filter = `host ${host} and tcp port ${port}`;
|
|
90
|
-
|
|
97
|
+
let command = platform === 'win32'
|
|
98
|
+
? `& ${psQuote(execPath)} ${psQuote(helper)} --capture ${psQuote(tool)} ${psQuote(names[index])} ${psQuote(host)} ${port} ${psQuote(`http://127.0.0.1:${localPort}`)} ${psQuote(token)} ${index}`
|
|
99
|
+
: `sudo ${quote(tool)} -i ${quote(names[index])} -nn -U -s 0 -w - ${quote(filter)} | ${quote(execPath)} ${quote(helper)} ${quote(`http://127.0.0.1:${localPort}`)} ${quote(token)} ${quote(index)}`;
|
|
100
|
+
if (electron) command = platform === 'win32'
|
|
101
|
+
? `& { $previous=$env:ELECTRON_RUN_AS_NODE; try { $env:ELECTRON_RUN_AS_NODE='1'; ${command} } finally { $env:ELECTRON_RUN_AS_NODE=$previous } }`
|
|
102
|
+
: command.replace(`| ${quote(execPath)}`, `| env ELECTRON_RUN_AS_NODE=1 ${quote(execPath)}`);
|
|
91
103
|
return { destination: `${host}:${port}`, interface: names[index], command, lastInputAt: null, bytes: 0 };
|
|
92
104
|
});
|
|
93
105
|
session = { id: randomBytes(8).toString('hex'), state: 'awaiting', destination: sources[0].destination, interface: sources[0].interface,
|
|
94
|
-
sources, startedAt: now(), expiresAt:
|
|
95
|
-
|
|
106
|
+
sources, startedAt: now(), expiresAt: null, lastInputAt: null, token, bytes: 0, issues: {} };
|
|
107
|
+
const parser = ({ host, port }) => wire({ host, port, onExport: addExport, onResponse: (id, response) => {
|
|
96
108
|
const event = events.get(id);
|
|
97
109
|
if (event) { event.response = response; event.outcome = response.outcome; }
|
|
98
|
-
}, onObservation: value => { observations.unshift({ id: nextId++, ...value, observedAt: now() }); observations = observations.slice(0,
|
|
110
|
+
}, onObservation: value => { observations.unshift({ id: nextId++, ...value, observedAt: now() }); observations = observations.slice(0, MAX_EXPORTS); }, onIssue: code => { session.issues[code] = (session.issues[code] ?? 0) + 1; } });
|
|
111
|
+
parsers = addresses.map(parser);
|
|
112
|
+
await persist(true);
|
|
113
|
+
if (activeGeneration !== generation) throw new CaptureSetupError("Monitoring setup was cancelled.");
|
|
114
|
+
if (managed) monitors = addresses.map(({ host, port }, index) => monitor({ platform, tool, host, port, resolveInterface: findInterface,
|
|
115
|
+
reset: iface => { parsers[index]?.close(); parsers[index] = parser({ host, port }); session.sources[index].interface = iface; },
|
|
116
|
+
ingest: bytes => api.ingest(token, bytes, index),
|
|
117
|
+
status: error => { const source = session.sources[index]; source.error = error; if (error) { source.lastInputAt = null; session.state = 'interrupted'; } },
|
|
118
|
+
}));
|
|
99
119
|
if (!timer) { timer = setInterval(prune, 1000); timer.unref?.(); }
|
|
100
120
|
return { command: sources[0].command, commands: sources.map(({ command, destination }) => ({ command, destination })), expiresAt: session.expiresAt, sessionId: session.id };
|
|
101
121
|
},
|
|
122
|
+
async resume(port) {
|
|
123
|
+
const operation = generation;
|
|
124
|
+
localPort = port;
|
|
125
|
+
const saved = await preferences?.read(port);
|
|
126
|
+
if (operation !== generation) return;
|
|
127
|
+
if (saved?.enabled && Array.isArray(saved.destinations)) {
|
|
128
|
+
try { await api.prepare(saved.destinations, port); }
|
|
129
|
+
catch { session = { state: "error", sources: [], issues: {}, token: null }; }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
102
132
|
accepts(token) {
|
|
103
133
|
prune();
|
|
104
134
|
return typeof token === 'string' && /^[a-f0-9]{64}$/.test(token) && !!session?.token
|
|
@@ -111,26 +141,28 @@ export function createTrafficCapture({ now = Date.now, platform = process.platfo
|
|
|
111
141
|
session.sources[source].lastInputAt = now();
|
|
112
142
|
session.sources[source].bytes += chunk.length;
|
|
113
143
|
session.lastInputAt = now();
|
|
114
|
-
if (chunk.length) session.state = 'capturing';
|
|
115
|
-
else if (session.state
|
|
144
|
+
if (chunk.length && (!managed || session.sources[source].bytes > 24)) session.state = 'capturing';
|
|
145
|
+
else if (['awaiting', 'interrupted'].includes(session.state)) session.state = 'receiving';
|
|
116
146
|
session.bytes += chunk.length;
|
|
117
|
-
try { parsers[source].feed(chunk); } catch { stop('error'); return false; }
|
|
147
|
+
try { parsers[source].feed(chunk); } catch { void stop('error').catch(() => {}); return false; }
|
|
118
148
|
return true;
|
|
119
149
|
},
|
|
120
150
|
read() {
|
|
121
151
|
prune();
|
|
122
|
-
return { ok: true, sessionId: session?.id ?? null, state: session?.state ?? 'idle', destination: session?.destination ?? null, interface: session?.interface ?? null,
|
|
152
|
+
return { ok: true, managed, enabled: !!session?.token, platform, backend: platform === 'win32' ? 'dumpcap' : 'tcpdump', shell: platform === 'win32' ? 'PowerShell' : 'Terminal', sessionId: session?.id ?? null, state: session?.state ?? 'idle', destination: session?.destination ?? null, interface: session?.interface ?? null,
|
|
123
153
|
startedAt: session?.startedAt ?? null, expiresAt: session?.expiresAt ?? null, lastInputAt: session?.lastInputAt ?? null,
|
|
124
154
|
sources: session?.sources.map(({ command, ...source }) => ({ ...source, active: source.lastInputAt !== null && now() - source.lastInputAt <= 5000 && !!session.token })) ?? [],
|
|
125
155
|
observations: [...observations],
|
|
156
|
+
retention: { windowMs: WINDOW, maxExports: MAX_EXPORTS, payloadBudgetBytes: MAX_CHARS, evictedExports },
|
|
126
157
|
bytes: session?.bytes ?? 0, issues: { ...session?.issues },
|
|
127
158
|
events: [...events.values()].reverse().map(({ payload, protobufBase64, chars, flow, ...event }) => event),
|
|
128
159
|
};
|
|
129
160
|
},
|
|
130
161
|
detail(id) { prune(); const event = events.get(id); return event ? { payload: event.payload, protobufBase64: event.protobufBase64 } : null; },
|
|
131
162
|
stop,
|
|
132
|
-
clear() { events.clear(); observations = []; chars = 0; },
|
|
133
|
-
dispose() { stop(); events.clear(); observations = []; if (timer) clearInterval(timer); timer = null; },
|
|
163
|
+
clear() { events.clear(); observations = []; chars = 0; evictedExports = 0; },
|
|
164
|
+
dispose() { stop("stopped", false); events.clear(); observations = []; if (timer) clearInterval(timer); timer = null; },
|
|
134
165
|
};
|
|
166
|
+
return api;
|
|
135
167
|
}
|
|
136
|
-
export const trafficCapture = createTrafficCapture();
|
|
168
|
+
export const trafficCapture = createTrafficCapture({ managed: true, preferences: capturePreferences() });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { open } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { posix, win32 } from "node:path";
|
|
4
4
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
5
5
|
|
|
6
6
|
const BOOLEAN_KEYS = [
|
|
@@ -68,12 +68,15 @@ async function readBoundedJson(path) {
|
|
|
68
68
|
} finally { await file.close(); }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export async function readRadarConfig({ home = homedir(), env = process.env, readJson = readBoundedJson } = {}) {
|
|
72
|
-
const
|
|
71
|
+
export async function readRadarConfig({ home = homedir(), env = process.env, platform = process.platform, readJson = readBoundedJson } = {}) {
|
|
72
|
+
const pathJoin = platform === 'win32' ? win32.join : posix.join;
|
|
73
|
+
const dir = claudeConfigDir(env, home, platform);
|
|
74
|
+
const managed = platform === 'win32' ? pathJoin(env.ProgramFiles || 'C:\\Program Files', 'ClaudeCode', 'managed-settings.json')
|
|
75
|
+
: platform === 'linux' ? '/etc/claude-code/managed-settings.json' : '/Library/Application Support/ClaudeCode/managed-settings.json';
|
|
73
76
|
const files = [
|
|
74
|
-
["User settings",
|
|
75
|
-
["Cached organization settings",
|
|
76
|
-
["Managed settings",
|
|
77
|
+
["User settings", pathJoin(dir, "settings.json")],
|
|
78
|
+
["Cached organization settings", pathJoin(dir, "remote-settings.json")],
|
|
79
|
+
["Managed settings", managed],
|
|
77
80
|
];
|
|
78
81
|
const sources = await Promise.all(files.map(async ([name, path]) => {
|
|
79
82
|
try {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { captureDestination } from './traffic-radar-capture.mjs';
|
|
3
|
+
import { captureInterfaceValid } from './traffic-radar-platform.mjs';
|
|
1
4
|
import { pathToFileURL } from "node:url";
|
|
2
5
|
|
|
3
6
|
export async function streamCapture({ base, token, source = 0, input = process.stdin, send = fetch, report = message => process.stderr.write(message + '\n') }) {
|
|
@@ -20,8 +23,7 @@ export async function streamCapture({ base, token, source = 0, input = process.s
|
|
|
20
23
|
return sequence;
|
|
21
24
|
};
|
|
22
25
|
const heartbeat = setInterval(() => { if (!stopped) void post(Buffer.alloc(0)); }, 1000);
|
|
23
|
-
|
|
24
|
-
report('Telemetry Radar: sending capture bytes only to local ccdeck; no file is written. Press Ctrl+C to stop tcpdump.');
|
|
26
|
+
report('Telemetry Radar: sending capture bytes only to local ccdeck; no file is written. Press Ctrl+C to stop capture.');
|
|
25
27
|
try {
|
|
26
28
|
for await (const chunk of input) {
|
|
27
29
|
for (let offset = 0; offset < chunk.length && !stopped; offset += 65_536) await post(chunk.subarray(offset, offset + 65_536));
|
|
@@ -31,13 +33,35 @@ export async function streamCapture({ base, token, source = 0, input = process.s
|
|
|
31
33
|
} catch {
|
|
32
34
|
if (!stopped) report('Capture input interrupted.');
|
|
33
35
|
} finally {
|
|
34
|
-
stopped = true; clearInterval(heartbeat);
|
|
35
|
-
report('Telemetry Radar: local receiver stopped. Press Ctrl+C if
|
|
36
|
+
stopped = true; clearInterval(heartbeat);
|
|
37
|
+
report('Telemetry Radar: local receiver stopped. Press Ctrl+C if the capture tool is still running.');
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
// PowerShell 5 pipelines convert binary output to text. Stream dumpcap directly in Node.
|
|
42
|
+
export async function runCaptureTool({ tool, interface: iface, host, port, base, token, source = 0, launch = spawn, send = fetch, report = message => process.stderr.write(message + '\n') }) {
|
|
43
|
+
captureDestination(`${host}:${port}`);
|
|
44
|
+
if (!captureInterfaceValid(iface, 'win32') || !tool || /[\r\n]/.test(tool)) throw new Error('Invalid capture tool or interface.');
|
|
45
|
+
// Validate before launching a child, not after it has started capturing.
|
|
46
|
+
const url = new URL(base);
|
|
47
|
+
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.pathname !== '/' || url.search || url.hash || url.username || url.password || !/^[a-f0-9]{64}$/.test(token) || !Number.isInteger(source) || source < 0 || source > 7) throw new Error('Invalid local capture destination.');
|
|
48
|
+
const child = launch(tool, ['-i', iface, '-F', 'pcap', '-s', '0', '-f', `host ${host} and tcp port ${port}`, '-w', '-'], { stdio: ['ignore', 'pipe', 'inherit'], windowsHide: true, shell: false });
|
|
49
|
+
let failure = null;
|
|
50
|
+
child.on('error', error => { failure = error; child.stdout.destroy(error); });
|
|
51
|
+
child.on('exit', code => { if (code) failure = new Error('Capture tool failed. Check Npcap and capture permissions.'); });
|
|
52
|
+
const stop = () => { child.kill(); child.stdout.destroy(); };
|
|
53
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
54
|
+
try {
|
|
55
|
+
await streamCapture({ base, token, source, input: child.stdout, send, report });
|
|
56
|
+
if (failure) throw failure;
|
|
57
|
+
} finally {
|
|
58
|
+
child.kill(); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
39
61
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
const job = args[0] === '--capture'
|
|
64
|
+
? runCaptureTool({ tool: args[1], interface: args[2], host: args[3], port: Number(args[4]), base: args[5], token: args[6], source: Number(args[7] ?? 0) })
|
|
65
|
+
: streamCapture({ base: args[0], token: args[1], source: Number(args[2] ?? 0) });
|
|
66
|
+
job.catch(() => { process.stderr.write('Could not start capture. Check that the capture tool is installed and its permissions allow this user.\n'); process.exitCode = 1; });
|
|
43
67
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile, mkdir, writeFile, chmod } from 'node:fs/promises';
|
|
3
|
+
import { renameWithRetry } from './atomic-write.mjs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
// Only destinations and the opt-in survive a restart. Tokens and packets never do.
|
|
8
|
+
export function capturePreferences(directory = join(homedir(), '.agents-deck')) {
|
|
9
|
+
let writes = Promise.resolve();
|
|
10
|
+
const file = port => join(directory, `radar-monitor-${port}.json`);
|
|
11
|
+
return {
|
|
12
|
+
async read(port) { try { return JSON.parse(await readFile(file(port), 'utf8')); } catch { return null; } },
|
|
13
|
+
write(port, value) {
|
|
14
|
+
writes = writes.catch(() => {}).then(async () => {
|
|
15
|
+
await mkdir(directory, { recursive: true });
|
|
16
|
+
const target = file(port), temp = target + `.${process.pid}.tmp`;
|
|
17
|
+
await writeFile(temp, JSON.stringify(value), { mode: 0o600 });
|
|
18
|
+
await chmod(temp, 0o600); await renameWithRetry(temp, target);
|
|
19
|
+
});
|
|
20
|
+
return writes;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const activeMonitors = new Set();
|
|
26
|
+
const stopAtExit = () => { for (const stop of activeMonitors) stop(); };
|
|
27
|
+
|
|
28
|
+
// A child per destination keeps pcap headers and TCP decoders independent.
|
|
29
|
+
// Retry after a crash or adapter change; an explicit stop cancels every retry.
|
|
30
|
+
export function monitorDestination({ platform, tool, host, port, resolveInterface, reset, ingest, status, launch = spawn, retryMs = 3000 }) {
|
|
31
|
+
let stopped = false, child = null, retry = null, heartbeat = null, ready = false;
|
|
32
|
+
const stopHeartbeat = () => { clearInterval(heartbeat); heartbeat = null; };
|
|
33
|
+
const retryLater = () => { if (!stopped) { retry = setTimeout(start, retryMs); retry.unref?.(); } };
|
|
34
|
+
async function start() {
|
|
35
|
+
if (stopped) return;
|
|
36
|
+
try {
|
|
37
|
+
const iface = await resolveInterface(host);
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
if (!iface) throw new Error('interface');
|
|
40
|
+
reset(iface); ready = false;
|
|
41
|
+
const args = platform === 'win32'
|
|
42
|
+
? ['-i', iface, '-F', 'pcap', '-s', '0', '-f', `host ${host} and tcp port ${port}`, '-w', '-']
|
|
43
|
+
: ['-i', iface, '-nn', '-U', '-s', '0', '-w', '-', `host ${host} and tcp port ${port}`];
|
|
44
|
+
child = launch(tool, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
|
|
45
|
+
const capturing = child;
|
|
46
|
+
let ended = false, error = '', stderr = '';
|
|
47
|
+
const listening = () => {
|
|
48
|
+
if (ready || stopped || ended) return;
|
|
49
|
+
ready = true; status(null); ingest(Buffer.alloc(0));
|
|
50
|
+
heartbeat = setInterval(() => { if (!stopped) ingest(Buffer.alloc(0)); }, 1000); heartbeat.unref?.();
|
|
51
|
+
};
|
|
52
|
+
const end = () => {
|
|
53
|
+
if (ended) return; ended = true; stopHeartbeat(); capturing.stdout.destroy(); capturing.kill();
|
|
54
|
+
if (!stopped) { status(error || 'Capture disconnected. Retrying automatically.'); retryLater(); }
|
|
55
|
+
};
|
|
56
|
+
child.stderr.on('data', bytes => {
|
|
57
|
+
stderr = (stderr + bytes.toString()).slice(-4096);
|
|
58
|
+
if (/permission|access.*denied|not permitted|administrator/i.test(stderr)) error = 'Packet capture permission is required.';
|
|
59
|
+
if (/listening on|Capturing on/i.test(stderr)) listening();
|
|
60
|
+
});
|
|
61
|
+
child.stdout.on('data', bytes => {
|
|
62
|
+
if (stopped) return;
|
|
63
|
+
listening();
|
|
64
|
+
for (let i = 0; i < bytes.length; i += 65536) ingest(bytes.subarray(i, i + 65536));
|
|
65
|
+
});
|
|
66
|
+
child.stdout.on('error', end);
|
|
67
|
+
child.once('error', reason => { if (['EACCES', 'EPERM'].includes(reason.code)) error = 'Packet capture permission is required.'; end(); }); child.once('exit', end);
|
|
68
|
+
} catch { if (!stopped) { status('Capture interface unavailable. Retrying automatically.'); retryLater(); } }
|
|
69
|
+
}
|
|
70
|
+
const stop = () => {
|
|
71
|
+
stopped = true; clearTimeout(retry); stopHeartbeat(); child?.stdout.destroy(); child?.kill();
|
|
72
|
+
activeMonitors.delete(stop); if (!activeMonitors.size) process.removeListener("exit", stopAtExit);
|
|
73
|
+
};
|
|
74
|
+
if (!activeMonitors.size) process.once("exit", stopAtExit);
|
|
75
|
+
activeMonitors.add(stop); void start();
|
|
76
|
+
return stop;
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// OS-specific inspection stays here; the Radar history and decoder share one model.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { win32 } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export function systemCommand(file, args) {
|
|
8
|
+
return new Promise(resolve => execFile(file, args, { timeout: 8000, maxBuffer: 262144, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
|
|
9
|
+
if (!error || (file === '/usr/sbin/lsof' && error.code === 1 && !stdout && !stderr)) resolve(stdout);
|
|
10
|
+
else resolve(null);
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
export const powershell = script => ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop'; " + script];
|
|
14
|
+
export const WINDOWS_PROCESSES = "Get-Process | Where-Object { $_.ProcessName -in @('claude','claude-native') } | ForEach-Object { '{0} {1}' -f $_.Id,$_.ProcessName }";
|
|
15
|
+
export function windowsSockets(pids) {
|
|
16
|
+
return `$ids=@(${pids.join(',')}); Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Established' -and $_.OwningProcess -in $ids } | ForEach-Object { $peer=$_.RemoteAddress; if($peer.Contains(':')){$peer='['+$peer+']'}; 'p'+$_.OwningProcess; 'nlocal->'+$peer+':'+$_.RemotePort }`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// /proc avoids an ss/lsof dependency and reads socket ownership only for selected Claude PIDs.
|
|
20
|
+
export async function linuxSockets(pids, { read = readFile, list = readdir, link = readlink } = {}) {
|
|
21
|
+
const sockets = [], cwd = [];
|
|
22
|
+
let unavailable = false;
|
|
23
|
+
await Promise.all(pids.map(async pid => {
|
|
24
|
+
try {
|
|
25
|
+
const descriptors = await list(`/proc/${pid}/fd`);
|
|
26
|
+
const owned = new Set((await Promise.all(descriptors.map(fd => link(`/proc/${pid}/fd/${fd}`).catch(() => ''))))
|
|
27
|
+
.map(value => /^socket:\[(\d+)\]$/.exec(value)?.[1]).filter(Boolean));
|
|
28
|
+
for (const family of ['tcp', 'tcp6']) {
|
|
29
|
+
const table = await read(`/proc/${pid}/net/${family}`, 'utf8');
|
|
30
|
+
for (const row of table.trim().split('\n').slice(1)) {
|
|
31
|
+
const fields = row.trim().split(/\s+/);
|
|
32
|
+
if (fields[3] !== '01' || !owned.has(fields[9])) continue;
|
|
33
|
+
const [hex, port] = fields[2].split(':');
|
|
34
|
+
const bytes = Buffer.from(hex, 'hex');
|
|
35
|
+
for (let i = 0; i < bytes.length; i += 4) bytes.subarray(i, i + 4).reverse();
|
|
36
|
+
const address = family === 'tcp' ? [...bytes].join('.') : '[' + Array.from({ length: 8 }, (_, i) => bytes.readUInt16BE(i * 2).toString(16)).join(':') + ']';
|
|
37
|
+
sockets.push(`p${pid}\nnlocal->${address}:${parseInt(port, 16)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const path = await link(`/proc/${pid}/cwd`).catch(() => null);
|
|
41
|
+
if (path) cwd.push(`p${pid}\nn${path}`);
|
|
42
|
+
} catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ESRCH') unavailable = true; }
|
|
43
|
+
}));
|
|
44
|
+
return { sockets: unavailable ? null : sockets.join('\n'), cwd: cwd.join('\n') };
|
|
45
|
+
}
|
|
46
|
+
export function captureInterfaceValid(name, platform) {
|
|
47
|
+
return typeof name === 'string' && (platform === 'win32'
|
|
48
|
+
? /^\\Device\\NPF_(?:Loopback|\{[a-fA-F0-9-]{36}\})$/.test(name)
|
|
49
|
+
: /^[a-zA-Z0-9_.:-]{1,32}$/.test(name));
|
|
50
|
+
}
|
|
51
|
+
export async function captureInterface(host, platform, run = systemCommand) {
|
|
52
|
+
if (platform === 'linux') return 'any'; // tcpdump's cooked capture includes loopback and every routed adapter.
|
|
53
|
+
if (platform === 'darwin') {
|
|
54
|
+
const text = await run('/sbin/route', ['-n', 'get', host]);
|
|
55
|
+
return /interface:\s+([a-zA-Z0-9_.:-]{1,32})/.exec(text ?? '')?.[1] ?? null;
|
|
56
|
+
}
|
|
57
|
+
if (platform === 'win32') {
|
|
58
|
+
if (host.startsWith('127.')) return '\\Device\\NPF_Loopback';
|
|
59
|
+
const text = await run('powershell.exe', powershell(`if(Get-NetIPAddress -IPAddress '${host}' -ErrorAction SilentlyContinue) { '\\Device\\NPF_Loopback'; exit }; $route=Find-NetRoute -RemoteIPAddress '${host}'; $index=($route | Select-Object -First 1).InterfaceIndex; $adapter=Get-NetAdapter -IncludeHidden | Where-Object { $_.ifIndex -eq $index } | Select-Object -First 1; '\\Device\\NPF_{'+$adapter.InterfaceGuid.ToString().Trim('{}')+'}'`));
|
|
60
|
+
return text?.trim() ?? null;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export function dumpcapPath(env = process.env, exists = existsSync) {
|
|
65
|
+
for (const root of [env.ProgramW6432, env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files']) {
|
|
66
|
+
if (!root) continue;
|
|
67
|
+
const file = win32.join(root, 'Wireshark', 'dumpcap.exe');
|
|
68
|
+
if (exists(file)) return file;
|
|
69
|
+
}
|
|
70
|
+
return 'dumpcap.exe'; // Custom installs may expose it on PATH.
|
|
71
|
+
}
|
|
72
|
+
export class CaptureSetupError extends Error {}
|
|
73
|
+
export async function captureTool(platform, run = systemCommand) {
|
|
74
|
+
const file = platform === 'win32' ? dumpcapPath() : platform === 'darwin' ? '/usr/sbin/tcpdump' : 'tcpdump';
|
|
75
|
+
if (await run(file, [platform === 'win32' ? '-v' : '--version']) === null)
|
|
76
|
+
throw new CaptureSetupError(platform === 'win32' ? 'Install Wireshark with Npcap to capture traffic, then retry. Configuration and file inspection work without it.' : 'Install tcpdump to capture traffic, then retry. Configuration and file inspection work without it.');
|
|
77
|
+
if (platform === 'win32' && await run(file, ['-D']) === null)
|
|
78
|
+
throw new CaptureSetupError('Npcap interfaces are unavailable. Check that Npcap is installed and its capture permissions allow this user.');
|
|
79
|
+
return file;
|
|
80
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { trafficCapture } from "./traffic-radar-capture.mjs";
|
|
2
|
+
import { CaptureSetupError } from './traffic-radar-platform.mjs';
|
|
2
3
|
const BASE = '/api/system/traffic-radar';
|
|
3
4
|
const loopback = req => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
|
|
4
5
|
export function isAuthorizedTrafficIngest(req, url) {
|
|
@@ -33,8 +34,8 @@ export async function handleTrafficCapture(req, res, url, capture = trafficCaptu
|
|
|
33
34
|
const data = JSON.parse((await body(req, 4096)).toString());
|
|
34
35
|
return reply(res, 200, await capture.prepare(data.destination, req.socket.localPort));
|
|
35
36
|
}
|
|
36
|
-
if (req.method === 'POST' && action === '/capture/stop') { capture.stop(); return reply(res, 200, { ok: true }); }
|
|
37
|
+
if (req.method === 'POST' && action === '/capture/stop') { await capture.stop(); return reply(res, 200, { ok: true }); }
|
|
37
38
|
if (req.method === 'POST' && action === '/capture/clear') { capture.clear(); return reply(res, 200, { ok: true }); }
|
|
38
39
|
return reply(res, 404, { error: 'Unknown capture action.' });
|
|
39
|
-
} catch { return reply(res, 400, { error: 'Could not complete capture setup. Use a valid IPv4 destination and check its network route.' }); }
|
|
40
|
+
} catch (error) { return reply(res, 400, { error: error instanceof CaptureSetupError ? error.message : 'Could not complete capture setup. Use a valid IPv4 destination and check its network route.' }); }
|
|
40
41
|
}
|