@calltelemetry/cucm-mcp 0.1.7 → 0.2.3
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 +61 -7
- package/dist/axl.js +235 -0
- package/dist/axl.js.map +7 -0
- package/dist/dime.js +231 -269
- package/dist/dime.js.map +7 -0
- package/dist/index.js +680 -207
- package/dist/index.js.map +7 -0
- package/dist/multipart.js +58 -68
- package/dist/multipart.js.map +7 -0
- package/dist/packetCapture.js +315 -373
- package/dist/packetCapture.js.map +7 -0
- package/dist/pcap-analyze.js +601 -0
- package/dist/pcap-analyze.js.map +7 -0
- package/dist/state.js +105 -104
- package/dist/state.js.map +7 -0
- package/dist/time.js +25 -23
- package/dist/time.js.map +7 -0
- package/package.json +4 -6
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/packetCapture.ts"],
|
|
4
|
+
"sourcesContent": ["import crypto from \"node:crypto\";\nimport { Client, type ClientChannel } from \"ssh2\";\n\nimport { defaultStateStore, type CaptureStateStore } from \"./state.js\";\n\nexport type SshAuth = { username?: string; password?: string };\n\nexport type PacketCaptureStart = {\n host: string;\n sshPort?: number;\n auth?: SshAuth;\n iface?: string;\n fileBase?: string;\n count?: number;\n size?: string;\n hostFilterIp?: string;\n portFilter?: number;\n // If set, stop the capture after this long even if packet count isn't reached.\n maxDurationMs?: number;\n // Guardrail so packet_capture_start doesn't hang forever.\n startTimeoutMs?: number;\n};\n\nexport type PacketCaptureSession = {\n id: string;\n host: string;\n startedAt: string;\n stoppedAt?: string;\n iface: string;\n fileBase: string;\n remoteFilePath: string;\n remoteFileCandidates: string[];\n stopTimedOut?: boolean;\n lastStdout?: string;\n lastStderr?: string;\n exitCode?: number | null;\n};\n\nfunction extractCapFilePath(text?: string): string | undefined {\n if (!text) return undefined;\n // CUCM typically writes captures to /var/log/active/platform/cli/<name>.cap\n // Sometimes CLI output includes the final on-disk location.\n const re = /\\/var\\/log\\/active\\/platform\\/cli\\/[A-Za-z0-9._-]+\\.cap/g;\n const matches = text.match(re);\n if (!matches || matches.length === 0) return undefined;\n return matches[matches.length - 1];\n}\n\nfunction looksLikeCucmPrompt(text?: string): boolean {\n const t = String(text || \"\");\n // CUCM CLI commonly ends commands by printing a prompt like:\n // admin:\n // Some environments might show other usernames.\n // We only look at the tail to avoid false positives.\n const tail = t.slice(-80);\n return /(?:^|\\n)[A-Za-z0-9_-]+:\\s*$/.test(tail);\n}\n\nexport function resolveSshAuth(auth?: SshAuth): Required<SshAuth> {\n const username = auth?.username || process.env.CUCM_SSH_USERNAME;\n const password = auth?.password || process.env.CUCM_SSH_PASSWORD;\n if (!username || !password) {\n throw new Error(\"Missing SSH credentials (provide auth or set CUCM_SSH_USERNAME/CUCM_SSH_PASSWORD)\");\n }\n return { username, password };\n}\n\nexport function sanitizeFileBase(s: string): string {\n // CUCM note: fname should not contain '.'\n // Keep it conservative.\n const cleaned = String(s || \"\")\n .trim()\n .replaceAll(\".\", \"_\")\n .replace(/[^A-Za-z0-9_-]+/g, \"_\")\n .replace(/^_+/, \"\")\n .replace(/_+$/, \"\");\n if (!cleaned) throw new Error(\"fileBase is required (after sanitization)\");\n return cleaned;\n}\n\nexport function buildCaptureCommand(opts: {\n iface: string;\n fileBase: string;\n count: number;\n size: string;\n hostFilterIp?: string;\n portFilter?: number;\n}): string {\n const iface = String(opts.iface || \"eth0\").trim() || \"eth0\";\n const fileBase = sanitizeFileBase(opts.fileBase);\n const count = Number.isFinite(opts.count) ? Math.trunc(opts.count) : 100000;\n if (count <= 0) throw new Error(\"count must be > 0\");\n const size = String(opts.size || \"all\").trim() || \"all\";\n\n const args: string[] = [\n \"utils\",\n \"network\",\n \"capture\",\n iface,\n \"file\",\n fileBase,\n \"count\",\n String(count),\n \"size\",\n size,\n ];\n\n if (opts.portFilter != null) {\n const p = Math.trunc(opts.portFilter);\n if (p < 1 || p > 65535) throw new Error(\"portFilter must be 1..65535\");\n args.push(\"port\", String(p));\n }\n\n if (opts.hostFilterIp) {\n const ip = String(opts.hostFilterIp).trim();\n if (!ip) throw new Error(\"hostFilterIp must be non-empty\");\n args.push(\"host\", \"ip\", ip);\n }\n\n return args.join(\" \");\n}\n\nexport function remoteCapturePath(fileBase: string): string {\n const fb = sanitizeFileBase(fileBase);\n return `/var/log/active/platform/cli/${fb}.cap`;\n}\n\nexport function remoteCaptureCandidates(fileBase: string, maxParts = 10): string[] {\n const fb = sanitizeFileBase(fileBase);\n const base = `/var/log/active/platform/cli/${fb}.cap`;\n const out: string[] = [base];\n // Some CUCM/VOS versions roll packet capture files as .cap01, .cap02, ...\n for (let i = 1; i <= maxParts; i++) {\n const suffix = String(i).padStart(2, \"0\");\n out.push(`/var/log/active/platform/cli/${fb}.cap${suffix}`);\n }\n return out;\n}\n\ntype Active = {\n session: PacketCaptureSession;\n client: Client;\n channel: ClientChannel;\n};\n\nfunction waitFor(\n channel: ClientChannel,\n session: PacketCaptureSession,\n predicate: () => boolean,\n timeoutMs: number,\n timeoutMessage: string\n): Promise<void> {\n return new Promise((resolve, reject) => {\n if (predicate()) return resolve();\n\n const onData = () => {\n if (predicate()) cleanup(true);\n };\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const cleanup = (ok: boolean) => {\n channel.off(\"data\", onData);\n channel.stderr.off(\"data\", onData);\n if (timer) clearTimeout(timer);\n if (ok) resolve();\n else {\n const tail = (session.lastStdout || session.lastStderr || \"\").slice(-400);\n reject(new Error(`${timeoutMessage}. lastOutput=${JSON.stringify(tail)}`));\n }\n };\n\n channel.on(\"data\", onData);\n channel.stderr.on(\"data\", onData);\n\n timer = setTimeout(() => cleanup(false), Math.max(1000, timeoutMs));\n (timer as any).unref?.();\n });\n}\n\nexport class PacketCaptureManager {\n private active = new Map<string, Active>();\n private state: CaptureStateStore;\n\n constructor(opts?: { state?: CaptureStateStore }) {\n this.state = opts?.state || defaultStateStore();\n }\n\n list(): PacketCaptureSession[] {\n return [...this.active.values()].map((a) => a.session);\n }\n\n async start(opts: PacketCaptureStart): Promise<PacketCaptureSession> {\n const iface = String(opts.iface || \"eth0\").trim() || \"eth0\";\n const fileBase = sanitizeFileBase(opts.fileBase || `cap_${Date.now()}`);\n const count = opts.count ?? 100000;\n const size = opts.size ?? \"all\";\n const cmd = buildCaptureCommand({ iface, fileBase, count, size, hostFilterIp: opts.hostFilterIp, portFilter: opts.portFilter });\n\n const id = crypto.randomUUID();\n const sshPort = opts.sshPort ?? (process.env.CUCM_SSH_PORT ? Number.parseInt(process.env.CUCM_SSH_PORT, 10) : 22);\n const auth = resolveSshAuth(opts.auth);\n\n const client = new Client();\n const startedAt = new Date().toISOString();\n const session: PacketCaptureSession = {\n id,\n host: opts.host,\n startedAt,\n iface,\n fileBase,\n remoteFilePath: remoteCapturePath(fileBase),\n remoteFileCandidates: remoteCaptureCandidates(fileBase),\n };\n\n // Persist early so we can recover/download even if the MCP process restarts.\n this.state.upsert({\n ...session,\n stoppedAt: session.stoppedAt,\n });\n\n const startTimeoutMs = Math.max(2000, Math.trunc(opts.startTimeoutMs ?? 30_000));\n\n let connectTimer: ReturnType<typeof setTimeout> | undefined;\n const connectTimeout = new Promise<never>((_, reject) => {\n connectTimer = setTimeout(() => reject(new Error(`SSH connect timed out after ${startTimeoutMs}ms`)), startTimeoutMs);\n (connectTimer as any).unref?.();\n });\n\n try {\n await Promise.race([\n new Promise<void>((resolve, reject) => {\n client\n .on(\"ready\", () => resolve())\n .on(\"error\", (e: unknown) => reject(e))\n .connect({\n host: opts.host,\n port: sshPort,\n username: auth.username,\n password: auth.password,\n readyTimeout: 15000,\n });\n }),\n connectTimeout,\n ]);\n } finally {\n if (connectTimer) clearTimeout(connectTimer);\n }\n\n // CUCM SSH presents an interactive CLI shell. `exec()` is not reliably supported,\n // so we open a shell and type commands at the prompt.\n const channel = await new Promise<ClientChannel>((resolve, reject) => {\n client.shell({ term: \"vt100\", cols: 120, rows: 40 }, (err: unknown, ch: ClientChannel) => {\n if (err) return reject(err);\n resolve(ch);\n });\n });\n\n channel.on(\"data\", (buf: Buffer) => {\n session.lastStdout = buf.toString(\"utf8\").slice(-2000);\n this.state.upsert({ ...session, stoppedAt: session.stoppedAt });\n });\n channel.stderr.on(\"data\", (buf: Buffer) => {\n session.lastStderr = buf.toString(\"utf8\").slice(-2000);\n this.state.upsert({ ...session, stoppedAt: session.stoppedAt });\n });\n channel.on(\"close\", (code: number | null) => {\n session.exitCode = code;\n session.stoppedAt = session.stoppedAt || new Date().toISOString();\n // If the capture stopped unexpectedly, drop it from the active map.\n this.active.delete(id);\n this.state.upsert({ ...session, stoppedAt: session.stoppedAt });\n try {\n client.end();\n client.destroy();\n } catch {\n // ignore\n }\n });\n\n // Wait for prompt before running capture command.\n // Some CUCM shells don't print a prompt until you send a newline.\n const promptTimeoutMs = startTimeoutMs;\n try {\n try {\n channel.write(\"\\n\");\n } catch {\n // ignore\n }\n\n // Nudge a couple times if no prompt yet.\n void (async () => {\n const delays = [400, 1200, 2500];\n for (const d of delays) {\n await new Promise((r) => setTimeout(r, d));\n if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) return;\n try {\n channel.write(\"\\n\");\n } catch {\n // ignore\n }\n }\n })();\n\n await waitFor(\n channel,\n session,\n () => looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr),\n promptTimeoutMs,\n \"Timeout waiting for CUCM CLI prompt\"\n );\n channel.write(`${cmd}\\n`);\n } catch (e) {\n try {\n channel.end();\n channel.close();\n } catch {\n // ignore\n }\n try {\n client.end();\n client.destroy();\n } catch {\n // ignore\n }\n throw e;\n }\n\n this.active.set(id, { session, client, channel });\n\n // Optional guard: stop after a duration even if packet count isn't reached.\n if (opts.maxDurationMs != null) {\n const dur = Math.max(250, Math.trunc(opts.maxDurationMs));\n const t = setTimeout(() => {\n void this.stop(id).catch(() => {\n // ignore\n });\n }, dur);\n (t as any).unref?.();\n }\n\n return session;\n }\n\n async stop(captureId: string, timeoutMs = 90_000): Promise<PacketCaptureSession> {\n const a = this.active.get(captureId);\n if (!a) throw new Error(`Unknown captureId: ${captureId}`);\n\n const { channel, client, session } = a;\n const done = new Promise<void>((resolve) => {\n if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {\n return resolve();\n }\n // CUCM CLI can keep the SSH channel open after the command finishes\n // (it returns to a prompt rather than exiting). Treat prompt as \"stopped\".\n const onData = () => {\n if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {\n cleanup();\n resolve();\n }\n };\n const cleanup = () => {\n channel.off(\"data\", onData);\n channel.stderr.off(\"data\", onData);\n };\n\n const finish = () => {\n cleanup();\n resolve();\n };\n\n channel.once(\"exit\", finish);\n channel.once(\"close\", finish);\n channel.once(\"end\", finish);\n channel.on(\"data\", onData);\n channel.stderr.on(\"data\", onData);\n });\n\n const sendInterrupt = () => {\n // Best-effort interrupt.\n try {\n if (typeof (channel as any).signal === \"function\") (channel as any).signal(\"INT\");\n // Always also write Ctrl-C for PTY sessions.\n channel.write(\"\\x03\");\n } catch {\n try {\n channel.write(\"\\x03\");\n } catch {\n // ignore\n }\n }\n };\n\n // CUCM can take a while to flush/close captures (especially big buffers).\n // Also, some CLI flows require a second Ctrl-C or a newline to return to prompt.\n const resolvedTimeoutMs = Math.max(5000, timeoutMs || 0);\n const deadline = Date.now() + resolvedTimeoutMs;\n sendInterrupt();\n\n // Nudge again a couple times if it doesn't exit quickly.\n void (async () => {\n const delays = [750, 1500, 3000];\n for (const d of delays) {\n await new Promise((r) => setTimeout(r, d));\n if (Date.now() >= deadline) return;\n // If channel already closed, no-op.\n sendInterrupt();\n try {\n channel.write(\"\\n\");\n } catch {\n // ignore\n }\n }\n })();\n\n try {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((_, reject) => {\n timer = setTimeout(() => reject(new Error(\"Timeout waiting for capture to stop\")), resolvedTimeoutMs);\n // Don't keep the process alive just because we're waiting.\n (timer as any).unref?.();\n });\n\n try {\n await Promise.race([done, timeout]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n } catch (e) {\n // Don't hard-fail: if the CLI doesn't emit a prompt/exit, we still want to:\n // - close SSH resources\n // - let the caller try DIME downloads (.cap, .cap01, etc)\n session.stopTimedOut = true;\n session.stoppedAt = session.stoppedAt || new Date().toISOString();\n this.state.upsert({ ...session, stoppedAt: session.stoppedAt });\n try {\n channel.close();\n } catch {\n // ignore\n }\n }\n\n session.stoppedAt = new Date().toISOString();\n this.state.upsert({ ...session, stoppedAt: session.stoppedAt });\n\n // If we're back at a CUCM prompt, try to close the session cleanly.\n if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {\n try {\n channel.write(\"exit\\n\");\n } catch {\n // ignore\n }\n }\n\n // Ensure the channel is closed so the SSH client can terminate promptly.\n try {\n channel.end();\n } catch {\n // ignore\n }\n try {\n channel.close();\n } catch {\n // ignore\n }\n\n // Attempt to learn the actual remote file path from CLI output.\n // This helps when CUCM appends suffixes or reports a different on-disk location.\n const inferred = extractCapFilePath(session.lastStdout) || extractCapFilePath(session.lastStderr);\n if (inferred) {\n session.remoteFilePath = inferred;\n if (!session.remoteFileCandidates.includes(inferred)) {\n session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];\n }\n }\n\n this.state.upsert({ ...session, stoppedAt: session.stoppedAt });\n\n // stop() implies cleanup\n this.active.delete(captureId);\n try {\n client.end();\n client.destroy();\n } catch {\n // ignore\n }\n return session;\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
const TSHARK_CANDIDATES = [
|
|
4
|
+
process.env.TSHARK_PATH,
|
|
5
|
+
"tshark",
|
|
6
|
+
"/Applications/Wireshark.app/Contents/MacOS/tshark",
|
|
7
|
+
"/usr/local/bin/tshark",
|
|
8
|
+
"/usr/bin/tshark",
|
|
9
|
+
"/opt/homebrew/bin/tshark"
|
|
10
|
+
].filter(Boolean);
|
|
11
|
+
let cachedTsharkPath = null;
|
|
12
|
+
function findTshark() {
|
|
13
|
+
if (cachedTsharkPath) return cachedTsharkPath;
|
|
14
|
+
for (const candidate of TSHARK_CANDIDATES) {
|
|
15
|
+
try {
|
|
16
|
+
if (existsSync(candidate)) {
|
|
17
|
+
cachedTsharkPath = candidate;
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return "tshark";
|
|
24
|
+
}
|
|
25
|
+
const TSHARK_TIMEOUT_MS = Number(process.env.CUCM_MCP_TSHARK_TIMEOUT_MS) || 6e4;
|
|
26
|
+
function runTshark(args, timeoutMs) {
|
|
27
|
+
const bin = findTshark();
|
|
28
|
+
const timeout = timeoutMs ?? TSHARK_TIMEOUT_MS;
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
execFile(bin, args, { timeout, maxBuffer: 50 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
31
|
+
if (err) {
|
|
32
|
+
const msg = stderr?.trim() || err.message;
|
|
33
|
+
if (msg.includes("No such file") || msg.includes("doesn't exist")) {
|
|
34
|
+
reject(new Error(`File not found: ${args.find((a) => a.endsWith(".cap") || a.endsWith(".pcap")) || "unknown"}`));
|
|
35
|
+
} else if (msg.includes("not found") || msg.includes("ENOENT")) {
|
|
36
|
+
reject(
|
|
37
|
+
new Error(
|
|
38
|
+
`tshark not found. Install Wireshark or set TSHARK_PATH. Tried: ${TSHARK_CANDIDATES.join(", ")}`
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
reject(new Error(`tshark error: ${msg}`));
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
resolve(stdout);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function validateCapFile(filePath) {
|
|
51
|
+
if (!existsSync(filePath)) {
|
|
52
|
+
throw new Error(`Capture file not found: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
const stat = statSync(filePath);
|
|
55
|
+
if (stat.size === 0) {
|
|
56
|
+
throw new Error(`Capture file is empty: ${filePath}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const SKINNY_MESSAGE_NAMES = {
|
|
60
|
+
0: "KeepAliveMessage",
|
|
61
|
+
1: "RegisterMessage",
|
|
62
|
+
2: "IpPortMessage",
|
|
63
|
+
3: "KeypadButtonMessage",
|
|
64
|
+
4: "EnblocCallMessage",
|
|
65
|
+
5: "StimulusMessage",
|
|
66
|
+
6: "OffHookMessage",
|
|
67
|
+
7: "OnHookMessage",
|
|
68
|
+
8: "HookFlashMessage",
|
|
69
|
+
9: "ForwardStatReqMessage",
|
|
70
|
+
10: "SpeedDialStatReqMessage",
|
|
71
|
+
11: "LineStatReqMessage",
|
|
72
|
+
12: "ConfigStatReqMessage",
|
|
73
|
+
13: "TimeDateReqMessage",
|
|
74
|
+
14: "ButtonTemplateReqMessage",
|
|
75
|
+
15: "VersionReqMessage",
|
|
76
|
+
16: "CapabilitiesResMessage",
|
|
77
|
+
32: "AlarmMessage",
|
|
78
|
+
34: "MulticastMediaReceptionAck",
|
|
79
|
+
35: "OpenReceiveChannelAck",
|
|
80
|
+
36: "ConnectionStatisticsRes",
|
|
81
|
+
37: "OffHookWithCgpnMessage",
|
|
82
|
+
38: "SoftKeySetReqMessage",
|
|
83
|
+
39: "SoftKeyEventMessage",
|
|
84
|
+
41: "UnregisterMessage",
|
|
85
|
+
42: "SoftKeyTemplateReqMessage",
|
|
86
|
+
43: "RegisterTokenReq",
|
|
87
|
+
44: "MediaTransmissionFailure",
|
|
88
|
+
45: "HeadsetStatusMessage",
|
|
89
|
+
46: "MediaResourceNotification",
|
|
90
|
+
47: "RegisterAvailableLinesMessage",
|
|
91
|
+
48: "DeviceToUserDataMessage",
|
|
92
|
+
49: "DeviceToUserDataResponseMessage",
|
|
93
|
+
50: "UpdateCapabilitiesMessage",
|
|
94
|
+
52: "ServiceURLStatReqMessage",
|
|
95
|
+
53: "FeatureStatReqMessage",
|
|
96
|
+
72: "DeviceToUserDataVersion1Message",
|
|
97
|
+
73: "DeviceToUserDataResponseVersion1Message",
|
|
98
|
+
// Station → CallManager
|
|
99
|
+
129: "RegisterAckMessage",
|
|
100
|
+
130: "StartToneMessage",
|
|
101
|
+
131: "StopToneMessage",
|
|
102
|
+
133: "SetRingerMessage",
|
|
103
|
+
134: "SetLampMessage",
|
|
104
|
+
135: "SetHkFDetectMessage",
|
|
105
|
+
136: "SetSpeakerModeMessage",
|
|
106
|
+
137: "SetMicroModeMessage",
|
|
107
|
+
138: "StartMediaTransmission",
|
|
108
|
+
139: "StopMediaTransmission",
|
|
109
|
+
143: "CallInfoMessage",
|
|
110
|
+
144: "ForwardStatMessage",
|
|
111
|
+
145: "SpeedDialStatMessage",
|
|
112
|
+
146: "LineStatMessage",
|
|
113
|
+
147: "ConfigStatMessage",
|
|
114
|
+
148: "DefineTimeDate",
|
|
115
|
+
149: "StartSessionTransmission",
|
|
116
|
+
150: "StopSessionTransmission",
|
|
117
|
+
151: "ButtonTemplateMessage",
|
|
118
|
+
152: "VersionMessage",
|
|
119
|
+
153: "DisplayTextMessage",
|
|
120
|
+
154: "ClearDisplay",
|
|
121
|
+
155: "CapabilitiesReqMessage",
|
|
122
|
+
157: "RegisterRejectMessage",
|
|
123
|
+
158: "ServerResMessage",
|
|
124
|
+
159: "Reset",
|
|
125
|
+
256: "KeepAliveAckMessage",
|
|
126
|
+
257: "StartMulticastMediaReception",
|
|
127
|
+
258: "StartMulticastMediaTransmission",
|
|
128
|
+
259: "StopMulticastMediaReception",
|
|
129
|
+
260: "StopMulticastMediaTransmission",
|
|
130
|
+
261: "OpenReceiveChannel",
|
|
131
|
+
262: "CloseReceiveChannel",
|
|
132
|
+
263: "ConnectionStatisticsReq",
|
|
133
|
+
264: "SoftKeyTemplateResMessage",
|
|
134
|
+
265: "SoftKeySetResMessage",
|
|
135
|
+
272: "SelectSoftKeysMessage",
|
|
136
|
+
273: "CallStateMessage",
|
|
137
|
+
274: "DisplayPromptStatusMessage",
|
|
138
|
+
275: "ClearPromptStatusMessage",
|
|
139
|
+
276: "DisplayNotifyMessage",
|
|
140
|
+
277: "ClearNotifyMessage",
|
|
141
|
+
278: "ActivateCallPlaneMessage",
|
|
142
|
+
279: "DeactivateCallPlaneMessage",
|
|
143
|
+
280: "UnregisterAckMessage",
|
|
144
|
+
281: "BackSpaceReqMessage",
|
|
145
|
+
282: "RegisterTokenAck",
|
|
146
|
+
283: "RegisterTokenReject",
|
|
147
|
+
284: "StartMediaFailureDetection",
|
|
148
|
+
285: "DialedNumberMessage",
|
|
149
|
+
286: "UserToDeviceDataMessage",
|
|
150
|
+
287: "FeatureStatMessage",
|
|
151
|
+
288: "DisplayPriNotifyMessage",
|
|
152
|
+
289: "ClearPriNotifyMessage",
|
|
153
|
+
290: "StartAnnouncementMessage",
|
|
154
|
+
291: "StopAnnouncementMessage",
|
|
155
|
+
292: "AnnouncementFinishMessage",
|
|
156
|
+
295: "NotifyDtmfToneMessage",
|
|
157
|
+
296: "SendDtmfToneMessage",
|
|
158
|
+
298: "SubscribeDtmfPayloadReqMessage",
|
|
159
|
+
299: "SubscribeDtmfPayloadResMessage",
|
|
160
|
+
300: "SubscribeDtmfPayloadErrMessage",
|
|
161
|
+
301: "UnSubscribeDtmfPayloadReqMessage",
|
|
162
|
+
302: "UnSubscribeDtmfPayloadResMessage",
|
|
163
|
+
303: "UnSubscribeDtmfPayloadErrMessage",
|
|
164
|
+
304: "ServiceURLStatMessage",
|
|
165
|
+
314: "UserToDeviceDataVersion1Message",
|
|
166
|
+
319: "DialedPhoneBookMessage",
|
|
167
|
+
321: "XMLAlarmMessage",
|
|
168
|
+
323: "SpeedDialStatDynamicMessage",
|
|
169
|
+
338: "CallInfoMessage2"
|
|
170
|
+
};
|
|
171
|
+
function skinnyMessageName(id) {
|
|
172
|
+
return SKINNY_MESSAGE_NAMES[id] ?? `Unknown(0x${id.toString(16).padStart(4, "0")})`;
|
|
173
|
+
}
|
|
174
|
+
const RTP_PAYLOAD_TYPES = {
|
|
175
|
+
0: "PCMU (G.711 u-law)",
|
|
176
|
+
3: "GSM",
|
|
177
|
+
4: "G.723",
|
|
178
|
+
8: "PCMA (G.711 A-law)",
|
|
179
|
+
9: "G.722",
|
|
180
|
+
10: "L16 stereo",
|
|
181
|
+
11: "L16 mono",
|
|
182
|
+
13: "CN (comfort noise)",
|
|
183
|
+
18: "G.729",
|
|
184
|
+
31: "H.261",
|
|
185
|
+
32: "MPV (MPEG video)",
|
|
186
|
+
33: "MP2T (MPEG transport)",
|
|
187
|
+
34: "H.263",
|
|
188
|
+
96: "dynamic (96)",
|
|
189
|
+
97: "dynamic (97)",
|
|
190
|
+
98: "dynamic (98)",
|
|
191
|
+
99: "dynamic (99)",
|
|
192
|
+
100: "dynamic (100)",
|
|
193
|
+
101: "telephone-event (DTMF)",
|
|
194
|
+
110: "dynamic (110)",
|
|
195
|
+
111: "dynamic (111)",
|
|
196
|
+
112: "dynamic (112)",
|
|
197
|
+
114: "iLBC",
|
|
198
|
+
116: "dynamic (116)",
|
|
199
|
+
118: "dynamic (118)",
|
|
200
|
+
119: "dynamic (119)",
|
|
201
|
+
120: "dynamic (120)",
|
|
202
|
+
121: "dynamic (121)",
|
|
203
|
+
122: "dynamic (122)",
|
|
204
|
+
123: "dynamic (123)",
|
|
205
|
+
124: "dynamic (124)",
|
|
206
|
+
125: "dynamic (125)",
|
|
207
|
+
126: "dynamic (126)",
|
|
208
|
+
127: "dynamic (127)"
|
|
209
|
+
};
|
|
210
|
+
function rtpCodecName(pt) {
|
|
211
|
+
return RTP_PAYLOAD_TYPES[pt] ?? `PT ${pt}`;
|
|
212
|
+
}
|
|
213
|
+
async function pcapCallSummary(filePath) {
|
|
214
|
+
validateCapFile(filePath);
|
|
215
|
+
const stat = statSync(filePath);
|
|
216
|
+
const phsRaw = await runTshark(["-r", filePath, "-q", "-z", "io,phs"]);
|
|
217
|
+
const protocols = {};
|
|
218
|
+
for (const line of phsRaw.split("\n")) {
|
|
219
|
+
const match = line.match(/^\s*([\w:]+)\s+frames:(\d+)\s+bytes:(\d+)/);
|
|
220
|
+
if (match) {
|
|
221
|
+
const proto = match[1].split(":").pop().toLowerCase();
|
|
222
|
+
if (["sip", "skinny", "rtp", "rtcp", "sdp", "stun", "tcp", "udp", "ip", "eth"].includes(proto)) {
|
|
223
|
+
protocols[proto] = { frames: parseInt(match[2]), bytes: parseInt(match[3]) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let sipCallIds = [];
|
|
228
|
+
try {
|
|
229
|
+
const sipRaw = await runTshark([
|
|
230
|
+
"-r",
|
|
231
|
+
filePath,
|
|
232
|
+
"-Y",
|
|
233
|
+
"sip",
|
|
234
|
+
"-T",
|
|
235
|
+
"fields",
|
|
236
|
+
"-e",
|
|
237
|
+
"sip.Call-ID",
|
|
238
|
+
"-E",
|
|
239
|
+
"header=n"
|
|
240
|
+
]);
|
|
241
|
+
sipCallIds = [...new Set(sipRaw.split("\n").map((l) => l.trim()).filter(Boolean))];
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
let rtpStreamCount = 0;
|
|
245
|
+
try {
|
|
246
|
+
const rtpRaw = await runTshark(["-r", filePath, "-q", "-z", "rtp,streams"]);
|
|
247
|
+
const rtpLines = rtpRaw.split("\n").filter((l) => /^\s*\d+\.\d+/.test(l));
|
|
248
|
+
rtpStreamCount = rtpLines.length;
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
let endpoints = [];
|
|
252
|
+
try {
|
|
253
|
+
const convRaw = await runTshark(["-r", filePath, "-q", "-z", "conv,ip"]);
|
|
254
|
+
const ips = /* @__PURE__ */ new Set();
|
|
255
|
+
for (const line of convRaw.split("\n")) {
|
|
256
|
+
const m = line.match(/(\d+\.\d+\.\d+\.\d+)\s+<->\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
257
|
+
if (m) {
|
|
258
|
+
ips.add(m[1]);
|
|
259
|
+
ips.add(m[2]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
endpoints = [...ips].sort();
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
let durationSec = 0;
|
|
266
|
+
try {
|
|
267
|
+
const capRaw = await runTshark([
|
|
268
|
+
"-r",
|
|
269
|
+
filePath,
|
|
270
|
+
"-T",
|
|
271
|
+
"fields",
|
|
272
|
+
"-e",
|
|
273
|
+
"frame.time_relative",
|
|
274
|
+
"-E",
|
|
275
|
+
"header=n",
|
|
276
|
+
"-c",
|
|
277
|
+
"1",
|
|
278
|
+
"-Y",
|
|
279
|
+
"frame.number == 0"
|
|
280
|
+
]);
|
|
281
|
+
const durRaw = await runTshark([
|
|
282
|
+
"-r",
|
|
283
|
+
filePath,
|
|
284
|
+
"-q",
|
|
285
|
+
"-z",
|
|
286
|
+
"io,stat,0"
|
|
287
|
+
]);
|
|
288
|
+
const durMatch = durRaw.match(/Duration:\s+([\d.]+)/);
|
|
289
|
+
if (durMatch) durationSec = parseFloat(durMatch[1]);
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
let totalPackets = 0;
|
|
293
|
+
for (const p of Object.values(protocols)) {
|
|
294
|
+
if (p.frames > totalPackets) totalPackets = p.frames;
|
|
295
|
+
}
|
|
296
|
+
totalPackets = protocols["ip"]?.frames ?? protocols["eth"]?.frames ?? totalPackets;
|
|
297
|
+
return {
|
|
298
|
+
file: filePath,
|
|
299
|
+
bytes: stat.size,
|
|
300
|
+
packets: totalPackets,
|
|
301
|
+
duration: `${durationSec.toFixed(1)}s`,
|
|
302
|
+
protocols: {
|
|
303
|
+
sip: protocols["sip"]?.frames ?? 0,
|
|
304
|
+
skinny: protocols["skinny"]?.frames ?? 0,
|
|
305
|
+
rtp: protocols["rtp"]?.frames ?? 0,
|
|
306
|
+
rtcp: protocols["rtcp"]?.frames ?? 0,
|
|
307
|
+
sdp: protocols["sdp"]?.frames ?? 0
|
|
308
|
+
},
|
|
309
|
+
endpoints,
|
|
310
|
+
sipCalls: sipCallIds.length,
|
|
311
|
+
sipCallIds: sipCallIds.length <= 20 ? sipCallIds : sipCallIds.slice(0, 20),
|
|
312
|
+
rtpStreams: rtpStreamCount
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
async function pcapSipCalls(filePath, callId) {
|
|
316
|
+
validateCapFile(filePath);
|
|
317
|
+
const filter = callId ? `sip and sip.Call-ID == "${callId}"` : "sip";
|
|
318
|
+
const raw = await runTshark([
|
|
319
|
+
"-r",
|
|
320
|
+
filePath,
|
|
321
|
+
"-Y",
|
|
322
|
+
filter,
|
|
323
|
+
"-T",
|
|
324
|
+
"fields",
|
|
325
|
+
"-e",
|
|
326
|
+
"frame.time_relative",
|
|
327
|
+
"-e",
|
|
328
|
+
"ip.src",
|
|
329
|
+
"-e",
|
|
330
|
+
"ip.dst",
|
|
331
|
+
"-e",
|
|
332
|
+
"sip.Method",
|
|
333
|
+
"-e",
|
|
334
|
+
"sip.Status-Code",
|
|
335
|
+
"-e",
|
|
336
|
+
"sip.Status-Line",
|
|
337
|
+
"-e",
|
|
338
|
+
"sip.Call-ID",
|
|
339
|
+
"-e",
|
|
340
|
+
"sip.from.addr",
|
|
341
|
+
"-e",
|
|
342
|
+
"sip.to.addr",
|
|
343
|
+
"-e",
|
|
344
|
+
"sip.CSeq",
|
|
345
|
+
"-e",
|
|
346
|
+
"sip.r-uri",
|
|
347
|
+
"-e",
|
|
348
|
+
"sdp.media",
|
|
349
|
+
"-e",
|
|
350
|
+
"sdp.connection_info",
|
|
351
|
+
"-E",
|
|
352
|
+
"header=n",
|
|
353
|
+
"-E",
|
|
354
|
+
"separator= ",
|
|
355
|
+
"-E",
|
|
356
|
+
"occurrence=f"
|
|
357
|
+
]);
|
|
358
|
+
const callMap = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const line of raw.split("\n")) {
|
|
360
|
+
if (!line.trim()) continue;
|
|
361
|
+
const fields = line.split(" ");
|
|
362
|
+
const [time, src, dst, method, statusCodeStr, statusLine, cid, from, to, cseq, ruri, sdpMedia, sdpConn] = fields;
|
|
363
|
+
if (!cid) continue;
|
|
364
|
+
const msg = {
|
|
365
|
+
time: time || "0",
|
|
366
|
+
src: src || "",
|
|
367
|
+
dst: dst || "",
|
|
368
|
+
callId: cid,
|
|
369
|
+
from: from || "",
|
|
370
|
+
to: to || "",
|
|
371
|
+
cseq: cseq || ""
|
|
372
|
+
};
|
|
373
|
+
if (method) msg.method = method;
|
|
374
|
+
if (statusCodeStr) msg.statusCode = parseInt(statusCodeStr);
|
|
375
|
+
if (statusLine) msg.statusLine = statusLine;
|
|
376
|
+
if (ruri) msg.requestUri = ruri;
|
|
377
|
+
if (sdpMedia) msg.sdpMedia = sdpMedia;
|
|
378
|
+
if (sdpConn) msg.sdpConnectionInfo = sdpConn;
|
|
379
|
+
if (!callMap.has(cid)) callMap.set(cid, []);
|
|
380
|
+
callMap.get(cid).push(msg);
|
|
381
|
+
}
|
|
382
|
+
const flows = [];
|
|
383
|
+
for (const [cid, messages] of callMap) {
|
|
384
|
+
const firstMsg = messages[0];
|
|
385
|
+
const lastMsg = messages[messages.length - 1];
|
|
386
|
+
const answered = messages.some((m) => m.statusCode === 200 && m.cseq?.includes("INVITE"));
|
|
387
|
+
let setupTimeMs;
|
|
388
|
+
const invite = messages.find((m) => m.method === "INVITE");
|
|
389
|
+
const ok200 = messages.find((m) => m.statusCode === 200 && m.cseq?.includes("INVITE"));
|
|
390
|
+
if (invite && ok200) {
|
|
391
|
+
setupTimeMs = Math.round((parseFloat(ok200.time) - parseFloat(invite.time)) * 1e3);
|
|
392
|
+
}
|
|
393
|
+
flows.push({
|
|
394
|
+
callId: cid,
|
|
395
|
+
from: firstMsg.from,
|
|
396
|
+
to: firstMsg.to,
|
|
397
|
+
messages,
|
|
398
|
+
metrics: {
|
|
399
|
+
firstSeen: firstMsg.time,
|
|
400
|
+
lastSeen: lastMsg.time,
|
|
401
|
+
messageCount: messages.length,
|
|
402
|
+
answered,
|
|
403
|
+
setupTimeMs
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return flows;
|
|
408
|
+
}
|
|
409
|
+
async function pcapScppMessages(filePath, deviceFilter) {
|
|
410
|
+
validateCapFile(filePath);
|
|
411
|
+
const filter = deviceFilter ? `skinny and ip.addr == ${deviceFilter}` : "skinny";
|
|
412
|
+
const raw = await runTshark([
|
|
413
|
+
"-r",
|
|
414
|
+
filePath,
|
|
415
|
+
"-Y",
|
|
416
|
+
filter,
|
|
417
|
+
"-T",
|
|
418
|
+
"fields",
|
|
419
|
+
"-e",
|
|
420
|
+
"frame.time_relative",
|
|
421
|
+
"-e",
|
|
422
|
+
"ip.src",
|
|
423
|
+
"-e",
|
|
424
|
+
"ip.dst",
|
|
425
|
+
"-e",
|
|
426
|
+
"skinny.messageId",
|
|
427
|
+
"-e",
|
|
428
|
+
"skinny.CallingPartyName",
|
|
429
|
+
"-e",
|
|
430
|
+
"skinny.CallingPartyNumber",
|
|
431
|
+
"-e",
|
|
432
|
+
"skinny.calledPartyName",
|
|
433
|
+
"-e",
|
|
434
|
+
"skinny.calledPartyNumber",
|
|
435
|
+
"-e",
|
|
436
|
+
"skinny.callIdentifier",
|
|
437
|
+
"-e",
|
|
438
|
+
"skinny.lineInstance",
|
|
439
|
+
"-e",
|
|
440
|
+
"skinny.callState",
|
|
441
|
+
"-E",
|
|
442
|
+
"header=n",
|
|
443
|
+
"-E",
|
|
444
|
+
"separator= ",
|
|
445
|
+
"-E",
|
|
446
|
+
"occurrence=f"
|
|
447
|
+
]);
|
|
448
|
+
const messages = [];
|
|
449
|
+
const devices = /* @__PURE__ */ new Set();
|
|
450
|
+
const typeCounts = {};
|
|
451
|
+
for (const line of raw.split("\n")) {
|
|
452
|
+
if (!line.trim()) continue;
|
|
453
|
+
const fields = line.split(" ");
|
|
454
|
+
const [time, src, dst, msgIdStr, callingName, callingNum, calledName, calledNum, callIdStr, lineInst, callSt] = fields;
|
|
455
|
+
const messageId = parseInt(msgIdStr || "0");
|
|
456
|
+
const messageName = skinnyMessageName(messageId);
|
|
457
|
+
devices.add(src || "");
|
|
458
|
+
devices.add(dst || "");
|
|
459
|
+
typeCounts[messageName] = (typeCounts[messageName] || 0) + 1;
|
|
460
|
+
const msg = {
|
|
461
|
+
time: time || "0",
|
|
462
|
+
src: src || "",
|
|
463
|
+
dst: dst || "",
|
|
464
|
+
messageId,
|
|
465
|
+
messageName
|
|
466
|
+
};
|
|
467
|
+
if (callingName) msg.callingPartyName = callingName;
|
|
468
|
+
if (callingNum) msg.callingPartyNumber = callingNum;
|
|
469
|
+
if (calledName) msg.calledPartyName = calledName;
|
|
470
|
+
if (calledNum) msg.calledPartyNumber = calledNum;
|
|
471
|
+
if (callIdStr) msg.callId = callIdStr;
|
|
472
|
+
if (lineInst) msg.lineInstance = lineInst;
|
|
473
|
+
if (callSt) msg.callState = callSt;
|
|
474
|
+
messages.push(msg);
|
|
475
|
+
}
|
|
476
|
+
devices.delete("");
|
|
477
|
+
return {
|
|
478
|
+
messages,
|
|
479
|
+
devices: [...devices].sort(),
|
|
480
|
+
messageTypes: typeCounts,
|
|
481
|
+
totalMessages: messages.length
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
async function pcapRtpStreams(filePath, ssrcFilter) {
|
|
485
|
+
validateCapFile(filePath);
|
|
486
|
+
const raw = await runTshark(["-r", filePath, "-q", "-z", "rtp,streams"]);
|
|
487
|
+
const streams = [];
|
|
488
|
+
const lines = raw.split("\n");
|
|
489
|
+
let inTable = false;
|
|
490
|
+
for (const line of lines) {
|
|
491
|
+
if (line.includes("========================")) {
|
|
492
|
+
inTable = !inTable;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (!inTable) continue;
|
|
496
|
+
if (!line.trim() || line.startsWith(" Src")) continue;
|
|
497
|
+
const parts = line.trim().split(/\s+/);
|
|
498
|
+
if (parts.length < 10) continue;
|
|
499
|
+
const srcAddr = parts[0];
|
|
500
|
+
const srcPort = parts[1];
|
|
501
|
+
const dstAddr = parts[2];
|
|
502
|
+
const dstPort = parts[3];
|
|
503
|
+
const ssrc = parts[4];
|
|
504
|
+
const ptStr = parts[5];
|
|
505
|
+
const packets = parseInt(parts[6]) || 0;
|
|
506
|
+
const lost = parseInt(parts[7]) || 0;
|
|
507
|
+
const maxDelta = parseFloat(parts[8]) || 0;
|
|
508
|
+
const maxJitter = parseFloat(parts[9]) || 0;
|
|
509
|
+
const meanJitter = parseFloat(parts[10]) || 0;
|
|
510
|
+
const pt = parseInt(ptStr) || 0;
|
|
511
|
+
if (ssrcFilter && ssrc !== ssrcFilter) continue;
|
|
512
|
+
streams.push({
|
|
513
|
+
src: `${srcAddr}:${srcPort}`,
|
|
514
|
+
dst: `${dstAddr}:${dstPort}`,
|
|
515
|
+
ssrc,
|
|
516
|
+
payloadType: pt,
|
|
517
|
+
codec: rtpCodecName(pt),
|
|
518
|
+
packets,
|
|
519
|
+
lost,
|
|
520
|
+
lossPercent: packets > 0 ? Math.round(lost / packets * 1e4) / 100 : 0,
|
|
521
|
+
maxDelta,
|
|
522
|
+
maxJitter,
|
|
523
|
+
meanJitter
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const worstLoss = streams.length ? Math.max(...streams.map((s) => s.lossPercent)) : 0;
|
|
527
|
+
const worstJitter = streams.length ? Math.max(...streams.map((s) => s.maxJitter)) : 0;
|
|
528
|
+
return {
|
|
529
|
+
streams,
|
|
530
|
+
summary: {
|
|
531
|
+
totalStreams: streams.length,
|
|
532
|
+
worstLoss: `${worstLoss}%`,
|
|
533
|
+
worstJitter: `${worstJitter}ms`
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async function pcapProtocolFilter(filePath, displayFilter, fields, maxPackets) {
|
|
538
|
+
validateCapFile(filePath);
|
|
539
|
+
const max = Math.min(maxPackets ?? 100, 1e3);
|
|
540
|
+
const args = ["-r", filePath, "-Y", displayFilter, "-c", String(max)];
|
|
541
|
+
if (fields && fields.length > 0) {
|
|
542
|
+
args.push("-T", "fields", "-E", "header=n", "-E", "separator= ");
|
|
543
|
+
for (const f of fields) {
|
|
544
|
+
args.push("-e", f);
|
|
545
|
+
}
|
|
546
|
+
const raw2 = await runTshark(args);
|
|
547
|
+
const results2 = [];
|
|
548
|
+
for (const line of raw2.split("\n")) {
|
|
549
|
+
if (!line.trim()) continue;
|
|
550
|
+
const values = line.split(" ");
|
|
551
|
+
const obj = {};
|
|
552
|
+
fields.forEach((f, i) => {
|
|
553
|
+
obj[f] = values[i] || "";
|
|
554
|
+
});
|
|
555
|
+
results2.push(obj);
|
|
556
|
+
}
|
|
557
|
+
return results2;
|
|
558
|
+
}
|
|
559
|
+
args.push(
|
|
560
|
+
"-T",
|
|
561
|
+
"fields",
|
|
562
|
+
"-E",
|
|
563
|
+
"header=n",
|
|
564
|
+
"-E",
|
|
565
|
+
"separator= ",
|
|
566
|
+
"-e",
|
|
567
|
+
"frame.number",
|
|
568
|
+
"-e",
|
|
569
|
+
"frame.time_relative",
|
|
570
|
+
"-e",
|
|
571
|
+
"ip.src",
|
|
572
|
+
"-e",
|
|
573
|
+
"ip.dst",
|
|
574
|
+
"-e",
|
|
575
|
+
"frame.protocols",
|
|
576
|
+
"-e",
|
|
577
|
+
"frame.len"
|
|
578
|
+
);
|
|
579
|
+
const raw = await runTshark(args);
|
|
580
|
+
const defaultFields = ["frame.number", "frame.time_relative", "ip.src", "ip.dst", "frame.protocols", "frame.len"];
|
|
581
|
+
const results = [];
|
|
582
|
+
for (const line of raw.split("\n")) {
|
|
583
|
+
if (!line.trim()) continue;
|
|
584
|
+
const values = line.split(" ");
|
|
585
|
+
const obj = {};
|
|
586
|
+
defaultFields.forEach((f, i) => {
|
|
587
|
+
obj[f] = values[i] || "";
|
|
588
|
+
});
|
|
589
|
+
results.push(obj);
|
|
590
|
+
}
|
|
591
|
+
return results;
|
|
592
|
+
}
|
|
593
|
+
export {
|
|
594
|
+
pcapCallSummary,
|
|
595
|
+
pcapProtocolFilter,
|
|
596
|
+
pcapRtpStreams,
|
|
597
|
+
pcapScppMessages,
|
|
598
|
+
pcapSipCalls,
|
|
599
|
+
runTshark
|
|
600
|
+
};
|
|
601
|
+
//# sourceMappingURL=pcap-analyze.js.map
|