geonix 1.23.8 → 1.30.4

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/src/Connection.js CHANGED
@@ -4,93 +4,166 @@ import { Stream } from "./Stream.js";
4
4
  import { webserver } from "./WebServer.js";
5
5
  import { logger } from "./Logger.js";
6
6
  import { decode, encode } from "./Codec.js";
7
+ import { encryptPayload, decryptPayload, encryptSubject, wrapSubscription } from "./Crypto.js";
7
8
 
8
9
  // -------------------------------------------------------------------------------------------------
9
10
  const CONNECTION_TIMEOUT = 10000;
10
11
 
11
12
  const defaultRequestOptions = {
12
- timeout: 300000
13
+ timeout: 300000,
13
14
  };
14
15
 
16
+ const DEFAULT_CONNECTION_COUNT = 1;
17
+
15
18
  const defaultConnectionOptions = {
16
19
  timeout: CONNECTION_TIMEOUT,
17
20
  reconnect: true,
18
- debug: process.env.TRANSPORT_DEBUG === "true",
21
+ debug: (process.env.GX_TRANSPORT_DEBUG || process.env.TRANSPORT_DEBUG) === "true",
19
22
  maxReconnectAttempts: 30,
20
23
  pingInterval: 30000,
21
24
  waitOnFirstConnect: true,
22
- connections: 1
25
+ };
26
+
27
+ const TRANSPORT_TIMEOUT = 60_000;
28
+ const WATCHDOG_INTERVAL = 1000;
29
+
30
+ // env read via a function so tests can set GX_TRANSPORT_TIMEOUT after this module is imported
31
+ const getTransportTimeout = () => {
32
+ const raw = parseInt(process.env.GX_TRANSPORT_TIMEOUT, 10);
33
+ return Number.isFinite(raw) ? raw : TRANSPORT_TIMEOUT;
23
34
  };
24
35
  // -------------------------------------------------------------------------------------------------
25
36
 
26
37
  /**
27
- * Connection class is responsible for starting a connection to a NATS server and provides shortcut
28
- * methods for publish, subscribe and request methods of the NATS client.
29
- *
30
- * It is implemented as an singleton so that multiple services can be implemented in the same
31
- * process while using just a single connection to the NATS server.
38
+ * Manages one or more NATS connections and exposes publish, subscribe, and request helpers.
39
+ * Instantiated as a singleton (`connection`) so multiple services in the same process share a
40
+ * single pool of connections. Optional subject-level HMAC encryption and payload AES-GCM
41
+ * encryption are activated by setting the `GX_SECRET` environment variable.
32
42
  */
33
43
  class Connection {
34
-
35
44
  #draining = false;
36
45
  #closed = false;
37
46
  #ready = false;
38
47
  #connections = [];
39
48
  #connectionRoundRobin = 0;
49
+ #disconnectedSince = Date.now();
50
+ #watchdogInterval = null;
40
51
 
41
52
  #getConnection() {
42
- this.#connectionRoundRobin = ++this.#connectionRoundRobin % this.#connections.length;
43
- return this.#connections[this.#connectionRoundRobin];
53
+ return this.#connections[this.#connectionRoundRobin++ % this.#connections.length];
44
54
  }
45
55
 
46
56
  /**
47
- * Initiates connection to transport
48
- * @param {string} transport
57
+ * Connects to the NATS transport. Called automatically on module load via the singleton.
58
+ *
59
+ * @param {string} [transport] - NATS URL, optionally including credentials and query-string
60
+ * options (e.g. `nats://user:pass@host:4222?connections=2`). Defaults to `GX_TRANSPORT`,
61
+ * `TRANSPORT`, or `nats://localhost`.
62
+ * @returns {Promise<void>}
49
63
  */
50
- async start(transport = process.env.TRANSPORT || "nats://localhost") {
51
- const options = {
64
+ async start(transport = process.env.GX_TRANSPORT || process.env.TRANSPORT || "nats://localhost") {
65
+ const { connections: _connectionCount, ...natsOptions } = {
52
66
  ...defaultConnectionOptions,
53
- ...parseURL(transport)
67
+ ...parseURL(transport),
54
68
  };
69
+ const connectionCount = parseInt(_connectionCount) || DEFAULT_CONNECTION_COUNT;
70
+
71
+ this.#startWatchdog();
55
72
 
56
- for (let i = 0; i < options.connections; i++) {
57
- this.#connections.push(await connect(options));
73
+ for (let i = 0; i < connectionCount; i++) {
74
+ this.#connections.push(await connect(natsOptions));
58
75
  }
59
76
 
77
+ this.#disconnectedSince = null;
78
+
60
79
  logger.info("gx.connection.connected");
61
80
 
62
81
  this.#ready = true;
63
82
 
64
83
  this.monitorStatus();
65
- this.waitUntilClosed();
84
+ this.waitUntilClosed().catch((e) => logger.error("gx.connection.waitUntilClosed:", e));
66
85
  }
67
86
 
68
- async monitorStatus() {
69
- for await (const event of this.#getConnection().status()) {
70
- logger.info("gx.connection.status", JSON.stringify(event));
87
+ // Polls the disconnect timestamp; exits the process if the transport has been
88
+ // unreachable longer than GX_TRANSPORT_TIMEOUT (default 60s, 0 disables).
89
+ #startWatchdog() {
90
+ if (this.#watchdogInterval) {
91
+ return;
92
+ }
93
+
94
+ this.#watchdogInterval = setInterval(() => {
95
+ const limit = getTransportTimeout();
96
+ if (limit === 0) {
97
+ return;
98
+ }
99
+ if (this.#disconnectedSince === null) {
100
+ return;
101
+ }
102
+
103
+ const elapsed = Date.now() - this.#disconnectedSince;
104
+ if (elapsed >= limit) {
105
+ logger.error(
106
+ `gx.connection: transport unreachable for ${Math.round(elapsed / 1000)}s (limit ${limit}ms), exiting`,
107
+ );
108
+ process.exit(1);
109
+ }
110
+ }, WATCHDOG_INTERVAL);
111
+
112
+ this.#watchdogInterval.unref?.();
113
+ }
114
+
115
+ /**
116
+ * Starts background logging of NATS status events for all connections.
117
+ * @returns {void}
118
+ */
119
+ monitorStatus() {
120
+ for (const conn of this.#connections) {
121
+ (async () => {
122
+ for await (const event of conn.status()) {
123
+ if (event.type === "disconnect") {
124
+ if (this.#disconnectedSince === null) {
125
+ this.#disconnectedSince = Date.now();
126
+ }
127
+ } else if (event.type === "reconnect") {
128
+ this.#disconnectedSince = null;
129
+ }
130
+
131
+ logger.debug("gx.connection.status", JSON.stringify(event));
132
+ }
133
+ })().catch((e) => logger.error("gx.connection.status:", e));
71
134
  }
72
135
  }
73
136
 
74
137
  /**
75
- * Wait for the connection to be safely closed
138
+ * Resolves once all NATS connections have been fully closed, then stops the web server and
139
+ * exits the process (exit code 0 on graceful drain, 1 on unexpected closure).
140
+ *
141
+ * @returns {Promise<void>}
76
142
  */
77
143
  async waitUntilClosed() {
78
144
  // wait for all connections to be closed
79
- await Promise.all(this.#connections.map(connection => connection.closed()));
145
+ await Promise.all(this.#connections.map((connection) => connection.closed()));
80
146
 
81
147
  this.#closed = true;
82
148
  logger.info("gx.connection.closed");
83
149
 
150
+ if (this.#watchdogInterval) {
151
+ clearInterval(this.#watchdogInterval);
152
+ this.#watchdogInterval = null;
153
+ }
154
+
84
155
  webserver.stop();
85
156
 
86
157
  await sleep(5000);
87
158
 
88
159
  logger.info("gx.terminate");
89
- process.exit(1);
160
+ process.exit(this.#draining ? 0 : 1);
90
161
  }
91
162
 
92
163
  /**
93
- * Wait for the connection to be fully established
164
+ * Resolves once the NATS connection pool is ready to publish and subscribe.
165
+ *
166
+ * @returns {Promise<void>}
94
167
  */
95
168
  async waitUntilReady() {
96
169
  while (!this.#ready) {
@@ -100,9 +173,9 @@ class Connection {
100
173
 
101
174
  /**
102
175
  * Publish JSON
103
- *
104
- * @param {string} subject
105
- * @param {object} json
176
+ *
177
+ * @param {string} subject
178
+ * @param {object} json
106
179
  * @returns void
107
180
  */
108
181
  async publish(subject, json) {
@@ -114,17 +187,17 @@ class Connection {
114
187
 
115
188
  // if payload is too big, convert it to Stream
116
189
  if (payload.length > this.getMaxPayloadSize()) {
117
- payload = encode(Stream(JSON.stringify(json)));
190
+ payload = encode(Stream(Buffer.from(payload)));
118
191
  }
119
192
 
120
- await this.#getConnection().publish(subject, payload);
193
+ await this.#getConnection().publish(encryptSubject(subject), encryptPayload(payload));
121
194
  }
122
195
 
123
196
  /**
124
197
  * Publish RAW
125
- *
126
- * @param {string} subject
127
- * @param {string | Buffer} data
198
+ *
199
+ * @param {string} subject
200
+ * @param {string | Buffer} data
128
201
  * @returns void
129
202
  */
130
203
  async publishRaw(subject, data) {
@@ -132,21 +205,24 @@ class Connection {
132
205
  return;
133
206
  }
134
207
 
135
- await this.#getConnection().publish(subject, data);
208
+ await this.#getConnection().publish(
209
+ encryptSubject(subject),
210
+ encryptPayload(data != null ? Buffer.from(data) : Buffer.alloc(0)),
211
+ );
136
212
  }
137
213
 
138
214
  /**
139
215
  * Request/Reply pattern on top of pub/sub
140
- *
141
- * @param {string} subject
142
- * @param {object} json
143
- * @param {object} options
216
+ *
217
+ * @param {string} subject
218
+ * @param {object} json
219
+ * @param {object} options
144
220
  * @returns any
145
221
  */
146
222
  async request(subject, json, opts = {}) {
147
223
  const options = {
148
224
  ...defaultRequestOptions,
149
- ...opts
225
+ ...opts,
150
226
  };
151
227
 
152
228
  const respondTo = `gx2.r.${picoid(16)}`;
@@ -159,24 +235,40 @@ class Connection {
159
235
  }
160
236
 
161
237
  const nc = this.#getConnection();
162
- let response = await nc.subscribe(respondTo, { max: 1, ...options });
238
+ const response = await nc.subscribe(encryptSubject(respondTo), { max: 1, ...options });
163
239
 
164
- await nc.publish(subject, payload);
240
+ await nc.publish(encryptSubject(subject), encryptPayload(payload));
165
241
 
166
242
  const event = await getFirstItemFromAsyncIterable(response);
167
- return decode(event.data);
243
+ return decode(decryptPayload(event.data));
168
244
  }
169
245
 
246
+ /**
247
+ * Subscribes to a NATS subject and returns an async-iterable subscription. If payload
248
+ * encryption is enabled each message is transparently decrypted before being yielded.
249
+ *
250
+ * @param {string} subject - NATS subject to subscribe to.
251
+ * @param {object} [options] - NATS subscription options (e.g. `queue`, `max`).
252
+ * @returns {Promise<AsyncIterable>} Subscription iterable.
253
+ */
170
254
  async subscribe(subject, options) {
171
- return this.#getConnection().subscribe(subject, options);
255
+ return wrapSubscription(await this.#getConnection().subscribe(encryptSubject(subject), options));
172
256
  }
173
257
 
174
- async unsubscribe(subscription) {
258
+ /**
259
+ * Cancels an active subscription.
260
+ *
261
+ * @param {AsyncIterable} subscription - Subscription returned by {@link subscribe}.
262
+ * @returns {void}
263
+ */
264
+ unsubscribe(subscription) {
175
265
  subscription.unsubscribe();
176
266
  }
177
267
 
178
268
  /**
179
- *
269
+ * Returns the maximum NATS message payload size in bytes as reported by the server.
270
+ * Falls back to 512 KB if the connection info is not yet available.
271
+ *
180
272
  * @returns {number}
181
273
  */
182
274
  getMaxPayloadSize() {
@@ -184,25 +276,44 @@ class Connection {
184
276
  return nc?.info?.max_payload || 1024 * 512;
185
277
  }
186
278
 
279
+ /**
280
+ * Returns `true` after all NATS connections have been fully closed.
281
+ *
282
+ * @returns {boolean}
283
+ */
187
284
  isClosed() {
188
285
  return this.#closed;
189
286
  }
190
287
 
288
+ /**
289
+ * Drains all active NATS connections, flushing pending messages before closing them.
290
+ *
291
+ * @returns {Promise<void>}
292
+ */
191
293
  async drain() {
192
294
  this.#draining = true;
193
295
 
194
- await Promise.all(this.#connections.map(connection => connection.drain()));
296
+ await Promise.all(this.#connections.map((connection) => connection.drain()));
195
297
  }
196
-
197
298
  }
198
299
 
300
+ /**
301
+ * Singleton {@link Connection} instance shared across the process. Started automatically on
302
+ * module load; callers should use `connection.waitUntilReady()` before publishing or subscribing.
303
+ *
304
+ * @type {Connection}
305
+ */
199
306
  export const connection = new Connection();
200
- connection.start();
307
+ connection.start().catch((e) => {
308
+ logger.error("gx.connection.start:", e);
309
+ process.exit(1);
310
+ });
201
311
 
312
+ /**
313
+ * Initiates a graceful shutdown by draining all NATS connections.
314
+ *
315
+ * @returns {void}
316
+ */
202
317
  export const stopConnection = () => {
203
- if (!connection) {
204
- return;
205
- }
206
-
207
318
  connection.drain();
208
319
  };
package/src/Crypto.js ADDED
@@ -0,0 +1,117 @@
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
2
+
3
+ const _secret = process.env.GX_SECRET || null;
4
+
5
+ // Subject key is derived separately so HMAC-subject and AES-payload never share key material.
6
+ const _subjectKey = _secret
7
+ ? createHash("sha256")
8
+ .update(_secret + "\x00subject")
9
+ .digest()
10
+ : null;
11
+
12
+ /**
13
+ * AES-256-GCM key derived from `GX_SECRET`, or `null` when encryption is disabled.
14
+ * Used internally by {@link encryptPayload} and {@link decryptPayload}.
15
+ *
16
+ * @type {Buffer|null}
17
+ */
18
+ export const _payloadKey = _secret
19
+ ? createHash("sha256")
20
+ .update(_secret + "\x00payload")
21
+ .digest()
22
+ : null;
23
+
24
+ /**
25
+ * Encrypts `data` with AES-256-GCM using {@link _payloadKey}. Returns `data` unchanged when
26
+ * encryption is disabled. The output format is `[12-byte IV][16-byte auth tag][ciphertext]`.
27
+ *
28
+ * @param {Buffer|null} data - Plaintext bytes to encrypt.
29
+ * @returns {Buffer}
30
+ */
31
+ export function encryptPayload(data) {
32
+ if (!_payloadKey) {
33
+ return data;
34
+ }
35
+ const buf = data != null ? Buffer.from(data) : Buffer.alloc(0);
36
+ const iv = randomBytes(12);
37
+ const cipher = createCipheriv("aes-256-gcm", _payloadKey, iv);
38
+ const encrypted = cipher.update(buf);
39
+ cipher.final();
40
+ const out = Buffer.allocUnsafe(12 + 16 + encrypted.length);
41
+ iv.copy(out, 0);
42
+ cipher.getAuthTag().copy(out, 12);
43
+ encrypted.copy(out, 28);
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Decrypts a buffer produced by {@link encryptPayload}. Returns `data` unchanged when
49
+ * encryption is disabled. Expects the layout `[12-byte IV][16-byte auth tag][ciphertext]`.
50
+ *
51
+ * @param {Buffer} data - Encrypted bytes to decrypt.
52
+ * @returns {Buffer}
53
+ */
54
+ export function decryptPayload(data) {
55
+ if (!_payloadKey) {
56
+ return data;
57
+ }
58
+ const buf = Buffer.from(data);
59
+ const decipher = createDecipheriv("aes-256-gcm", _payloadKey, buf.subarray(0, 12));
60
+ decipher.setAuthTag(buf.subarray(12, 28));
61
+ const decrypted = decipher.update(buf.subarray(28));
62
+ decipher.final();
63
+ return decrypted;
64
+ }
65
+
66
+ /**
67
+ * Encrypts each dot-separated segment of a NATS subject using HMAC-SHA256, truncated to 32 hex
68
+ * characters. Wildcard tokens (`*`, `>`) are passed through unchanged so NATS subscription
69
+ * semantics are preserved. Returns the subject unchanged when encryption is disabled.
70
+ *
71
+ * @param {string} subject - Plain NATS subject string (e.g. `"gx2.service.abc"`).
72
+ * @returns {string} Encrypted subject string.
73
+ */
74
+ export function encryptSubject(subject) {
75
+ if (!_subjectKey) {
76
+ return subject;
77
+ }
78
+
79
+ return subject
80
+ .split(".")
81
+ .map((seg) =>
82
+ seg === "*" || seg === ">" ? seg : createHmac("sha256", _subjectKey).update(seg).digest("hex").slice(0, 32),
83
+ )
84
+ .join(".");
85
+ }
86
+
87
+ /**
88
+ * Wraps a NATS subscription so that every incoming message's `data` field is transparently
89
+ * decrypted before being yielded. Returns the subscription unchanged when encryption is
90
+ * disabled.
91
+ *
92
+ * @param {object} sub - NATS subscription object with `[Symbol.asyncIterator]`, `drain`, and `unsubscribe`.
93
+ * @returns {object} Wrapped subscription with the same interface.
94
+ */
95
+ export function wrapSubscription(sub) {
96
+ if (!_payloadKey) {
97
+ return sub;
98
+ }
99
+
100
+ return {
101
+ [Symbol.asyncIterator]() {
102
+ const iter = sub[Symbol.asyncIterator]();
103
+ return {
104
+ async next() {
105
+ const { value, done } = await iter.next();
106
+ if (done) {
107
+ return { value, done };
108
+ }
109
+
110
+ return { value: { ...value, data: decryptPayload(value.data) }, done: false };
111
+ },
112
+ };
113
+ },
114
+ drain: () => sub.drain(),
115
+ unsubscribe: () => sub.unsubscribe(),
116
+ };
117
+ }