@traffic-twin/sdk 0.0.0-stage → 0.1.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.
Files changed (133) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +171 -2
  3. package/dist/cjs/agent.d.ts +143 -0
  4. package/dist/cjs/agent.js +927 -0
  5. package/dist/cjs/capture/body.d.ts +11 -0
  6. package/dist/cjs/capture/body.js +73 -0
  7. package/dist/cjs/capture/common.d.ts +75 -0
  8. package/dist/cjs/capture/common.js +180 -0
  9. package/dist/cjs/capture/context.d.ts +28 -0
  10. package/dist/cjs/capture/context.js +92 -0
  11. package/dist/cjs/capture/inbound.d.ts +12 -0
  12. package/dist/cjs/capture/inbound.js +195 -0
  13. package/dist/cjs/capture/outbound-http.d.ts +4 -0
  14. package/dist/cjs/capture/outbound-http.js +200 -0
  15. package/dist/cjs/capture/outbound-undici.d.ts +2 -0
  16. package/dist/cjs/capture/outbound-undici.js +242 -0
  17. package/dist/cjs/capture/wire.d.ts +27 -0
  18. package/dist/cjs/capture/wire.js +68 -0
  19. package/dist/cjs/export/event.d.ts +46 -0
  20. package/dist/cjs/export/event.js +91 -0
  21. package/dist/cjs/export/exporter.d.ts +50 -0
  22. package/dist/cjs/export/exporter.js +192 -0
  23. package/dist/cjs/export/pipeline.d.ts +20 -0
  24. package/dist/cjs/export/pipeline.js +40 -0
  25. package/dist/cjs/fleet/asn1.d.ts +44 -0
  26. package/dist/cjs/fleet/asn1.js +150 -0
  27. package/dist/cjs/fleet/client.d.ts +84 -0
  28. package/dist/cjs/fleet/client.js +216 -0
  29. package/dist/cjs/fleet/http.d.ts +17 -0
  30. package/dist/cjs/fleet/http.js +80 -0
  31. package/dist/cjs/fleet/store.d.ts +18 -0
  32. package/dist/cjs/fleet/store.js +115 -0
  33. package/dist/cjs/index.d.ts +14 -0
  34. package/dist/cjs/index.js +69 -0
  35. package/dist/cjs/log.d.ts +4 -0
  36. package/dist/cjs/log.js +30 -0
  37. package/dist/cjs/package.json +1 -0
  38. package/dist/cjs/register.d.ts +1 -0
  39. package/dist/cjs/register.js +18 -0
  40. package/dist/cjs/sanitizer/bodies.d.ts +55 -0
  41. package/dist/cjs/sanitizer/bodies.js +710 -0
  42. package/dist/cjs/sanitizer/formats.d.ts +26 -0
  43. package/dist/cjs/sanitizer/formats.js +110 -0
  44. package/dist/cjs/sanitizer/graphql.d.ts +23 -0
  45. package/dist/cjs/sanitizer/graphql.js +478 -0
  46. package/dist/cjs/sanitizer/hostpat.d.ts +15 -0
  47. package/dist/cjs/sanitizer/hostpat.js +129 -0
  48. package/dist/cjs/sanitizer/json.d.ts +60 -0
  49. package/dist/cjs/sanitizer/json.js +460 -0
  50. package/dist/cjs/sanitizer/policy.d.ts +224 -0
  51. package/dist/cjs/sanitizer/policy.js +820 -0
  52. package/dist/cjs/sanitizer/sanitizer.d.ts +149 -0
  53. package/dist/cjs/sanitizer/sanitizer.js +1473 -0
  54. package/dist/cjs/sanitizer/utf8.d.ts +22 -0
  55. package/dist/cjs/sanitizer/utf8.js +180 -0
  56. package/dist/cjs/sanitizer/vectors.d.ts +22 -0
  57. package/dist/cjs/sanitizer/vectors.js +130 -0
  58. package/dist/cjs/version.d.ts +2 -0
  59. package/dist/cjs/version.js +5 -0
  60. package/dist/cjs/worker/dir.d.ts +1 -0
  61. package/dist/cjs/worker/dir.js +8 -0
  62. package/dist/cjs/worker/pool.d.ts +47 -0
  63. package/dist/cjs/worker/pool.js +179 -0
  64. package/dist/cjs/worker/protocol.d.ts +37 -0
  65. package/dist/cjs/worker/protocol.js +3 -0
  66. package/dist/cjs/worker/sanitizer-worker.d.ts +1 -0
  67. package/dist/cjs/worker/sanitizer-worker.js +53 -0
  68. package/dist/esm/agent.d.ts +143 -0
  69. package/dist/esm/agent.js +921 -0
  70. package/dist/esm/capture/body.d.ts +11 -0
  71. package/dist/esm/capture/body.js +69 -0
  72. package/dist/esm/capture/common.d.ts +75 -0
  73. package/dist/esm/capture/common.js +166 -0
  74. package/dist/esm/capture/context.d.ts +28 -0
  75. package/dist/esm/capture/context.js +84 -0
  76. package/dist/esm/capture/inbound.d.ts +12 -0
  77. package/dist/esm/capture/inbound.js +187 -0
  78. package/dist/esm/capture/outbound-http.d.ts +4 -0
  79. package/dist/esm/capture/outbound-http.js +193 -0
  80. package/dist/esm/capture/outbound-undici.d.ts +2 -0
  81. package/dist/esm/capture/outbound-undici.js +236 -0
  82. package/dist/esm/capture/wire.d.ts +27 -0
  83. package/dist/esm/capture/wire.js +65 -0
  84. package/dist/esm/export/event.d.ts +46 -0
  85. package/dist/esm/export/event.js +85 -0
  86. package/dist/esm/export/exporter.d.ts +50 -0
  87. package/dist/esm/export/exporter.js +188 -0
  88. package/dist/esm/export/pipeline.d.ts +20 -0
  89. package/dist/esm/export/pipeline.js +37 -0
  90. package/dist/esm/fleet/asn1.d.ts +44 -0
  91. package/dist/esm/fleet/asn1.js +128 -0
  92. package/dist/esm/fleet/client.d.ts +84 -0
  93. package/dist/esm/fleet/client.js +204 -0
  94. package/dist/esm/fleet/http.d.ts +17 -0
  95. package/dist/esm/fleet/http.js +73 -0
  96. package/dist/esm/fleet/store.d.ts +18 -0
  97. package/dist/esm/fleet/store.js +110 -0
  98. package/dist/esm/index.d.ts +14 -0
  99. package/dist/esm/index.js +48 -0
  100. package/dist/esm/log.d.ts +4 -0
  101. package/dist/esm/log.js +25 -0
  102. package/dist/esm/package.json +1 -0
  103. package/dist/esm/register.d.ts +1 -0
  104. package/dist/esm/register.js +16 -0
  105. package/dist/esm/sanitizer/bodies.d.ts +55 -0
  106. package/dist/esm/sanitizer/bodies.js +694 -0
  107. package/dist/esm/sanitizer/formats.d.ts +26 -0
  108. package/dist/esm/sanitizer/formats.js +101 -0
  109. package/dist/esm/sanitizer/graphql.d.ts +23 -0
  110. package/dist/esm/sanitizer/graphql.js +472 -0
  111. package/dist/esm/sanitizer/hostpat.d.ts +15 -0
  112. package/dist/esm/sanitizer/hostpat.js +122 -0
  113. package/dist/esm/sanitizer/json.d.ts +60 -0
  114. package/dist/esm/sanitizer/json.js +447 -0
  115. package/dist/esm/sanitizer/policy.d.ts +224 -0
  116. package/dist/esm/sanitizer/policy.js +800 -0
  117. package/dist/esm/sanitizer/sanitizer.d.ts +149 -0
  118. package/dist/esm/sanitizer/sanitizer.js +1459 -0
  119. package/dist/esm/sanitizer/utf8.d.ts +22 -0
  120. package/dist/esm/sanitizer/utf8.js +166 -0
  121. package/dist/esm/sanitizer/vectors.d.ts +22 -0
  122. package/dist/esm/sanitizer/vectors.js +125 -0
  123. package/dist/esm/version.d.ts +2 -0
  124. package/dist/esm/version.js +2 -0
  125. package/dist/esm/worker/dir.d.ts +1 -0
  126. package/dist/esm/worker/dir.js +5 -0
  127. package/dist/esm/worker/pool.d.ts +47 -0
  128. package/dist/esm/worker/pool.js +175 -0
  129. package/dist/esm/worker/protocol.d.ts +37 -0
  130. package/dist/esm/worker/protocol.js +2 -0
  131. package/dist/esm/worker/sanitizer-worker.d.ts +1 -0
  132. package/dist/esm/worker/sanitizer-worker.js +51 -0
  133. package/package.json +75 -4
@@ -0,0 +1,921 @@
1
+ // The agent: Fleet lifecycle (policy.md 4, fleet-api), capture gate, asynchronous
2
+ // sanitization and export. Fail-closed everywhere: any doubt means "do not capture".
3
+ import { hostname } from 'node:os';
4
+ import { readFileSync } from 'node:fs';
5
+ import { verify as cryptoVerify } from 'node:crypto';
6
+ import { loadOtelApi } from './capture/context.js';
7
+ import { installInbound } from './capture/inbound.js';
8
+ import { installOutboundHttp } from './capture/outbound-http.js';
9
+ import { installOutboundUndici } from './capture/outbound-undici.js';
10
+ import { sanitizeToRecord } from './export/pipeline.js';
11
+ import { SanitizerPool } from './worker/pool.js';
12
+ import { availableParallelism } from 'node:os';
13
+ import { Exporter } from './export/exporter.js';
14
+ import { enroll, FleetClient, FleetError, mtlsAgent } from './fleet/client.js';
15
+ import { StateStore, defaultStateDir } from './fleet/store.js';
16
+ import { debug, setDebug, warn } from './log.js';
17
+ import { nodeToPlain, parseJson } from './sanitizer/json.js';
18
+ import { Policy, Refused, UNSAFE_DETAIL, b64urlDecodeStrict, checkAcceptance, ed25519PublicKey, parseKeyRing, selectKey, verifyEnvelope, } from './sanitizer/policy.js';
19
+ import { Sanitizer, normalizeUrlBase } from './sanitizer/sanitizer.js';
20
+ import { asciiLower } from './sanitizer/utf8.js';
21
+ import { SDK_VERSION, SOURCE_KIND } from './version.js';
22
+ const PENDING_MAX = 2000;
23
+ const PENDING_MAX_BYTES = 64 << 20;
24
+ const DRAIN_BUDGET_MS = 2;
25
+ const WORKER_BATCH = 256;
26
+ const WORKER_BATCH_MS = 8;
27
+ const WORKER_MIN_BP = 200;
28
+ const RAW_PATH_SVC = /^[a-z0-9][a-z0-9._-]{0,62}$/;
29
+ export function serviceNameFromEnv(env) {
30
+ const raw = env.TT_SERVICE_NAME || env.OTEL_SERVICE_NAME;
31
+ if (!raw)
32
+ return undefined;
33
+ const s = asciiLower(raw).replace(/[^a-z0-9._-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 63);
34
+ return RAW_PATH_SVC.test(s) ? s : undefined;
35
+ }
36
+ export function optionsFromEnv(env = process.env) {
37
+ return {
38
+ enrollUrl: env.TT_ENROLL_URL,
39
+ enrollToken: env.TT_ENROLL_TOKEN,
40
+ hmacKey: env.TT_HMAC_KEY,
41
+ hmacKeyFile: env.TT_HMAC_KEY_FILE,
42
+ serviceName: serviceNameFromEnv(env),
43
+ stateDir: env.TT_STATE_DIR,
44
+ instance: env.TT_INSTANCE,
45
+ useCsr: env.TT_ENROLL_CSR !== '0',
46
+ debug: /^(1|true|yes|debug)$/i.test(env.TT_DEBUG ?? ''),
47
+ heartbeatIntervalMs: msEnv(env.TT_HEARTBEAT_INTERVAL_MS),
48
+ policyPollIntervalMs: msEnv(env.TT_POLICY_POLL_INTERVAL_MS),
49
+ exportFlushMs: msEnv(env.TT_EXPORT_FLUSH_MS),
50
+ sanitizerThreads: /^[0-9]{1,2}$/.test(env.TT_SANITIZER_THREADS ?? '') ? Number(env.TT_SANITIZER_THREADS) : undefined,
51
+ };
52
+ }
53
+ function msEnv(v) {
54
+ if (!v || !/^[0-9]{1,9}$/.test(v))
55
+ return undefined;
56
+ return Math.max(100, Number(v));
57
+ }
58
+ export class Agent {
59
+ opts;
60
+ counters = {
61
+ exchanges_seen: 0,
62
+ exchanges_sampled: 0,
63
+ events_sent: 0,
64
+ events_dropped_queue: 0,
65
+ events_rejected: 0,
66
+ send_errors: 0,
67
+ sanitizer_errors: 0,
68
+ };
69
+ state = 'starting';
70
+ san = null;
71
+ policy = null;
72
+ policyKid = '';
73
+ rejected = null;
74
+ ring = null;
75
+ keyState = null;
76
+ heartbeatOk = false;
77
+ lastContact = 0;
78
+ persisted = {};
79
+ overrideBp = null;
80
+ decisionNames = new Set(['cookie', 'baggage']);
81
+ overhead = new Float64Array(1024);
82
+ overheadN = 0;
83
+ lastErrors = [];
84
+ pending = [];
85
+ pendingBytes = 0;
86
+ drainScheduled = false;
87
+ pool = null;
88
+ /** The last drain handed its work to the workers: the next one is batched on a timer. */
89
+ workerMode = false;
90
+ inflight = 0;
91
+ inflightBytes = 0;
92
+ uninstall = [];
93
+ timers = [];
94
+ stopped = false;
95
+ store = null;
96
+ enrollment = null;
97
+ keyPem = '';
98
+ httpsAgent = null;
99
+ fleet = null;
100
+ exporter = null;
101
+ etag;
102
+ // A heartbeat said a newer policy is current (a normal policy change): capture pauses until it
103
+ // is fetched, and only a failed fetch is an error (policy.md 4).
104
+ switching = false;
105
+ // Hashes of the last policies accepted: a response that brings one of them again (a poll that
106
+ // started before the change) is stale, not a replay.
107
+ acceptedHashes = [];
108
+ polling = null;
109
+ pollAgain = false;
110
+ constructor(opts) {
111
+ this.opts = opts;
112
+ if (opts.debug)
113
+ setDebug(true);
114
+ }
115
+ // --- CaptureHost (hot path) --------------------------------------------------------
116
+ active() {
117
+ return this.san;
118
+ }
119
+ serviceName() {
120
+ return this.enrollment?.service_name ?? '';
121
+ }
122
+ decisionHeaders() {
123
+ return this.decisionNames;
124
+ }
125
+ propagates() {
126
+ const p = this.policy;
127
+ return p !== null && p.propagateTo.size > 0;
128
+ }
129
+ mayPropagate(urlBase) {
130
+ const p = this.policy;
131
+ if (!p || p.propagateTo.size === 0)
132
+ return false;
133
+ if (this.propMemoFor !== p) {
134
+ this.propMemoFor = p;
135
+ this.propMemo.clear();
136
+ }
137
+ let r = this.propMemo.get(urlBase);
138
+ if (r === undefined) {
139
+ r = this.mayPropagateUncached(p, urlBase);
140
+ if (this.propMemo.size >= 1024)
141
+ this.propMemo.clear();
142
+ this.propMemo.set(urlBase, r);
143
+ }
144
+ return r;
145
+ }
146
+ propMemoFor = null;
147
+ propMemo = new Map();
148
+ mayPropagateUncached(p, urlBase) {
149
+ const u = normalizeUrlBase(urlBase);
150
+ // propagate_to and bases hold names, never IPv6 literals (v1.5).
151
+ if (u.invalid || u.ip6)
152
+ return false;
153
+ const host = u.hostport.replace(/:[0-9]+$/, '');
154
+ // D-M8: never toward a host that matches a policy base (external service).
155
+ if (p.baseHosts.has(u.hostport) || p.baseHosts.has(host))
156
+ return false;
157
+ return p.propagateTo.has(u.hostport) || p.propagateTo.has(host);
158
+ }
159
+ noteSeen() {
160
+ this.counters.exchanges_seen++;
161
+ }
162
+ noteSampled() {
163
+ this.counters.exchanges_sampled++;
164
+ }
165
+ noteOverhead(ns) {
166
+ this.overhead[this.overheadN++ % this.overhead.length] = ns;
167
+ }
168
+ noteError(code) {
169
+ this.lastErrors.push({ code: code.slice(0, 64), at: new Date().toISOString() });
170
+ if (this.lastErrors.length > 20)
171
+ this.lastErrors.shift();
172
+ debug('error', code);
173
+ }
174
+ submit(raw) {
175
+ if (this.san === null || this.pending.length + this.inflight >= PENDING_MAX || this.pendingBytes + this.inflightBytes + raw.heldBytes > PENDING_MAX_BYTES) {
176
+ this.counters.events_dropped_queue++;
177
+ return;
178
+ }
179
+ this.pending.push(raw);
180
+ this.pendingBytes += raw.heldBytes;
181
+ if (!this.drainScheduled) {
182
+ this.drainScheduled = true;
183
+ this.scheduleDrain();
184
+ }
185
+ }
186
+ // --- asynchronous sanitization ------------------------------------------------------
187
+ threads() {
188
+ const t = this.opts.sanitizerThreads;
189
+ if (typeof t === 'number')
190
+ return t;
191
+ return Math.min(2, Math.max(1, availableParallelism() - 1));
192
+ }
193
+ /** Worker threads pay off only when many exchanges are sampled (high rate). */
194
+ wantWorkers() {
195
+ const san = this.san;
196
+ if (!san || this.threads() <= 0)
197
+ return false;
198
+ if (san.rateBp() < (this.opts.workerMinBp ?? WORKER_MIN_BP))
199
+ return false;
200
+ if (!this.pool) {
201
+ this.pool = new SanitizerPool({
202
+ size: this.threads(),
203
+ onOut: (b) => this.onWorkerOut(b),
204
+ onLost: (b) => {
205
+ this.inflight -= b.n;
206
+ this.inflightBytes -= b.bytes;
207
+ this.counters.events_dropped_queue += b.n;
208
+ this.noteError('sanitizer_worker_lost');
209
+ },
210
+ });
211
+ }
212
+ this.pool.warm();
213
+ return this.pool.usable;
214
+ }
215
+ onWorkerOut(b) {
216
+ this.inflight -= b.n;
217
+ this.inflightBytes -= b.bytes;
218
+ if (b.stale) {
219
+ this.counters.events_dropped_queue += b.n;
220
+ return;
221
+ }
222
+ const ex = this.exporter;
223
+ if (ex)
224
+ for (const record of b.recs)
225
+ ex.enqueue({ json: '', record, size: record.length, tsMs: 0, policyHash: '' });
226
+ for (const code of b.errs) {
227
+ this.counters.sanitizer_errors++;
228
+ this.noteError(code);
229
+ }
230
+ }
231
+ /**
232
+ * Inline sanitization runs right after the response (setImmediate). With worker threads
233
+ * the exchanges are collected for a few milliseconds and posted as one batch: one message
234
+ * and one thread wake-up for many exchanges instead of one each.
235
+ */
236
+ scheduleDrain() {
237
+ if (this.workerMode) {
238
+ const t = setTimeout(() => this.drain(), WORKER_BATCH_MS);
239
+ t.unref();
240
+ }
241
+ else {
242
+ setImmediate(() => this.drain());
243
+ }
244
+ }
245
+ drain() {
246
+ this.drainScheduled = false;
247
+ this.workerMode = false;
248
+ if (this.pending.length > 0 && this.wantWorkers() && this.drainToWorkers()) {
249
+ this.workerMode = true;
250
+ if (this.pending.length > 0 && !this.drainScheduled) {
251
+ this.drainScheduled = true;
252
+ this.scheduleDrain();
253
+ }
254
+ return;
255
+ }
256
+ const t0 = performance.now();
257
+ while (this.pending.length > 0) {
258
+ const raw = this.pending.shift();
259
+ this.pendingBytes -= raw.heldBytes;
260
+ this.process(raw);
261
+ if (performance.now() - t0 > DRAIN_BUDGET_MS)
262
+ break;
263
+ }
264
+ if (this.pending.length > 0 && !this.drainScheduled) {
265
+ this.drainScheduled = true;
266
+ this.scheduleDrain();
267
+ }
268
+ }
269
+ /** Posts up to WORKER_BATCH pending exchanges to a worker. False when none could be posted. */
270
+ drainToWorkers() {
271
+ const pool = this.pool;
272
+ const enr = this.enrollment;
273
+ if (!enr || !this.exporter)
274
+ return false;
275
+ const items = [];
276
+ const taken = [];
277
+ let bytes = 0;
278
+ while (this.pending.length > 0 && taken.length < WORKER_BATCH) {
279
+ const raw = this.pending[0];
280
+ if (!pool.canHandle(raw.sanitizer)) {
281
+ // An exchange captured under a policy the workers cannot rebuild: sanitize it here.
282
+ if (taken.length > 0)
283
+ break;
284
+ return false;
285
+ }
286
+ this.pending.shift();
287
+ this.pendingBytes -= raw.heldBytes;
288
+ bytes += raw.heldBytes;
289
+ taken.push(raw);
290
+ items.push({ san: raw.sanitizer, job: { gen: 0, decision: raw.decision, meta: metaOf(raw), wire: raw.wire } });
291
+ }
292
+ if (items.length === 0)
293
+ return false;
294
+ if (pool.post(items, bytes, enr.source_id)) {
295
+ this.inflight += items.length;
296
+ this.inflightBytes += bytes;
297
+ return true;
298
+ }
299
+ // No worker could take them: put them back (in order) and sanitize inline.
300
+ this.pending.unshift(...taken);
301
+ this.pendingBytes += bytes;
302
+ return false;
303
+ }
304
+ process(raw) {
305
+ if (!this.exporter || !this.enrollment || this.san === null)
306
+ return;
307
+ const r = sanitizeToRecord(raw.sanitizer, raw.wire, raw.decision, metaOf(raw), this.enrollment.source_id, Date.now());
308
+ if (r === null)
309
+ return;
310
+ if ('err' in r) {
311
+ this.counters.sanitizer_errors++;
312
+ this.noteError(r.err);
313
+ return;
314
+ }
315
+ this.exporter.enqueue({ json: '', record: r.record, size: r.record.length, tsMs: raw.tsMs, policyHash: raw.sanitizer.policy.hash });
316
+ }
317
+ // --- lifecycle --------------------------------------------------------------------
318
+ /** Installs the hooks and runs the first Fleet cycle. Never throws. */
319
+ async start() {
320
+ try {
321
+ loadOtelApi();
322
+ this.uninstall.push(installInbound(this), installOutboundHttp(this), installOutboundUndici(this));
323
+ const o = this.opts;
324
+ const svc = o.serviceName;
325
+ if (!o.enrollUrl || !svc) {
326
+ this.noteError('not_configured');
327
+ return;
328
+ }
329
+ this.store = new StateStore(o.stateDir ?? defaultStateDir(o.enrollUrl, svc));
330
+ this.store.onUntrusted = () => this.noteError('state_untrusted');
331
+ this.persisted = this.store.readJson('state.json') ?? {};
332
+ this.loadKeys();
333
+ const saved = this.store.readJson('enrollment.json');
334
+ const savedKey = this.store.readText('client.key');
335
+ if (saved && savedKey && saved.enroll_url === o.enrollUrl) {
336
+ this.enrollment = saved;
337
+ this.keyPem = savedKey;
338
+ }
339
+ else {
340
+ await this.enrollWithRetry();
341
+ if (!this.enrollment)
342
+ return;
343
+ }
344
+ this.connect();
345
+ // Re-verify the cached policy (its issued_seq may equal the last accepted one).
346
+ const cached = this.store.readText('policy.envelope.json');
347
+ if (cached !== null) {
348
+ try {
349
+ this.tryAccept(nodeToPlain(parseJson(Buffer.from(cached, 'utf8')), true), true, cached);
350
+ }
351
+ catch {
352
+ this.noteError('policy_cache_invalid');
353
+ }
354
+ }
355
+ await this.heartbeat();
356
+ await this.pollPolicy();
357
+ this.schedule();
358
+ }
359
+ catch (e) {
360
+ this.noteError(e instanceof FleetError ? e.code : 'start_failed');
361
+ }
362
+ this.recompute();
363
+ }
364
+ async stop() {
365
+ this.stopped = true;
366
+ for (const t of this.timers)
367
+ clearTimeout(t);
368
+ this.timers = [];
369
+ for (const u of this.uninstall)
370
+ u();
371
+ this.uninstall = [];
372
+ this.san = null;
373
+ this.pool?.terminate();
374
+ this.exporter?.stop();
375
+ this.httpsAgent?.destroy();
376
+ }
377
+ /** Sanitizes what is pending and sends everything queued (tests, graceful shutdown). */
378
+ async flush() {
379
+ while (this.pending.length > 0) {
380
+ const raw = this.pending.shift();
381
+ this.pendingBytes -= raw.heldBytes;
382
+ this.process(raw);
383
+ }
384
+ await this.pool?.sync();
385
+ await this.exporter?.flush();
386
+ }
387
+ status() {
388
+ return { state: this.state, counters: { ...this.counters }, queue: this.exporter?.size ?? { events: 0, bytes: 0 } };
389
+ }
390
+ async enrollWithRetry() {
391
+ const o = this.opts;
392
+ if (!o.enrollToken) {
393
+ this.noteError('enroll_token_missing');
394
+ return;
395
+ }
396
+ let delay = 2000;
397
+ for (let attempt = 0; attempt < 8 && !this.stopped; attempt++) {
398
+ try {
399
+ const { enrollment, keyPem } = await enroll({
400
+ enrollUrl: o.enrollUrl,
401
+ token: o.enrollToken,
402
+ useCsr: o.useCsr !== false,
403
+ source: {
404
+ kind: SOURCE_KIND,
405
+ version: SDK_VERSION,
406
+ service_name: o.serviceName,
407
+ instance: (o.instance ?? hostname()).slice(0, 128),
408
+ runtime: { lang: 'node', lang_version: process.versions.node, os: process.platform, arch: process.arch },
409
+ hmac_kids: this.ring ? this.ring.order.slice(0, 8) : undefined,
410
+ },
411
+ });
412
+ this.enrollment = enrollment;
413
+ this.keyPem = keyPem;
414
+ this.store.writeText('client.key', keyPem);
415
+ this.store.writeJson('enrollment.json', enrollment);
416
+ return;
417
+ }
418
+ catch (e) {
419
+ const code = e instanceof FleetError ? e.code : 'enroll_failed';
420
+ this.noteError(code);
421
+ // Token or identity errors will not fix themselves.
422
+ if (e instanceof FleetError && e.status !== undefined && e.status < 500)
423
+ return;
424
+ if (/pin|root|token|chain|bad_/.test(code))
425
+ return;
426
+ }
427
+ await new Promise((r) => {
428
+ const t = setTimeout(r, delay);
429
+ t.unref();
430
+ });
431
+ delay = Math.min(delay * 2, 300_000);
432
+ }
433
+ }
434
+ connect() {
435
+ const e = this.enrollment;
436
+ this.httpsAgent?.destroy();
437
+ this.httpsAgent = mtlsAgent(e, this.keyPem);
438
+ if (this.fleet)
439
+ this.fleet.setAgent(this.httpsAgent);
440
+ else
441
+ this.fleet = new FleetClient(e, this.httpsAgent);
442
+ if (this.exporter)
443
+ this.exporter.setTarget(e.intake_url, this.httpsAgent);
444
+ else {
445
+ this.exporter = new Exporter({
446
+ url: e.intake_url,
447
+ agent: this.httpsAgent,
448
+ resource: { serviceName: e.service_name, sourceId: e.source_id },
449
+ counters: this.counters,
450
+ onError: (c) => this.noteError(c),
451
+ flushMs: this.opts.exportFlushMs,
452
+ });
453
+ }
454
+ }
455
+ schedule() {
456
+ const e = this.enrollment;
457
+ const hb = this.opts.heartbeatIntervalMs ?? Math.max(5, e.heartbeat_interval_s) * 1000;
458
+ const pp = this.opts.policyPollIntervalMs ?? Math.max(5, e.policy_poll_interval_s) * 1000;
459
+ const loop = (ms, fn) => {
460
+ const tick = () => {
461
+ if (this.stopped)
462
+ return;
463
+ void fn().finally(() => {
464
+ if (this.stopped)
465
+ return;
466
+ const t = setTimeout(tick, ms);
467
+ t.unref();
468
+ this.timers.push(t);
469
+ if (this.timers.length > 16)
470
+ this.timers = this.timers.slice(-4);
471
+ });
472
+ };
473
+ const t = setTimeout(tick, ms);
474
+ t.unref();
475
+ this.timers.push(t);
476
+ };
477
+ loop(hb, () => this.heartbeat());
478
+ loop(pp, () => this.pollPolicy());
479
+ }
480
+ // --- keys -------------------------------------------------------------------------
481
+ loadKeys() {
482
+ let text = this.opts.hmacKey;
483
+ let why = 'not_set';
484
+ if (text === undefined && this.opts.hmacKeyFile) {
485
+ try {
486
+ text = readFileSync(this.opts.hmacKeyFile, 'utf8');
487
+ }
488
+ catch {
489
+ text = undefined;
490
+ why = 'unreadable';
491
+ }
492
+ }
493
+ try {
494
+ this.ring = parseKeyRing(text);
495
+ this.keyWarned = '';
496
+ }
497
+ catch {
498
+ this.ring = null;
499
+ if (text !== undefined)
500
+ why = 'malformed';
501
+ this.warnKeyMissing(why);
502
+ }
503
+ }
504
+ /** Said once per cause, whatever TT_DEBUG: without the key nothing is captured. Never prints key material or paths. */
505
+ keyWarned = '';
506
+ warnKeyMissing(why) {
507
+ if (this.keyWarned === why)
508
+ return;
509
+ this.keyWarned = why;
510
+ const cause = why === 'unreadable'
511
+ ? 'the file in TT_HMAC_KEY_FILE cannot be read'
512
+ : why === 'malformed'
513
+ ? 'the HMAC key is malformed (want one line <kid>:<base64url of 32+ bytes>, e.g. k1:...)'
514
+ : 'neither TT_HMAC_KEY_FILE nor TT_HMAC_KEY is set';
515
+ warn('key_missing: ' + cause + '. The SDK does not capture anything until the key is there (the application is not affected). ' +
516
+ 'The key file is installation-wide and comes from your platform team: same kid as the policy (hmac.kid, e.g. k1) and the same key keyguard holds. ' +
517
+ 'Console: Help > "Chiavi HMAC".');
518
+ }
519
+ signingKeys() {
520
+ const revoked = new Set(this.persisted.revoked_kids ?? []);
521
+ const m = new Map();
522
+ for (const k of [...(this.enrollment?.policy_signing_keys ?? []), ...(this.persisted.added_keys ?? [])]) {
523
+ if (revoked.has(k.kid) || m.has(k.kid))
524
+ continue;
525
+ const pk = ed25519PublicKey(k.public_key);
526
+ if (pk)
527
+ m.set(k.kid, pk);
528
+ }
529
+ return m;
530
+ }
531
+ persist() {
532
+ try {
533
+ this.store?.writeJson('state.json', this.persisted);
534
+ }
535
+ catch {
536
+ this.noteError('state_write_failed');
537
+ }
538
+ }
539
+ // --- policy (policy.md 4) -----------------------------------------------------------
540
+ tryAccept(envelope, fromCache, rawText) {
541
+ try {
542
+ const v = verifyEnvelope(envelope, this.signingKeys(), true);
543
+ const p = new Policy(v.body);
544
+ p.hash = v.hash;
545
+ p.bodyBytes = v.bodyBytes;
546
+ if (this.policy && this.policy.hash === p.hash && !fromCache)
547
+ return;
548
+ if (!fromCache && this.acceptedHashes.includes(p.hash) && p.issuedSeq <= (this.persisted.last_issued_seq ?? 0)) {
549
+ // An answer that started before a newer policy was accepted (two polls crossing).
550
+ debug('info', 'policy_stale_response: issued_seq ' + p.issuedSeq + ' already superseded, kept the newer policy');
551
+ return;
552
+ }
553
+ checkAcceptance(p, {
554
+ group: this.enrollment.group,
555
+ now: Date.now(),
556
+ lastIssuedSeq: this.persisted.last_issued_seq ?? 0,
557
+ minIssuedSeq: this.persisted.min_issued_seq ?? 0,
558
+ allowEqualSeq: fromCache,
559
+ });
560
+ if (this.switching)
561
+ debug('info', 'policy_changed: now issued_seq ' + p.issuedSeq);
562
+ this.policy = p;
563
+ this.policyKid = String(envelope.signature?.kid ?? '');
564
+ this.rejected = null;
565
+ this.switching = false;
566
+ this.acceptedHashes = [...this.acceptedHashes.filter((h) => h !== p.hash), p.hash].slice(-4);
567
+ this.persisted.last_issued_seq = p.issuedSeq;
568
+ this.persist();
569
+ // The cache holds the signed envelope exactly as received; it is re-verified at start.
570
+ if (!fromCache)
571
+ this.store?.writeText('policy.envelope.json', rawText);
572
+ const names = new Set(['cookie', 'baggage']);
573
+ if (p.subject?.from === 'header')
574
+ names.add(asciiLower(p.subject.name));
575
+ this.decisionNames = names;
576
+ }
577
+ catch (e) {
578
+ const code = e instanceof Refused ? e.code : 'policy_rejected';
579
+ // hardening.md 2.2: a policy refused for the safety subset is reported as policy_unsafe.
580
+ this.noteError(e instanceof Refused && e.detail.startsWith(UNSAFE_DETAIL) ? 'policy_unsafe' : code);
581
+ // Keep the previous policy if it is still valid; otherwise report the rejection.
582
+ if (!this.policy)
583
+ this.rejected = code === 'expired_policy' ? 'expired_policy' : 'policy_rejected';
584
+ }
585
+ this.recompute();
586
+ }
587
+ // One poll at a time: a heartbeat that sees a new policy and the periodic poll would otherwise
588
+ // race, and the older answer could arrive last.
589
+ pollPolicy() {
590
+ if (this.polling) {
591
+ this.pollAgain = true;
592
+ return this.polling;
593
+ }
594
+ this.polling = (async () => {
595
+ try {
596
+ do {
597
+ this.pollAgain = false;
598
+ await this.pollOnce();
599
+ } while (this.pollAgain && !this.stopped);
600
+ }
601
+ finally {
602
+ this.polling = null;
603
+ }
604
+ })();
605
+ return this.polling;
606
+ }
607
+ async pollOnce() {
608
+ if (!this.fleet || this.stopped)
609
+ return;
610
+ try {
611
+ const r = await this.fleet.getPolicy(this.policy ? this.etag : undefined);
612
+ if (r.status === 304) {
613
+ this.lastContact = Date.now();
614
+ }
615
+ else if (r.status === 200 && r.body) {
616
+ this.lastContact = Date.now();
617
+ let env;
618
+ try {
619
+ env = nodeToPlain(parseJson(r.body), true);
620
+ }
621
+ catch {
622
+ this.noteError('policy_json');
623
+ if (!this.policy)
624
+ this.rejected = 'policy_rejected';
625
+ this.recompute();
626
+ return;
627
+ }
628
+ this.tryAccept(env, false, r.body.toString('utf8'));
629
+ if (this.policy && r.etag)
630
+ this.etag = r.etag;
631
+ }
632
+ else if (r.status === 404) {
633
+ this.lastContact = Date.now();
634
+ this.policy = null;
635
+ this.rejected = 'no_policy';
636
+ }
637
+ else if (r.status === 403) {
638
+ this.revoke();
639
+ }
640
+ else {
641
+ this.noteError('policy_http_' + r.status);
642
+ }
643
+ }
644
+ catch {
645
+ this.noteError('policy_network');
646
+ }
647
+ if (this.switching && !this.policy) {
648
+ // The newer policy announced by the heartbeat could not be fetched or accepted.
649
+ this.switching = false;
650
+ if (this.rejected === 'starting') {
651
+ this.rejected = 'policy_rejected';
652
+ this.noteError('policy_below_min_issued_seq');
653
+ }
654
+ }
655
+ this.recompute();
656
+ }
657
+ // --- heartbeat --------------------------------------------------------------------
658
+ overheadUs() {
659
+ const n = Math.min(this.overheadN, this.overhead.length);
660
+ if (n === 0)
661
+ return { p50: 0, p99: 0 };
662
+ const a = Array.from(this.overhead.subarray(0, n)).sort((x, y) => x - y);
663
+ const q = (f) => Math.round(a[Math.min(n - 1, Math.floor(f * n))] / 1000);
664
+ return { p50: q(0.5), p99: q(0.99) };
665
+ }
666
+ async heartbeat() {
667
+ if (!this.fleet || this.stopped)
668
+ return;
669
+ this.loadKeys();
670
+ this.recompute();
671
+ const p = this.policy;
672
+ const body = {
673
+ source_id: this.enrollment.source_id,
674
+ state: this.state,
675
+ ...(p ? { policy: { id: p.policyId, version: p.version, issued_seq: p.issuedSeq, hash: p.hash } } : {}),
676
+ counters: { ...this.counters },
677
+ overhead_us: this.overheadUs(),
678
+ queue: this.exporter?.size ?? { events: 0, bytes: 0 },
679
+ last_errors: this.lastErrors.slice(-20),
680
+ runtime: { lang: 'node', lang_version: process.versions.node },
681
+ // The policy signing kids trusted now: tt-core waits for a rotation to reach the source
682
+ // before it lets the old key be revoked.
683
+ signing_kids: [...this.signingKeys().keys()].sort(),
684
+ };
685
+ try {
686
+ const r = await this.fleet.heartbeat(body);
687
+ if (r.status === 403) {
688
+ this.revoke();
689
+ }
690
+ else if (r.status === 200 && r.json) {
691
+ this.applyHeartbeat(r.json);
692
+ }
693
+ else {
694
+ this.noteError('heartbeat_http_' + r.status);
695
+ }
696
+ }
697
+ catch {
698
+ this.noteError('heartbeat_network');
699
+ }
700
+ this.recompute();
701
+ }
702
+ applyHeartbeat(j) {
703
+ this.heartbeatOk = true;
704
+ this.lastContact = Date.now();
705
+ const kill = j.kill === true;
706
+ if (kill !== (this.persisted.kill === true)) {
707
+ this.persisted.kill = kill;
708
+ this.persist();
709
+ }
710
+ if (kill)
711
+ this.dropQueues();
712
+ if (typeof j.min_issued_seq === 'number' && Number.isInteger(j.min_issued_seq) && j.min_issued_seq >= 0) {
713
+ if (j.min_issued_seq !== this.persisted.min_issued_seq) {
714
+ this.persisted.min_issued_seq = j.min_issued_seq;
715
+ this.persist();
716
+ }
717
+ if (this.policy && this.policy.issuedSeq < j.min_issued_seq) {
718
+ const cur = j.policy_current;
719
+ const newer = !!cur && typeof cur.hash === 'string' && cur.hash !== this.policy.hash &&
720
+ typeof cur.issued_seq === 'number' && cur.issued_seq >= j.min_issued_seq;
721
+ // The policy in use is superseded for good: capture stops at once (fail-closed).
722
+ this.policy = null;
723
+ if (newer) {
724
+ // A normal policy change: the newer policy is fetched right away (policy_current below).
725
+ this.switching = true;
726
+ this.rejected = 'starting';
727
+ debug('info', 'policy_superseded: fetching issued_seq ' + String(cur.issued_seq));
728
+ }
729
+ else {
730
+ this.rejected = 'policy_rejected';
731
+ this.noteError('policy_below_min_issued_seq');
732
+ }
733
+ }
734
+ }
735
+ const o = j.sampling_override_bp;
736
+ this.overrideBp = typeof o === 'number' && o >= 0 && o <= 10000 ? o : null;
737
+ // Revocations first: they only remove trust, and a key endorsed by a kid revoked in this
738
+ // very response must not be pinned (RT3-KEY-3).
739
+ if (Array.isArray(j.revoke_signing_keys)) {
740
+ const rk = new Set(this.persisted.revoked_kids ?? []);
741
+ const before = rk.size;
742
+ for (const k of j.revoke_signing_keys)
743
+ if (typeof k === 'string')
744
+ rk.add(k);
745
+ if (rk.size !== before) {
746
+ this.persisted.revoked_kids = [...rk].sort();
747
+ this.persist();
748
+ }
749
+ if (this.policy && rk.has(this.policyKid)) {
750
+ this.policy = null;
751
+ this.rejected = 'policy_rejected';
752
+ this.noteError('policy_key_revoked');
753
+ }
754
+ }
755
+ if (Array.isArray(j.add_signing_keys))
756
+ this.addSigningKeys(j.add_signing_keys);
757
+ const cur = j.policy_current;
758
+ if (cur && typeof cur.hash === 'string' && cur.hash !== this.policy?.hash)
759
+ setImmediate(() => void this.pollPolicy());
760
+ if (typeof j.renew_before === 'string' && Date.now() >= Date.parse(j.renew_before))
761
+ setImmediate(() => void this.renew());
762
+ }
763
+ // tt-core sends every endorsed key at every heartbeat, an endorser before the keys it
764
+ // endorses: the list is processed in order, so a key pinned earlier in it can endorse a later
765
+ // one. A revoked kid is never pinned again; a kid already pinned with the same key is skipped
766
+ // silently, one pinned with another key is refused.
767
+ addSigningKeys(list) {
768
+ const revoked = new Set(this.persisted.revoked_kids ?? []);
769
+ const known = this.signingKeys();
770
+ const pinned = new Map();
771
+ for (const k of [...(this.enrollment?.policy_signing_keys ?? []), ...(this.persisted.added_keys ?? [])]) {
772
+ if (revoked.has(k.kid) || pinned.has(k.kid))
773
+ continue;
774
+ const raw = b64urlDecodeStrict(k.public_key);
775
+ if (raw)
776
+ pinned.set(k.kid, raw);
777
+ }
778
+ const added = [...(this.persisted.added_keys ?? [])];
779
+ let changed = false;
780
+ for (const item of list) {
781
+ const k = (item && typeof item === 'object' ? item : {});
782
+ // Canonical codes (docs/spec/policy.md §3.1): malformed -> rejected; a kid pinned to
783
+ // other bytes -> conflict; unknown/revoked endorser -> endorser_unknown; bad signature -> endorsement_invalid.
784
+ if (typeof k.kid !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(k.kid)) {
785
+ this.noteError('signing_key_rejected');
786
+ continue;
787
+ }
788
+ if (revoked.has(k.kid))
789
+ continue;
790
+ if (k.alg !== 'Ed25519' || typeof k.public_key !== 'string' || typeof k.endorsed_by !== 'string' || typeof k.endorsement !== 'string') {
791
+ this.noteError('signing_key_rejected');
792
+ continue;
793
+ }
794
+ const raw = b64urlDecodeStrict(k.public_key);
795
+ const sig = b64urlDecodeStrict(k.endorsement);
796
+ const pk = raw && raw.length === 32 ? ed25519PublicKey(k.public_key) : undefined;
797
+ if (!raw || raw.length !== 32 || !sig || sig.length !== 64 || !pk) {
798
+ this.noteError('signing_key_rejected');
799
+ continue;
800
+ }
801
+ const have = pinned.get(k.kid);
802
+ if (have) {
803
+ if (!have.equals(raw))
804
+ this.noteError('signing_key_conflict');
805
+ continue;
806
+ }
807
+ const endorser = revoked.has(k.endorsed_by) ? undefined : known.get(k.endorsed_by);
808
+ if (!endorser) {
809
+ this.noteError('signing_key_endorser_unknown');
810
+ continue;
811
+ }
812
+ const msg = Buffer.concat([Buffer.from('tt-fleet-key/v1\0' + k.kid + '\0', 'latin1'), raw]);
813
+ let ok = false;
814
+ try {
815
+ ok = cryptoVerify(null, msg, endorser, sig);
816
+ }
817
+ catch {
818
+ ok = false;
819
+ }
820
+ if (!ok) {
821
+ this.noteError('signing_key_endorsement_invalid');
822
+ continue;
823
+ }
824
+ added.push({ kid: k.kid, alg: 'Ed25519', public_key: k.public_key });
825
+ known.set(k.kid, pk);
826
+ pinned.set(k.kid, raw);
827
+ changed = true;
828
+ }
829
+ if (changed) {
830
+ this.persisted.added_keys = added;
831
+ this.persist();
832
+ }
833
+ }
834
+ async renew() {
835
+ if (!this.fleet || !this.enrollment)
836
+ return;
837
+ try {
838
+ const r = await this.fleet.renew(this.opts.useCsr !== false, this.enrollment.service_name);
839
+ if (!r || !r.keyPem) {
840
+ this.noteError('renew_failed');
841
+ return;
842
+ }
843
+ this.enrollment.client_cert = r.cert;
844
+ if (r.notAfter)
845
+ this.enrollment.not_after = r.notAfter;
846
+ this.keyPem = r.keyPem;
847
+ this.store?.writeText('client.key', r.keyPem);
848
+ this.store?.writeJson('enrollment.json', this.enrollment);
849
+ this.connect();
850
+ }
851
+ catch {
852
+ this.noteError('renew_network');
853
+ }
854
+ }
855
+ revoke() {
856
+ this.persisted.revoked = true;
857
+ this.persist();
858
+ this.dropQueues();
859
+ this.noteError('source_revoked');
860
+ }
861
+ dropQueues() {
862
+ this.counters.events_dropped_queue += this.pending.length;
863
+ this.pending = [];
864
+ this.pendingBytes = 0;
865
+ this.pool?.discard();
866
+ this.exporter?.clear();
867
+ }
868
+ /** Recomputes the state and the capture gate. */
869
+ recompute() {
870
+ const now = Date.now();
871
+ const p = this.policy;
872
+ const maxAgeMs = (this.enrollment?.policy_max_age_s ?? 86400) * 1000;
873
+ let st;
874
+ let key = null;
875
+ if (this.persisted.kill || this.persisted.revoked)
876
+ st = 'killed';
877
+ // A missing key is said first: with or without a policy nothing is captured until it is there.
878
+ else if (!this.ring)
879
+ st = 'key_missing';
880
+ else if (!p)
881
+ st = this.rejected ?? 'no_policy';
882
+ else if (!(now < p.notAfter) || (this.lastContact > 0 && now - this.lastContact > maxAgeMs))
883
+ st = 'expired_policy';
884
+ else {
885
+ try {
886
+ key = selectKey(this.ring, p);
887
+ st = this.heartbeatOk ? 'capturing' : 'starting';
888
+ }
889
+ catch {
890
+ st = 'key_mismatch';
891
+ }
892
+ }
893
+ // Capture only after a heartbeat in this process (kill switch and min_issued_seq known).
894
+ if (st === 'capturing' && key && p && this.lastContact > 0) {
895
+ const cur = this.san;
896
+ if (!cur || cur.policy !== p || cur.key.kid !== key.kid || cur.key.key !== key.key || cur.options.rateOverrideBp !== this.overrideBp) {
897
+ this.san = new Sanitizer(p, key, { rateOverrideBp: this.overrideBp });
898
+ }
899
+ }
900
+ else {
901
+ if (st === 'capturing')
902
+ st = 'starting';
903
+ this.san = null;
904
+ }
905
+ if (st !== this.state)
906
+ debug('state', st);
907
+ this.state = st;
908
+ }
909
+ }
910
+ function metaOf(raw) {
911
+ return {
912
+ dir: raw.dir,
913
+ tsMs: raw.tsMs,
914
+ latencyMs: raw.latencyMs,
915
+ status: raw.status,
916
+ netError: raw.netError,
917
+ traceId: raw.traceId,
918
+ spanId: raw.spanId,
919
+ parentSpanId: raw.parentSpanId,
920
+ };
921
+ }