@camstack/server 1.2.563 → 1.2.565

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.
@@ -57,6 +57,7 @@ const fs = __importStar(require("node:fs"));
57
57
  const path = __importStar(require("node:path"));
58
58
  const fastify_1 = __importDefault(require("fastify"));
59
59
  const system_1 = require("@camstack/system");
60
+ const agent_sniffing_listener_js_1 = require("./agent-sniffing-listener.js");
60
61
  const agent_http_auth_js_1 = require("./agent-http-auth.js");
61
62
  const node_1 = require("@camstack/types/node");
62
63
  const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
@@ -619,13 +620,33 @@ async function createAgentHttpServer(getBroker, config) {
619
620
  // ---------------------------------------------------------------------------
620
621
  // Start helper
621
622
  // ---------------------------------------------------------------------------
623
+ /**
624
+ * Start the agent API on its port, which also speaks TLS (D856): the first byte
625
+ * of a connection picks the protocol, so there is no second port. Resolves with
626
+ * the listener (to hot-swap the certificate), or `null` when it failed to start.
627
+ */
622
628
  async function startAgentHttpServer(getBroker, config) {
623
629
  try {
624
630
  const app = await createAgentHttpServer(getBroker, config);
625
- await app.listen({ port: config.port, host: '0.0.0.0' });
626
- console.log(`[Agent] HTTP server: http://localhost:${config.port}`);
631
+ await app.ready();
632
+ const material = (0, system_1.loadAgentHttps)(config.dataDir, (reason) => config.log?.error('The HTTPS bundle on disk is unusable - serving plain HTTP only', {
633
+ meta: { reason },
634
+ }));
635
+ const listener = await (0, agent_sniffing_listener_js_1.startSniffingListener)({
636
+ httpServer: app.server,
637
+ port: config.port,
638
+ host: '0.0.0.0',
639
+ material: material === null ? null : { keyPem: material.keyPem, certPem: material.certPem },
640
+ ...(config.log !== undefined ? { log: config.log } : {}),
641
+ onTlsRefused: () => config.log?.warn('A TLS client reached the agent port but no HTTPS certificate is installed yet - closed'),
642
+ });
643
+ config.log?.info('Agent HTTP server listening', {
644
+ meta: { port: config.port, https: material !== null },
645
+ });
646
+ return listener;
627
647
  }
628
648
  catch (err) {
629
649
  console.warn(`[Agent] HTTP server failed to start on port ${config.port}:`, err);
650
+ return null;
630
651
  }
631
652
  }
@@ -37,6 +37,8 @@ exports.MeshEnrollError = void 0;
37
37
  exports.decodeJoinTokenClaims = decodeJoinTokenClaims;
38
38
  exports.hubHostFromMeshAddress = hubHostFromMeshAddress;
39
39
  exports.enrollAgentMesh = enrollAgentMesh;
40
+ exports.readJsonFile = readJsonFile;
41
+ exports.writeJsonFile0600 = writeJsonFile0600;
40
42
  exports.persistEnrollment = persistEnrollment;
41
43
  exports.discardPendingJoinToken = discardPendingJoinToken;
42
44
  exports.enrollAndPersist = enrollAndPersist;
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAgentNodeCertificates = createAgentNodeCertificates;
4
+ /**
5
+ * The agent's side of certificate management (D856): the `node-certificates`
6
+ * capability, provided by the agent runtime and called by the hub through the
7
+ * ordinary node-pinned capability call.
8
+ *
9
+ * MESH leaf. The hub asks for a CSR; this node makes a NEW key pair (the key never
10
+ * leaves it) and keeps it in memory only. When the signed leaf arrives it is persisted
11
+ * into `agent.json` (0600) BESIDE the old one (`meshTlsPrevious`) together with the
12
+ * swap's state (`meshSwap`: which leaf, until when), then the agent reconnects through
13
+ * the same `reconnect()` the UI uses, which rebuilds the broker from the file.
14
+ *
15
+ * A swap is PENDING from install until the hub confirms. Only while it is pending may
16
+ * the node go back, and only to a previous leaf that is still unexpired:
17
+ * - the swap state is in the file, not in memory, so it survives a restart: at boot a
18
+ * swap whose deadline is still ahead re-arms its rollback timer; one whose deadline
19
+ * has passed is settled (the current leaf stays, the leftovers are dropped);
20
+ * - a `meshTlsPrevious` with no pending swap is a leftover and is deleted at boot and on
21
+ * the next renewal, so a failed renewal can never restore a stale or expired leaf.
22
+ *
23
+ * HTTPS leaf. Same shape: key pair + CSR here, signed public key back, ONE atomic file
24
+ * under `<dataDir>/tls`, hot-swapped into the sniffing listener.
25
+ */
26
+ const node_crypto_1 = require("node:crypto");
27
+ const system_1 = require("@camstack/system");
28
+ const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
29
+ /** Let the capability answer before the broker is torn down and rebuilt. */
30
+ const RECONNECT_DELAY_MS = 400;
31
+ /** A swap found pending at boot gets at least this long for the hub to confirm. */
32
+ const MIN_REARM_MS = 10_000;
33
+ function isMeshMaterial(value) {
34
+ return (value !== null &&
35
+ typeof value === 'object' &&
36
+ typeof Reflect.get(value, 'certPem') === 'string' &&
37
+ typeof Reflect.get(value, 'keyPem') === 'string' &&
38
+ typeof Reflect.get(value, 'caCertPem') === 'string');
39
+ }
40
+ function isMeshSwap(value) {
41
+ return (value !== null &&
42
+ typeof value === 'object' &&
43
+ typeof Reflect.get(value, 'fingerprint') === 'string' &&
44
+ typeof Reflect.get(value, 'deadline') === 'number');
45
+ }
46
+ function defaultSchedule(fn, ms) {
47
+ const timer = setTimeout(fn, ms);
48
+ timer.unref?.();
49
+ return { cancel: () => clearTimeout(timer) };
50
+ }
51
+ function createAgentNodeCertificates(deps) {
52
+ const { nodeId, log } = deps;
53
+ const tags = { nodeId };
54
+ const schedule = deps.schedule ?? defaultSchedule;
55
+ const now = deps.now ?? Date.now;
56
+ let pendingKeyPem = null;
57
+ let rollbackTimer = null;
58
+ const readMesh = () => {
59
+ const raw = (0, agent_mesh_enroll_js_1.readJsonFile)(deps.configPath);
60
+ return {
61
+ current: isMeshMaterial(raw['meshTls']) ? raw['meshTls'] : null,
62
+ previous: isMeshMaterial(raw['meshTlsPrevious']) ? raw['meshTlsPrevious'] : null,
63
+ swap: isMeshSwap(raw['meshSwap']) ? raw['meshSwap'] : null,
64
+ };
65
+ };
66
+ const stillValid = (material) => {
67
+ try {
68
+ return (0, system_1.describeMeshLeaf)(material.certPem).notAfter > now();
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ };
74
+ /** Write `agent.json` with the swap leftovers removed and `meshTls` optionally replaced. */
75
+ const writeSettled = (replaceCurrent) => {
76
+ const raw = (0, agent_mesh_enroll_js_1.readJsonFile)(deps.configPath);
77
+ const { meshTlsPrevious: _p, meshSwap: _s, ...rest } = raw;
78
+ (0, agent_mesh_enroll_js_1.writeJsonFile0600)(deps.configPath, {
79
+ ...rest,
80
+ ...(replaceCurrent !== undefined ? { meshTls: replaceCurrent } : {}),
81
+ });
82
+ };
83
+ const reconnectSoon = () => {
84
+ schedule(() => {
85
+ deps.reconnect().catch((error) => {
86
+ log.error('Mesh leaf swap: reconnect failed', {
87
+ tags,
88
+ meta: { error: error instanceof Error ? error.message : String(error) },
89
+ });
90
+ });
91
+ }, RECONNECT_DELAY_MS);
92
+ };
93
+ /**
94
+ * Go back to the previous leaf. Only while a swap is PENDING and only to a leaf that is
95
+ * still unexpired; otherwise the file is settled as it is and `false` is returned.
96
+ */
97
+ const revert = (why) => {
98
+ const { current, previous, swap } = readMesh();
99
+ rollbackTimer?.cancel();
100
+ rollbackTimer = null;
101
+ if (swap === null) {
102
+ log.warn('Mesh leaf rollback refused: no swap is pending', { tags, meta: { why } });
103
+ if (previous !== null)
104
+ writeSettled(); // a leftover; never a rollback target
105
+ return false;
106
+ }
107
+ if (previous === null || !stillValid(previous)) {
108
+ log.warn('Mesh leaf rollback refused: the previous leaf is gone or expired - keeping the current one', {
109
+ tags,
110
+ meta: { why, currentValid: current !== null && stillValid(current) },
111
+ });
112
+ writeSettled();
113
+ return false;
114
+ }
115
+ writeSettled(previous);
116
+ log.warn('Mesh leaf swap: reverted to the previous leaf', { tags, meta: { why } });
117
+ reconnectSoon();
118
+ return true;
119
+ };
120
+ const armRollback = (ms) => {
121
+ rollbackTimer?.cancel();
122
+ rollbackTimer = schedule(() => {
123
+ revert('the hub did not confirm the new leaf in time');
124
+ }, ms);
125
+ };
126
+ // Boot: decide which leaf is current and drop whatever a past swap left behind.
127
+ const settleAtBoot = () => {
128
+ const { current, previous, swap } = readMesh();
129
+ if (previous === null && swap === null)
130
+ return;
131
+ if (swap !== null && previous !== null && swap.deadline > now()) {
132
+ armRollback(Math.max(MIN_REARM_MS, swap.deadline - now()));
133
+ log.warn('Mesh leaf swap was pending at boot - waiting for the hub to confirm', {
134
+ tags,
135
+ meta: { remainingMs: swap.deadline - now() },
136
+ });
137
+ return;
138
+ }
139
+ if (current !== null && stillValid(current)) {
140
+ writeSettled();
141
+ log.info('Mesh leaf swap leftovers removed at boot (the current leaf is valid)', { tags });
142
+ return;
143
+ }
144
+ if (previous !== null && stillValid(previous)) {
145
+ writeSettled(previous);
146
+ log.warn('The current mesh leaf is expired and the previous one is valid - restored it', {
147
+ tags,
148
+ });
149
+ reconnectSoon();
150
+ return;
151
+ }
152
+ writeSettled();
153
+ log.error('No valid mesh leaf on this node: renew it from the hub or re-join with a join token', {
154
+ tags,
155
+ });
156
+ };
157
+ settleAtBoot();
158
+ return {
159
+ getCertificates: async () => {
160
+ const { current } = readMesh();
161
+ const https = (0, system_1.loadAgentHttps)(deps.dataDir, (reason) => log.error('The HTTPS bundle on disk is unusable - serving plain HTTP only', {
162
+ tags,
163
+ meta: { reason },
164
+ }));
165
+ let mesh = null;
166
+ if (current !== null) {
167
+ const leaf = (0, system_1.describeMeshLeaf)(current.certPem);
168
+ mesh = {
169
+ cn: leaf.cn,
170
+ serial: leaf.serial,
171
+ fingerprintSha256: leaf.fingerprint,
172
+ notBefore: leaf.notBefore,
173
+ notAfter: leaf.notAfter,
174
+ };
175
+ }
176
+ return {
177
+ nodeId,
178
+ mesh,
179
+ https: https === null
180
+ ? {
181
+ state: 'not-issued',
182
+ reason: 'No hub-issued HTTPS certificate has been installed on this node yet.',
183
+ }
184
+ : { state: 'served', leaf: (0, system_1.describeHttpsLeaf)(https.certPem) },
185
+ };
186
+ },
187
+ prepareMeshRenewal: async () => {
188
+ const { current, swap } = readMesh();
189
+ if (current === null) {
190
+ throw new Error('this node is not enrolled on mesh mTLS - there is no leaf to renew');
191
+ }
192
+ if (swap !== null && swap.deadline > now()) {
193
+ throw new Error('a mesh leaf swap is already pending on this node');
194
+ }
195
+ // A leftover from a finished or failed swap is never a rollback target.
196
+ writeSettled();
197
+ const keys = await (0, system_1.generateMeshKeyPair)();
198
+ pendingKeyPem = await (0, system_1.exportMeshPrivateKeyPem)(keys.privateKey);
199
+ const csrPem = await (0, system_1.createMeshCsrPem)(keys, nodeId);
200
+ log.info('Mesh leaf renewal: new key and CSR generated', { tags });
201
+ return { csrPem };
202
+ },
203
+ installMeshLeaf: async (input) => {
204
+ const { current } = readMesh();
205
+ if (current === null)
206
+ throw new Error('this node is not enrolled on mesh mTLS');
207
+ if (pendingKeyPem === null) {
208
+ throw new Error('no pending key: prepareMeshRenewal was not called (or the node restarted)');
209
+ }
210
+ const leaf = new node_crypto_1.X509Certificate(input.certPem);
211
+ const ca = new node_crypto_1.X509Certificate(input.caCertPem);
212
+ const currentCa = new node_crypto_1.X509Certificate(current.caCertPem);
213
+ if (ca.fingerprint256 !== currentCa.fingerprint256) {
214
+ throw new Error('the CA that came with the new leaf is not the CA this node trusts');
215
+ }
216
+ if (!leaf.verify(ca.publicKey))
217
+ throw new Error('the new leaf is not signed by that CA');
218
+ if (!new RegExp(`(?:^|\\n)CN=${nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\n|$)`).test(leaf.subject)) {
219
+ throw new Error(`the new leaf is not issued for "${nodeId}"`);
220
+ }
221
+ if (!leaf.checkPrivateKey((0, node_crypto_1.createPrivateKey)(pendingKeyPem))) {
222
+ throw new Error('the new leaf does not match the key this node generated');
223
+ }
224
+ const raw = (0, agent_mesh_enroll_js_1.readJsonFile)(deps.configPath);
225
+ const swap = {
226
+ fingerprint: leaf.fingerprint256,
227
+ deadline: now() + input.confirmWithinMs,
228
+ };
229
+ (0, agent_mesh_enroll_js_1.writeJsonFile0600)(deps.configPath, {
230
+ ...raw,
231
+ meshTls: { certPem: input.certPem, keyPem: pendingKeyPem, caCertPem: current.caCertPem },
232
+ meshTlsPrevious: current,
233
+ meshSwap: swap,
234
+ });
235
+ pendingKeyPem = null;
236
+ armRollback(input.confirmWithinMs);
237
+ log.info('Mesh leaf swap: new leaf persisted, reconnecting', {
238
+ tags,
239
+ meta: { serial: leaf.serialNumber, confirmWithinMs: input.confirmWithinMs },
240
+ });
241
+ reconnectSoon();
242
+ return { fingerprintSha256: leaf.fingerprint256, serial: leaf.serialNumber };
243
+ },
244
+ confirmMeshLeaf: async (input) => {
245
+ const { swap } = readMesh();
246
+ if (swap === null || swap.fingerprint !== input.fingerprintSha256) {
247
+ return { confirmed: false };
248
+ }
249
+ rollbackTimer?.cancel();
250
+ rollbackTimer = null;
251
+ writeSettled();
252
+ log.info('Mesh leaf swap: confirmed by the hub, previous leaf forgotten', { tags });
253
+ return { confirmed: true };
254
+ },
255
+ rollbackMeshLeaf: async () => ({ rolledBack: revert('the hub asked for a rollback') }),
256
+ prepareHttpsCertificate: async () => {
257
+ const names = (0, system_1.collectAgentNames)(nodeId, deps.hostFacts);
258
+ const request = await (0, system_1.createAgentHttpsRequest)(deps.dataDir, nodeId, names);
259
+ log.info('HTTPS certificate: new key and CSR generated', {
260
+ tags,
261
+ meta: { hostnames: names.hostnames.length, addresses: names.addresses.length },
262
+ });
263
+ return {
264
+ csrPem: request.csrPem,
265
+ hostnames: [...names.hostnames],
266
+ addresses: [...names.addresses],
267
+ pinnedCaFingerprintSha256: (0, system_1.pinnedHttpsCaFingerprint)(deps.dataDir),
268
+ };
269
+ },
270
+ installHttpsCertificate: async (input) => {
271
+ const material = (0, system_1.installAgentHttps)(deps.dataDir, input);
272
+ const listener = deps.getHttpsListener();
273
+ if (listener === null) {
274
+ log.warn('HTTPS certificate installed on disk but the agent HTTP listener is not up', {
275
+ tags,
276
+ });
277
+ }
278
+ else if (!listener.setMaterial({ keyPem: material.keyPem, certPem: material.certPem })) {
279
+ log.error('The new HTTPS certificate was installed but the listener refused it - the previous one keeps serving', {
280
+ tags,
281
+ });
282
+ }
283
+ const leaf = (0, system_1.describeHttpsLeaf)(material.certPem);
284
+ log.info('HTTPS certificate installed and hot-swapped', {
285
+ tags,
286
+ meta: {
287
+ fingerprint: leaf.fingerprintSha256,
288
+ notAfter: new Date(leaf.notAfter).toISOString(),
289
+ },
290
+ });
291
+ return leaf;
292
+ },
293
+ };
294
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createReconnectRunner = createReconnectRunner;
4
+ function createReconnectRunner(deps) {
5
+ let generation = 0;
6
+ return async () => {
7
+ const mine = ++generation;
8
+ const superseded = () => mine !== generation;
9
+ try {
10
+ await deps.stop(deps.current());
11
+ }
12
+ catch {
13
+ /* already stopped */
14
+ }
15
+ if (superseded()) {
16
+ deps.onSuperseded?.('before-build');
17
+ return;
18
+ }
19
+ deps.prepare();
20
+ const next = deps.build();
21
+ if (superseded()) {
22
+ deps.onSuperseded?.('after-build');
23
+ await deps.stop(next).catch(() => undefined);
24
+ return;
25
+ }
26
+ deps.assign(next);
27
+ await deps.start(next);
28
+ if (superseded()) {
29
+ // A newer run replaced `next` while it was starting: do not leave it running.
30
+ deps.onSuperseded?.('after-start');
31
+ await deps.stop(next).catch(() => undefined);
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,263 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.HANDSHAKE_TIMEOUT_MS = exports.SNIFF_TIMEOUT_MS = exports.TLS_HANDSHAKE_RECORD = void 0;
37
+ exports.startSniffingListener = startSniffingListener;
38
+ /**
39
+ * One port, two protocols (D856): the agent's HTTP port also speaks TLS.
40
+ *
41
+ * The first byte of a connection decides. A TLS ClientHello starts with the
42
+ * handshake record type `0x16`; HTTP starts with an ASCII method letter. So a
43
+ * plain `curl http://agent:4444` and an `https://agent:4444` both reach the same
44
+ * Fastify instance on the SAME port - the container contract (ports, volumes,
45
+ * the templates that mirror them) does not change.
46
+ *
47
+ * Why this is safe: no HTTP method begins with 0x16 (all are upper-case ASCII
48
+ * letters), and no TLS record begins with a letter, so the two cannot be
49
+ * confused. A client that sends nothing is dropped after {@link SNIFF_TIMEOUT_MS}.
50
+ * A TLS client when no certificate is installed is closed (and counted), never
51
+ * served in the clear. Fastify's `http.Server` is used as-is: both paths hand it a
52
+ * socket through its `connection` event.
53
+ *
54
+ * The serving certificate is swapped with `setSecureContext` - new handshakes use
55
+ * it at once, existing connections keep the old one, nothing restarts.
56
+ */
57
+ const net = __importStar(require("node:net"));
58
+ const node_stream_1 = require("node:stream");
59
+ const tls = __importStar(require("node:tls"));
60
+ exports.TLS_HANDSHAKE_RECORD = 0x16;
61
+ exports.SNIFF_TIMEOUT_MS = 10_000;
62
+ exports.HANDSHAKE_TIMEOUT_MS = 10_000;
63
+ const STALL_WARN_EVERY_MS = 60_000;
64
+ /** On shutdown, connections still open after this long are destroyed. */
65
+ const CLOSE_GRACE_MS = 2_000;
66
+ /**
67
+ * The raw socket once its first chunk has been read. Node's HTTP parser and TLS
68
+ * wrapper both read a net.Socket NATIVELY (`socket._handle`), bypassing any bytes
69
+ * already pulled into JS - so handing them the raw socket loses the byte(s) we
70
+ * sniffed. This duplex replays the first chunk and then forwards the socket, and
71
+ * exposes the address fields the servers (and the agent's loopback check) read.
72
+ */
73
+ class ReplayDuplex extends node_stream_1.Duplex {
74
+ socket;
75
+ pending;
76
+ constructor(socket, first) {
77
+ super();
78
+ this.socket = socket;
79
+ this.pending = first;
80
+ socket.on('data', (chunk) => {
81
+ if (!this.push(chunk))
82
+ socket.pause();
83
+ });
84
+ socket.on('end', () => this.push(null));
85
+ socket.on('error', (err) => this.destroy(err));
86
+ socket.on('close', () => this.destroy());
87
+ socket.on('timeout', () => this.emit('timeout'));
88
+ }
89
+ get remoteAddress() {
90
+ return this.socket.remoteAddress;
91
+ }
92
+ get remotePort() {
93
+ return this.socket.remotePort;
94
+ }
95
+ get remoteFamily() {
96
+ return this.socket.remoteFamily;
97
+ }
98
+ get localAddress() {
99
+ return this.socket.localAddress;
100
+ }
101
+ get localPort() {
102
+ return this.socket.localPort;
103
+ }
104
+ setTimeout(ms, callback) {
105
+ this.socket.setTimeout(ms);
106
+ if (callback !== undefined)
107
+ this.once('timeout', callback);
108
+ return this;
109
+ }
110
+ setNoDelay(noDelay) {
111
+ this.socket.setNoDelay(noDelay);
112
+ return this;
113
+ }
114
+ setKeepAlive(enable, delay) {
115
+ this.socket.setKeepAlive(enable, delay);
116
+ return this;
117
+ }
118
+ ref() {
119
+ this.socket.ref();
120
+ return this;
121
+ }
122
+ unref() {
123
+ this.socket.unref();
124
+ return this;
125
+ }
126
+ _read() {
127
+ if (this.pending !== null) {
128
+ const first = this.pending;
129
+ this.pending = null;
130
+ this.push(first);
131
+ }
132
+ this.socket.resume();
133
+ }
134
+ _write(chunk, encoding, callback) {
135
+ this.socket.write(chunk, encoding, callback);
136
+ }
137
+ _final(callback) {
138
+ this.socket.end(callback);
139
+ }
140
+ _destroy(error, callback) {
141
+ this.socket.destroy(error ?? undefined);
142
+ callback(error);
143
+ }
144
+ }
145
+ function startSniffingListener(options) {
146
+ // The serving context. A swap replaces it: every NEW handshake uses the new one,
147
+ // connections already established keep what they negotiated.
148
+ let secureContext = null;
149
+ let refused = 0;
150
+ const sniffTimeoutMs = options.sniffTimeoutMs ?? exports.SNIFF_TIMEOUT_MS;
151
+ /** Every live connection, so shutdown can end them instead of waiting on idle keep-alives. */
152
+ const live = new Set();
153
+ const track = (socket) => {
154
+ live.add(socket);
155
+ socket.once('close', () => live.delete(socket));
156
+ };
157
+ // A scanner opening many half handshakes must not fill the log: one warning a minute,
158
+ // with how many were suppressed.
159
+ let lastStallWarnAt = 0;
160
+ let suppressedStalls = 0;
161
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? exports.HANDSHAKE_TIMEOUT_MS;
162
+ const setMaterial = (material) => {
163
+ try {
164
+ secureContext = tls.createSecureContext({ key: material.keyPem, cert: material.certPem });
165
+ return true;
166
+ }
167
+ catch (error) {
168
+ options.log?.error('HTTPS material rejected - the previous certificate (or plain HTTP) keeps serving', {
169
+ meta: { error: error instanceof Error ? error.message : String(error) },
170
+ });
171
+ return false;
172
+ }
173
+ };
174
+ if (options.material !== undefined && options.material !== null) {
175
+ setMaterial(options.material);
176
+ }
177
+ /** A server-side TLS socket over the replayed raw socket, handed to HTTP once secure. */
178
+ const serveTls = (replayed, context) => {
179
+ const secure = new tls.TLSSocket(replayed, {
180
+ isServer: true,
181
+ secureContext: context,
182
+ minVersion: 'TLSv1.2',
183
+ });
184
+ // The decrypted socket does not carry the peer address of the stream under it, and
185
+ // the agent's auth gate reads it (loopback is trusted, a remote caller is not).
186
+ Object.defineProperty(secure, 'remoteAddress', { value: replayed.remoteAddress });
187
+ Object.defineProperty(secure, 'remotePort', { value: replayed.remotePort });
188
+ secure.on('error', () => undefined);
189
+ // A client that starts a handshake and stalls must not hold a socket for ever.
190
+ const stalled = setTimeout(() => {
191
+ const at = Date.now();
192
+ if (at - lastStallWarnAt >= STALL_WARN_EVERY_MS) {
193
+ options.log?.warn('TLS handshake did not finish in time - connection destroyed', {
194
+ meta: {
195
+ remote: replayed.remoteAddress ?? null,
196
+ afterMs: handshakeTimeoutMs,
197
+ alsoSuppressed: suppressedStalls,
198
+ },
199
+ });
200
+ lastStallWarnAt = at;
201
+ suppressedStalls = 0;
202
+ }
203
+ else {
204
+ suppressedStalls += 1;
205
+ }
206
+ secure.destroy();
207
+ replayed.destroy();
208
+ }, handshakeTimeoutMs);
209
+ secure.once('close', () => clearTimeout(stalled));
210
+ secure.once('secure', () => {
211
+ clearTimeout(stalled);
212
+ options.httpServer.emit('connection', secure);
213
+ });
214
+ };
215
+ const server = net.createServer({ pauseOnConnect: true }, (socket) => {
216
+ track(socket);
217
+ const timer = setTimeout(() => socket.destroy(), sniffTimeoutMs);
218
+ socket.on('error', () => undefined);
219
+ // `data` (not `readable`): the chunk is handed back to the stream with `unshift`
220
+ // before the chosen server takes the socket, so the protocol parser sees every byte.
221
+ socket.once('data', (chunk) => {
222
+ clearTimeout(timer);
223
+ socket.pause();
224
+ if (chunk[0] === exports.TLS_HANDSHAKE_RECORD) {
225
+ if (secureContext === null) {
226
+ refused += 1;
227
+ options.onTlsRefused?.();
228
+ socket.destroy();
229
+ return;
230
+ }
231
+ serveTls(new ReplayDuplex(socket, chunk), secureContext);
232
+ }
233
+ else {
234
+ options.httpServer.emit('connection', new ReplayDuplex(socket, chunk));
235
+ }
236
+ });
237
+ // The connection arrived paused (`pauseOnConnect`); start reading to see the first byte.
238
+ socket.resume();
239
+ });
240
+ return new Promise((resolve, reject) => {
241
+ server.once('error', reject);
242
+ server.listen({ port: options.port, host: options.host }, () => {
243
+ const address = server.address();
244
+ const port = address !== null && typeof address === 'object' ? address.port : options.port;
245
+ resolve({
246
+ port,
247
+ setMaterial,
248
+ tlsRefusedCount: () => refused,
249
+ close: () => new Promise((done) => {
250
+ // Stop listening, give in-flight requests a moment, then end every connection:
251
+ // an idle keep-alive would otherwise keep the process (and the port) alive.
252
+ server.close(() => done());
253
+ const grace = setTimeout(() => {
254
+ for (const socket of live)
255
+ socket.destroy();
256
+ }, CLOSE_GRACE_MS);
257
+ grace.unref?.();
258
+ server.once('close', () => clearTimeout(grace));
259
+ }),
260
+ });
261
+ });
262
+ });
263
+ }
@@ -348,5 +348,8 @@ function writeAgentRestartMarker(dataDir, requestedBy, update) {
348
348
  * though no `loadedAddons` entry owns it.
349
349
  */
350
350
  function agentRuntimeManifestEntry() {
351
- return { addonId: exports.AGENT_RUNTIME_ADDON_ID, capabilities: ['server-management'] };
351
+ return {
352
+ addonId: exports.AGENT_RUNTIME_ADDON_ID,
353
+ capabilities: ['server-management', 'node-certificates'],
354
+ };
352
355
  }