ccdeck 3.38.6 → 3.39.0
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 +68 -9
- package/THIRD_PARTY_NOTICES.md +12 -3
- package/dist/web/assets/BrowserWatchModal-DdBoC2NT.js +1 -0
- package/dist/web/assets/TrafficRadar-CQdTrL4_.js +1 -0
- package/dist/web/assets/{UsageHistoryModal-C775SS_q.js → UsageHistoryModal-W9_3fKq4.js} +1 -1
- package/dist/web/assets/index-BQRqT36t.css +1 -0
- package/dist/web/assets/index-DUlU5kmL.js +305 -0
- package/dist/web/index.html +3 -3
- package/package.json +8 -4
- package/release-notes.json +18 -0
- package/src/server/cswap-admin.mjs +74 -11
- package/src/server/index.mjs +7 -1
- package/src/server/request-gates.mjs +3 -0
- package/src/server/traffic-radar-capture.mjs +136 -0
- package/src/server/traffic-radar-codec-LICENSES.md +597 -0
- package/src/server/traffic-radar-codec-source.mjs +34 -0
- package/src/server/traffic-radar-codec.mjs +41 -0
- package/src/server/traffic-radar-config.mjs +87 -0
- package/src/server/traffic-radar-helper.mjs +43 -0
- package/src/server/traffic-radar-otlp.json +1 -0
- package/src/server/traffic-radar-routes.mjs +40 -0
- package/src/server/traffic-radar-wire.mjs +228 -0
- package/src/server/traffic-radar.mjs +134 -0
- package/dist/web/assets/BrowserWatchModal-FsBjYpPs.js +0 -1
- package/dist/web/assets/index-BKLXyjIh.js +0 -303
- package/dist/web/assets/index-DnJPDhs9.css +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
5
|
+
|
|
6
|
+
const BOOLEAN_KEYS = [
|
|
7
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY", "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA",
|
|
8
|
+
"ENABLE_ENHANCED_TELEMETRY_BETA", "OTEL_LOG_USER_PROMPTS",
|
|
9
|
+
"OTEL_LOG_ASSISTANT_RESPONSES", "OTEL_LOG_TOOL_DETAILS",
|
|
10
|
+
"OTEL_LOG_TOOL_CONTENT", "OTEL_LOG_RAW_API_BODIES", "OTEL_LOG_MANAGED_SETTINGS",
|
|
11
|
+
];
|
|
12
|
+
const SIGNALS = ["METRICS", "LOGS", "TRACES"];
|
|
13
|
+
const ENDPOINT_KEYS = ["OTEL_EXPORTER_OTLP_ENDPOINT", ...SIGNALS.map(s => `OTEL_EXPORTER_OTLP_${s}_ENDPOINT`)];
|
|
14
|
+
const EXPORTER_KEYS = SIGNALS.map(s => `OTEL_${s}_EXPORTER`);
|
|
15
|
+
const INTERVAL_KEYS = ["OTEL_METRIC_EXPORT_INTERVAL", "OTEL_LOGS_EXPORT_INTERVAL", "OTEL_TRACES_EXPORT_INTERVAL"];
|
|
16
|
+
const HEADER_KEYS = ["OTEL_EXPORTER_OTLP_HEADERS", ...SIGNALS.map(s => `OTEL_EXPORTER_OTLP_${s}_HEADERS`)];
|
|
17
|
+
const PROTOCOL_KEYS = ["OTEL_EXPORTER_OTLP_PROTOCOL", ...SIGNALS.map(s => `OTEL_EXPORTER_OTLP_${s}_PROTOCOL`)];
|
|
18
|
+
export const RADAR_KEYS = [...BOOLEAN_KEYS, ...ENDPOINT_KEYS, ...EXPORTER_KEYS, ...INTERVAL_KEYS, ...HEADER_KEYS, ...PROTOCOL_KEYS];
|
|
19
|
+
|
|
20
|
+
export function safeRadarValue(key, raw) {
|
|
21
|
+
if (HEADER_KEYS.includes(key)) return "Configured (hidden)";
|
|
22
|
+
if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean") return "Invalid (value hidden)";
|
|
23
|
+
const value = String(raw).trim();
|
|
24
|
+
if (PROTOCOL_KEYS.includes(key)) return ['grpc', 'http/protobuf', 'http/json'].includes(value) ? value : "Unknown (value hidden)";
|
|
25
|
+
if (BOOLEAN_KEYS.includes(key)) {
|
|
26
|
+
if (key === "OTEL_LOG_RAW_API_BODIES" && value.startsWith("file:")) return "Enabled (file destination hidden)";
|
|
27
|
+
if (["1", "true"].includes(value)) return "Enabled";
|
|
28
|
+
if (["0", "false"].includes(value)) return "Disabled";
|
|
29
|
+
return "Unknown (value hidden)";
|
|
30
|
+
}
|
|
31
|
+
if (ENDPOINT_KEYS.includes(key)) {
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (!["http:", "https:"].includes(url.protocol) || !url.hostname) return "Invalid (value hidden)";
|
|
35
|
+
return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`;
|
|
36
|
+
} catch { return "Invalid (value hidden)"; }
|
|
37
|
+
}
|
|
38
|
+
if (EXPORTER_KEYS.includes(key)) {
|
|
39
|
+
const exporters = value.split(",").map(s => s.trim());
|
|
40
|
+
return exporters.length && exporters.every(s => ["otlp", "console", "none", "prometheus"].includes(s))
|
|
41
|
+
? exporters.join(", ") : "Unknown (value hidden)";
|
|
42
|
+
}
|
|
43
|
+
if (INTERVAL_KEYS.includes(key)) {
|
|
44
|
+
return /^\d+$/.test(value) && Number(value) > 0 && Number(value) <= 86_400_000
|
|
45
|
+
? `${Number(value)} ms` : "Invalid (value hidden)";
|
|
46
|
+
}
|
|
47
|
+
return "Unknown (value hidden)";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function radarVariables(settings, source) {
|
|
51
|
+
const env = settings?.env;
|
|
52
|
+
if (!env || typeof env !== "object" || Array.isArray(env)) return [];
|
|
53
|
+
return RADAR_KEYS.filter(key => Object.hasOwn(env, key)).map(key => ({
|
|
54
|
+
key, value: safeRadarValue(key, env[key]), source,
|
|
55
|
+
...(BOOLEAN_KEYS.includes(key) && ["1", "0", "true", "false"].includes(String(env[key]).trim()) ? { rawValue: String(env[key]).trim() } : {}),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readBoundedJson(path) {
|
|
60
|
+
const file = await open(path, "r");
|
|
61
|
+
try {
|
|
62
|
+
const stat = await file.stat();
|
|
63
|
+
if (!stat.isFile() || stat.size > 262_144) throw new Error("invalid_file");
|
|
64
|
+
const buffer = Buffer.alloc(262_145);
|
|
65
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
66
|
+
if (bytesRead > 262_144) throw new Error("oversize");
|
|
67
|
+
return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
68
|
+
} finally { await file.close(); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readRadarConfig({ home = homedir(), env = process.env, readJson = readBoundedJson } = {}) {
|
|
72
|
+
const dir = claudeConfigDir(env, home);
|
|
73
|
+
const files = [
|
|
74
|
+
["User settings", join(dir, "settings.json")],
|
|
75
|
+
["Cached organization settings", join(dir, "remote-settings.json")],
|
|
76
|
+
["Managed settings", "/Library/Application Support/ClaudeCode/managed-settings.json"],
|
|
77
|
+
];
|
|
78
|
+
const sources = await Promise.all(files.map(async ([name, path]) => {
|
|
79
|
+
try {
|
|
80
|
+
const settings = await readJson(path);
|
|
81
|
+
return { name, status: "read", variables: radarVariables(settings, name) };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { name, status: error?.code === "ENOENT" ? "absent" : "unavailable", variables: [] };
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
return { sources: sources.map(({ name, status }) => ({ name, status })), variables: sources.flatMap(s => s.variables) };
|
|
87
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
|
|
3
|
+
export async function streamCapture({ base, token, source = 0, input = process.stdin, send = fetch, report = message => process.stderr.write(message + '\n') }) {
|
|
4
|
+
const url = new URL(base);
|
|
5
|
+
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.username || url.password || url.pathname !== '/' || url.search || url.hash || !/^[a-f0-9]{64}$/.test(token)) throw new Error('Invalid local capture destination.');
|
|
6
|
+
if (!Number.isInteger(source) || source < 0 || source > 7) throw new Error('Invalid capture source.');
|
|
7
|
+
let stopped = false, sequence = Promise.resolve();
|
|
8
|
+
const finish = () => { stopped = true; input.destroy(); };
|
|
9
|
+
const post = bytes => {
|
|
10
|
+
sequence = sequence.then(async () => {
|
|
11
|
+
if (stopped) return;
|
|
12
|
+
try {
|
|
13
|
+
const response = await send(new URL('/api/system/traffic-radar/ingest', url), {
|
|
14
|
+
method: 'POST', headers: { 'x-radar-capture': token, 'x-radar-source': String(source), 'content-type': 'application/octet-stream' },
|
|
15
|
+
body: bytes, signal: AbortSignal.timeout(3000), redirect: 'error',
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) finish();
|
|
18
|
+
} catch { finish(); }
|
|
19
|
+
});
|
|
20
|
+
return sequence;
|
|
21
|
+
};
|
|
22
|
+
const heartbeat = setInterval(() => { if (!stopped) void post(Buffer.alloc(0)); }, 1000);
|
|
23
|
+
const deadline = setTimeout(finish, 600_000);
|
|
24
|
+
report('Telemetry Radar: sending capture bytes only to local ccdeck; no file is written. Press Ctrl+C to stop tcpdump.');
|
|
25
|
+
try {
|
|
26
|
+
for await (const chunk of input) {
|
|
27
|
+
for (let offset = 0; offset < chunk.length && !stopped; offset += 65_536) await post(chunk.subarray(offset, offset + 65_536));
|
|
28
|
+
if (stopped) break;
|
|
29
|
+
}
|
|
30
|
+
await sequence;
|
|
31
|
+
} catch {
|
|
32
|
+
if (!stopped) report('Capture input interrupted.');
|
|
33
|
+
} finally {
|
|
34
|
+
stopped = true; clearInterval(heartbeat); clearTimeout(deadline);
|
|
35
|
+
report('Telemetry Radar: local receiver stopped. Press Ctrl+C if tcpdump is still running.');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
40
|
+
streamCapture({ base: process.argv[2], token: process.argv[3], source: Number(process.argv[4] ?? 0) }).catch(() => {
|
|
41
|
+
process.stderr.write('Could not start the local capture helper.\n'); process.exitCode = 1;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"nested":{"opentelemetry":{"nested":{"proto":{"nested":{"collector":{"nested":{"logs":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Collector.Logs.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.collector.logs.v1","java_outer_classname":"LogsServiceProto","go_package":"go.opentelemetry.io/proto/otlp/collector/logs/v1"},"nested":{"LogsService":{"methods":{"Export":{"requestType":"ExportLogsServiceRequest","responseType":"ExportLogsServiceResponse"}}},"ExportLogsServiceRequest":{"fields":{"resourceLogs":{"rule":"repeated","type":"opentelemetry.proto.logs.v1.ResourceLogs","id":1,"protoName":"resource_logs"}}},"ExportLogsServiceResponse":{"fields":{"partialSuccess":{"type":"ExportLogsPartialSuccess","id":1,"protoName":"partial_success"}}},"ExportLogsPartialSuccess":{"fields":{"rejectedLogRecords":{"type":"int64","id":1,"protoName":"rejected_log_records"},"errorMessage":{"type":"string","id":2,"protoName":"error_message"}}}}}}},"trace":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Collector.Trace.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.collector.trace.v1","java_outer_classname":"TraceServiceProto","go_package":"go.opentelemetry.io/proto/otlp/collector/trace/v1"},"nested":{"TraceService":{"methods":{"Export":{"requestType":"ExportTraceServiceRequest","responseType":"ExportTraceServiceResponse"}}},"ExportTraceServiceRequest":{"fields":{"resourceSpans":{"rule":"repeated","type":"opentelemetry.proto.trace.v1.ResourceSpans","id":1,"protoName":"resource_spans"}}},"ExportTraceServiceResponse":{"fields":{"partialSuccess":{"type":"ExportTracePartialSuccess","id":1,"protoName":"partial_success"}}},"ExportTracePartialSuccess":{"fields":{"rejectedSpans":{"type":"int64","id":1,"protoName":"rejected_spans"},"errorMessage":{"type":"string","id":2,"protoName":"error_message"}}}}}}},"metrics":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Collector.Metrics.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.collector.metrics.v1","java_outer_classname":"MetricsServiceProto","go_package":"go.opentelemetry.io/proto/otlp/collector/metrics/v1"},"nested":{"MetricsService":{"methods":{"Export":{"requestType":"ExportMetricsServiceRequest","responseType":"ExportMetricsServiceResponse"}}},"ExportMetricsServiceRequest":{"fields":{"resourceMetrics":{"rule":"repeated","type":"opentelemetry.proto.metrics.v1.ResourceMetrics","id":1,"protoName":"resource_metrics"}}},"ExportMetricsServiceResponse":{"fields":{"partialSuccess":{"type":"ExportMetricsPartialSuccess","id":1,"protoName":"partial_success"}}},"ExportMetricsPartialSuccess":{"fields":{"rejectedDataPoints":{"type":"int64","id":1,"protoName":"rejected_data_points"},"errorMessage":{"type":"string","id":2,"protoName":"error_message"}}}}}}}}},"trace":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Trace.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.trace.v1","java_outer_classname":"TraceProto","go_package":"go.opentelemetry.io/proto/otlp/trace/v1"},"nested":{"TracesData":{"fields":{"resourceSpans":{"rule":"repeated","type":"ResourceSpans","id":1,"protoName":"resource_spans"}}},"ResourceSpans":{"fields":{"resource":{"type":"opentelemetry.proto.resource.v1.Resource","id":1},"scopeSpans":{"rule":"repeated","type":"ScopeSpans","id":2,"protoName":"scope_spans"},"schemaUrl":{"type":"string","id":3,"protoName":"schema_url"}},"reserved":[[1000,1000]]},"ScopeSpans":{"fields":{"scope":{"type":"opentelemetry.proto.common.v1.InstrumentationScope","id":1},"spans":{"rule":"repeated","type":"Span","id":2},"schemaUrl":{"type":"string","id":3,"protoName":"schema_url"}}},"Span":{"fields":{"traceId":{"type":"bytes","id":1,"protoName":"trace_id"},"spanId":{"type":"bytes","id":2,"protoName":"span_id"},"traceState":{"type":"string","id":3,"protoName":"trace_state"},"parentSpanId":{"type":"bytes","id":4,"protoName":"parent_span_id"},"flags":{"type":"fixed32","id":16},"name":{"type":"string","id":5},"kind":{"type":"SpanKind","id":6},"startTimeUnixNano":{"type":"fixed64","id":7,"protoName":"start_time_unix_nano"},"endTimeUnixNano":{"type":"fixed64","id":8,"protoName":"end_time_unix_nano"},"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":9},"droppedAttributesCount":{"type":"uint32","id":10,"protoName":"dropped_attributes_count"},"events":{"rule":"repeated","type":"Event","id":11},"droppedEventsCount":{"type":"uint32","id":12,"protoName":"dropped_events_count"},"links":{"rule":"repeated","type":"Link","id":13},"droppedLinksCount":{"type":"uint32","id":14,"protoName":"dropped_links_count"},"status":{"type":"Status","id":15}},"nested":{"SpanKind":{"values":{"SPAN_KIND_UNSPECIFIED":0,"SPAN_KIND_INTERNAL":1,"SPAN_KIND_SERVER":2,"SPAN_KIND_CLIENT":3,"SPAN_KIND_PRODUCER":4,"SPAN_KIND_CONSUMER":5}},"Event":{"fields":{"timeUnixNano":{"type":"fixed64","id":1,"protoName":"time_unix_nano"},"name":{"type":"string","id":2},"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":3},"droppedAttributesCount":{"type":"uint32","id":4,"protoName":"dropped_attributes_count"}}},"Link":{"fields":{"traceId":{"type":"bytes","id":1,"protoName":"trace_id"},"spanId":{"type":"bytes","id":2,"protoName":"span_id"},"traceState":{"type":"string","id":3,"protoName":"trace_state"},"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":4},"droppedAttributesCount":{"type":"uint32","id":5,"protoName":"dropped_attributes_count"},"flags":{"type":"fixed32","id":6}}}}},"Status":{"fields":{"message":{"type":"string","id":2},"code":{"type":"StatusCode","id":3}},"reserved":[[1,1]],"nested":{"StatusCode":{"values":{"STATUS_CODE_UNSET":0,"STATUS_CODE_OK":1,"STATUS_CODE_ERROR":2}}}},"SpanFlags":{"values":{"SPAN_FLAGS_DO_NOT_USE":0,"SPAN_FLAGS_TRACE_FLAGS_MASK":255,"SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK":256,"SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK":512}}}}}},"logs":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Logs.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.logs.v1","java_outer_classname":"LogsProto","go_package":"go.opentelemetry.io/proto/otlp/logs/v1"},"nested":{"LogsData":{"fields":{"resourceLogs":{"rule":"repeated","type":"ResourceLogs","id":1,"protoName":"resource_logs"}}},"ResourceLogs":{"fields":{"resource":{"type":"opentelemetry.proto.resource.v1.Resource","id":1},"scopeLogs":{"rule":"repeated","type":"ScopeLogs","id":2,"protoName":"scope_logs"},"schemaUrl":{"type":"string","id":3,"protoName":"schema_url"}},"reserved":[[1000,1000]]},"ScopeLogs":{"fields":{"scope":{"type":"opentelemetry.proto.common.v1.InstrumentationScope","id":1},"logRecords":{"rule":"repeated","type":"LogRecord","id":2,"protoName":"log_records"},"schemaUrl":{"type":"string","id":3,"protoName":"schema_url"}}},"SeverityNumber":{"values":{"SEVERITY_NUMBER_UNSPECIFIED":0,"SEVERITY_NUMBER_TRACE":1,"SEVERITY_NUMBER_TRACE2":2,"SEVERITY_NUMBER_TRACE3":3,"SEVERITY_NUMBER_TRACE4":4,"SEVERITY_NUMBER_DEBUG":5,"SEVERITY_NUMBER_DEBUG2":6,"SEVERITY_NUMBER_DEBUG3":7,"SEVERITY_NUMBER_DEBUG4":8,"SEVERITY_NUMBER_INFO":9,"SEVERITY_NUMBER_INFO2":10,"SEVERITY_NUMBER_INFO3":11,"SEVERITY_NUMBER_INFO4":12,"SEVERITY_NUMBER_WARN":13,"SEVERITY_NUMBER_WARN2":14,"SEVERITY_NUMBER_WARN3":15,"SEVERITY_NUMBER_WARN4":16,"SEVERITY_NUMBER_ERROR":17,"SEVERITY_NUMBER_ERROR2":18,"SEVERITY_NUMBER_ERROR3":19,"SEVERITY_NUMBER_ERROR4":20,"SEVERITY_NUMBER_FATAL":21,"SEVERITY_NUMBER_FATAL2":22,"SEVERITY_NUMBER_FATAL3":23,"SEVERITY_NUMBER_FATAL4":24}},"LogRecordFlags":{"values":{"LOG_RECORD_FLAGS_DO_NOT_USE":0,"LOG_RECORD_FLAGS_TRACE_FLAGS_MASK":255}},"LogRecord":{"fields":{"timeUnixNano":{"type":"fixed64","id":1,"protoName":"time_unix_nano"},"observedTimeUnixNano":{"type":"fixed64","id":11,"protoName":"observed_time_unix_nano"},"severityNumber":{"type":"SeverityNumber","id":2,"protoName":"severity_number"},"severityText":{"type":"string","id":3,"protoName":"severity_text"},"body":{"type":"opentelemetry.proto.common.v1.AnyValue","id":5},"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":6},"droppedAttributesCount":{"type":"uint32","id":7,"protoName":"dropped_attributes_count"},"flags":{"type":"fixed32","id":8},"traceId":{"type":"bytes","id":9,"protoName":"trace_id"},"spanId":{"type":"bytes","id":10,"protoName":"span_id"},"eventName":{"type":"string","id":12,"protoName":"event_name"}},"reserved":[[4,4]]}}}}},"metrics":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Metrics.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.metrics.v1","java_outer_classname":"MetricsProto","go_package":"go.opentelemetry.io/proto/otlp/metrics/v1"},"nested":{"MetricsData":{"fields":{"resourceMetrics":{"rule":"repeated","type":"ResourceMetrics","id":1,"protoName":"resource_metrics"}}},"ResourceMetrics":{"fields":{"resource":{"type":"opentelemetry.proto.resource.v1.Resource","id":1},"scopeMetrics":{"rule":"repeated","type":"ScopeMetrics","id":2,"protoName":"scope_metrics"},"schemaUrl":{"type":"string","id":3,"protoName":"schema_url"}},"reserved":[[1000,1000]]},"ScopeMetrics":{"fields":{"scope":{"type":"opentelemetry.proto.common.v1.InstrumentationScope","id":1},"metrics":{"rule":"repeated","type":"Metric","id":2},"schemaUrl":{"type":"string","id":3,"protoName":"schema_url"}}},"Metric":{"oneofs":{"data":{"oneof":["gauge","sum","histogram","exponentialHistogram","summary"]}},"fields":{"name":{"type":"string","id":1},"description":{"type":"string","id":2},"unit":{"type":"string","id":3},"gauge":{"type":"Gauge","id":5},"sum":{"type":"Sum","id":7},"histogram":{"type":"Histogram","id":9},"exponentialHistogram":{"type":"ExponentialHistogram","id":10,"protoName":"exponential_histogram"},"summary":{"type":"Summary","id":11},"metadata":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":12}},"reserved":[[4,4],[6,6],[8,8]]},"Gauge":{"fields":{"dataPoints":{"rule":"repeated","type":"NumberDataPoint","id":1,"protoName":"data_points"}}},"Sum":{"fields":{"dataPoints":{"rule":"repeated","type":"NumberDataPoint","id":1,"protoName":"data_points"},"aggregationTemporality":{"type":"AggregationTemporality","id":2,"protoName":"aggregation_temporality"},"isMonotonic":{"type":"bool","id":3,"protoName":"is_monotonic"}}},"Histogram":{"fields":{"dataPoints":{"rule":"repeated","type":"HistogramDataPoint","id":1,"protoName":"data_points"},"aggregationTemporality":{"type":"AggregationTemporality","id":2,"protoName":"aggregation_temporality"}}},"ExponentialHistogram":{"fields":{"dataPoints":{"rule":"repeated","type":"ExponentialHistogramDataPoint","id":1,"protoName":"data_points"},"aggregationTemporality":{"type":"AggregationTemporality","id":2,"protoName":"aggregation_temporality"}}},"Summary":{"fields":{"dataPoints":{"rule":"repeated","type":"SummaryDataPoint","id":1,"protoName":"data_points"}}},"AggregationTemporality":{"values":{"AGGREGATION_TEMPORALITY_UNSPECIFIED":0,"AGGREGATION_TEMPORALITY_DELTA":1,"AGGREGATION_TEMPORALITY_CUMULATIVE":2}},"DataPointFlags":{"values":{"DATA_POINT_FLAGS_DO_NOT_USE":0,"DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK":1}},"NumberDataPoint":{"oneofs":{"value":{"oneof":["asDouble","asInt"]}},"fields":{"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":7},"startTimeUnixNano":{"type":"fixed64","id":2,"protoName":"start_time_unix_nano"},"timeUnixNano":{"type":"fixed64","id":3,"protoName":"time_unix_nano"},"asDouble":{"type":"double","id":4,"protoName":"as_double"},"asInt":{"type":"sfixed64","id":6,"protoName":"as_int"},"exemplars":{"rule":"repeated","type":"Exemplar","id":5},"flags":{"type":"uint32","id":8}},"reserved":[[1,1]]},"HistogramDataPoint":{"oneofs":{"_sum":{"oneof":["sum"]},"_min":{"oneof":["min"]},"_max":{"oneof":["max"]}},"fields":{"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":9},"startTimeUnixNano":{"type":"fixed64","id":2,"protoName":"start_time_unix_nano"},"timeUnixNano":{"type":"fixed64","id":3,"protoName":"time_unix_nano"},"count":{"type":"fixed64","id":4},"sum":{"type":"double","id":5,"options":{"proto3_optional":true}},"bucketCounts":{"rule":"repeated","type":"fixed64","id":6,"protoName":"bucket_counts"},"explicitBounds":{"rule":"repeated","type":"double","id":7,"protoName":"explicit_bounds"},"exemplars":{"rule":"repeated","type":"Exemplar","id":8},"flags":{"type":"uint32","id":10},"min":{"type":"double","id":11,"options":{"proto3_optional":true}},"max":{"type":"double","id":12,"options":{"proto3_optional":true}}},"reserved":[[1,1]]},"ExponentialHistogramDataPoint":{"oneofs":{"_sum":{"oneof":["sum"]},"_min":{"oneof":["min"]},"_max":{"oneof":["max"]}},"fields":{"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":1},"startTimeUnixNano":{"type":"fixed64","id":2,"protoName":"start_time_unix_nano"},"timeUnixNano":{"type":"fixed64","id":3,"protoName":"time_unix_nano"},"count":{"type":"fixed64","id":4},"sum":{"type":"double","id":5,"options":{"proto3_optional":true}},"scale":{"type":"sint32","id":6},"zeroCount":{"type":"fixed64","id":7,"protoName":"zero_count"},"positive":{"type":"Buckets","id":8},"negative":{"type":"Buckets","id":9},"flags":{"type":"uint32","id":10},"exemplars":{"rule":"repeated","type":"Exemplar","id":11},"min":{"type":"double","id":12,"options":{"proto3_optional":true}},"max":{"type":"double","id":13,"options":{"proto3_optional":true}},"zeroThreshold":{"type":"double","id":14,"protoName":"zero_threshold"}},"nested":{"Buckets":{"fields":{"offset":{"type":"sint32","id":1},"bucketCounts":{"rule":"repeated","type":"uint64","id":2,"protoName":"bucket_counts"}}}}},"SummaryDataPoint":{"fields":{"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":7},"startTimeUnixNano":{"type":"fixed64","id":2,"protoName":"start_time_unix_nano"},"timeUnixNano":{"type":"fixed64","id":3,"protoName":"time_unix_nano"},"count":{"type":"fixed64","id":4},"sum":{"type":"double","id":5},"quantileValues":{"rule":"repeated","type":"ValueAtQuantile","id":6,"protoName":"quantile_values"},"flags":{"type":"uint32","id":8}},"reserved":[[1,1]],"nested":{"ValueAtQuantile":{"fields":{"quantile":{"type":"double","id":1},"value":{"type":"double","id":2}}}}},"Exemplar":{"oneofs":{"value":{"oneof":["asDouble","asInt"]}},"fields":{"filteredAttributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":7,"protoName":"filtered_attributes"},"timeUnixNano":{"type":"fixed64","id":2,"protoName":"time_unix_nano"},"asDouble":{"type":"double","id":3,"protoName":"as_double"},"asInt":{"type":"sfixed64","id":6,"protoName":"as_int"},"spanId":{"type":"bytes","id":4,"protoName":"span_id"},"traceId":{"type":"bytes","id":5,"protoName":"trace_id"}},"reserved":[[1,1]]}}}}},"common":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Common.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.common.v1","java_outer_classname":"CommonProto","go_package":"go.opentelemetry.io/proto/otlp/common/v1"},"nested":{"AnyValue":{"oneofs":{"value":{"oneof":["stringValue","boolValue","intValue","doubleValue","arrayValue","kvlistValue","bytesValue"]}},"fields":{"stringValue":{"type":"string","id":1,"protoName":"string_value"},"boolValue":{"type":"bool","id":2,"protoName":"bool_value"},"intValue":{"type":"int64","id":3,"protoName":"int_value"},"doubleValue":{"type":"double","id":4,"protoName":"double_value"},"arrayValue":{"type":"ArrayValue","id":5,"protoName":"array_value"},"kvlistValue":{"type":"KeyValueList","id":6,"protoName":"kvlist_value"},"bytesValue":{"type":"bytes","id":7,"protoName":"bytes_value"}}},"ArrayValue":{"fields":{"values":{"rule":"repeated","type":"AnyValue","id":1}}},"KeyValueList":{"fields":{"values":{"rule":"repeated","type":"KeyValue","id":1}}},"KeyValue":{"fields":{"key":{"type":"string","id":1},"value":{"type":"AnyValue","id":2}}},"InstrumentationScope":{"fields":{"name":{"type":"string","id":1},"version":{"type":"string","id":2},"attributes":{"rule":"repeated","type":"KeyValue","id":3},"droppedAttributesCount":{"type":"uint32","id":4,"protoName":"dropped_attributes_count"}}},"EntityRef":{"fields":{"schemaUrl":{"type":"string","id":1,"protoName":"schema_url"},"type":{"type":"string","id":2},"idKeys":{"rule":"repeated","type":"string","id":3,"protoName":"id_keys"},"descriptionKeys":{"rule":"repeated","type":"string","id":4,"protoName":"description_keys"}}}}}}},"resource":{"nested":{"v1":{"options":{"csharp_namespace":"OpenTelemetry.Proto.Resource.V1","java_multiple_files":true,"java_package":"io.opentelemetry.proto.resource.v1","java_outer_classname":"ResourceProto","go_package":"go.opentelemetry.io/proto/otlp/resource/v1"},"nested":{"Resource":{"fields":{"attributes":{"rule":"repeated","type":"opentelemetry.proto.common.v1.KeyValue","id":1},"droppedAttributesCount":{"type":"uint32","id":2,"protoName":"dropped_attributes_count"},"entityRefs":{"rule":"repeated","type":"opentelemetry.proto.common.v1.EntityRef","id":3,"protoName":"entity_refs"}}}}}}}}}}}}}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { trafficCapture } from "./traffic-radar-capture.mjs";
|
|
2
|
+
const BASE = '/api/system/traffic-radar';
|
|
3
|
+
const loopback = req => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
|
|
4
|
+
export function isAuthorizedTrafficIngest(req, url) {
|
|
5
|
+
return req.method === 'POST' && url.pathname === BASE + '/ingest' && loopback(req)
|
|
6
|
+
&& trafficCapture.accepts(req.headers['x-radar-capture']);
|
|
7
|
+
}
|
|
8
|
+
function reply(res, status, body) {
|
|
9
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
10
|
+
res.end(JSON.stringify(body));
|
|
11
|
+
}
|
|
12
|
+
async function body(req, max) {
|
|
13
|
+
const chunks = []; let size = 0;
|
|
14
|
+
for await (const chunk of req) { size += chunk.length; if (size > max) throw new Error('Request too large.'); chunks.push(chunk); }
|
|
15
|
+
return Buffer.concat(chunks);
|
|
16
|
+
}
|
|
17
|
+
export async function handleTrafficCapture(req, res, url, capture = trafficCapture) {
|
|
18
|
+
if (!loopback(req)) return reply(res, 403, { error: 'Telemetry contents are available on this machine only.' });
|
|
19
|
+
const action = url.pathname.slice(BASE.length);
|
|
20
|
+
try {
|
|
21
|
+
if (req.method === 'GET' && action === '/capture') return reply(res, 200, capture.read());
|
|
22
|
+
if (req.method === 'GET' && action === '/capture/event') {
|
|
23
|
+
const detail = capture.detail(Number(url.searchParams.get('id')));
|
|
24
|
+
return reply(res, detail ? 200 : 404, detail ?? { error: 'This event is no longer retained.' });
|
|
25
|
+
}
|
|
26
|
+
if (req.method === 'POST' && action === '/ingest') {
|
|
27
|
+
const token = req.headers['x-radar-capture'];
|
|
28
|
+
if (!capture.accepts(token)) return reply(res, 401, { error: 'Capture session ended or unauthorized.' });
|
|
29
|
+
const data = await body(req, 262_144);
|
|
30
|
+
return reply(res, capture.ingest(token, data, Number(req.headers['x-radar-source'] ?? 0)) ? 200 : 409, { ok: capture.read().state === 'capturing' });
|
|
31
|
+
}
|
|
32
|
+
if (req.method === 'POST' && action === '/capture/prepare') {
|
|
33
|
+
const data = JSON.parse((await body(req, 4096)).toString());
|
|
34
|
+
return reply(res, 200, await capture.prepare(data.destination, req.socket.localPort));
|
|
35
|
+
}
|
|
36
|
+
if (req.method === 'POST' && action === '/capture/stop') { capture.stop(); return reply(res, 200, { ok: true }); }
|
|
37
|
+
if (req.method === 'POST' && action === '/capture/clear') { capture.clear(); return reply(res, 200, { ok: true }); }
|
|
38
|
+
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
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { decodeOtlp, headerDecoder } from "./traffic-radar-codec.mjs";
|
|
3
|
+
|
|
4
|
+
const LIMIT = 262_144;
|
|
5
|
+
const PREFACE = Buffer.from("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
|
|
6
|
+
const PATHS = Object.fromEntries([['logs', 'Logs'], ['metrics', 'Metrics'], ['traces', 'Trace']].map(([signal, name]) => [
|
|
7
|
+
`/opentelemetry.proto.collector.${signal === 'traces' ? 'trace' : signal}.v1.${name}Service/Export`, signal,
|
|
8
|
+
]));
|
|
9
|
+
|
|
10
|
+
export function createWireCapture({ host, port, onExport, onResponse, onIssue, onObservation }) {
|
|
11
|
+
let pending = Buffer.alloc(0);
|
|
12
|
+
let read32, link, nanos;
|
|
13
|
+
const flows = new Map();
|
|
14
|
+
const issue = code => onIssue(code);
|
|
15
|
+
function direction() {
|
|
16
|
+
return { next: null, queue: new Map(), buffer: Buffer.alloc(0), headers: headerDecoder(), block: null };
|
|
17
|
+
}
|
|
18
|
+
function discard(key, code) { flows.delete(key); issue(code); }
|
|
19
|
+
function payload(stream, side, bytes, at, flow, id) {
|
|
20
|
+
const key = side === 'out' ? 'request' : 'response';
|
|
21
|
+
stream[key] = Buffer.concat([stream[key] ?? Buffer.alloc(0), bytes]);
|
|
22
|
+
if (stream[key].length > LIMIT) throw new Error('payload_limit');
|
|
23
|
+
while (stream[key].length >= 5) {
|
|
24
|
+
const size = stream[key].readUInt32BE(1);
|
|
25
|
+
if (size > LIMIT) throw new Error('payload_limit');
|
|
26
|
+
if (stream[key].length < size + 5) return;
|
|
27
|
+
const compressed = stream[key][0];
|
|
28
|
+
let body = stream[key].subarray(5, 5 + size);
|
|
29
|
+
stream[key] = stream[key].subarray(5 + size);
|
|
30
|
+
if (compressed > 1) throw new Error('unsupported_protocol');
|
|
31
|
+
if (compressed) {
|
|
32
|
+
if (stream[side + 'Encoding'] !== 'gzip') throw new Error('unsupported_compression');
|
|
33
|
+
body = gunzipSync(body, { maxOutputLength: LIMIT });
|
|
34
|
+
}
|
|
35
|
+
if (!stream.signal) throw new Error('unknown_export');
|
|
36
|
+
const data = decodeOtlp(stream.signal, key, body);
|
|
37
|
+
if (side === 'out') {
|
|
38
|
+
if (stream.eventId) throw new Error('unsupported_protocol');
|
|
39
|
+
stream.eventId = onExport({ at, signal: stream.signal, destination: `${host}:${port}`, bytes: size + 5, payload: data, protobufBase64: body.toString('base64'), flow: flow.key, stream: id });
|
|
40
|
+
} else {
|
|
41
|
+
stream.responseDecoded = true;
|
|
42
|
+
stream.partial = data.partialSuccess ?? null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function finishResponse(stream, at) {
|
|
47
|
+
if (!stream.eventId) return;
|
|
48
|
+
const status = stream.grpcStatus;
|
|
49
|
+
const partial = stream.partial;
|
|
50
|
+
const rejected = partial && Object.entries(partial).find(([key]) => key.startsWith('rejected'))?.[1];
|
|
51
|
+
const outcome = status === '0' && stream.httpStatus === '200' && stream.responseDecoded ? (partial?.errorMessage || (rejected && rejected !== '0') ? 'partial' : 'accepted')
|
|
52
|
+
: status !== undefined && status !== '0' ? 'rejected' : 'unknown';
|
|
53
|
+
onResponse(stream.eventId, { outcome, at, grpcStatus: status ?? null, rejected: rejected ?? null, message: partial?.errorMessage ?? null });
|
|
54
|
+
}
|
|
55
|
+
function headers(flow, side, id, bytes, end, at) {
|
|
56
|
+
const values = flow[side].headers(bytes);
|
|
57
|
+
const stream = flow.streams.get(id) ?? {};
|
|
58
|
+
if (!flow.streams.has(id)) {
|
|
59
|
+
if (flow.streams.size >= 32) throw new Error('stream_limit');
|
|
60
|
+
flow.streams.set(id, stream);
|
|
61
|
+
}
|
|
62
|
+
if (side === 'out' && values[':path']) {
|
|
63
|
+
if (values[':method'] !== 'POST' || !values['content-type']?.startsWith('application/grpc')) throw new Error('unknown_export');
|
|
64
|
+
stream.signal = PATHS[values[':path']];
|
|
65
|
+
}
|
|
66
|
+
if (values['grpc-encoding']) stream[side + 'Encoding'] = values['grpc-encoding'];
|
|
67
|
+
if (side === 'in' && values['grpc-status'] !== undefined) stream.grpcStatus = values['grpc-status'];
|
|
68
|
+
if (side === 'in' && values[':status']) stream.httpStatus = values[':status'];
|
|
69
|
+
if (side === 'in' && end) { finishResponse(stream, at); flow.streams.delete(id); }
|
|
70
|
+
}
|
|
71
|
+
function frames(flow, side, bytes, at) {
|
|
72
|
+
const d = flow[side];
|
|
73
|
+
d.buffer = Buffer.concat([d.buffer, bytes]);
|
|
74
|
+
if (d.buffer.length > LIMIT * 2) throw new Error('stream_limit');
|
|
75
|
+
if (!flow.ready) {
|
|
76
|
+
if (side === 'in') return;
|
|
77
|
+
if (d.buffer[0] === 22) throw new Error('encrypted');
|
|
78
|
+
if (!PREFACE.subarray(0, Math.min(PREFACE.length, d.buffer.length)).equals(d.buffer.subarray(0, PREFACE.length))) throw new Error('joined_midstream');
|
|
79
|
+
if (d.buffer.length < PREFACE.length) return;
|
|
80
|
+
d.buffer = d.buffer.subarray(PREFACE.length);
|
|
81
|
+
flow.ready = true;
|
|
82
|
+
frames(flow, 'in', Buffer.alloc(0), at);
|
|
83
|
+
}
|
|
84
|
+
while (d.buffer.length >= 9) {
|
|
85
|
+
const length = d.buffer.readUIntBE(0, 3), type = d.buffer[3], flags = d.buffer[4], id = d.buffer.readUInt32BE(5) & 0x7fffffff;
|
|
86
|
+
if (length > LIMIT || type > 9) throw new Error('unsupported_protocol');
|
|
87
|
+
if (d.buffer.length < length + 9) return;
|
|
88
|
+
let body = d.buffer.subarray(9, 9 + length);
|
|
89
|
+
d.buffer = d.buffer.subarray(9 + length);
|
|
90
|
+
if (d.block && (type !== 9 || id !== d.block.id)) throw new Error('capture_gap');
|
|
91
|
+
if (type === 0 || type === 1) {
|
|
92
|
+
if (flags & 8) {
|
|
93
|
+
const pad = body[0];
|
|
94
|
+
if (pad === undefined || pad >= body.length) throw new Error('capture_gap');
|
|
95
|
+
body = body.subarray(1, body.length - pad);
|
|
96
|
+
}
|
|
97
|
+
if (type === 1 && (flags & 32)) {
|
|
98
|
+
if (body.length < 5) throw new Error('capture_gap');
|
|
99
|
+
body = body.subarray(5);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (type === 1) {
|
|
103
|
+
if (flags & 4) headers(flow, side, id, body, flags & 1, at);
|
|
104
|
+
else d.block = { id, bytes: Buffer.from(body), end: flags & 1 };
|
|
105
|
+
} else if (type === 9) {
|
|
106
|
+
if (!d.block) throw new Error('capture_gap');
|
|
107
|
+
d.block.bytes = Buffer.concat([d.block.bytes, body]);
|
|
108
|
+
if (d.block.bytes.length > 65_536) throw new Error('stream_limit');
|
|
109
|
+
if (flags & 4) { const b = d.block; d.block = null; headers(flow, side, id, b.bytes, b.end, at); }
|
|
110
|
+
} else if (type === 0) {
|
|
111
|
+
const stream = flow.streams.get(id);
|
|
112
|
+
if (!stream) throw new Error('unknown_export');
|
|
113
|
+
payload(stream, side, body, at, flow, id);
|
|
114
|
+
if (side === 'in' && (flags & 1)) { finishResponse(stream, at); flow.streams.delete(id); }
|
|
115
|
+
} else if (type === 3) {
|
|
116
|
+
const stream = flow.streams.get(id);
|
|
117
|
+
if (stream?.eventId) onResponse(stream.eventId, { outcome: 'reset', at, grpcStatus: null, rejected: null, message: null });
|
|
118
|
+
flow.streams.delete(id);
|
|
119
|
+
} else if (type === 7) {
|
|
120
|
+
throw new Error('connection_closed');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let buffered = flow.out.buffer.length + flow.in.buffer.length;
|
|
124
|
+
for (const stream of flow.streams.values()) buffered += (stream.request?.length ?? 0) + (stream.response?.length ?? 0);
|
|
125
|
+
if (buffered > LIMIT * 4) throw new Error('stream_limit');
|
|
126
|
+
}
|
|
127
|
+
function tcp(packet, at) {
|
|
128
|
+
let offset = 0;
|
|
129
|
+
if (link === 1) {
|
|
130
|
+
if (packet.length < 14) return;
|
|
131
|
+
let protocol = packet.readUInt16BE(12); offset = 14;
|
|
132
|
+
for (let tag = 0; tag < 2 && [0x8100, 0x88a8].includes(protocol); tag++) {
|
|
133
|
+
if (packet.length < offset + 4) return;
|
|
134
|
+
protocol = packet.readUInt16BE(offset + 2); offset += 4;
|
|
135
|
+
}
|
|
136
|
+
if (protocol !== 0x800) { issue('unsupported_network'); return; }
|
|
137
|
+
} else if ([0, 108].includes(link)) offset = 4;
|
|
138
|
+
else if (link === 113) offset = 16;
|
|
139
|
+
else if (link === 276) offset = 20;
|
|
140
|
+
else if (link !== 101 && link !== 12) { issue('unsupported_link'); return; }
|
|
141
|
+
if (packet.length < offset + 20 || packet[offset] >> 4 !== 4) { issue('unsupported_network'); return; }
|
|
142
|
+
const ip = packet.subarray(offset);
|
|
143
|
+
const ihl = (ip[0] & 15) * 4, total = ip.readUInt16BE(2);
|
|
144
|
+
if (ihl < 20 || total < ihl || ip.length < total) { issue('capture_gap'); return; }
|
|
145
|
+
if (ip[9] !== 6) return;
|
|
146
|
+
if (ip.readUInt16BE(6) & 0x3fff) { issue('fragmented'); return; }
|
|
147
|
+
const src = [...ip.subarray(12, 16)].join('.'), dst = [...ip.subarray(16, 20)].join('.');
|
|
148
|
+
const t = ip.subarray(ihl, total);
|
|
149
|
+
if (t.length < 20) { issue('capture_gap'); return; }
|
|
150
|
+
const srcPort = t.readUInt16BE(0), dstPort = t.readUInt16BE(2);
|
|
151
|
+
const side = dst === host && dstPort === port ? 'out' : src === host && srcPort === port ? 'in' : null;
|
|
152
|
+
if (!side) return;
|
|
153
|
+
const key = side === 'out' ? `${src}:${srcPort}` : `${dst}:${dstPort}`;
|
|
154
|
+
for (const [k, f] of flows) if (at - f.at > 60_000) discard(k, 'idle_connection');
|
|
155
|
+
let flow = flows.get(key);
|
|
156
|
+
const syn = !!(t[13] & 2), ack = !!(t[13] & 16);
|
|
157
|
+
if (syn && !ack) flows.delete(key);
|
|
158
|
+
if (!flow || (syn && !ack)) {
|
|
159
|
+
if (flows.size >= 16) discard(flows.keys().next().value, 'connection_limit');
|
|
160
|
+
flow = { key, at, ready: false, out: direction(), in: direction(), streams: new Map(), ignored: false };
|
|
161
|
+
flows.set(key, flow);
|
|
162
|
+
}
|
|
163
|
+
flow.at = at;
|
|
164
|
+
const size = (t[12] >> 4) * 4;
|
|
165
|
+
if (size < 20 || size > t.length) { discard(key, 'capture_gap'); return; }
|
|
166
|
+
const body = t.subarray(size), d = flow[side];
|
|
167
|
+
let seq = (t.readUInt32BE(4) + (syn ? 1 : 0)) >>> 0;
|
|
168
|
+
if (syn && d.next === null) d.next = seq;
|
|
169
|
+
if (body.length && !flow.ignored) {
|
|
170
|
+
if (d.next === null) d.next = seq;
|
|
171
|
+
const delta = (seq - d.next) | 0;
|
|
172
|
+
if (delta > 0) {
|
|
173
|
+
d.queue.set(seq, Buffer.from(body));
|
|
174
|
+
let queued = 0; for (const b of d.queue.values()) queued += b.length;
|
|
175
|
+
if (d.queue.size > 32 || queued > LIMIT) discard(key, 'capture_gap');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const part = delta < 0 ? body.subarray(-delta) : body;
|
|
179
|
+
if (part.length) {
|
|
180
|
+
d.next = (d.next + part.length) >>> 0;
|
|
181
|
+
try {
|
|
182
|
+
frames(flow, side, part, at);
|
|
183
|
+
while (d.queue.size) {
|
|
184
|
+
const next = [...d.queue.keys()].find(n => ((n - d.next) | 0) <= 0);
|
|
185
|
+
if (next === undefined) break;
|
|
186
|
+
const p = d.queue.get(next); d.queue.delete(next);
|
|
187
|
+
const remainder = p.subarray(Math.max(0, (d.next - next) | 0));
|
|
188
|
+
if (remainder.length) { d.next = (d.next + remainder.length) >>> 0; frames(flow, side, remainder, at); }
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
onObservation?.({ at, destination: `${host}:${port}`, reason: ["encrypted", "joined_midstream", "payload_limit", "stream_limit", "unsupported_compression", "unknown_export", "connection_closed"].includes(error.message) ? error.message : "decode_failed", source: flow.key });
|
|
192
|
+
issue(['encrypted', 'joined_midstream', 'payload_limit', 'stream_limit', 'unsupported_compression', 'unknown_export', 'connection_closed'].includes(error.message) ? error.message : 'decode_failed');
|
|
193
|
+
flow.ignored = true;
|
|
194
|
+
flow.out.buffer = flow.in.buffer = Buffer.alloc(0);
|
|
195
|
+
flow.out.queue.clear(); flow.in.queue.clear(); flow.streams.clear();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (t[13] & 5) flows.delete(key);
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
feed(chunk) {
|
|
203
|
+
if (chunk.length > LIMIT) throw new Error('capture_chunk_limit');
|
|
204
|
+
pending = Buffer.concat([pending, chunk]);
|
|
205
|
+
if (pending.length > LIMIT * 2) throw new Error('capture_buffer_limit');
|
|
206
|
+
if (!read32) {
|
|
207
|
+
if (pending.length < 24) return;
|
|
208
|
+
const magic = pending.subarray(0, 4).toString('hex');
|
|
209
|
+
if (!['d4c3b2a1', 'a1b2c3d4', '4d3cb2a1', 'a1b23c4d'].includes(magic)) throw new Error('pcap_required');
|
|
210
|
+
nanos = ['4d3cb2a1', 'a1b23c4d'].includes(magic);
|
|
211
|
+
read32 = magic.startsWith('a1') ? b => b.readUInt32BE() : b => b.readUInt32LE();
|
|
212
|
+
link = read32(pending.subarray(20));
|
|
213
|
+
if (![0, 1, 12, 101, 108, 113, 276].includes(link)) throw new Error('unsupported_link');
|
|
214
|
+
pending = pending.subarray(24);
|
|
215
|
+
}
|
|
216
|
+
while (pending.length >= 16) {
|
|
217
|
+
const size = read32(pending.subarray(8));
|
|
218
|
+
if (size > LIMIT) throw new Error('packet_limit');
|
|
219
|
+
if (pending.length < size + 16) return;
|
|
220
|
+
const at = read32(pending) * 1000 + read32(pending.subarray(4)) / (nanos ? 1e6 : 1000);
|
|
221
|
+
if (size !== read32(pending.subarray(12))) issue('capture_gap');
|
|
222
|
+
else tcp(pending.subarray(16, 16 + size), at);
|
|
223
|
+
pending = pending.subarray(size + 16);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
close() { pending = Buffer.alloc(0); flows.clear(); },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { readRadarConfig } from "./traffic-radar-config.mjs";
|
|
5
|
+
|
|
6
|
+
function systemCommand(file, args) {
|
|
7
|
+
return new Promise(resolve => {
|
|
8
|
+
execFile(file, args, { timeout: 2_000, maxBuffer: 262_144, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
|
|
9
|
+
if (!error) return resolve(stdout);
|
|
10
|
+
if (file === "/usr/sbin/lsof" && error.code === 1 && !stdout && !stderr) return resolve("");
|
|
11
|
+
resolve(null);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function claudePids(text) {
|
|
17
|
+
const pids = [];
|
|
18
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
19
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
20
|
+
if (match && ["claude", "claude-native"].includes(basename(match[2]))) {
|
|
21
|
+
const pid = Number(match[1]);
|
|
22
|
+
if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(pids)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseRadarSockets(text, pids) {
|
|
29
|
+
const allowed = new Set(pids);
|
|
30
|
+
const sockets = new Map();
|
|
31
|
+
let pid = null;
|
|
32
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
33
|
+
if (/^p\d+$/.test(line)) pid = Number(line.slice(1));
|
|
34
|
+
if (!allowed.has(pid) || !line.startsWith("n")) continue;
|
|
35
|
+
const peer = line.slice(1).split("->")[1];
|
|
36
|
+
if (!peer) continue;
|
|
37
|
+
const match = /^(\[[0-9a-fA-F:.%]+\]|[0-9.]+):(\d+)$/.exec(peer);
|
|
38
|
+
if (!match) continue;
|
|
39
|
+
const port = Number(match[2]);
|
|
40
|
+
if (port < 1 || port > 65535) continue;
|
|
41
|
+
const destination = `${match[1]}:${port}`;
|
|
42
|
+
sockets.set(`${pid}|${destination}`, { pid, destination });
|
|
43
|
+
}
|
|
44
|
+
return [...sockets.values()];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseRadarWorkspaces(text, pids, home = homedir()) {
|
|
48
|
+
const allowed = new Set(pids);
|
|
49
|
+
const workspaces = new Map();
|
|
50
|
+
let pid = null;
|
|
51
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
52
|
+
if (/^p\d+$/.test(line)) pid = Number(line.slice(1));
|
|
53
|
+
if (allowed.has(pid) && line.startsWith("n/")) {
|
|
54
|
+
const path = line.slice(1);
|
|
55
|
+
workspaces.set(pid, path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return workspaces;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createTrafficRadar({ platform = process.platform, now = Date.now, run = systemCommand, config = readRadarConfig } = {}) {
|
|
62
|
+
let pending = null;
|
|
63
|
+
let cached = null;
|
|
64
|
+
let previousAt = null;
|
|
65
|
+
let previousConfig = null;
|
|
66
|
+
const seen = new Map();
|
|
67
|
+
let alerts = [];
|
|
68
|
+
let serial = 0;
|
|
69
|
+
const addAlert = (message, at) => {
|
|
70
|
+
alerts.unshift({ id: ++serial, message, at });
|
|
71
|
+
alerts = alerts.slice(0, 8);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
async function sample() {
|
|
75
|
+
const sampledAt = now();
|
|
76
|
+
if (previousAt !== null && sampledAt - previousAt > 30_000) {
|
|
77
|
+
seen.clear();
|
|
78
|
+
alerts = [];
|
|
79
|
+
previousConfig = null;
|
|
80
|
+
}
|
|
81
|
+
const base = { ok: true, sampledAt, platform, pollMs: 5_000, processCount: 0, connections: [], alerts: [] };
|
|
82
|
+
if (platform !== "darwin") return { ...base, status: "unsupported", config: null };
|
|
83
|
+
const [configuration, processes] = await Promise.all([config(), run("/bin/ps", ["-axo", "pid=,comm="])]);
|
|
84
|
+
const nextConfig = new Map(configuration.variables.filter(v => !v.key.endsWith("HEADERS")).map(v => [`${v.source}:${v.key}`, v.value]));
|
|
85
|
+
if (previousConfig) {
|
|
86
|
+
for (const [key, value] of nextConfig) {
|
|
87
|
+
if (previousConfig.get(key) === value) continue;
|
|
88
|
+
const variable = key.slice(key.indexOf(":") + 1);
|
|
89
|
+
if (variable.includes("ENDPOINT")) addAlert("Telemetry destination configuration changed. Review settings below.", sampledAt);
|
|
90
|
+
if (/^OTEL_LOG_/.test(variable) && value.startsWith("Enabled")) addAlert("Content capture configuration changed to enabled. Review settings below.", sampledAt);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const pids = claudePids(processes);
|
|
94
|
+
const selected = pids.slice(0, 64);
|
|
95
|
+
let sockets = "";
|
|
96
|
+
let cwd = "";
|
|
97
|
+
if (selected.length) [sockets, cwd] = await Promise.all([
|
|
98
|
+
run("/usr/sbin/lsof", ["-nP", "-a", "-p", selected.join(","), "-iTCP", "-sTCP:ESTABLISHED", "-Fpn"]),
|
|
99
|
+
run("/usr/sbin/lsof", ["-nP", "-a", "-p", selected.join(","), "-d", "cwd", "-Fpn"]),
|
|
100
|
+
]);
|
|
101
|
+
const workspaces = parseRadarWorkspaces(cwd, selected);
|
|
102
|
+
const status = processes === null || sockets === null ? "unavailable" : "observing";
|
|
103
|
+
if (cached?.status === "observing" && status === "unavailable") addAlert("Connection visibility lost. A failed sample does not mean traffic stopped.", sampledAt);
|
|
104
|
+
const current = parseRadarSockets(sockets, selected);
|
|
105
|
+
const active = new Set(current.map(s => `${s.pid}|${s.destination}`));
|
|
106
|
+
const destinations = new Set([...seen.values()].map(s => s.destination));
|
|
107
|
+
for (const connection of current) {
|
|
108
|
+
const key = `${connection.pid}|${connection.destination}`;
|
|
109
|
+
if (!seen.has(key)) {
|
|
110
|
+
if (previousAt !== null && previousConfig && !destinations.has(connection.destination)) addAlert(`New Claude connection destination: ${connection.destination}`, sampledAt);
|
|
111
|
+
destinations.add(connection.destination);
|
|
112
|
+
seen.set(key, { ...connection, workspace: workspaces.get(connection.pid) ?? null, firstSeenAt: sampledAt, lastSeenAt: sampledAt });
|
|
113
|
+
} else seen.get(key).lastSeenAt = sampledAt;
|
|
114
|
+
}
|
|
115
|
+
for (const [key, connection] of seen) if (sampledAt - connection.lastSeenAt > 300_000) seen.delete(key);
|
|
116
|
+
while (seen.size > 100) seen.delete(seen.keys().next().value);
|
|
117
|
+
previousAt = sampledAt;
|
|
118
|
+
previousConfig = nextConfig;
|
|
119
|
+
return { ...base, status, config: configuration, processCount: pids.length, limited: pids.length > selected.length,
|
|
120
|
+
connections: [...seen.entries()].map(([key, value]) => ({ ...value, active: status === "observing" ? active.has(key) : null }))
|
|
121
|
+
.sort((a, b) => b.lastSeenAt - a.lastSeenAt), alerts };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
read() {
|
|
126
|
+
if (pending) return pending;
|
|
127
|
+
if (cached && now() - cached.sampledAt < 3_000) return Promise.resolve(cached);
|
|
128
|
+
pending = sample().then(result => { cached = result; return result; }).finally(() => { pending = null; });
|
|
129
|
+
return pending;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const trafficRadar = createTrafficRadar();
|