geonix 1.23.6 → 1.30.2

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