@homaiohq/napi-quickfix 0.2.0 → 0.3.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/README.md +109 -0
- package/cpp/addon.cpp +1 -1
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/tls-tunnel.d.ts +143 -0
- package/dist/cjs/tls-tunnel.js +261 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/tls-tunnel.d.ts +143 -0
- package/dist/esm/tls-tunnel.js +256 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/node.napi.node +0 -0
- package/prebuilds/darwin-x64/node.napi.node +0 -0
- package/prebuilds/linux-x64/node.napi.glibc.node +0 -0
- package/prebuilds/linux-x64/node.napi.musl.node +0 -0
- package/prebuilds/win32-x64/node.napi.node +0 -0
package/README.md
CHANGED
|
@@ -34,6 +34,9 @@ included.
|
|
|
34
34
|
field (`MsgType`, `Side`, `ExecType`, `OrdStatus`, ...) are
|
|
35
35
|
generated from the bundled QuickFIX headers with literal types, frozen, and
|
|
36
36
|
importable without loading the native addon.
|
|
37
|
+
- **TLS for initiators** — `createTlsTunnel` is a built-in, stunnel-style TLS
|
|
38
|
+
tunnel (mTLS, SNI, certificate verification via Node's `tls`), so you don't need
|
|
39
|
+
a separate stunnel process.
|
|
37
40
|
|
|
38
41
|
## Install
|
|
39
42
|
|
|
@@ -385,6 +388,110 @@ the engine owning it has been stopped. A constructor that fails on a later
|
|
|
385
388
|
(so the ids are not locked out by the failed attempt) and the engine-level log
|
|
386
389
|
QuickFIX had already opened for it.
|
|
387
390
|
|
|
391
|
+
### TLS (stunnel-style) for initiators
|
|
392
|
+
|
|
393
|
+
QuickFIX's own `SSLSocketInitiator` needs OpenSSL compiled into the engine, and
|
|
394
|
+
this package's prebuilds are built without it. Instead, `createTlsTunnel` does
|
|
395
|
+
what a stunnel client does, inside your process. It listens on a loopback port,
|
|
396
|
+
you point the initiator's `SocketConnectHost`/`SocketConnectPort` at it, and it
|
|
397
|
+
carries each connection to the counterparty over Node's
|
|
398
|
+
[`tls.connect`](https://nodejs.org/api/tls.html#tlsconnectoptions-callback):
|
|
399
|
+
|
|
400
|
+
```text
|
|
401
|
+
Initiator --plain TCP--> 127.0.0.1:<tunnel.port> --TLS--> fix.broker.com:9443
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { readFileSync } from 'node:fs';
|
|
406
|
+
import { createTlsTunnel, Initiator, SessionSettings } from '@homaiohq/napi-quickfix';
|
|
407
|
+
|
|
408
|
+
const tunnel = await createTlsTunnel({
|
|
409
|
+
host: 'fix.broker.com',
|
|
410
|
+
port: 9443,
|
|
411
|
+
tls: {
|
|
412
|
+
ca: readFileSync('broker-ca.pem'), // omit to trust Node's default CAs
|
|
413
|
+
cert: readFileSync('client.pem'), // mTLS, if the venue requires it
|
|
414
|
+
key: readFileSync('client.key'),
|
|
415
|
+
minVersion: 'TLSv1.2',
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
tunnel.on('secureConnect', (info) => console.log('TLS up', info.protocol, info.peerCertificate.subject));
|
|
419
|
+
tunnel.on('tlsError', (err) => console.error('TLS failed:', err.message));
|
|
420
|
+
|
|
421
|
+
const settings = SessionSettings.fromString(`
|
|
422
|
+
[DEFAULT]
|
|
423
|
+
ConnectionType=initiator
|
|
424
|
+
SocketConnectHost=${tunnel.host}
|
|
425
|
+
SocketConnectPort=${tunnel.port}
|
|
426
|
+
...
|
|
427
|
+
`);
|
|
428
|
+
const initiator = new Initiator({ settings, handlers });
|
|
429
|
+
await initiator.start();
|
|
430
|
+
|
|
431
|
+
// Shutdown: the engine first, so its Logout still crosses the tunnel.
|
|
432
|
+
await initiator.stop();
|
|
433
|
+
await tunnel.close();
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
- **Options.** `tls` accepts any `tls.ConnectionOptions` except
|
|
437
|
+
`host`/`port`/`socket`/`path`. `servername` (SNI, and the name the server
|
|
438
|
+
certificate is checked against) defaults to `host`, unless `host` is an IP
|
|
439
|
+
address.
|
|
440
|
+
- **Fixed port.** Pass `localPort` to listen on a fixed port, for example to
|
|
441
|
+
keep `SocketConnectPort=9001` in a static `.cfg` file. A port that is already
|
|
442
|
+
in use makes `createTlsTunnel` reject with `EADDRINUSE`.
|
|
443
|
+
- **Failover.** Open one tunnel per `SocketConnectHost<n>`, and set
|
|
444
|
+
`SocketConnectHost<n>=127.0.0.1` with `SocketConnectPort<n>=${tunnelN.port}`.
|
|
445
|
+
- **Failures.** If the TLS connection fails or breaks (DNS, TCP, handshake,
|
|
446
|
+
certificate, or `tls` options that `tls.connect` rejects such as an
|
|
447
|
+
unreadable key), the tunnel emits `'tlsError'` and drops the initiator's
|
|
448
|
+
loopback connection. QuickFIX then reconnects after its `ReconnectInterval`.
|
|
449
|
+
The tunnel never emits `'error'`, so a tunnel with no listeners cannot crash
|
|
450
|
+
the process.
|
|
451
|
+
- **Shutdown of a connection.** When either side closes, the other is ended
|
|
452
|
+
rather than reset, so data still in the tunnel (a Logout, TLS's
|
|
453
|
+
`close_notify`) is delivered. A connection that has not fully closed 5 seconds
|
|
454
|
+
after the first side closed is destroyed.
|
|
455
|
+
- **Lifecycle.** The tunnel's handles are unref'd, so the tunnel alone never
|
|
456
|
+
keeps the process alive (a started engine does). `close()` destroys live
|
|
457
|
+
connections at once. It is idempotent, and
|
|
458
|
+
`await using tunnel = await createTlsTunnel(...)` closes the tunnel at the end
|
|
459
|
+
of the block.
|
|
460
|
+
|
|
461
|
+
**Security notes**
|
|
462
|
+
|
|
463
|
+
- The plain-text hop listens on `127.0.0.1` only. As with stunnel, other
|
|
464
|
+
processes on the same host can still connect to that port, so treat the host
|
|
465
|
+
as trusted.
|
|
466
|
+
- QuickFIX's own logs show the loopback address (`Connecting to 127.0.0.1 on
|
|
467
|
+
port ...`). Use the `'secureConnect'` event to see the real peer and its
|
|
468
|
+
certificate.
|
|
469
|
+
- Tunnel traffic runs on the JS event loop, which the application handlers
|
|
470
|
+
already depend on. A blocked loop stalls both in the same way.
|
|
471
|
+
|
|
472
|
+
**Acceptor side.** There is no in-process equivalent for acceptors. QuickFIX
|
|
473
|
+
1.16's acceptor always listens on every interface, and it checks
|
|
474
|
+
`AllowedRemoteAddresses` only after processing the Logon. A tunnel in front of
|
|
475
|
+
it could therefore be bypassed by connecting to the plain port directly. To
|
|
476
|
+
accept TLS, terminate it outside the process (stunnel in server mode, a load
|
|
477
|
+
balancer, a service mesh) and firewall the acceptor's port.
|
|
478
|
+
|
|
479
|
+
If you'd rather run stunnel as a sidecar on the initiator side, this config is
|
|
480
|
+
equivalent to the example above:
|
|
481
|
+
|
|
482
|
+
```ini
|
|
483
|
+
[fix-broker]
|
|
484
|
+
client = yes
|
|
485
|
+
accept = 127.0.0.1:9001
|
|
486
|
+
connect = fix.broker.com:9443
|
|
487
|
+
CAfile = broker-ca.pem
|
|
488
|
+
cert = client.pem
|
|
489
|
+
key = client.key
|
|
490
|
+
verifyChain = yes
|
|
491
|
+
checkHost = fix.broker.com
|
|
492
|
+
sslVersionMin = TLSv1.2
|
|
493
|
+
```
|
|
494
|
+
|
|
388
495
|
### Session
|
|
389
496
|
|
|
390
497
|
A handle on one **live** session, obtained from `engine.getSession(id)` or the
|
|
@@ -503,6 +610,8 @@ numSessions(): number;
|
|
|
503
610
|
|
|
504
611
|
version(): string; // engine / addon version string
|
|
505
612
|
|
|
613
|
+
createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>; // see "TLS (stunnel-style) for initiators"
|
|
614
|
+
|
|
506
615
|
FIELD // field-name -> tag number, e.g. FIELD.MsgType === 35, FIELD.Password === 554
|
|
507
616
|
MsgType // MsgType values, e.g. MsgType.Logon === 'A'
|
|
508
617
|
Side // Side values, e.g. Side.Buy === '1'
|
package/cpp/addon.cpp
CHANGED
|
@@ -30,7 +30,7 @@ void RegisterSessionStatic(Napi::Env env, Napi::Object exports);
|
|
|
30
30
|
namespace {
|
|
31
31
|
|
|
32
32
|
// Keep in sync with package.json "version".
|
|
33
|
-
constexpr const char* kAddonVersion = "0.
|
|
33
|
+
constexpr const char* kAddonVersion = "0.3.0";
|
|
34
34
|
|
|
35
35
|
Napi::Value Version(const Napi::CallbackInfo& info) {
|
|
36
36
|
return Napi::String::New(info.Env(), kAddonVersion);
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { Session, lookupSession, doesSessionExist, getSessions, numSessions } fr
|
|
|
14
14
|
export { FixReject, fixReject } from './application.js';
|
|
15
15
|
export type { ApplicationHandlers, FixRejectKind } from './application.js';
|
|
16
16
|
export type { QuickFixError } from './native.js';
|
|
17
|
+
export { createTlsTunnel } from './tls-tunnel.js';
|
|
18
|
+
export type { TlsTunnel, TlsTunnelOptions, TlsTunnelEvents, TlsTunnelSecureConnectInfo, } from './tls-tunnel.js';
|
|
17
19
|
export { enums, FIELD, VALUES, MsgType, Side, OrdType, TimeInForce } from './enums.js';
|
|
18
20
|
export type { Enums, EnumGroup, FieldName, ValueGroups, ValueGroupName } from './enums.js';
|
|
19
21
|
/**
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.quickfixParseMsgType = exports.version = exports.TimeInForce = exports.OrdType = exports.Side = exports.MsgType = exports.VALUES = exports.FIELD = exports.enums = exports.fixReject = exports.FixReject = exports.numSessions = exports.getSessions = exports.doesSessionExist = exports.lookupSession = exports.Session = exports.Engine = exports.Acceptor = exports.Initiator = exports.DataDictionary = exports.SessionSettings = exports.SessionID = exports.Group = exports.parseMessage = exports.createMessage = exports.Message = void 0;
|
|
3
|
+
exports.quickfixParseMsgType = exports.version = exports.TimeInForce = exports.OrdType = exports.Side = exports.MsgType = exports.VALUES = exports.FIELD = exports.enums = exports.createTlsTunnel = exports.fixReject = exports.FixReject = exports.numSessions = exports.getSessions = exports.doesSessionExist = exports.lookupSession = exports.Session = exports.Engine = exports.Acceptor = exports.Initiator = exports.DataDictionary = exports.SessionSettings = exports.SessionID = exports.Group = exports.parseMessage = exports.createMessage = exports.Message = void 0;
|
|
4
4
|
exports.sendToTarget = sendToTarget;
|
|
5
5
|
/**
|
|
6
6
|
* `@homaiohq/napi-quickfix` — a modern Node-API binding for the QuickFIX C++
|
|
@@ -39,6 +39,8 @@ Object.defineProperty(exports, "numSessions", { enumerable: true, get: function
|
|
|
39
39
|
var application_js_1 = require("./application.js");
|
|
40
40
|
Object.defineProperty(exports, "FixReject", { enumerable: true, get: function () { return application_js_1.FixReject; } });
|
|
41
41
|
Object.defineProperty(exports, "fixReject", { enumerable: true, get: function () { return application_js_1.fixReject; } });
|
|
42
|
+
var tls_tunnel_js_1 = require("./tls-tunnel.js");
|
|
43
|
+
Object.defineProperty(exports, "createTlsTunnel", { enumerable: true, get: function () { return tls_tunnel_js_1.createTlsTunnel; } });
|
|
42
44
|
// --- Enums / constants ----------------------------------------------------
|
|
43
45
|
// Every other value group (ExecType, OrdStatus, SecurityType, ...) is reachable
|
|
44
46
|
// as `enums.<Field>` / `VALUES.<Field>`, or by name from the
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process TLS tunnel for initiators: the stunnel client model.
|
|
3
|
+
*
|
|
4
|
+
* QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
|
|
5
|
+
* CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
|
|
6
|
+
* a loopback port, the plain `FIX::SocketInitiator` connects to it (point
|
|
7
|
+
* `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
|
|
8
|
+
* {@link TlsTunnel.port}), and every accepted connection is carried to the real
|
|
9
|
+
* counterparty over `tls.connect`.
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import { type Server } from 'node:net';
|
|
13
|
+
import { type CipherNameAndProtocol, type ConnectionOptions, type PeerCertificate } from 'node:tls';
|
|
14
|
+
/** Options for {@link createTlsTunnel}. */
|
|
15
|
+
export interface TlsTunnelOptions {
|
|
16
|
+
/** Host name or IP of the counterparty's TLS endpoint. */
|
|
17
|
+
host: string;
|
|
18
|
+
/** Port of the counterparty's TLS endpoint. */
|
|
19
|
+
port: number;
|
|
20
|
+
/**
|
|
21
|
+
* Passed to `tls.connect()` for every connection (CA, client certificate for
|
|
22
|
+
* mTLS, `minVersion`, ciphers, ...). `servername` (SNI, and the name the
|
|
23
|
+
* server certificate is checked against) defaults to `host` unless `host` is
|
|
24
|
+
* an IP address.
|
|
25
|
+
*/
|
|
26
|
+
tls?: Omit<ConnectionOptions, 'host' | 'port' | 'socket' | 'path'>;
|
|
27
|
+
/**
|
|
28
|
+
* Loopback port to listen on. Defaults to `0`, an ephemeral port read back
|
|
29
|
+
* from {@link TlsTunnel.port}. Set it to point a static `.cfg` file at the
|
|
30
|
+
* tunnel, like stunnel's `accept = 127.0.0.1:<port>`.
|
|
31
|
+
*/
|
|
32
|
+
localPort?: number;
|
|
33
|
+
}
|
|
34
|
+
/** Details of an established TLS connection, emitted as `'secureConnect'`. */
|
|
35
|
+
export interface TlsTunnelSecureConnectInfo {
|
|
36
|
+
/** Whether the server certificate was verified against the trusted CAs. */
|
|
37
|
+
authorized: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Why verification failed, as the OpenSSL verification code (e.g.
|
|
40
|
+
* `'UNABLE_TO_VERIFY_LEAF_SIGNATURE'`, the `code` a `'tlsError'` carries
|
|
41
|
+
* otherwise). Only reachable with `rejectUnauthorized: false`.
|
|
42
|
+
*/
|
|
43
|
+
authorizationError?: string;
|
|
44
|
+
/** Negotiated protocol, e.g. `'TLSv1.3'`. */
|
|
45
|
+
protocol: string | null;
|
|
46
|
+
/** Negotiated cipher. */
|
|
47
|
+
cipher: CipherNameAndProtocol;
|
|
48
|
+
/** The server's certificate (an empty object if it sent none). */
|
|
49
|
+
peerCertificate: PeerCertificate;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Events emitted by a {@link TlsTunnel}. There is deliberately no `'error'`
|
|
53
|
+
* event: connection failures are reported as `'tlsError'`, so a tunnel without
|
|
54
|
+
* listeners never crashes the process.
|
|
55
|
+
*/
|
|
56
|
+
export interface TlsTunnelEvents {
|
|
57
|
+
/** A connection to the counterparty completed its TLS handshake. */
|
|
58
|
+
secureConnect: [info: TlsTunnelSecureConnectInfo];
|
|
59
|
+
/**
|
|
60
|
+
* A connection to the counterparty failed (DNS, TCP, handshake, certificate
|
|
61
|
+
* verification, `tls` options `tls.connect` rejects) or broke. The
|
|
62
|
+
* initiator's loopback connection is closed with it, so QuickFIX reconnects
|
|
63
|
+
* after its `ReconnectInterval`. Also emitted when the loopback server fails
|
|
64
|
+
* to accept a connection (e.g. `EMFILE`).
|
|
65
|
+
*/
|
|
66
|
+
tlsError: [error: Error];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A running TLS tunnel, created by {@link createTlsTunnel}.
|
|
70
|
+
*
|
|
71
|
+
* Its handles are unref'd: the tunnel alone never keeps the process alive (a
|
|
72
|
+
* started engine does). Close it after the engine has stopped, so a graceful
|
|
73
|
+
* Logout still reaches the counterparty.
|
|
74
|
+
*/
|
|
75
|
+
export declare class TlsTunnel extends EventEmitter {
|
|
76
|
+
#private;
|
|
77
|
+
/** Loopback address the tunnel listens on (`SocketConnectHost`). */
|
|
78
|
+
readonly host = "127.0.0.1";
|
|
79
|
+
/** Loopback port the tunnel listens on (`SocketConnectPort`). */
|
|
80
|
+
readonly port: number;
|
|
81
|
+
/** The counterparty's TLS endpoint. */
|
|
82
|
+
readonly remote: {
|
|
83
|
+
readonly host: string;
|
|
84
|
+
readonly port: number;
|
|
85
|
+
};
|
|
86
|
+
/** @internal Use {@link createTlsTunnel}. */
|
|
87
|
+
constructor(server: Server, port: number, options: TlsTunnelOptions);
|
|
88
|
+
/**
|
|
89
|
+
* Stop listening and destroy every live connection. Idempotent: later calls
|
|
90
|
+
* return the same Promise.
|
|
91
|
+
*/
|
|
92
|
+
close(): Promise<void>;
|
|
93
|
+
/** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
|
|
94
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Open a TLS tunnel to `options.host`:`options.port` and resolve once it
|
|
98
|
+
* listens on a loopback port.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const tunnel = await createTlsTunnel({
|
|
103
|
+
* host: 'fix.broker.com',
|
|
104
|
+
* port: 9443,
|
|
105
|
+
* tls: { ca: readFileSync('broker-ca.pem') },
|
|
106
|
+
* });
|
|
107
|
+
* tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
|
|
108
|
+
* const settings = SessionSettings.fromString(`
|
|
109
|
+
* [DEFAULT]
|
|
110
|
+
* ConnectionType=initiator
|
|
111
|
+
* SocketConnectHost=${tunnel.host}
|
|
112
|
+
* SocketConnectPort=${tunnel.port}
|
|
113
|
+
* ...`);
|
|
114
|
+
* const initiator = new Initiator({ settings, handlers });
|
|
115
|
+
* await initiator.start();
|
|
116
|
+
* // ... later: engine first, so the Logout still crosses the tunnel
|
|
117
|
+
* await initiator.stop();
|
|
118
|
+
* await tunnel.close();
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @returns A Promise that rejects with a `TypeError` if `host`, `port` or
|
|
122
|
+
* `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
|
|
123
|
+
* fixed `localPort` already in use) if the loopback port cannot be bound.
|
|
124
|
+
*/
|
|
125
|
+
export declare function createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>;
|
|
126
|
+
export interface TlsTunnel {
|
|
127
|
+
/** Register a listener for a tunnel event (typed per {@link TlsTunnelEvents}). */
|
|
128
|
+
on<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
129
|
+
/** Register a one-shot listener for a tunnel event. */
|
|
130
|
+
once<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
131
|
+
/** Alias of {@link TlsTunnel.on}. */
|
|
132
|
+
addListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
133
|
+
/** Remove a previously registered listener. */
|
|
134
|
+
off<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
135
|
+
/** Alias of {@link TlsTunnel.off}. */
|
|
136
|
+
removeListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
137
|
+
/** Remove all listeners (optionally for a single event). */
|
|
138
|
+
removeAllListeners<E extends keyof TlsTunnelEvents>(event?: E): this;
|
|
139
|
+
/** Emit a tunnel event with its typed arguments. */
|
|
140
|
+
emit<E extends keyof TlsTunnelEvents>(event: E, ...args: TlsTunnelEvents[E]): boolean;
|
|
141
|
+
/** List the listeners registered for an event. */
|
|
142
|
+
listeners<E extends keyof TlsTunnelEvents>(event: E): Array<(...args: TlsTunnelEvents[E]) => void>;
|
|
143
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TlsTunnel = void 0;
|
|
4
|
+
exports.createTlsTunnel = createTlsTunnel;
|
|
5
|
+
/**
|
|
6
|
+
* In-process TLS tunnel for initiators: the stunnel client model.
|
|
7
|
+
*
|
|
8
|
+
* QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
|
|
9
|
+
* CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
|
|
10
|
+
* a loopback port, the plain `FIX::SocketInitiator` connects to it (point
|
|
11
|
+
* `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
|
|
12
|
+
* {@link TlsTunnel.port}), and every accepted connection is carried to the real
|
|
13
|
+
* counterparty over `tls.connect`.
|
|
14
|
+
*/
|
|
15
|
+
const node_events_1 = require("node:events");
|
|
16
|
+
const node_net_1 = require("node:net");
|
|
17
|
+
const node_tls_1 = require("node:tls");
|
|
18
|
+
const LOOPBACK = '127.0.0.1';
|
|
19
|
+
/** How long a connection may take to wind down once either side has ended. */
|
|
20
|
+
const DRAIN_TIMEOUT_MS = 5000;
|
|
21
|
+
/**
|
|
22
|
+
* A running TLS tunnel, created by {@link createTlsTunnel}.
|
|
23
|
+
*
|
|
24
|
+
* Its handles are unref'd: the tunnel alone never keeps the process alive (a
|
|
25
|
+
* started engine does). Close it after the engine has stopped, so a graceful
|
|
26
|
+
* Logout still reaches the counterparty.
|
|
27
|
+
*/
|
|
28
|
+
class TlsTunnel extends node_events_1.EventEmitter {
|
|
29
|
+
/** Loopback address the tunnel listens on (`SocketConnectHost`). */
|
|
30
|
+
host = LOOPBACK;
|
|
31
|
+
/** Loopback port the tunnel listens on (`SocketConnectPort`). */
|
|
32
|
+
port;
|
|
33
|
+
/** The counterparty's TLS endpoint. */
|
|
34
|
+
remote;
|
|
35
|
+
#server;
|
|
36
|
+
#tls;
|
|
37
|
+
#pairs = new Set();
|
|
38
|
+
#closing;
|
|
39
|
+
/** @internal Use {@link createTlsTunnel}. */
|
|
40
|
+
constructor(server, port, options) {
|
|
41
|
+
super();
|
|
42
|
+
this.#server = server;
|
|
43
|
+
this.port = port;
|
|
44
|
+
this.remote = Object.freeze({ host: options.host, port: options.port });
|
|
45
|
+
// `socket` and `path` are excluded by the types only: drop them for JS
|
|
46
|
+
// callers too, or every connection would go to them instead of host:port.
|
|
47
|
+
const { socket: _socket, path: _path, ...tls } = (options.tls ?? {});
|
|
48
|
+
const servername = tls.servername ?? ((0, node_net_1.isIP)(options.host) === 0 ? options.host : undefined);
|
|
49
|
+
this.#tls = {
|
|
50
|
+
...tls,
|
|
51
|
+
...(servername === undefined ? {} : { servername }),
|
|
52
|
+
host: options.host,
|
|
53
|
+
port: options.port,
|
|
54
|
+
};
|
|
55
|
+
server.on('connection', (local) => this.#onConnection(local));
|
|
56
|
+
// A failed accept() (EMFILE, ENFILE, ...) is emitted on the server: without
|
|
57
|
+
// a listener it would be thrown.
|
|
58
|
+
server.on('error', (err) => {
|
|
59
|
+
if (!this.#closing)
|
|
60
|
+
this.emit('tlsError', err);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
#onConnection(local) {
|
|
64
|
+
if (this.#closing) {
|
|
65
|
+
local.destroy();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
local.unref();
|
|
69
|
+
// QuickFIX writes its Logon as soon as it connects: hold it until the
|
|
70
|
+
// handshake is done and the pipe below takes over. It is read rather than
|
|
71
|
+
// left in a paused socket, which is not read at all: the engine's EOF or
|
|
72
|
+
// reset would go unseen, and a handshake completing after the engine gave
|
|
73
|
+
// up would still deliver its stale Logon.
|
|
74
|
+
const pending = [];
|
|
75
|
+
const hold = (chunk) => pending.push(chunk);
|
|
76
|
+
local.on('data', hold);
|
|
77
|
+
// tls.connect throws synchronously on options it cannot build a secure
|
|
78
|
+
// context from (bad PEM, unknown minVersion, ...): report it like any other
|
|
79
|
+
// connection failure instead of letting it escape the 'connection' handler.
|
|
80
|
+
let remote;
|
|
81
|
+
try {
|
|
82
|
+
remote = (0, node_tls_1.connect)(this.#tls);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
local.on('error', () => { });
|
|
86
|
+
local.destroy();
|
|
87
|
+
this.emit('tlsError', err instanceof Error ? err : new Error(String(err)));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
remote.unref();
|
|
91
|
+
const pair = { local, remote };
|
|
92
|
+
this.#pairs.add(pair);
|
|
93
|
+
// Once piped, a side that ends or closes is wound down gracefully on the
|
|
94
|
+
// other: it is ended, so the data still queued in the tunnel (a Logout, the
|
|
95
|
+
// TLS close_notify) is delivered rather than reset, and drained of what it
|
|
96
|
+
// still receives so it sees its peer's EOF. Anything still open
|
|
97
|
+
// DRAIN_TIMEOUT_MS after the first EOF is destroyed. Before the handshake
|
|
98
|
+
// completes there is nothing to deliver: an EOF or close drops both sides,
|
|
99
|
+
// and QuickFIX reconnects on its own schedule.
|
|
100
|
+
let piped = false;
|
|
101
|
+
let open = 2;
|
|
102
|
+
let drainTimer;
|
|
103
|
+
const linger = () => {
|
|
104
|
+
drainTimer ??= setTimeout(() => {
|
|
105
|
+
local.destroy();
|
|
106
|
+
remote.destroy();
|
|
107
|
+
}, DRAIN_TIMEOUT_MS).unref();
|
|
108
|
+
};
|
|
109
|
+
const onClose = (closed, other) => () => {
|
|
110
|
+
if (--open === 0) {
|
|
111
|
+
clearTimeout(drainTimer);
|
|
112
|
+
this.#pairs.delete(pair);
|
|
113
|
+
}
|
|
114
|
+
else if (!piped) {
|
|
115
|
+
other.destroy();
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
other.unpipe(closed);
|
|
119
|
+
other.resume();
|
|
120
|
+
other.end();
|
|
121
|
+
linger();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
local.on('error', () => {
|
|
125
|
+
// ECONNRESET and the like from the engine's side: nothing to report.
|
|
126
|
+
});
|
|
127
|
+
remote.on('error', (err) => {
|
|
128
|
+
if (!this.#closing)
|
|
129
|
+
this.emit('tlsError', err);
|
|
130
|
+
});
|
|
131
|
+
local.once('close', onClose(local, remote));
|
|
132
|
+
remote.once('close', onClose(remote, local));
|
|
133
|
+
local.once('end', () => {
|
|
134
|
+
if (piped) {
|
|
135
|
+
linger();
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
local.destroy();
|
|
139
|
+
remote.destroy();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
remote.once('secureConnect', () => {
|
|
143
|
+
local.setNoDelay(true);
|
|
144
|
+
remote.setNoDelay(true);
|
|
145
|
+
// Each side's EOF ends the other. The loopback socket is half-open, so
|
|
146
|
+
// after QuickFIX's EOF the counterparty's last bytes still reach it; the
|
|
147
|
+
// TLS socket ends itself on the counterparty's EOF, so what QuickFIX
|
|
148
|
+
// still sends is discarded (its peer has gone).
|
|
149
|
+
local.off('data', hold);
|
|
150
|
+
for (const chunk of pending)
|
|
151
|
+
remote.write(chunk);
|
|
152
|
+
pending.length = 0;
|
|
153
|
+
local.pipe(remote);
|
|
154
|
+
remote.pipe(local);
|
|
155
|
+
remote.once('end', () => {
|
|
156
|
+
local.unpipe(remote);
|
|
157
|
+
local.resume();
|
|
158
|
+
linger();
|
|
159
|
+
});
|
|
160
|
+
piped = true;
|
|
161
|
+
// Typed as an Error, but a string code at runtime, and null once the
|
|
162
|
+
// certificate verified.
|
|
163
|
+
const authorizationError = remote.authorizationError;
|
|
164
|
+
this.emit('secureConnect', {
|
|
165
|
+
authorized: remote.authorized,
|
|
166
|
+
...(authorizationError ? { authorizationError } : {}),
|
|
167
|
+
protocol: remote.getProtocol(),
|
|
168
|
+
cipher: remote.getCipher(),
|
|
169
|
+
peerCertificate: remote.getPeerCertificate(),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Stop listening and destroy every live connection. Idempotent: later calls
|
|
175
|
+
* return the same Promise.
|
|
176
|
+
*/
|
|
177
|
+
close() {
|
|
178
|
+
this.#closing ??= new Promise((resolve) => {
|
|
179
|
+
for (const { local, remote } of this.#pairs) {
|
|
180
|
+
local.destroy();
|
|
181
|
+
remote.destroy();
|
|
182
|
+
}
|
|
183
|
+
this.#pairs.clear();
|
|
184
|
+
// The only possible error is "not running", which still means closed.
|
|
185
|
+
this.#server.close(() => resolve());
|
|
186
|
+
});
|
|
187
|
+
return this.#closing;
|
|
188
|
+
}
|
|
189
|
+
/** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
|
|
190
|
+
[Symbol.asyncDispose]() {
|
|
191
|
+
return this.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
exports.TlsTunnel = TlsTunnel;
|
|
195
|
+
/**
|
|
196
|
+
* Open a TLS tunnel to `options.host`:`options.port` and resolve once it
|
|
197
|
+
* listens on a loopback port.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* const tunnel = await createTlsTunnel({
|
|
202
|
+
* host: 'fix.broker.com',
|
|
203
|
+
* port: 9443,
|
|
204
|
+
* tls: { ca: readFileSync('broker-ca.pem') },
|
|
205
|
+
* });
|
|
206
|
+
* tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
|
|
207
|
+
* const settings = SessionSettings.fromString(`
|
|
208
|
+
* [DEFAULT]
|
|
209
|
+
* ConnectionType=initiator
|
|
210
|
+
* SocketConnectHost=${tunnel.host}
|
|
211
|
+
* SocketConnectPort=${tunnel.port}
|
|
212
|
+
* ...`);
|
|
213
|
+
* const initiator = new Initiator({ settings, handlers });
|
|
214
|
+
* await initiator.start();
|
|
215
|
+
* // ... later: engine first, so the Logout still crosses the tunnel
|
|
216
|
+
* await initiator.stop();
|
|
217
|
+
* await tunnel.close();
|
|
218
|
+
* ```
|
|
219
|
+
*
|
|
220
|
+
* @returns A Promise that rejects with a `TypeError` if `host`, `port` or
|
|
221
|
+
* `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
|
|
222
|
+
* fixed `localPort` already in use) if the loopback port cannot be bound.
|
|
223
|
+
*/
|
|
224
|
+
async function createTlsTunnel(options) {
|
|
225
|
+
if (typeof options !== 'object' || options === null) {
|
|
226
|
+
throw new TypeError('createTlsTunnel({ host, port, tls?, localPort? })');
|
|
227
|
+
}
|
|
228
|
+
if (typeof options.host !== 'string' || options.host === '') {
|
|
229
|
+
throw new TypeError('host must be a non-empty string');
|
|
230
|
+
}
|
|
231
|
+
if (!isPort(options.port, 1)) {
|
|
232
|
+
throw new TypeError('port must be an integer from 1 to 65535');
|
|
233
|
+
}
|
|
234
|
+
const localPort = options.localPort ?? 0;
|
|
235
|
+
if (!isPort(localPort, 0)) {
|
|
236
|
+
throw new TypeError('localPort must be an integer from 0 to 65535');
|
|
237
|
+
}
|
|
238
|
+
// Half-open, so QuickFIX's EOF does not end the loopback socket before the
|
|
239
|
+
// counterparty's last bytes have crossed it (see #onConnection).
|
|
240
|
+
const server = (0, node_net_1.createServer)({ allowHalfOpen: true });
|
|
241
|
+
await new Promise((resolve, reject) => {
|
|
242
|
+
const onError = (err) => {
|
|
243
|
+
server.off('listening', onListening);
|
|
244
|
+
reject(err);
|
|
245
|
+
};
|
|
246
|
+
const onListening = () => {
|
|
247
|
+
server.off('error', onError);
|
|
248
|
+
resolve();
|
|
249
|
+
};
|
|
250
|
+
server.once('error', onError);
|
|
251
|
+
server.once('listening', onListening);
|
|
252
|
+
server.listen(localPort, LOOPBACK);
|
|
253
|
+
});
|
|
254
|
+
server.unref();
|
|
255
|
+
const addr = server.address();
|
|
256
|
+
const port = typeof addr === 'object' && addr ? addr.port : localPort;
|
|
257
|
+
return new TlsTunnel(server, port, options);
|
|
258
|
+
}
|
|
259
|
+
function isPort(value, min) {
|
|
260
|
+
return Number.isInteger(value) && value >= min && value <= 65535;
|
|
261
|
+
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { Session, lookupSession, doesSessionExist, getSessions, numSessions } fr
|
|
|
14
14
|
export { FixReject, fixReject } from './application.js';
|
|
15
15
|
export type { ApplicationHandlers, FixRejectKind } from './application.js';
|
|
16
16
|
export type { QuickFixError } from './native.js';
|
|
17
|
+
export { createTlsTunnel } from './tls-tunnel.js';
|
|
18
|
+
export type { TlsTunnel, TlsTunnelOptions, TlsTunnelEvents, TlsTunnelSecureConnectInfo, } from './tls-tunnel.js';
|
|
17
19
|
export { enums, FIELD, VALUES, MsgType, Side, OrdType, TimeInForce } from './enums.js';
|
|
18
20
|
export type { Enums, EnumGroup, FieldName, ValueGroups, ValueGroupName } from './enums.js';
|
|
19
21
|
/**
|
package/dist/esm/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { Acceptor } from './acceptor.js';
|
|
|
18
18
|
export { Engine } from './engine.js';
|
|
19
19
|
export { Session, lookupSession, doesSessionExist, getSessions, numSessions } from './session.js';
|
|
20
20
|
export { FixReject, fixReject } from './application.js';
|
|
21
|
+
export { createTlsTunnel } from './tls-tunnel.js';
|
|
21
22
|
// --- Enums / constants ----------------------------------------------------
|
|
22
23
|
// Every other value group (ExecType, OrdStatus, SecurityType, ...) is reachable
|
|
23
24
|
// as `enums.<Field>` / `VALUES.<Field>`, or by name from the
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process TLS tunnel for initiators: the stunnel client model.
|
|
3
|
+
*
|
|
4
|
+
* QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
|
|
5
|
+
* CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
|
|
6
|
+
* a loopback port, the plain `FIX::SocketInitiator` connects to it (point
|
|
7
|
+
* `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
|
|
8
|
+
* {@link TlsTunnel.port}), and every accepted connection is carried to the real
|
|
9
|
+
* counterparty over `tls.connect`.
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import { type Server } from 'node:net';
|
|
13
|
+
import { type CipherNameAndProtocol, type ConnectionOptions, type PeerCertificate } from 'node:tls';
|
|
14
|
+
/** Options for {@link createTlsTunnel}. */
|
|
15
|
+
export interface TlsTunnelOptions {
|
|
16
|
+
/** Host name or IP of the counterparty's TLS endpoint. */
|
|
17
|
+
host: string;
|
|
18
|
+
/** Port of the counterparty's TLS endpoint. */
|
|
19
|
+
port: number;
|
|
20
|
+
/**
|
|
21
|
+
* Passed to `tls.connect()` for every connection (CA, client certificate for
|
|
22
|
+
* mTLS, `minVersion`, ciphers, ...). `servername` (SNI, and the name the
|
|
23
|
+
* server certificate is checked against) defaults to `host` unless `host` is
|
|
24
|
+
* an IP address.
|
|
25
|
+
*/
|
|
26
|
+
tls?: Omit<ConnectionOptions, 'host' | 'port' | 'socket' | 'path'>;
|
|
27
|
+
/**
|
|
28
|
+
* Loopback port to listen on. Defaults to `0`, an ephemeral port read back
|
|
29
|
+
* from {@link TlsTunnel.port}. Set it to point a static `.cfg` file at the
|
|
30
|
+
* tunnel, like stunnel's `accept = 127.0.0.1:<port>`.
|
|
31
|
+
*/
|
|
32
|
+
localPort?: number;
|
|
33
|
+
}
|
|
34
|
+
/** Details of an established TLS connection, emitted as `'secureConnect'`. */
|
|
35
|
+
export interface TlsTunnelSecureConnectInfo {
|
|
36
|
+
/** Whether the server certificate was verified against the trusted CAs. */
|
|
37
|
+
authorized: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Why verification failed, as the OpenSSL verification code (e.g.
|
|
40
|
+
* `'UNABLE_TO_VERIFY_LEAF_SIGNATURE'`, the `code` a `'tlsError'` carries
|
|
41
|
+
* otherwise). Only reachable with `rejectUnauthorized: false`.
|
|
42
|
+
*/
|
|
43
|
+
authorizationError?: string;
|
|
44
|
+
/** Negotiated protocol, e.g. `'TLSv1.3'`. */
|
|
45
|
+
protocol: string | null;
|
|
46
|
+
/** Negotiated cipher. */
|
|
47
|
+
cipher: CipherNameAndProtocol;
|
|
48
|
+
/** The server's certificate (an empty object if it sent none). */
|
|
49
|
+
peerCertificate: PeerCertificate;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Events emitted by a {@link TlsTunnel}. There is deliberately no `'error'`
|
|
53
|
+
* event: connection failures are reported as `'tlsError'`, so a tunnel without
|
|
54
|
+
* listeners never crashes the process.
|
|
55
|
+
*/
|
|
56
|
+
export interface TlsTunnelEvents {
|
|
57
|
+
/** A connection to the counterparty completed its TLS handshake. */
|
|
58
|
+
secureConnect: [info: TlsTunnelSecureConnectInfo];
|
|
59
|
+
/**
|
|
60
|
+
* A connection to the counterparty failed (DNS, TCP, handshake, certificate
|
|
61
|
+
* verification, `tls` options `tls.connect` rejects) or broke. The
|
|
62
|
+
* initiator's loopback connection is closed with it, so QuickFIX reconnects
|
|
63
|
+
* after its `ReconnectInterval`. Also emitted when the loopback server fails
|
|
64
|
+
* to accept a connection (e.g. `EMFILE`).
|
|
65
|
+
*/
|
|
66
|
+
tlsError: [error: Error];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A running TLS tunnel, created by {@link createTlsTunnel}.
|
|
70
|
+
*
|
|
71
|
+
* Its handles are unref'd: the tunnel alone never keeps the process alive (a
|
|
72
|
+
* started engine does). Close it after the engine has stopped, so a graceful
|
|
73
|
+
* Logout still reaches the counterparty.
|
|
74
|
+
*/
|
|
75
|
+
export declare class TlsTunnel extends EventEmitter {
|
|
76
|
+
#private;
|
|
77
|
+
/** Loopback address the tunnel listens on (`SocketConnectHost`). */
|
|
78
|
+
readonly host = "127.0.0.1";
|
|
79
|
+
/** Loopback port the tunnel listens on (`SocketConnectPort`). */
|
|
80
|
+
readonly port: number;
|
|
81
|
+
/** The counterparty's TLS endpoint. */
|
|
82
|
+
readonly remote: {
|
|
83
|
+
readonly host: string;
|
|
84
|
+
readonly port: number;
|
|
85
|
+
};
|
|
86
|
+
/** @internal Use {@link createTlsTunnel}. */
|
|
87
|
+
constructor(server: Server, port: number, options: TlsTunnelOptions);
|
|
88
|
+
/**
|
|
89
|
+
* Stop listening and destroy every live connection. Idempotent: later calls
|
|
90
|
+
* return the same Promise.
|
|
91
|
+
*/
|
|
92
|
+
close(): Promise<void>;
|
|
93
|
+
/** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
|
|
94
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Open a TLS tunnel to `options.host`:`options.port` and resolve once it
|
|
98
|
+
* listens on a loopback port.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const tunnel = await createTlsTunnel({
|
|
103
|
+
* host: 'fix.broker.com',
|
|
104
|
+
* port: 9443,
|
|
105
|
+
* tls: { ca: readFileSync('broker-ca.pem') },
|
|
106
|
+
* });
|
|
107
|
+
* tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
|
|
108
|
+
* const settings = SessionSettings.fromString(`
|
|
109
|
+
* [DEFAULT]
|
|
110
|
+
* ConnectionType=initiator
|
|
111
|
+
* SocketConnectHost=${tunnel.host}
|
|
112
|
+
* SocketConnectPort=${tunnel.port}
|
|
113
|
+
* ...`);
|
|
114
|
+
* const initiator = new Initiator({ settings, handlers });
|
|
115
|
+
* await initiator.start();
|
|
116
|
+
* // ... later: engine first, so the Logout still crosses the tunnel
|
|
117
|
+
* await initiator.stop();
|
|
118
|
+
* await tunnel.close();
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @returns A Promise that rejects with a `TypeError` if `host`, `port` or
|
|
122
|
+
* `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
|
|
123
|
+
* fixed `localPort` already in use) if the loopback port cannot be bound.
|
|
124
|
+
*/
|
|
125
|
+
export declare function createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>;
|
|
126
|
+
export interface TlsTunnel {
|
|
127
|
+
/** Register a listener for a tunnel event (typed per {@link TlsTunnelEvents}). */
|
|
128
|
+
on<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
129
|
+
/** Register a one-shot listener for a tunnel event. */
|
|
130
|
+
once<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
131
|
+
/** Alias of {@link TlsTunnel.on}. */
|
|
132
|
+
addListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
133
|
+
/** Remove a previously registered listener. */
|
|
134
|
+
off<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
135
|
+
/** Alias of {@link TlsTunnel.off}. */
|
|
136
|
+
removeListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
|
|
137
|
+
/** Remove all listeners (optionally for a single event). */
|
|
138
|
+
removeAllListeners<E extends keyof TlsTunnelEvents>(event?: E): this;
|
|
139
|
+
/** Emit a tunnel event with its typed arguments. */
|
|
140
|
+
emit<E extends keyof TlsTunnelEvents>(event: E, ...args: TlsTunnelEvents[E]): boolean;
|
|
141
|
+
/** List the listeners registered for an event. */
|
|
142
|
+
listeners<E extends keyof TlsTunnelEvents>(event: E): Array<(...args: TlsTunnelEvents[E]) => void>;
|
|
143
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process TLS tunnel for initiators: the stunnel client model.
|
|
3
|
+
*
|
|
4
|
+
* QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
|
|
5
|
+
* CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
|
|
6
|
+
* a loopback port, the plain `FIX::SocketInitiator` connects to it (point
|
|
7
|
+
* `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
|
|
8
|
+
* {@link TlsTunnel.port}), and every accepted connection is carried to the real
|
|
9
|
+
* counterparty over `tls.connect`.
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import { createServer, isIP } from 'node:net';
|
|
13
|
+
import { connect as tlsConnect, } from 'node:tls';
|
|
14
|
+
const LOOPBACK = '127.0.0.1';
|
|
15
|
+
/** How long a connection may take to wind down once either side has ended. */
|
|
16
|
+
const DRAIN_TIMEOUT_MS = 5000;
|
|
17
|
+
/**
|
|
18
|
+
* A running TLS tunnel, created by {@link createTlsTunnel}.
|
|
19
|
+
*
|
|
20
|
+
* Its handles are unref'd: the tunnel alone never keeps the process alive (a
|
|
21
|
+
* started engine does). Close it after the engine has stopped, so a graceful
|
|
22
|
+
* Logout still reaches the counterparty.
|
|
23
|
+
*/
|
|
24
|
+
export class TlsTunnel extends EventEmitter {
|
|
25
|
+
/** Loopback address the tunnel listens on (`SocketConnectHost`). */
|
|
26
|
+
host = LOOPBACK;
|
|
27
|
+
/** Loopback port the tunnel listens on (`SocketConnectPort`). */
|
|
28
|
+
port;
|
|
29
|
+
/** The counterparty's TLS endpoint. */
|
|
30
|
+
remote;
|
|
31
|
+
#server;
|
|
32
|
+
#tls;
|
|
33
|
+
#pairs = new Set();
|
|
34
|
+
#closing;
|
|
35
|
+
/** @internal Use {@link createTlsTunnel}. */
|
|
36
|
+
constructor(server, port, options) {
|
|
37
|
+
super();
|
|
38
|
+
this.#server = server;
|
|
39
|
+
this.port = port;
|
|
40
|
+
this.remote = Object.freeze({ host: options.host, port: options.port });
|
|
41
|
+
// `socket` and `path` are excluded by the types only: drop them for JS
|
|
42
|
+
// callers too, or every connection would go to them instead of host:port.
|
|
43
|
+
const { socket: _socket, path: _path, ...tls } = (options.tls ?? {});
|
|
44
|
+
const servername = tls.servername ?? (isIP(options.host) === 0 ? options.host : undefined);
|
|
45
|
+
this.#tls = {
|
|
46
|
+
...tls,
|
|
47
|
+
...(servername === undefined ? {} : { servername }),
|
|
48
|
+
host: options.host,
|
|
49
|
+
port: options.port,
|
|
50
|
+
};
|
|
51
|
+
server.on('connection', (local) => this.#onConnection(local));
|
|
52
|
+
// A failed accept() (EMFILE, ENFILE, ...) is emitted on the server: without
|
|
53
|
+
// a listener it would be thrown.
|
|
54
|
+
server.on('error', (err) => {
|
|
55
|
+
if (!this.#closing)
|
|
56
|
+
this.emit('tlsError', err);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
#onConnection(local) {
|
|
60
|
+
if (this.#closing) {
|
|
61
|
+
local.destroy();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
local.unref();
|
|
65
|
+
// QuickFIX writes its Logon as soon as it connects: hold it until the
|
|
66
|
+
// handshake is done and the pipe below takes over. It is read rather than
|
|
67
|
+
// left in a paused socket, which is not read at all: the engine's EOF or
|
|
68
|
+
// reset would go unseen, and a handshake completing after the engine gave
|
|
69
|
+
// up would still deliver its stale Logon.
|
|
70
|
+
const pending = [];
|
|
71
|
+
const hold = (chunk) => pending.push(chunk);
|
|
72
|
+
local.on('data', hold);
|
|
73
|
+
// tls.connect throws synchronously on options it cannot build a secure
|
|
74
|
+
// context from (bad PEM, unknown minVersion, ...): report it like any other
|
|
75
|
+
// connection failure instead of letting it escape the 'connection' handler.
|
|
76
|
+
let remote;
|
|
77
|
+
try {
|
|
78
|
+
remote = tlsConnect(this.#tls);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
local.on('error', () => { });
|
|
82
|
+
local.destroy();
|
|
83
|
+
this.emit('tlsError', err instanceof Error ? err : new Error(String(err)));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
remote.unref();
|
|
87
|
+
const pair = { local, remote };
|
|
88
|
+
this.#pairs.add(pair);
|
|
89
|
+
// Once piped, a side that ends or closes is wound down gracefully on the
|
|
90
|
+
// other: it is ended, so the data still queued in the tunnel (a Logout, the
|
|
91
|
+
// TLS close_notify) is delivered rather than reset, and drained of what it
|
|
92
|
+
// still receives so it sees its peer's EOF. Anything still open
|
|
93
|
+
// DRAIN_TIMEOUT_MS after the first EOF is destroyed. Before the handshake
|
|
94
|
+
// completes there is nothing to deliver: an EOF or close drops both sides,
|
|
95
|
+
// and QuickFIX reconnects on its own schedule.
|
|
96
|
+
let piped = false;
|
|
97
|
+
let open = 2;
|
|
98
|
+
let drainTimer;
|
|
99
|
+
const linger = () => {
|
|
100
|
+
drainTimer ??= setTimeout(() => {
|
|
101
|
+
local.destroy();
|
|
102
|
+
remote.destroy();
|
|
103
|
+
}, DRAIN_TIMEOUT_MS).unref();
|
|
104
|
+
};
|
|
105
|
+
const onClose = (closed, other) => () => {
|
|
106
|
+
if (--open === 0) {
|
|
107
|
+
clearTimeout(drainTimer);
|
|
108
|
+
this.#pairs.delete(pair);
|
|
109
|
+
}
|
|
110
|
+
else if (!piped) {
|
|
111
|
+
other.destroy();
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
other.unpipe(closed);
|
|
115
|
+
other.resume();
|
|
116
|
+
other.end();
|
|
117
|
+
linger();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
local.on('error', () => {
|
|
121
|
+
// ECONNRESET and the like from the engine's side: nothing to report.
|
|
122
|
+
});
|
|
123
|
+
remote.on('error', (err) => {
|
|
124
|
+
if (!this.#closing)
|
|
125
|
+
this.emit('tlsError', err);
|
|
126
|
+
});
|
|
127
|
+
local.once('close', onClose(local, remote));
|
|
128
|
+
remote.once('close', onClose(remote, local));
|
|
129
|
+
local.once('end', () => {
|
|
130
|
+
if (piped) {
|
|
131
|
+
linger();
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
local.destroy();
|
|
135
|
+
remote.destroy();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
remote.once('secureConnect', () => {
|
|
139
|
+
local.setNoDelay(true);
|
|
140
|
+
remote.setNoDelay(true);
|
|
141
|
+
// Each side's EOF ends the other. The loopback socket is half-open, so
|
|
142
|
+
// after QuickFIX's EOF the counterparty's last bytes still reach it; the
|
|
143
|
+
// TLS socket ends itself on the counterparty's EOF, so what QuickFIX
|
|
144
|
+
// still sends is discarded (its peer has gone).
|
|
145
|
+
local.off('data', hold);
|
|
146
|
+
for (const chunk of pending)
|
|
147
|
+
remote.write(chunk);
|
|
148
|
+
pending.length = 0;
|
|
149
|
+
local.pipe(remote);
|
|
150
|
+
remote.pipe(local);
|
|
151
|
+
remote.once('end', () => {
|
|
152
|
+
local.unpipe(remote);
|
|
153
|
+
local.resume();
|
|
154
|
+
linger();
|
|
155
|
+
});
|
|
156
|
+
piped = true;
|
|
157
|
+
// Typed as an Error, but a string code at runtime, and null once the
|
|
158
|
+
// certificate verified.
|
|
159
|
+
const authorizationError = remote.authorizationError;
|
|
160
|
+
this.emit('secureConnect', {
|
|
161
|
+
authorized: remote.authorized,
|
|
162
|
+
...(authorizationError ? { authorizationError } : {}),
|
|
163
|
+
protocol: remote.getProtocol(),
|
|
164
|
+
cipher: remote.getCipher(),
|
|
165
|
+
peerCertificate: remote.getPeerCertificate(),
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Stop listening and destroy every live connection. Idempotent: later calls
|
|
171
|
+
* return the same Promise.
|
|
172
|
+
*/
|
|
173
|
+
close() {
|
|
174
|
+
this.#closing ??= new Promise((resolve) => {
|
|
175
|
+
for (const { local, remote } of this.#pairs) {
|
|
176
|
+
local.destroy();
|
|
177
|
+
remote.destroy();
|
|
178
|
+
}
|
|
179
|
+
this.#pairs.clear();
|
|
180
|
+
// The only possible error is "not running", which still means closed.
|
|
181
|
+
this.#server.close(() => resolve());
|
|
182
|
+
});
|
|
183
|
+
return this.#closing;
|
|
184
|
+
}
|
|
185
|
+
/** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
|
|
186
|
+
[Symbol.asyncDispose]() {
|
|
187
|
+
return this.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Open a TLS tunnel to `options.host`:`options.port` and resolve once it
|
|
192
|
+
* listens on a loopback port.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```ts
|
|
196
|
+
* const tunnel = await createTlsTunnel({
|
|
197
|
+
* host: 'fix.broker.com',
|
|
198
|
+
* port: 9443,
|
|
199
|
+
* tls: { ca: readFileSync('broker-ca.pem') },
|
|
200
|
+
* });
|
|
201
|
+
* tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
|
|
202
|
+
* const settings = SessionSettings.fromString(`
|
|
203
|
+
* [DEFAULT]
|
|
204
|
+
* ConnectionType=initiator
|
|
205
|
+
* SocketConnectHost=${tunnel.host}
|
|
206
|
+
* SocketConnectPort=${tunnel.port}
|
|
207
|
+
* ...`);
|
|
208
|
+
* const initiator = new Initiator({ settings, handlers });
|
|
209
|
+
* await initiator.start();
|
|
210
|
+
* // ... later: engine first, so the Logout still crosses the tunnel
|
|
211
|
+
* await initiator.stop();
|
|
212
|
+
* await tunnel.close();
|
|
213
|
+
* ```
|
|
214
|
+
*
|
|
215
|
+
* @returns A Promise that rejects with a `TypeError` if `host`, `port` or
|
|
216
|
+
* `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
|
|
217
|
+
* fixed `localPort` already in use) if the loopback port cannot be bound.
|
|
218
|
+
*/
|
|
219
|
+
export async function createTlsTunnel(options) {
|
|
220
|
+
if (typeof options !== 'object' || options === null) {
|
|
221
|
+
throw new TypeError('createTlsTunnel({ host, port, tls?, localPort? })');
|
|
222
|
+
}
|
|
223
|
+
if (typeof options.host !== 'string' || options.host === '') {
|
|
224
|
+
throw new TypeError('host must be a non-empty string');
|
|
225
|
+
}
|
|
226
|
+
if (!isPort(options.port, 1)) {
|
|
227
|
+
throw new TypeError('port must be an integer from 1 to 65535');
|
|
228
|
+
}
|
|
229
|
+
const localPort = options.localPort ?? 0;
|
|
230
|
+
if (!isPort(localPort, 0)) {
|
|
231
|
+
throw new TypeError('localPort must be an integer from 0 to 65535');
|
|
232
|
+
}
|
|
233
|
+
// Half-open, so QuickFIX's EOF does not end the loopback socket before the
|
|
234
|
+
// counterparty's last bytes have crossed it (see #onConnection).
|
|
235
|
+
const server = createServer({ allowHalfOpen: true });
|
|
236
|
+
await new Promise((resolve, reject) => {
|
|
237
|
+
const onError = (err) => {
|
|
238
|
+
server.off('listening', onListening);
|
|
239
|
+
reject(err);
|
|
240
|
+
};
|
|
241
|
+
const onListening = () => {
|
|
242
|
+
server.off('error', onError);
|
|
243
|
+
resolve();
|
|
244
|
+
};
|
|
245
|
+
server.once('error', onError);
|
|
246
|
+
server.once('listening', onListening);
|
|
247
|
+
server.listen(localPort, LOOPBACK);
|
|
248
|
+
});
|
|
249
|
+
server.unref();
|
|
250
|
+
const addr = server.address();
|
|
251
|
+
const port = typeof addr === 'object' && addr ? addr.port : localPort;
|
|
252
|
+
return new TlsTunnel(server, port, options);
|
|
253
|
+
}
|
|
254
|
+
function isPort(value, min) {
|
|
255
|
+
return Number.isInteger(value) && value >= min && value <= 65535;
|
|
256
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|