@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.
- package/LICENSE +202 -0
- package/README.md +171 -2
- package/dist/cjs/agent.d.ts +143 -0
- package/dist/cjs/agent.js +927 -0
- package/dist/cjs/capture/body.d.ts +11 -0
- package/dist/cjs/capture/body.js +73 -0
- package/dist/cjs/capture/common.d.ts +75 -0
- package/dist/cjs/capture/common.js +180 -0
- package/dist/cjs/capture/context.d.ts +28 -0
- package/dist/cjs/capture/context.js +92 -0
- package/dist/cjs/capture/inbound.d.ts +12 -0
- package/dist/cjs/capture/inbound.js +195 -0
- package/dist/cjs/capture/outbound-http.d.ts +4 -0
- package/dist/cjs/capture/outbound-http.js +200 -0
- package/dist/cjs/capture/outbound-undici.d.ts +2 -0
- package/dist/cjs/capture/outbound-undici.js +242 -0
- package/dist/cjs/capture/wire.d.ts +27 -0
- package/dist/cjs/capture/wire.js +68 -0
- package/dist/cjs/export/event.d.ts +46 -0
- package/dist/cjs/export/event.js +91 -0
- package/dist/cjs/export/exporter.d.ts +50 -0
- package/dist/cjs/export/exporter.js +192 -0
- package/dist/cjs/export/pipeline.d.ts +20 -0
- package/dist/cjs/export/pipeline.js +40 -0
- package/dist/cjs/fleet/asn1.d.ts +44 -0
- package/dist/cjs/fleet/asn1.js +150 -0
- package/dist/cjs/fleet/client.d.ts +84 -0
- package/dist/cjs/fleet/client.js +216 -0
- package/dist/cjs/fleet/http.d.ts +17 -0
- package/dist/cjs/fleet/http.js +80 -0
- package/dist/cjs/fleet/store.d.ts +18 -0
- package/dist/cjs/fleet/store.js +115 -0
- package/dist/cjs/index.d.ts +14 -0
- package/dist/cjs/index.js +69 -0
- package/dist/cjs/log.d.ts +4 -0
- package/dist/cjs/log.js +30 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/register.d.ts +1 -0
- package/dist/cjs/register.js +18 -0
- package/dist/cjs/sanitizer/bodies.d.ts +55 -0
- package/dist/cjs/sanitizer/bodies.js +710 -0
- package/dist/cjs/sanitizer/formats.d.ts +26 -0
- package/dist/cjs/sanitizer/formats.js +110 -0
- package/dist/cjs/sanitizer/graphql.d.ts +23 -0
- package/dist/cjs/sanitizer/graphql.js +478 -0
- package/dist/cjs/sanitizer/hostpat.d.ts +15 -0
- package/dist/cjs/sanitizer/hostpat.js +129 -0
- package/dist/cjs/sanitizer/json.d.ts +60 -0
- package/dist/cjs/sanitizer/json.js +460 -0
- package/dist/cjs/sanitizer/policy.d.ts +224 -0
- package/dist/cjs/sanitizer/policy.js +820 -0
- package/dist/cjs/sanitizer/sanitizer.d.ts +149 -0
- package/dist/cjs/sanitizer/sanitizer.js +1473 -0
- package/dist/cjs/sanitizer/utf8.d.ts +22 -0
- package/dist/cjs/sanitizer/utf8.js +180 -0
- package/dist/cjs/sanitizer/vectors.d.ts +22 -0
- package/dist/cjs/sanitizer/vectors.js +130 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/worker/dir.d.ts +1 -0
- package/dist/cjs/worker/dir.js +8 -0
- package/dist/cjs/worker/pool.d.ts +47 -0
- package/dist/cjs/worker/pool.js +179 -0
- package/dist/cjs/worker/protocol.d.ts +37 -0
- package/dist/cjs/worker/protocol.js +3 -0
- package/dist/cjs/worker/sanitizer-worker.d.ts +1 -0
- package/dist/cjs/worker/sanitizer-worker.js +53 -0
- package/dist/esm/agent.d.ts +143 -0
- package/dist/esm/agent.js +921 -0
- package/dist/esm/capture/body.d.ts +11 -0
- package/dist/esm/capture/body.js +69 -0
- package/dist/esm/capture/common.d.ts +75 -0
- package/dist/esm/capture/common.js +166 -0
- package/dist/esm/capture/context.d.ts +28 -0
- package/dist/esm/capture/context.js +84 -0
- package/dist/esm/capture/inbound.d.ts +12 -0
- package/dist/esm/capture/inbound.js +187 -0
- package/dist/esm/capture/outbound-http.d.ts +4 -0
- package/dist/esm/capture/outbound-http.js +193 -0
- package/dist/esm/capture/outbound-undici.d.ts +2 -0
- package/dist/esm/capture/outbound-undici.js +236 -0
- package/dist/esm/capture/wire.d.ts +27 -0
- package/dist/esm/capture/wire.js +65 -0
- package/dist/esm/export/event.d.ts +46 -0
- package/dist/esm/export/event.js +85 -0
- package/dist/esm/export/exporter.d.ts +50 -0
- package/dist/esm/export/exporter.js +188 -0
- package/dist/esm/export/pipeline.d.ts +20 -0
- package/dist/esm/export/pipeline.js +37 -0
- package/dist/esm/fleet/asn1.d.ts +44 -0
- package/dist/esm/fleet/asn1.js +128 -0
- package/dist/esm/fleet/client.d.ts +84 -0
- package/dist/esm/fleet/client.js +204 -0
- package/dist/esm/fleet/http.d.ts +17 -0
- package/dist/esm/fleet/http.js +73 -0
- package/dist/esm/fleet/store.d.ts +18 -0
- package/dist/esm/fleet/store.js +110 -0
- package/dist/esm/index.d.ts +14 -0
- package/dist/esm/index.js +48 -0
- package/dist/esm/log.d.ts +4 -0
- package/dist/esm/log.js +25 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/register.d.ts +1 -0
- package/dist/esm/register.js +16 -0
- package/dist/esm/sanitizer/bodies.d.ts +55 -0
- package/dist/esm/sanitizer/bodies.js +694 -0
- package/dist/esm/sanitizer/formats.d.ts +26 -0
- package/dist/esm/sanitizer/formats.js +101 -0
- package/dist/esm/sanitizer/graphql.d.ts +23 -0
- package/dist/esm/sanitizer/graphql.js +472 -0
- package/dist/esm/sanitizer/hostpat.d.ts +15 -0
- package/dist/esm/sanitizer/hostpat.js +122 -0
- package/dist/esm/sanitizer/json.d.ts +60 -0
- package/dist/esm/sanitizer/json.js +447 -0
- package/dist/esm/sanitizer/policy.d.ts +224 -0
- package/dist/esm/sanitizer/policy.js +800 -0
- package/dist/esm/sanitizer/sanitizer.d.ts +149 -0
- package/dist/esm/sanitizer/sanitizer.js +1459 -0
- package/dist/esm/sanitizer/utf8.d.ts +22 -0
- package/dist/esm/sanitizer/utf8.js +166 -0
- package/dist/esm/sanitizer/vectors.d.ts +22 -0
- package/dist/esm/sanitizer/vectors.js +125 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/worker/dir.d.ts +1 -0
- package/dist/esm/worker/dir.js +5 -0
- package/dist/esm/worker/pool.d.ts +47 -0
- package/dist/esm/worker/pool.js +175 -0
- package/dist/esm/worker/protocol.d.ts +37 -0
- package/dist/esm/worker/protocol.js +2 -0
- package/dist/esm/worker/sanitizer-worker.d.ts +1 -0
- package/dist/esm/worker/sanitizer-worker.js +51 -0
- package/package.json +75 -4
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type https from 'node:https';
|
|
2
|
+
import { type QueuedEvent, type Resource } from './event.js';
|
|
3
|
+
export interface ExportCounters {
|
|
4
|
+
events_sent: number;
|
|
5
|
+
events_dropped_queue: number;
|
|
6
|
+
events_rejected: number;
|
|
7
|
+
send_errors: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ExporterOptions {
|
|
10
|
+
url: string;
|
|
11
|
+
agent: https.Agent;
|
|
12
|
+
resource: Resource;
|
|
13
|
+
counters: ExportCounters;
|
|
14
|
+
onError: (code: string) => void;
|
|
15
|
+
maxEvents?: number;
|
|
16
|
+
maxBytes?: number;
|
|
17
|
+
batchEvents?: number;
|
|
18
|
+
batchBytes?: number;
|
|
19
|
+
flushMs?: number;
|
|
20
|
+
maxRetries?: number;
|
|
21
|
+
baseBackoffMs?: number;
|
|
22
|
+
maxBackoffMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class Exporter {
|
|
25
|
+
private queue;
|
|
26
|
+
private head;
|
|
27
|
+
private bytes;
|
|
28
|
+
private inFlight;
|
|
29
|
+
private timer;
|
|
30
|
+
private retryTimer;
|
|
31
|
+
private stopped;
|
|
32
|
+
/** Bumped by clear() (kill switch, revocation): a batch built under an older generation is never sent. */
|
|
33
|
+
private gen;
|
|
34
|
+
private readonly o;
|
|
35
|
+
constructor(opts: ExporterOptions);
|
|
36
|
+
get size(): {
|
|
37
|
+
events: number;
|
|
38
|
+
bytes: number;
|
|
39
|
+
};
|
|
40
|
+
setTarget(url: string, agent: https.Agent): void;
|
|
41
|
+
enqueue(e: QueuedEvent): void;
|
|
42
|
+
/** Drops everything queued (kill switch). */
|
|
43
|
+
clear(): void;
|
|
44
|
+
stop(): void;
|
|
45
|
+
/** Sends what is queued; resolves when the queue is empty or a send failed. */
|
|
46
|
+
flush(): Promise<void>;
|
|
47
|
+
private kick;
|
|
48
|
+
private takeBatch;
|
|
49
|
+
private sendOne;
|
|
50
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// OTLP/HTTP JSON exporter (event.md 4): bounded queue, batches of at most 512 events or
|
|
2
|
+
// 4 MiB, retry with exponential backoff and jitter on 429/502/503/504 and network errors
|
|
3
|
+
// (at most 5 times, Retry-After honoured), drop-oldest under pressure. The app never waits.
|
|
4
|
+
import { otlpBody } from './event.js';
|
|
5
|
+
import { request } from '../fleet/http.js';
|
|
6
|
+
import { debug } from '../log.js';
|
|
7
|
+
const sizeOf = (e) => e.size ?? e.json.length;
|
|
8
|
+
const RETRYABLE = new Set([429, 502, 503, 504]);
|
|
9
|
+
const DROP = new Set([400, 401, 403, 413]);
|
|
10
|
+
export class Exporter {
|
|
11
|
+
queue = [];
|
|
12
|
+
head = 0;
|
|
13
|
+
bytes = 0;
|
|
14
|
+
inFlight = false;
|
|
15
|
+
timer;
|
|
16
|
+
retryTimer;
|
|
17
|
+
stopped = false;
|
|
18
|
+
/** Bumped by clear() (kill switch, revocation): a batch built under an older generation is never sent. */
|
|
19
|
+
gen = 0;
|
|
20
|
+
o;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.o = {
|
|
23
|
+
maxEvents: 10_000,
|
|
24
|
+
maxBytes: 16 << 20,
|
|
25
|
+
batchEvents: 512,
|
|
26
|
+
batchBytes: 4 << 20,
|
|
27
|
+
flushMs: 1000,
|
|
28
|
+
maxRetries: 5,
|
|
29
|
+
baseBackoffMs: 1000,
|
|
30
|
+
maxBackoffMs: 30_000,
|
|
31
|
+
...opts,
|
|
32
|
+
};
|
|
33
|
+
this.timer = setInterval(() => this.kick(), this.o.flushMs);
|
|
34
|
+
this.timer.unref();
|
|
35
|
+
}
|
|
36
|
+
get size() {
|
|
37
|
+
return { events: this.queue.length - this.head, bytes: this.bytes };
|
|
38
|
+
}
|
|
39
|
+
setTarget(url, agent) {
|
|
40
|
+
this.o.url = url;
|
|
41
|
+
this.o.agent = agent;
|
|
42
|
+
}
|
|
43
|
+
enqueue(e) {
|
|
44
|
+
if (this.stopped)
|
|
45
|
+
return;
|
|
46
|
+
this.queue.push(e);
|
|
47
|
+
this.bytes += sizeOf(e);
|
|
48
|
+
while (this.queue.length - this.head > this.o.maxEvents || this.bytes > this.o.maxBytes) {
|
|
49
|
+
const old = this.queue[this.head++];
|
|
50
|
+
this.bytes -= sizeOf(old);
|
|
51
|
+
this.o.counters.events_dropped_queue++;
|
|
52
|
+
}
|
|
53
|
+
if (this.head > 4096 && this.head * 2 > this.queue.length) {
|
|
54
|
+
this.queue = this.queue.slice(this.head);
|
|
55
|
+
this.head = 0;
|
|
56
|
+
}
|
|
57
|
+
if (this.queue.length - this.head >= this.o.batchEvents)
|
|
58
|
+
setImmediate(() => this.kick());
|
|
59
|
+
}
|
|
60
|
+
/** Drops everything queued (kill switch). */
|
|
61
|
+
clear() {
|
|
62
|
+
this.gen++;
|
|
63
|
+
this.o.counters.events_dropped_queue += this.queue.length - this.head;
|
|
64
|
+
this.queue = [];
|
|
65
|
+
this.head = 0;
|
|
66
|
+
this.bytes = 0;
|
|
67
|
+
}
|
|
68
|
+
stop() {
|
|
69
|
+
this.stopped = true;
|
|
70
|
+
if (this.timer)
|
|
71
|
+
clearInterval(this.timer);
|
|
72
|
+
if (this.retryTimer)
|
|
73
|
+
clearTimeout(this.retryTimer);
|
|
74
|
+
}
|
|
75
|
+
/** Sends what is queued; resolves when the queue is empty or a send failed. */
|
|
76
|
+
async flush() {
|
|
77
|
+
for (let i = 0; i < 1000 && this.queue.length - this.head > 0 && !this.stopped; i++) {
|
|
78
|
+
if (this.inFlight) {
|
|
79
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const ok = await this.sendOne(false);
|
|
83
|
+
if (!ok)
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
kick() {
|
|
88
|
+
if (this.inFlight || this.stopped || this.retryTimer || this.queue.length - this.head === 0)
|
|
89
|
+
return;
|
|
90
|
+
void this.sendOne(true);
|
|
91
|
+
}
|
|
92
|
+
takeBatch() {
|
|
93
|
+
const batch = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
while (this.head < this.queue.length && batch.length < this.o.batchEvents) {
|
|
96
|
+
const e = this.queue[this.head];
|
|
97
|
+
const sz = sizeOf(e);
|
|
98
|
+
if (batch.length > 0 && size + sz > this.o.batchBytes)
|
|
99
|
+
break;
|
|
100
|
+
batch.push(e);
|
|
101
|
+
size += sz;
|
|
102
|
+
this.head++;
|
|
103
|
+
this.bytes -= sz;
|
|
104
|
+
}
|
|
105
|
+
return batch;
|
|
106
|
+
}
|
|
107
|
+
async sendOne(retryLater) {
|
|
108
|
+
this.inFlight = true;
|
|
109
|
+
const batch = this.takeBatch();
|
|
110
|
+
const gen = this.gen;
|
|
111
|
+
try {
|
|
112
|
+
for (let attempt = 0;; attempt++) {
|
|
113
|
+
// Kill switch or revocation while this batch was held (first attempt or between
|
|
114
|
+
// retries, Retry-After included): it is dropped, never sent.
|
|
115
|
+
if (gen !== this.gen) {
|
|
116
|
+
this.o.counters.events_dropped_queue += batch.length;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
let res;
|
|
120
|
+
let netErr = false;
|
|
121
|
+
try {
|
|
122
|
+
res = await request({
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url: this.o.url,
|
|
125
|
+
agent: this.o.agent,
|
|
126
|
+
headers: { 'content-type': 'application/json' },
|
|
127
|
+
body: otlpBody(batch, this.o.resource, Date.now()),
|
|
128
|
+
timeoutMs: 15_000,
|
|
129
|
+
maxResponseBytes: 64 << 10,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
netErr = true;
|
|
134
|
+
}
|
|
135
|
+
if (res && res.status >= 200 && res.status < 300) {
|
|
136
|
+
let rejected = 0;
|
|
137
|
+
try {
|
|
138
|
+
const j = JSON.parse(res.body.toString('utf8') || '{}');
|
|
139
|
+
rejected = Number(j.partialSuccess?.rejectedLogRecords ?? 0) || 0;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
rejected = 0;
|
|
143
|
+
}
|
|
144
|
+
rejected = Math.min(Math.max(0, rejected), batch.length);
|
|
145
|
+
this.o.counters.events_sent += batch.length - rejected;
|
|
146
|
+
this.o.counters.events_rejected += rejected;
|
|
147
|
+
if (rejected)
|
|
148
|
+
this.o.onError('intake_partial_reject');
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const retryable = netErr || (res !== undefined && RETRYABLE.has(res.status));
|
|
152
|
+
if (!retryable || attempt >= this.o.maxRetries || this.stopped) {
|
|
153
|
+
if (res && DROP.has(res.status))
|
|
154
|
+
this.o.counters.events_rejected += batch.length;
|
|
155
|
+
else
|
|
156
|
+
this.o.counters.events_dropped_queue += batch.length;
|
|
157
|
+
this.o.counters.send_errors++;
|
|
158
|
+
this.o.onError(res ? 'intake_http_' + res.status : 'intake_network');
|
|
159
|
+
debug('export_drop', res ? res.status : 'network');
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
this.o.counters.send_errors++;
|
|
163
|
+
let delay = Math.min(this.o.maxBackoffMs, this.o.baseBackoffMs * 2 ** attempt);
|
|
164
|
+
delay = delay / 2 + Math.random() * (delay / 2);
|
|
165
|
+
const ra = res?.headers['retry-after'];
|
|
166
|
+
if (typeof ra === 'string') {
|
|
167
|
+
const secs = /^[0-9]+$/.test(ra.trim()) ? Number(ra) * 1000 : Date.parse(ra) - Date.now();
|
|
168
|
+
if (Number.isFinite(secs) && secs > 0)
|
|
169
|
+
delay = Math.min(Math.max(delay, secs), 300_000);
|
|
170
|
+
}
|
|
171
|
+
await new Promise((r) => {
|
|
172
|
+
this.retryTimer = setTimeout(() => {
|
|
173
|
+
this.retryTimer = undefined;
|
|
174
|
+
r();
|
|
175
|
+
}, delay);
|
|
176
|
+
this.retryTimer.unref();
|
|
177
|
+
});
|
|
178
|
+
if (this.stopped)
|
|
179
|
+
return false;
|
|
180
|
+
void retryLater;
|
|
181
|
+
// (the generation is checked again at the top of the loop)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
this.inFlight = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type WireExchange } from '../capture/wire.js';
|
|
2
|
+
import type { Decision, NetError, Sanitizer } from '../sanitizer/sanitizer.js';
|
|
3
|
+
/** Per-exchange facts the source fills in (event.md 1). */
|
|
4
|
+
export interface JobMeta {
|
|
5
|
+
dir: 'inbound' | 'outbound';
|
|
6
|
+
tsMs: number;
|
|
7
|
+
latencyMs: number;
|
|
8
|
+
status?: number;
|
|
9
|
+
netError?: NetError;
|
|
10
|
+
traceId?: string;
|
|
11
|
+
spanId?: string;
|
|
12
|
+
parentSpanId?: string;
|
|
13
|
+
}
|
|
14
|
+
export type JobResult = {
|
|
15
|
+
record: string;
|
|
16
|
+
} | {
|
|
17
|
+
err: 'event_too_large' | 'event_invalid_or_too_large' | 'sanitizer_error';
|
|
18
|
+
} | null;
|
|
19
|
+
/** Null when the exchange turned out not to be sampled. Never throws. */
|
|
20
|
+
export declare function sanitizeToRecord(san: Sanitizer, wire: WireExchange, decision: Decision, meta: JobMeta, sourceId: string, nowMs: number): JobResult;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The off-path half of one exchange: wire form -> sanitizer -> canonical event -> OTLP
|
|
2
|
+
// logRecord. Used both inline (main thread) and by the sanitizer worker threads, so the
|
|
3
|
+
// output is the same bytes either way.
|
|
4
|
+
import { wireToExchange } from '../capture/wire.js';
|
|
5
|
+
import { buildEvent, makeRecord, serializedParts } from './event.js';
|
|
6
|
+
/** Null when the exchange turned out not to be sampled. Never throws. */
|
|
7
|
+
export function sanitizeToRecord(san, wire, decision, meta, sourceId, nowMs) {
|
|
8
|
+
try {
|
|
9
|
+
const r = san.run(wireToExchange(wire), nowMs, decision);
|
|
10
|
+
if (r.outcome === 'not_sampled')
|
|
11
|
+
return null;
|
|
12
|
+
if (r.outcome === 'too_large')
|
|
13
|
+
return { err: 'event_too_large' };
|
|
14
|
+
const p = san.policy;
|
|
15
|
+
const json = buildEvent(serializedParts(r.parts), {
|
|
16
|
+
policyId: p.policyId,
|
|
17
|
+
policyVersion: p.version,
|
|
18
|
+
policyHash: p.hash,
|
|
19
|
+
sourceId,
|
|
20
|
+
dir: meta.dir,
|
|
21
|
+
tsMs: meta.tsMs,
|
|
22
|
+
latencyMs: meta.latencyMs,
|
|
23
|
+
status: meta.status,
|
|
24
|
+
netError: meta.netError,
|
|
25
|
+
traceId: meta.traceId,
|
|
26
|
+
spanId: meta.spanId,
|
|
27
|
+
parentSpanId: meta.parentSpanId,
|
|
28
|
+
});
|
|
29
|
+
if (json === null)
|
|
30
|
+
return { err: 'event_invalid_or_too_large' };
|
|
31
|
+
return { record: makeRecord({ json, tsMs: meta.tsMs, traceId: meta.traceId, spanId: meta.spanId, policyHash: p.hash }, Date.now()) };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// 0: an internal error never produces an event.
|
|
35
|
+
return { err: 'sanitizer_error' };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type KeyObject } from 'node:crypto';
|
|
2
|
+
export declare function tlv(tag: number, ...content: Buffer[]): Buffer;
|
|
3
|
+
export declare const seq: (...c: Buffer[]) => Buffer<ArrayBufferLike>;
|
|
4
|
+
export declare const set: (...c: Buffer[]) => Buffer<ArrayBufferLike>;
|
|
5
|
+
export declare const ctx: (n: number, ...c: Buffer[]) => Buffer<ArrayBufferLike>;
|
|
6
|
+
export declare const octets: (b: Buffer) => Buffer<ArrayBufferLike>;
|
|
7
|
+
export declare const bitString: (b: Buffer) => Buffer<ArrayBufferLike>;
|
|
8
|
+
export declare const utf8String: (s: string) => Buffer<ArrayBufferLike>;
|
|
9
|
+
export declare const bool: (v: boolean) => Buffer<ArrayBufferLike>;
|
|
10
|
+
export declare const nul: () => Buffer<ArrayBuffer>;
|
|
11
|
+
export declare function int(v: number | Buffer): Buffer;
|
|
12
|
+
export declare function oid(dotted: string): Buffer;
|
|
13
|
+
export declare const OID: {
|
|
14
|
+
commonName: string;
|
|
15
|
+
ecdsaWithSHA256: string;
|
|
16
|
+
ed25519: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function name(cn: string): Buffer;
|
|
19
|
+
export interface DerNode {
|
|
20
|
+
tag: number;
|
|
21
|
+
start: number;
|
|
22
|
+
header: number;
|
|
23
|
+
length: number;
|
|
24
|
+
raw: Buffer;
|
|
25
|
+
content: Buffer;
|
|
26
|
+
}
|
|
27
|
+
/** Reads one TLV at `off`. */
|
|
28
|
+
export declare function readTlv(buf: Buffer, off?: number): DerNode;
|
|
29
|
+
export declare function children(node: DerNode): DerNode[];
|
|
30
|
+
export declare function pem(label: string, der: Buffer): string;
|
|
31
|
+
export declare function pemBlocks(text: string, label: string): Buffer[];
|
|
32
|
+
/** Signs DER `data` with `key` and returns the AlgorithmIdentifier and the signature bits. */
|
|
33
|
+
export declare function signDer(data: Buffer, key: KeyObject): {
|
|
34
|
+
alg: Buffer;
|
|
35
|
+
sig: Buffer;
|
|
36
|
+
};
|
|
37
|
+
export interface GeneratedCsr {
|
|
38
|
+
csrPem: string;
|
|
39
|
+
keyPem: string;
|
|
40
|
+
}
|
|
41
|
+
/** Generates a key pair (default ECDSA P-256) and a PKCS#10 request with CN = commonName. */
|
|
42
|
+
export declare function generateCsr(commonName: string, kind?: 'ec' | 'ed25519'): GeneratedCsr;
|
|
43
|
+
/** base64url(SHA-256(DER)), the CA pin format of the enrollment token. */
|
|
44
|
+
export declare function pinOf(der: Buffer): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Tiny DER writer/reader, enough for a PKCS#10 certificate request (RFC 2986) with an
|
|
2
|
+
// ECDSA P-256 or Ed25519 key, without any dependency.
|
|
3
|
+
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|
4
|
+
function len(n) {
|
|
5
|
+
if (n < 0x80)
|
|
6
|
+
return Buffer.from([n]);
|
|
7
|
+
const bytes = [];
|
|
8
|
+
while (n > 0) {
|
|
9
|
+
bytes.unshift(n & 0xff);
|
|
10
|
+
n = Math.floor(n / 256);
|
|
11
|
+
}
|
|
12
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
13
|
+
}
|
|
14
|
+
export function tlv(tag, ...content) {
|
|
15
|
+
const c = Buffer.concat(content);
|
|
16
|
+
return Buffer.concat([Buffer.from([tag]), len(c.length), c]);
|
|
17
|
+
}
|
|
18
|
+
export const seq = (...c) => tlv(0x30, ...c);
|
|
19
|
+
export const set = (...c) => tlv(0x31, ...c);
|
|
20
|
+
export const ctx = (n, ...c) => tlv(0xa0 + n, ...c);
|
|
21
|
+
export const octets = (b) => tlv(0x04, b);
|
|
22
|
+
export const bitString = (b) => tlv(0x03, Buffer.from([0]), b);
|
|
23
|
+
export const utf8String = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
|
|
24
|
+
export const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
|
|
25
|
+
export const nul = () => Buffer.from([0x05, 0x00]);
|
|
26
|
+
export function int(v) {
|
|
27
|
+
let b;
|
|
28
|
+
if (typeof v === 'number') {
|
|
29
|
+
let h = v.toString(16);
|
|
30
|
+
if (h.length % 2)
|
|
31
|
+
h = '0' + h;
|
|
32
|
+
b = Buffer.from(h, 'hex');
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
b = v;
|
|
36
|
+
}
|
|
37
|
+
while (b.length > 1 && b[0] === 0 && b[1] < 0x80)
|
|
38
|
+
b = b.subarray(1);
|
|
39
|
+
if (b[0] >= 0x80)
|
|
40
|
+
b = Buffer.concat([Buffer.from([0]), b]);
|
|
41
|
+
return tlv(0x02, b);
|
|
42
|
+
}
|
|
43
|
+
export function oid(dotted) {
|
|
44
|
+
const parts = dotted.split('.').map(Number);
|
|
45
|
+
const out = [parts[0] * 40 + parts[1]];
|
|
46
|
+
for (const p of parts.slice(2)) {
|
|
47
|
+
const stack = [p & 0x7f];
|
|
48
|
+
let v = Math.floor(p / 128);
|
|
49
|
+
while (v > 0) {
|
|
50
|
+
stack.unshift((v & 0x7f) | 0x80);
|
|
51
|
+
v = Math.floor(v / 128);
|
|
52
|
+
}
|
|
53
|
+
out.push(...stack);
|
|
54
|
+
}
|
|
55
|
+
return tlv(0x06, Buffer.from(out));
|
|
56
|
+
}
|
|
57
|
+
export const OID = {
|
|
58
|
+
commonName: '2.5.4.3',
|
|
59
|
+
ecdsaWithSHA256: '1.2.840.10045.4.3.2',
|
|
60
|
+
ed25519: '1.3.101.112',
|
|
61
|
+
};
|
|
62
|
+
export function name(cn) {
|
|
63
|
+
return seq(set(seq(oid(OID.commonName), utf8String(cn))));
|
|
64
|
+
}
|
|
65
|
+
/** Reads one TLV at `off`. */
|
|
66
|
+
export function readTlv(buf, off = 0) {
|
|
67
|
+
const tag = buf[off];
|
|
68
|
+
let l = buf[off + 1];
|
|
69
|
+
let header = 2;
|
|
70
|
+
if (l & 0x80) {
|
|
71
|
+
const n = l & 0x7f;
|
|
72
|
+
if (n === 0 || n > 4)
|
|
73
|
+
throw new Error('der length');
|
|
74
|
+
l = 0;
|
|
75
|
+
for (let i = 0; i < n; i++)
|
|
76
|
+
l = l * 256 + buf[off + 2 + i];
|
|
77
|
+
header += n;
|
|
78
|
+
}
|
|
79
|
+
if (off + header + l > buf.length)
|
|
80
|
+
throw new Error('der overflow');
|
|
81
|
+
return { tag, start: off, header, length: l, raw: buf.subarray(off, off + header + l), content: buf.subarray(off + header, off + header + l) };
|
|
82
|
+
}
|
|
83
|
+
export function children(node) {
|
|
84
|
+
const out = [];
|
|
85
|
+
let off = 0;
|
|
86
|
+
while (off < node.content.length) {
|
|
87
|
+
const c = readTlv(node.content, off);
|
|
88
|
+
out.push(c);
|
|
89
|
+
off += c.header + c.length;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export function pem(label, der) {
|
|
94
|
+
const b64 = der.toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '');
|
|
95
|
+
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`;
|
|
96
|
+
}
|
|
97
|
+
export function pemBlocks(text, label) {
|
|
98
|
+
const re = new RegExp(`-----BEGIN ${label}-----([\\s\\S]*?)-----END ${label}-----`, 'g');
|
|
99
|
+
const out = [];
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = re.exec(text)))
|
|
102
|
+
out.push(Buffer.from(m[1].replace(/\s+/g, ''), 'base64'));
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** Signs DER `data` with `key` and returns the AlgorithmIdentifier and the signature bits. */
|
|
106
|
+
export function signDer(data, key) {
|
|
107
|
+
if (key.asymmetricKeyType === 'ed25519')
|
|
108
|
+
return { alg: seq(oid(OID.ed25519)), sig: sign(null, data, key) };
|
|
109
|
+
return { alg: seq(oid(OID.ecdsaWithSHA256)), sig: sign('sha256', data, key) };
|
|
110
|
+
}
|
|
111
|
+
/** Generates a key pair (default ECDSA P-256) and a PKCS#10 request with CN = commonName. */
|
|
112
|
+
export function generateCsr(commonName, kind = 'ec') {
|
|
113
|
+
const { publicKey, privateKey } = kind === 'ed25519'
|
|
114
|
+
? generateKeyPairSync('ed25519')
|
|
115
|
+
: generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
116
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' });
|
|
117
|
+
const info = seq(int(0), name(commonName), spki, ctx(0));
|
|
118
|
+
const { alg, sig } = signDer(info, privateKey);
|
|
119
|
+
const csr = seq(info, alg, bitString(sig));
|
|
120
|
+
return {
|
|
121
|
+
csrPem: pem('CERTIFICATE REQUEST', csr),
|
|
122
|
+
keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** base64url(SHA-256(DER)), the CA pin format of the enrollment token. */
|
|
126
|
+
export function pinOf(der) {
|
|
127
|
+
return createHash('sha256').update(der).digest('base64url');
|
|
128
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
export declare const TOKEN_RE: RegExp;
|
|
3
|
+
export interface SigningKey {
|
|
4
|
+
kid: string;
|
|
5
|
+
alg: 'Ed25519';
|
|
6
|
+
public_key: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Enrollment {
|
|
9
|
+
source_id: string;
|
|
10
|
+
group: string;
|
|
11
|
+
service_name: string;
|
|
12
|
+
client_cert: string;
|
|
13
|
+
ca_chain: string;
|
|
14
|
+
not_after?: string;
|
|
15
|
+
policy_signing_keys: SigningKey[];
|
|
16
|
+
intake_url: string;
|
|
17
|
+
fleet_url: string;
|
|
18
|
+
policy_max_age_s: number;
|
|
19
|
+
heartbeat_interval_s: number;
|
|
20
|
+
policy_poll_interval_s: number;
|
|
21
|
+
/** Local bookkeeping. */
|
|
22
|
+
enroll_url: string;
|
|
23
|
+
pin: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SourceInfo {
|
|
26
|
+
kind: string;
|
|
27
|
+
version: string;
|
|
28
|
+
service_name: string;
|
|
29
|
+
instance?: string;
|
|
30
|
+
runtime?: {
|
|
31
|
+
lang: string;
|
|
32
|
+
lang_version: string;
|
|
33
|
+
os: string;
|
|
34
|
+
arch: string;
|
|
35
|
+
};
|
|
36
|
+
hmac_kids?: string[];
|
|
37
|
+
}
|
|
38
|
+
export declare class FleetError extends Error {
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly status?: number | undefined;
|
|
41
|
+
constructor(code: string, status?: number | undefined);
|
|
42
|
+
}
|
|
43
|
+
/** Extracts the CA pin from an enrollment token. */
|
|
44
|
+
export declare function tokenPin(token: string): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Opens a TLS connection without trusting anything, walks the chain the server presents
|
|
47
|
+
* to its top (the root, which tt-core must include) and checks it against the pin.
|
|
48
|
+
* Returns the root as PEM, to be used as the only trust anchor for the real request.
|
|
49
|
+
*/
|
|
50
|
+
export declare function fetchPinnedRoot(url: URL, pin: string, timeoutMs?: number): Promise<string>;
|
|
51
|
+
/** Enrolls with a token. Returns the enrollment and the private key PEM (generated locally when useCsr). */
|
|
52
|
+
export declare function enroll(o: {
|
|
53
|
+
enrollUrl: string;
|
|
54
|
+
token: string;
|
|
55
|
+
source: SourceInfo;
|
|
56
|
+
useCsr: boolean;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}): Promise<{
|
|
59
|
+
enrollment: Enrollment;
|
|
60
|
+
keyPem: string;
|
|
61
|
+
}>;
|
|
62
|
+
/** mTLS agent for the intake and Fleet calls. Trust anchors: the CA chain received over the pinned channel. */
|
|
63
|
+
export declare function mtlsAgent(e: Enrollment, keyPem: string): https.Agent;
|
|
64
|
+
export declare class FleetClient {
|
|
65
|
+
private e;
|
|
66
|
+
private agent;
|
|
67
|
+
constructor(e: Enrollment, agent: https.Agent);
|
|
68
|
+
setAgent(agent: https.Agent): void;
|
|
69
|
+
private url;
|
|
70
|
+
heartbeat(body: unknown): Promise<{
|
|
71
|
+
status: number;
|
|
72
|
+
json?: Record<string, unknown>;
|
|
73
|
+
}>;
|
|
74
|
+
getPolicy(etag?: string): Promise<{
|
|
75
|
+
status: number;
|
|
76
|
+
body?: Buffer;
|
|
77
|
+
etag?: string;
|
|
78
|
+
}>;
|
|
79
|
+
renew(useCsr: boolean, commonName: string): Promise<{
|
|
80
|
+
cert: string;
|
|
81
|
+
keyPem?: string;
|
|
82
|
+
notAfter?: string;
|
|
83
|
+
} | null>;
|
|
84
|
+
}
|