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