@interopio/gateway-server 0.19.3 → 0.20.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/gateway-server ADDED
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+
3
+ `use strict`;
4
+
5
+ import { writeFileSync } from 'node:fs';
6
+ import { configure } from '@interopio/gateway/logging/core';
7
+
8
+ // import { GatewayServer } from './src/index.ts';
9
+ // import { mkcert, argon2 } from './src/tools/index.ts';
10
+
11
+ import { GatewayServer } from './dist/index.js';
12
+ import { mkcert, argon2 } from './dist/tools/index.js';
13
+
14
+ function showHelp() {
15
+ console.log(`
16
+ Usage: gateway-server <command> [options]
17
+
18
+ Commands:
19
+ run Run the gateway server (default if no command specified)
20
+ passwd Generate password hash (default using Argon2)
21
+ mkcert Generate client and/or server certificate signed by Dev CA
22
+
23
+ run options:
24
+ -p, --port <port> Specify port or port range to bind the server to (default: 0 i.e. random available port)
25
+ examples: 8385, 8000-8100, 3000,4000-4050
26
+ -H, --host <host> Network address to bind to (default: unspecified, listens on all interfaces)
27
+ examples: localhost, 127.0.0.1, 0.0.0.0, ::1
28
+ -u, --user <name> Enables basic authentication and sets the admin username
29
+ -S, --ssl, --tls Enable HTTPS. Auto-generates Dev CA and/or server certificates if not present. Enables x509 client cert auth if --user not specified.
30
+ --cert <file> File with SSL/TLS certificate (default: ./gateway-server.crt)
31
+ --key <file> File with SSL/TLS private key (default: ./gateway-server.key)
32
+ --ca <file> File with CA certificates (default: ./gateway-ca.crt)
33
+ --ca-key <file> File with CA private key (default: ./gateway-ca.key)
34
+ --debug Enable debug logging (default: info level)
35
+
36
+ passwd options:
37
+ (no args) Prompt for password interactively (masked input)
38
+ --stdin Read password from stdin (for piping, e.g., echo "pass" | ...)
39
+
40
+ mkcert options:
41
+ --client Generate client certificate (default: server certificate)
42
+ -u, --user <name> Common Name for the certificate (default: dev-user for client certs) (--client only)
43
+ --ca <file> File with CA certificate (default: ./gateway-ca.crt)
44
+ --ca-key <file> File with CA private key (default: ./gateway-ca.key)
45
+ --key <file> Output file for private key (default: ./gateway-server.key for server, ./gateway-client.key for client)
46
+ --cert <file> Output file for certificate (default: ./gateway-server.crt for server, ./gateway-client.crt for client)
47
+ [name...] DNS names, IP addresses, or email addresses for subjectAltName
48
+ (DNS by default, prefix with 'IP:' or 'EMAIL:' for other types)
49
+
50
+ Global Options:
51
+ -v, --version Show version information and exit
52
+ --help Show this help message and exit
53
+
54
+ Examples:
55
+ gateway-server run -p 3000
56
+ gateway-server run -u admin --port 8385,8388 --debug
57
+ gateway-server run -p 8443 --ssl --user admin
58
+ gateway-server passwd
59
+ echo "mySecret123" | gateway-server passwd --stdin
60
+ gateway-server mkcert
61
+ gateway-server mkcert localhost 127.0.0.1 IP:192.168.1.100
62
+ gateway-server mkcert --client EMAIL:john.doe@example.com
63
+ gateway-server mkcert example.com *.example.com --key ./gateway-server.key --cert ./gateway-server.crt
64
+ `);
65
+ process.exit(0);
66
+ }
67
+
68
+ function showVersion() {
69
+ console.log(GatewayServer.VERSION);
70
+ process.exit(0);
71
+ }
72
+
73
+
74
+ // Parse command-line arguments
75
+ const args = process.argv.slice(2);
76
+
77
+ // Check for global flags first
78
+ if (args.includes('--version') || args.includes('-v')) {
79
+ showVersion();
80
+ }
81
+ if (args.includes('--help') || args.includes('-h')) {
82
+ showHelp();
83
+ }
84
+
85
+ // Determine command (default to 'run' for backward compatibility)
86
+ let command = 'run';
87
+ let commandArgs = args;
88
+
89
+ if (args.length > 0 && !args[0].startsWith('-')) {
90
+ command = args[0];
91
+ commandArgs = args.slice(1);
92
+ }
93
+
94
+ // Route to appropriate command handler
95
+ switch (command) {
96
+ case 'run':
97
+ await runServer(commandArgs);
98
+ break;
99
+ case 'passwd':
100
+ await passwdCommand(commandArgs);
101
+ break;
102
+ case 'mkcert':
103
+ await mkcertCommand(commandArgs);
104
+ break;
105
+ default:
106
+ console.error(`Error: Unknown command "${command}"`);
107
+ console.log('Use --help to see available commands');
108
+ process.exit(1);
109
+ }
110
+
111
+ // Command: passwd - Generate password hash
112
+ async function passwdCommand(args) {
113
+ let password = undefined;
114
+ let fromStdin = false;
115
+
116
+ for (let i = 0; i < args.length; i++) {
117
+ if (args[i] === '--stdin') {
118
+ fromStdin = true;
119
+ } else {
120
+ console.error(`Error: Unknown option "${args[i]}"`);
121
+ console.log('Use: gateway-server passwd');
122
+ console.log(' or: gateway-server passwd --stdin');
123
+ process.exit(1);
124
+ }
125
+ }
126
+
127
+ // Read password from stdin if specified
128
+ if (fromStdin) {
129
+ password = await readPasswordFromStdin();
130
+ }
131
+
132
+ // If no password provided, prompt interactively
133
+ if (!password) {
134
+ password = await promptPassword('Enter password: ');
135
+ }
136
+
137
+ if (!password) {
138
+ console.error('Error: password is required');
139
+ process.exit(1);
140
+ }
141
+
142
+ const hashValue = await argon2.hash(password);
143
+ const hash = `{argon2id}${hashValue}`;
144
+ console.log(hash);
145
+ process.exit(0);
146
+ }
147
+
148
+ // Read password from piped stdin (non-interactive)
149
+ async function readPasswordFromStdin() {
150
+ // Check if stdin is a TTY (interactive terminal)
151
+ if (process.stdin.isTTY) {
152
+ // Interactive mode - use readline with hidden input
153
+ return promptPassword('Password: ');
154
+ }
155
+
156
+ // Piped mode - read until EOF or newline
157
+ const chunks = [];
158
+ for await (const chunk of process.stdin) {
159
+ chunks.push(chunk);
160
+ }
161
+ const content = Buffer.concat(chunks).toString('utf8');
162
+ // Strip trailing newline
163
+ return content.replace(/\r?\n$/, '');
164
+ }
165
+
166
+ // Prompt for password with masked input
167
+ async function promptPassword(prompt) {
168
+ return new Promise((resolve) => {
169
+ process.stdout.write(prompt);
170
+
171
+ // Only set raw mode if stdin is a TTY
172
+ if (!process.stdin.isTTY) {
173
+ console.error('Error: Interactive password prompt requires a terminal');
174
+ process.exit(1);
175
+ }
176
+
177
+ process.stdin.setRawMode(true);
178
+ process.stdin.resume();
179
+ process.stdin.setEncoding('utf8');
180
+
181
+ let password = '';
182
+
183
+ const cleanup = () => {
184
+ process.stdin.setRawMode(false);
185
+ process.stdin.pause();
186
+ process.stdin.removeListener('data', onData);
187
+ process.stdout.write('\n');
188
+ };
189
+
190
+ const onData = (char) => {
191
+ // Ctrl+C
192
+ if (char === '\u0003') {
193
+ cleanup();
194
+ process.exit(1);
195
+ }
196
+
197
+ // Enter
198
+ if (char === '\r' || char === '\n') {
199
+ cleanup();
200
+ resolve(password);
201
+ return;
202
+ }
203
+
204
+ // Backspace
205
+ if (char === '\u007f' || char === '\b') {
206
+ if (password.length > 0) {
207
+ password = password.slice(0, -1);
208
+ process.stdout.write('\b \b');
209
+ }
210
+ return;
211
+ }
212
+
213
+ // Regular character
214
+ password += char;
215
+ process.stdout.write('*');
216
+ };
217
+
218
+ process.stdin.on('data', onData);
219
+ });
220
+ }
221
+
222
+ // Command: mkcert - Generate client or server certificate
223
+ async function mkcertCommand(args) {
224
+ let isClientCert = false;
225
+ let user = 'dev-user';
226
+ let caCert = './gateway-ca.crt';
227
+ let caKey = './gateway-ca.key';
228
+ let keyPath = undefined;
229
+ let certPath = undefined;
230
+ const sanEntries = [];
231
+
232
+ for (let i = 0; i < args.length; i++) {
233
+ if (args[i] === '--client') {
234
+ isClientCert = true;
235
+ } else if ((args[i] === '--user' || args[i] === '-u') && i + 1 < args.length) {
236
+ user = args[i + 1];
237
+ i++;
238
+ } else if (args[i] === '--ca' && i + 1 < args.length) {
239
+ caCert = args[i + 1];
240
+ i++;
241
+ } else if (args[i] === '--ca-key' && i + 1 < args.length) {
242
+ caKey = args[i + 1];
243
+ i++;
244
+ } else if (args[i] === '--key' && i + 1 < args.length) {
245
+ keyPath = args[i + 1];
246
+ i++;
247
+ } else if (args[i] === '--cert' && i + 1 < args.length) {
248
+ certPath = args[i + 1];
249
+ i++;
250
+ } else if (!args[i].startsWith('-')) {
251
+ // Positional argument - add to SAN entries
252
+ sanEntries.push(args[i]);
253
+ } else {
254
+ console.error(`Error: Unknown option "${args[i]}"`);
255
+ console.log('Use: gateway-server mkcert [--client] [--user <name>] [--ca <file>] [--ca-key <file>] [--key <file>] [--cert <file>] [name...]');
256
+ process.exit(1);
257
+ }
258
+ }
259
+
260
+ // Default output paths
261
+ // Users typically specify just --cert, and key should go in the same file
262
+ if (!keyPath && !certPath) {
263
+ // Neither specified: use defaults based on certificate type
264
+ if (isClientCert) {
265
+ // Client certificate: combined file by default
266
+ keyPath = './gateway-client.key';
267
+ certPath = './gateway-client.crt';
268
+ } else {
269
+ // Server certificate: separate files by default
270
+ keyPath = './gateway-server.key';
271
+ certPath = './gateway-server.crt';
272
+ }
273
+ } else if (keyPath && !certPath) {
274
+ // Only --key specified: cert goes in the same file (combined)
275
+ certPath = keyPath;
276
+ } else if (!keyPath && certPath) {
277
+ // Only --cert specified: key goes in the same file (combined)
278
+ keyPath = certPath;
279
+ }
280
+ // else: both specified, use as-is
281
+
282
+ if (sanEntries.length === 0) {
283
+ if (isClientCert) {
284
+ sanEntries.push(user);
285
+ }
286
+ else {
287
+ // Default SAN entry for server certs
288
+ sanEntries.push('localhost');
289
+ }
290
+ }
291
+
292
+ const { readFileSync } = await import('node:fs');
293
+ const { KEYUTIL, X509 } = await import('jsrsasign');
294
+
295
+ // Load CA key
296
+ let caKeyObj;
297
+ try {
298
+ const caKeyPem = readFileSync(caKey, 'utf8');
299
+ caKeyObj = KEYUTIL.getKey(caKeyPem);
300
+ } catch (error) {
301
+ console.error(`Error: Cannot read CA key from ${caKey}`);
302
+ console.error(error.message);
303
+ process.exit(1);
304
+ }
305
+
306
+ // Extract issuer from CA certificate
307
+ let issuer;
308
+ try {
309
+ const caCertPem = readFileSync(caCert, 'utf8');
310
+ const caCertObj = new X509();
311
+ caCertObj.readCertPEM(caCertPem);
312
+ issuer = caCertObj.getSubjectString();
313
+ } catch (error) {
314
+ console.error(`Error: Cannot read CA certificate from ${caCert}`);
315
+ console.error(error.message);
316
+ process.exit(1);
317
+ }
318
+
319
+ // Generate certificate using SAN entries
320
+ const cert = mkcert.generateCert(caKeyObj, issuer, sanEntries, isClientCert);
321
+
322
+ // Write files
323
+ try {
324
+ if (keyPath === certPath) {
325
+ // Concatenate cert and key into single file (cert first, then key)
326
+ const combined = cert.cert + cert.key;
327
+ writeFileSync(keyPath, combined, { mode: 0o600 });
328
+ console.log(`${isClientCert ? 'Client' : 'Server'} certificate generated successfully:`);
329
+ console.log(` Combined (cert+key): ${keyPath}`);
330
+ if (sanEntries.length > 0) {
331
+ console.log(` SAN: ${sanEntries.join(', ')}`);
332
+ }
333
+ } else {
334
+ // Write separate files
335
+ writeFileSync(keyPath, cert.key, { mode: 0o600 });
336
+ writeFileSync(certPath, cert.cert, { mode: 0o644 });
337
+ console.log(`${isClientCert ? 'Client' : 'Server'} certificate generated successfully:`);
338
+ console.log(` Private key: ${keyPath}`);
339
+ console.log(` Certificate: ${certPath}`);
340
+ if (sanEntries.length > 0) {
341
+ console.log(` SAN: ${sanEntries.join(', ')}`);
342
+ }
343
+ }
344
+
345
+ process.exit(0);
346
+ } catch (error) {
347
+ console.error(`Error: Cannot write certificate files`);
348
+ console.error(error.message);
349
+ process.exit(1);
350
+ }
351
+ }
352
+
353
+ // Command: run - Start the server
354
+ async function runServer(args) {
355
+ const options = {
356
+ port: 0,
357
+ host: undefined,
358
+ user: undefined,
359
+ debug: false,
360
+ ssl: false,
361
+ cert: undefined,
362
+ key: undefined,
363
+ ca: undefined,
364
+ caKey: undefined
365
+ };
366
+
367
+ for (let i = 0; i < args.length; i++) {
368
+ if ((args[i] === '--port' || args[i] === '-p') && i + 1 < args.length) {
369
+ options.port = args[i + 1];
370
+ i++;
371
+ } else if ((args[i] === '--host' || args[i] === '-H') && i + 1 < args.length) {
372
+ options.host = args[i + 1];
373
+ i++;
374
+ } else if ((args[i] === '--user' || args[i] === '-u') && i + 1 < args.length) {
375
+ options.user = args[i + 1];
376
+ i++;
377
+ } else if (args[i] === '--ssl' || args[i] === '-S' || args[i] === '--tls') {
378
+ options.ssl = true;
379
+ } else if (args[i] === '--cert' && i + 1 < args.length) {
380
+ options.cert = args[i + 1];
381
+ i++;
382
+ } else if (args[i] === '--key' && i + 1 < args.length) {
383
+ options.key = args[i + 1];
384
+ i++;
385
+ } else if (args[i] === '--ca' && i + 1 < args.length) {
386
+ options.ca = args[i + 1];
387
+ i++;
388
+ } else if (args[i] === '--ca-key' && i + 1 < args.length) {
389
+ options.caKey = args[i + 1];
390
+ i++;
391
+ } else if (args[i] === '--debug') {
392
+ options.debug = true;
393
+ } else {
394
+ console.error(`Error: Unknown option "${args[i]}"`);
395
+ console.log('Use --help to see available options');
396
+ process.exit(1);
397
+ }
398
+ }
399
+
400
+ configure({
401
+ level: options.debug ? 'debug' : 'info',
402
+ });
403
+
404
+ const auth = {
405
+ type: options.user ? 'basic' : options.ssl ? 'x509' : 'none'
406
+ };
407
+
408
+ if (options.user) {
409
+ auth.user = { name: options.user, roles: ['admin'] };
410
+ }
411
+
412
+ if (options.ssl) {
413
+ auth.x509 = {
414
+ key: options.caKey || './gateway-ca.key',
415
+ // principalAltName: 'email',
416
+ };
417
+ }
418
+
419
+ const server = await GatewayServer.Factory({
420
+ port: options.port,
421
+ host: options.host,
422
+ ssl: options.ssl ? {
423
+ key: options.key,
424
+ cert: options.cert,
425
+ ca: options.ca,
426
+ requestCert: auth.type === 'x509',
427
+ rejectUnauthorized: false
428
+ } : undefined,
429
+ auth,
430
+ gateway: { route: '/gw', authorize: { access: auth.type !== 'none' ? 'authenticated' : 'permitted' } },
431
+ app: async (_configurer) => {
432
+ // No custom app logic for now
433
+ },
434
+ });
435
+
436
+ process.on('SIGINT', async () => {
437
+ await server.close();
438
+ process.exit(0);
439
+ });
440
+
441
+ process.title = `${GatewayServer.VERSION} ${server.address.port}`;
442
+ }
@@ -1,17 +1,75 @@
1
1
  import { IOGateway } from '@interopio/gateway';
2
+ import type { AddressInfo } from 'node:net';
2
3
  import type { ServerCorsConfig, ServerConfigurer, ServerWebSocketOptions } from './types/web/server';
4
+
3
5
  export default GatewayServer.Factory;
4
6
 
5
7
  export namespace GatewayServer {
6
8
  export const Factory: (options: ServerConfig) => Promise<Server>
7
- export type SslConfig = Readonly<{ key?: string, cert?: string, ca?: string }>
9
+ /**
10
+ * SSL/TLS configuration.
11
+ * All key and certificate files must be in PEM format.
12
+ */
13
+ export type SslConfig = Readonly<{
14
+ /** Path to server private key file (PEM format) */
15
+ key?: string,
16
+ /** Path to server certificate file (PEM format) */
17
+ cert?: string,
18
+ /** Path to CA certificate for client certificate verification (PEM format) */
19
+ ca?: string,
20
+ /**
21
+ * Passphrase for encrypted private key
22
+ * @since 0.20.0
23
+ */
24
+ passphrase?: string,
25
+ /**
26
+ * Request client certificate during TLS handshake.
27
+ * When false (default), clients will not send certificates.
28
+ * When true, clients are asked to send a certificate.
29
+ * @since 0.20.0
30
+ */
31
+ requestCert?: boolean,
32
+ /**
33
+ * Reject clients with invalid or missing certificates.
34
+ * Only effective when requestCert is true.
35
+ * When false, clients without valid certs are still allowed.
36
+ * When true, enforces mutual TLS authentication.
37
+ * @since 0.20.0
38
+ */
39
+ rejectUnauthorized?: boolean
40
+ }>
8
41
 
9
42
  export import LoggerConfig = IOGateway.Logging.LogConfig;
43
+
10
44
  export type AuthConfig = Readonly<{
11
- type: 'none' | 'basic' | 'oauth2',
12
- basic?: { user?: {name: string, password?: string}, realm?: string }
13
- oauth2?: { jwt: { issuerUri: string, issuer?: string, audience?: string | string[] } }
45
+ type: 'none'
46
+ | 'basic'
47
+ | 'x509' // since 0.20.0
48
+ | 'oauth2'
49
+ ,
50
+ /** X.509 client certificate authentication configuration
51
+ * @since 0.20.0
52
+ */
53
+ x509?: {
54
+ /** Path to CA private key file (PEM format), for generating client certificates and server certificates */
55
+ key?: string,
56
+ /** Passphrase for encrypted CA private key */
57
+ passphrase?: string,
58
+ /** Extract principal from Subject Alternative Name. Use 'email' to extract from email SAN. If undefined, uses subject (default). */
59
+ principalAltName?: 'email'
60
+ },
61
+ /** Basic authentication configuration */
62
+ basic?: {
63
+ realm?: string
64
+ },
65
+ /** OAuth2 authentication configuration */
66
+ oauth2?: {
67
+ jwt: { issuerUri: string, issuer?: string, audience?: string | string[] }
68
+ },
69
+ // in-memory user for basic auth
70
+ user?: { name: string, password?: string, readonly roles?: string[] }
14
71
  }>;
72
+
15
73
  export type ServerCustomizer = (configurer: ServerConfigurer, config: ServerConfig) => Promise<void>;
16
74
 
17
75
  export type ServerConfig = {
@@ -20,7 +78,7 @@ export namespace GatewayServer {
20
78
  * Accepts a single value or a range.
21
79
  * If a range is specified, will bind to the first available port in the range.
22
80
  *
23
- * Default: 0 - a random port.
81
+ * @defaultValue 0 (random port)
24
82
  */
25
83
  port?: number | string
26
84
  /**
@@ -52,13 +110,54 @@ export namespace GatewayServer {
52
110
 
53
111
  app?: ServerCustomizer,
54
112
  gateway?: IOGateway.GatewayConfig & ServerWebSocketOptions & {
55
- route?: string
113
+ route?: string,
114
+ /**
115
+ * Gateway scope - determines how gateway instances are managed:
116
+ * - 'principal': Each authenticated principal gets their own gateway instance.
117
+ * A default (shared) gateway instance handles all unauthenticated connections.
118
+ * - 'singleton': All connections share the same gateway instance
119
+ *
120
+ * @defaultValue 'principal'
121
+ * @since 0.20.0
122
+ */
123
+ scope?: 'singleton' | 'principal',
56
124
  }
57
125
  }
58
126
 
59
127
  export interface Server {
60
- readonly gateway: IOGateway.Gateway
128
+ readonly gateway: ScopedGateway
129
+ /**
130
+ * Returns the bound address info.
131
+ * Useful when the server is bound to a random port.
132
+ */
133
+ readonly address: AddressInfo | null
61
134
 
62
135
  close(): Promise<void>
63
136
  }
137
+
138
+ /**
139
+ * A scoped gateway that extends the base Gateway interface with methods
140
+ * to access and manage multiple gateway instances based on scope.
141
+ * @since 0.20.0
142
+ */
143
+ export interface ScopedGateway extends IOGateway.Gateway {
144
+
145
+ /**
146
+ * Get gateway info.
147
+ * @param gatewayId - The gateway ID. If omitted, returns aggregated info for all gateways.
148
+ */
149
+ info(gatewayId?: string): Record<string, unknown>
150
+
151
+ /**
152
+ * Stop a gateway instance.
153
+ * @param gatewayId - The gateway ID to stop. If omitted, stops all gateways.
154
+ */
155
+ stop(gatewayId?: string): Promise<IOGateway.Gateway>;
156
+
157
+ /**
158
+ * Get all managed gateway instances.
159
+ * @returns A map of gateway IDs to their corresponding Gateway instances
160
+ */
161
+ getGateways(): Map<string, IOGateway.Gateway>;
162
+ }
64
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interopio/gateway-server",
3
- "version": "0.19.3",
3
+ "version": "0.20.0",
4
4
  "keywords": [
5
5
  "gateway",
6
6
  "server",
@@ -48,6 +48,10 @@
48
48
  "types": "./types/web/test.d.ts",
49
49
  "import": "./dist/web/test.js"
50
50
  },
51
+ "./tools": {
52
+ "types": "./types/tools.d.ts",
53
+ "import": "./dist/tools/index.js"
54
+ },
51
55
  "./metrics/publisher/rest": {
52
56
  "import": "./dist/metrics/publisher/rest.js",
53
57
  "node": "./dist/metrics/publisher/rest.cjs",
@@ -68,20 +72,23 @@
68
72
  }
69
73
  }
70
74
  },
75
+ "bin": {
76
+ "gateway-server": "./gateway-server"
77
+ },
71
78
  "main": "dist/index.js",
72
79
  "types": "gateway-server.d.ts",
73
80
  "type": "module",
74
81
  "engines": {
75
- "node": ">=20.10 || >= 22.12 || >= 24"
82
+ "node": ">=20.18 || >=22.12 || >=24"
76
83
  },
77
84
  "dependencies": {
78
- "@interopio/gateway": "^0.22.3",
79
- "ws": "^8.18.3",
85
+ "@interopio/gateway": "^0.23.0",
80
86
  "tough-cookie": "^6.0.0",
81
- "http-cookie-agent": "^7.0.3"
87
+ "http-cookie-agent": "^7.0.3",
88
+ "ws": "^8.19.0"
82
89
  },
83
90
  "peerDependencies": {
84
- "undici": "^7.16.0"
91
+ "undici": "^7.18.2"
85
92
  },
86
93
  "peerDependenciesMeta": {
87
94
  "undici": {