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/LICENSE.md +1 -1
- package/README.md +348 -4
- package/exports.js +0 -2
- package/index.d.ts +292 -237
- package/package.json +11 -8
- package/src/Codec.js +20 -7
- package/src/Connection.js +94 -40
- package/src/Crypto.js +103 -0
- package/src/Gateway.js +155 -70
- package/src/Logger.js +90 -9
- package/src/Registry.js +133 -15
- package/src/Remote.js +15 -6
- package/src/Request.js +117 -80
- package/src/RequestOptions.js +11 -8
- package/src/Service.js +133 -91
- package/src/Stream.js +69 -15
- package/src/Util.js +196 -158
- package/src/WebServer.js +18 -10
- package/test/context.js +0 -35
- package/test/delayedStart.js +0 -24
- package/test/gateway.js +0 -34
- package/test/middleware.js +0 -24
- package/test/package.json +0 -16
- package/test/pubsub.js +0 -29
- package/test/simple.js +0 -29
- package/test/static/index.html +0 -1
- package/test/stream.js +0 -43
- package/test/upload.js +0 -34
- package/test/ws_auth.js +0 -21
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.#
|
|
43
|
-
return this.#connections[this.#connectionRoundRobin];
|
|
43
|
+
return this.#connections[this.#connectionRoundRobin++ % this.#connections.length];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
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 <
|
|
57
|
-
this.#connections.push(await connect(
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|