jspurefix 5.8.5 → 5.9.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.
Files changed (180) hide show
  1. package/.gitattributes +4 -0
  2. package/BACKPORT_PLAN.md +84 -0
  3. package/DEMO_PORT_PLAN.md +10 -0
  4. package/README.md +3 -0
  5. package/WORKLOG.md +119 -0
  6. package/dist/benchmark/bench-report.d.ts +5 -0
  7. package/dist/benchmark/bench-report.js +91 -0
  8. package/dist/benchmark/bench-report.js.map +1 -0
  9. package/dist/benchmark/bench-runner.d.ts +12 -0
  10. package/dist/benchmark/bench-runner.js +193 -0
  11. package/dist/benchmark/bench-runner.js.map +1 -0
  12. package/dist/benchmark/bench-stats.d.ts +10 -0
  13. package/dist/benchmark/bench-stats.js +56 -0
  14. package/dist/benchmark/bench-stats.js.map +1 -0
  15. package/dist/benchmark/bench-types.d.ts +74 -0
  16. package/dist/benchmark/bench-types.js +3 -0
  17. package/dist/benchmark/bench-types.js.map +1 -0
  18. package/dist/benchmark/gc-probe.d.ts +18 -0
  19. package/dist/benchmark/gc-probe.js +103 -0
  20. package/dist/benchmark/gc-probe.js.map +1 -0
  21. package/dist/benchmark/index.d.ts +6 -0
  22. package/dist/benchmark/index.js +23 -0
  23. package/dist/benchmark/index.js.map +1 -0
  24. package/dist/benchmark/parse-bench.d.ts +28 -0
  25. package/dist/benchmark/parse-bench.js +85 -0
  26. package/dist/benchmark/parse-bench.js.map +1 -0
  27. package/dist/buffer/ascii/ascii-parser.d.ts +1 -0
  28. package/dist/buffer/ascii/ascii-parser.js +4 -1
  29. package/dist/buffer/ascii/ascii-parser.js.map +1 -1
  30. package/dist/buffer/ascii/ascii-segment-parser.js +3 -2
  31. package/dist/buffer/ascii/ascii-segment-parser.js.map +1 -1
  32. package/dist/buffer/ascii/tag-index.js.map +1 -1
  33. package/dist/buffer/msg-view.d.ts +1 -0
  34. package/dist/buffer/msg-view.js +25 -19
  35. package/dist/buffer/msg-view.js.map +1 -1
  36. package/dist/config/js-fix-config.d.ts +4 -0
  37. package/dist/config/js-fix-config.js.map +1 -1
  38. package/dist/dictionary/parser/quickfix/dictionary-validator.d.ts +1 -1
  39. package/dist/dictionary/parser/quickfix/dictionary-validator.js.map +1 -1
  40. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.d.ts +1 -1
  41. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js +1 -2
  42. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js.map +1 -1
  43. package/dist/dictionary/parser/quickfix/validation-error.d.ts +2 -2
  44. package/dist/dictionary/parser/quickfix/validation-error.js.map +1 -1
  45. package/dist/jsfix-bench.d.ts +1 -0
  46. package/dist/jsfix-bench.js +213 -0
  47. package/dist/jsfix-bench.js.map +1 -0
  48. package/dist/runtime/session-launcher.js +1 -1
  49. package/dist/runtime/session-launcher.js.map +1 -1
  50. package/dist/runtime/session-scope.d.ts +3 -0
  51. package/dist/runtime/session-scope.js +47 -0
  52. package/dist/runtime/session-scope.js.map +1 -0
  53. package/dist/sample/http/oms/app.js +3 -3
  54. package/dist/sample/http/oms/app.js.map +1 -1
  55. package/dist/sample/tcp/qf-md/app.js +3 -3
  56. package/dist/sample/tcp/qf-md/app.js.map +1 -1
  57. package/dist/sample/tcp/skeleton/app.js +1 -1
  58. package/dist/sample/tcp/skeleton/app.js.map +1 -1
  59. package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
  60. package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
  61. package/dist/store/file-session-store.js +1 -1
  62. package/dist/store/file-session-store.js.map +1 -1
  63. package/dist/store/file-session-stream-provider.d.ts +1 -0
  64. package/dist/store/file-session-stream-provider.js +13 -13
  65. package/dist/store/file-session-stream-provider.js.map +1 -1
  66. package/dist/store/fix-session-store-factory.d.ts +1 -1
  67. package/dist/store/fix-session-store-factory.js.map +1 -1
  68. package/dist/store/fix-session-store.d.ts +11 -11
  69. package/dist/store/fix-session-store.js.map +1 -1
  70. package/dist/store/memory-session-store.js.map +1 -1
  71. package/dist/store/session-stream-provider.d.ts +13 -13
  72. package/dist/store/session-stream-provider.js.map +1 -1
  73. package/dist/transport/ascii/ascii-session.d.ts +17 -3
  74. package/dist/transport/ascii/ascii-session.js +121 -8
  75. package/dist/transport/ascii/ascii-session.js.map +1 -1
  76. package/dist/transport/duplex/fix-duplex.d.ts +1 -0
  77. package/dist/transport/duplex/fix-duplex.js +3 -0
  78. package/dist/transport/duplex/fix-duplex.js.map +1 -1
  79. package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
  80. package/dist/transport/duplex/tcp-duplex.js +5 -0
  81. package/dist/transport/duplex/tcp-duplex.js.map +1 -1
  82. package/dist/transport/factory/msg-transport.d.ts +2 -0
  83. package/dist/transport/factory/msg-transport.js +17 -2
  84. package/dist/transport/factory/msg-transport.js.map +1 -1
  85. package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
  86. package/dist/transport/session/a-session-msg-factory.js +4 -0
  87. package/dist/transport/session/a-session-msg-factory.js.map +1 -1
  88. package/dist/transport/session/clone-session-description.d.ts +6 -0
  89. package/dist/transport/session/clone-session-description.js +11 -0
  90. package/dist/transport/session/clone-session-description.js.map +1 -0
  91. package/dist/transport/session/fix-clock.d.ts +1 -1
  92. package/dist/transport/session/fix-clock.js.map +1 -1
  93. package/dist/transport/session/fix-session.d.ts +5 -1
  94. package/dist/transport/session/fix-session.js +93 -24
  95. package/dist/transport/session/fix-session.js.map +1 -1
  96. package/dist/transport/session/index.d.ts +2 -0
  97. package/dist/transport/session/index.js +2 -0
  98. package/dist/transport/session/index.js.map +1 -1
  99. package/dist/transport/session/resend-request-manager.d.ts +2 -2
  100. package/dist/transport/session/resend-request-manager.js.map +1 -1
  101. package/dist/transport/session/session-msg-factory.d.ts +1 -0
  102. package/dist/transport/session/session-msg-factory.js.map +1 -1
  103. package/dist/transport/session/session-registry.d.ts +23 -0
  104. package/dist/transport/session/session-registry.js +59 -0
  105. package/dist/transport/session/session-registry.js.map +1 -0
  106. package/dist/transport/session/session-sequence-coordinator.d.ts +1 -1
  107. package/dist/transport/session/session-sequence-coordinator.js.map +1 -1
  108. package/dist/transport/session/session-sequence-store.d.ts +3 -3
  109. package/dist/transport/session/session-sequence-store.js.map +1 -1
  110. package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
  111. package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
  112. package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
  113. package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
  114. package/dist/transport/tcp/tcp-acceptor.js +74 -20
  115. package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
  116. package/dist/transport/tcp/tcp-initiator.js +15 -5
  117. package/dist/transport/tcp/tcp-initiator.js.map +1 -1
  118. package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
  119. package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
  120. package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
  121. package/dist/transport/tcp/tls-diagnostics.js +64 -0
  122. package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
  123. package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
  124. package/dist/transport/tcp/tls-options-factory.js +24 -2
  125. package/dist/transport/tcp/tls-options-factory.js.map +1 -1
  126. package/docs/acceptor.md +207 -0
  127. package/docs/benchmarking.md +117 -0
  128. package/eslint.config.mjs +15 -1
  129. package/jsfix.test_client.txt +62 -75
  130. package/jsfix.test_server.txt +72 -72
  131. package/package.json +19 -10
  132. package/script/pack-local.js +68 -0
  133. package/src/benchmark/bench-report.ts +101 -0
  134. package/src/benchmark/bench-runner.ts +196 -0
  135. package/src/benchmark/bench-stats.ts +86 -0
  136. package/src/benchmark/bench-types.ts +110 -0
  137. package/src/benchmark/gc-probe.ts +145 -0
  138. package/src/benchmark/index.ts +6 -0
  139. package/src/benchmark/parse-bench.ts +144 -0
  140. package/src/buffer/ascii/ascii-parser.ts +16 -1
  141. package/src/buffer/ascii/ascii-segment-parser.ts +9 -6
  142. package/src/buffer/ascii/tag-index.ts +3 -3
  143. package/src/buffer/msg-view.ts +35 -18
  144. package/src/config/js-fix-config.ts +8 -0
  145. package/src/dictionary/parser/quickfix/dictionary-validator.ts +1 -1
  146. package/src/dictionary/parser/quickfix/quick-fix-graph-parser.ts +2 -3
  147. package/src/dictionary/parser/quickfix/validation-error.ts +1 -1
  148. package/src/jsfix-bench.ts +204 -0
  149. package/src/runtime/session-launcher.ts +5 -1
  150. package/src/runtime/session-scope.ts +84 -0
  151. package/src/sample/http/oms/app.ts +3 -3
  152. package/src/sample/tcp/qf-md/app.ts +3 -3
  153. package/src/sample/tcp/skeleton/app.ts +1 -1
  154. package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
  155. package/src/store/file-session-store.ts +2 -2
  156. package/src/store/file-session-stream-provider.ts +17 -11
  157. package/src/store/fix-session-store-factory.ts +1 -1
  158. package/src/store/fix-session-store.ts +11 -11
  159. package/src/store/memory-session-store.ts +1 -1
  160. package/src/store/session-stream-provider.ts +13 -13
  161. package/src/transport/ascii/ascii-session.ts +186 -11
  162. package/src/transport/duplex/fix-duplex.ts +9 -0
  163. package/src/transport/duplex/tcp-duplex.ts +10 -0
  164. package/src/transport/factory/msg-transport.ts +20 -0
  165. package/src/transport/session/a-session-msg-factory.ts +18 -0
  166. package/src/transport/session/clone-session-description.ts +41 -0
  167. package/src/transport/session/fix-clock.ts +1 -1
  168. package/src/transport/session/fix-session.ts +136 -25
  169. package/src/transport/session/index.ts +2 -0
  170. package/src/transport/session/resend-request-manager.ts +4 -4
  171. package/src/transport/session/session-msg-factory.ts +13 -0
  172. package/src/transport/session/session-registry.ts +112 -0
  173. package/src/transport/session/session-sequence-coordinator.ts +1 -1
  174. package/src/transport/session/session-sequence-store.ts +3 -3
  175. package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
  176. package/src/transport/tcp/tcp-acceptor.ts +92 -18
  177. package/src/transport/tcp/tcp-initiator.ts +26 -5
  178. package/src/transport/tcp/tcp-transport-description.ts +7 -0
  179. package/src/transport/tcp/tls-diagnostics.ts +76 -0
  180. package/src/transport/tcp/tls-options-factory.ts +34 -2
@@ -5,8 +5,10 @@ import { IJsFixConfig, IJsFixLogger } from '../../config'
5
5
  import { createServer as netCreateServer, Server, Socket } from 'net'
6
6
  import { createServer as tlsCreateServer, TlsOptions, TLSSocket } from 'tls'
7
7
  import { TlsOptionsFactory } from './tls-options-factory'
8
+ import { describeNegotiated, describePeerCertificate, describeServerOptions } from './tls-diagnostics'
8
9
  import { inject, injectable } from 'tsyringe'
9
10
  import { DITokens } from '../../runtime/di-tokens'
11
+ import { makeSessionScope } from '../../runtime/session-scope'
10
12
 
11
13
  @injectable()
12
14
  export class TcpAcceptor extends FixAcceptor {
@@ -40,19 +42,29 @@ export class TcpAcceptor extends FixAcceptor {
40
42
  const tlsOptions: TlsOptions | null = tcp?.tls ? TlsOptionsFactory.getTlsOptions(tcp.tls) : null
41
43
  this.logger.info('create tls server')
42
44
  if (!tlsOptions) return
45
+ this.logger.info(`tls server options ${describeServerOptions(tlsOptions)}`)
43
46
  this.server = tlsCreateServer(tlsOptions, (tlsSocket: TLSSocket) => {
44
47
  if (tcp?.tls?.enableTrace) {
45
48
  this.logger.info('enabling tls session trace')
46
49
  tlsSocket.enableTrace()
47
50
  }
48
- if (tlsSocket.authorized) {
49
- tlsSocket.setEncoding('utf8')
50
- const id: number = this.getId()
51
- this.logger.info(`tls creates session ${id} ${tlsSocket.authorized}`)
52
- this.onSocket(id, tlsSocket, config)
53
- } else {
54
- this.logger.info('no transport created on tls with no authorized connection')
51
+ this.logger.info(`tls negotiated ${describeNegotiated(tlsSocket)}`)
52
+ this.logger.info(`tls peer certificate ${describePeerCertificate(tlsSocket)}`)
53
+ // mirrors the initiator fix for #94 - an unauthorized peer is only fatal when the
54
+ // caller has not explicitly opted out. the default stays secure: rejectUnauthorized
55
+ // undefined means reject, exactly as before.
56
+ if (!tlsSocket.authorized && tlsOptions.rejectUnauthorized !== false) {
57
+ this.logger.warning(`no transport created, unauthorized tls connection: ${String(tlsSocket.authorizationError)}`)
58
+ tlsSocket.end()
59
+ return
55
60
  }
61
+ if (!tlsSocket.authorized) {
62
+ this.logger.warning(`accepting unauthorized tls peer (rejectUnauthorized: false): ${String(tlsSocket.authorizationError)}`)
63
+ }
64
+ tlsSocket.setEncoding('utf8')
65
+ const id: number = this.getId()
66
+ this.logger.info(`tls creates session ${id} authorized ${tlsSocket.authorized}`)
67
+ this.onSocket(id, tlsSocket, config)
56
68
  })
57
69
  } catch (e) {
58
70
  this.logger.error(e as Error)
@@ -82,17 +94,50 @@ export class TcpAcceptor extends FixAcceptor {
82
94
  }
83
95
 
84
96
  private onSocket (id: number, socket: Socket, config: IJsFixConfig): void {
85
- const transport: MsgTransport = new MsgTransport(id, config, new TcpDuplex(socket))
86
- this.saveTransport(id, transport)
97
+ // Every accepted connection gets its own DI scope: its own parse and transmit
98
+ // buffers, its own session description (hence its own SessionId and message
99
+ // store) and its own session message factory. Without this, concurrent clients
100
+ // on one listener share buffers and collide on a single store.
101
+ const sessionConfig = makeSessionScope(config)
102
+ const remote = `${socket.remoteAddress ?? '?'}:${socket.remotePort ?? 0}`
103
+ this.applyKeepAlive(id, socket)
104
+ this.logger.info(`transport ${id} from ${remote} given its own session scope`)
105
+ const transport: MsgTransport = new MsgTransport(id, sessionConfig, new TcpDuplex(socket))
106
+ this.saveTransport(id, transport, remote)
87
107
  transport.receiver.on('end', () => {
88
- this.harvestTransport(id)
108
+ this.harvestTransport(id, 'peer ended the connection')
89
109
  })
90
110
  transport.receiver.on('error', (e: Error) => {
91
111
  this.logger.error(e)
92
- this.harvestTransport(id)
112
+ this.harvestTransport(id, `receiver error: ${e.message}`)
113
+ })
114
+ // 'end' only fires on a clean FIN. A socket dropped by a firewall, or one this
115
+ // side destroyed after a failed graceful close, arrives here instead - without
116
+ // it the transport would sit in the map forever.
117
+ socket.on('close', (hadError: boolean) => {
118
+ this.harvestTransport(id, hadError ? 'socket closed after error' : 'socket closed')
93
119
  })
94
120
  }
95
121
 
122
+ /**
123
+ * TCP keep-alive is the only mechanism that detects a peer which has vanished
124
+ * without sending FIN or RST - the half open socket described in issue #153. The
125
+ * session level TestRequest timeout is the protocol level backstop; this is the
126
+ * transport level one.
127
+ */
128
+ private applyKeepAlive (id: number, socket: Socket): void {
129
+ const configured = this.config.description.application?.tcp?.keepAliveMs
130
+ const keepAliveMs = configured ?? TcpAcceptor.defaultKeepAliveMs
131
+ if (keepAliveMs <= 0) {
132
+ this.logger.info(`transport ${id} keep-alive disabled`)
133
+ return
134
+ }
135
+ socket.setKeepAlive(true, keepAliveMs)
136
+ this.logger.info(`transport ${id} keep-alive enabled, initial delay ${keepAliveMs}ms`)
137
+ }
138
+
139
+ public static defaultKeepAliveMs: number = 30000
140
+
96
141
  public listen (): void {
97
142
  const port = this.config.description.application?.tcp?.port
98
143
  this.logger.info(`start to listen ${port}`)
@@ -107,20 +152,49 @@ export class TcpAcceptor extends FixAcceptor {
107
152
 
108
153
  public close (callback?: (err?: Error) => void): void {
109
154
  const port = this.config.description.application?.tcp?.port ?? -1
110
- this.logger.info(`close listener on port ${port}`)
155
+ this.logger.info(`close listener on port ${port} with ${this.transportCount} live transport(s)`)
156
+ // Server.close only stops accepting - it waits for existing connections to go.
157
+ // Release them, otherwise a shutdown blocks on whichever counterparty happens to
158
+ // be connected (and forever on one that is half open).
159
+ Object.keys(this.transports).forEach(id => {
160
+ const tid = Number(id)
161
+ this.logger.info(`close: releasing transport ${tid}`)
162
+ this.transports[tid]?.end()
163
+ })
111
164
  this.server.close(callback)
112
165
  }
113
166
 
114
- private saveTransport (tid: number, transport: MsgTransport): void {
167
+ /** how many connections this listener currently holds */
168
+ public get transportCount (): number {
169
+ return Object.keys(this.transports).length
170
+ }
171
+
172
+ /** ids of the live transports, for a periodic census in the application log */
173
+ public liveTransports (): string[] {
174
+ return Object.keys(this.transports).map(id => {
175
+ const meta = this.accepted[id as any]
176
+ const age = meta ? Math.round((Date.now() - meta.at) / 1000) : -1
177
+ return `${id}@${meta?.remote ?? '?'} up ${age}s`
178
+ })
179
+ }
180
+
181
+ private readonly accepted: Record<number, { remote: string, at: number }> = {}
182
+
183
+ private saveTransport (tid: number, transport: MsgTransport, remote: string): void {
115
184
  this.transports[tid] = transport
116
- const keys: string[] = Object.keys(this.transports)
117
- this.logger.info(`new transport id = ${tid} created total transports = ${keys.length}`)
185
+ this.accepted[tid] = { remote, at: Date.now() }
186
+ this.logger.info(`new transport id = ${tid} from ${remote} created total transports = ${this.transportCount}`)
118
187
  this.emit('transport', transport)
119
188
  }
120
189
 
121
- private harvestTransport (tid: number): void {
190
+ private harvestTransport (tid: number, reason: string): void {
191
+ if (!this.transports[tid]) return
192
+ const meta = this.accepted[tid]
193
+ const lifetime = meta ? Math.round((Date.now() - meta.at) / 1000) : -1
122
194
  delete this.transports[tid]
123
- const keys: string[] = Object.keys(this.transports)
124
- this.logger.info(`transport ${tid} ends total transports = ${keys.length}`)
195
+ delete this.accepted[tid]
196
+ this.logger.info(`transport ${tid} from ${meta?.remote ?? '?'} harvested after ${lifetime}s ` +
197
+ `(${reason}) total transports = ${this.transportCount}`)
198
+ this.emit('harvested', tid, reason)
125
199
  }
126
200
  }
@@ -9,6 +9,7 @@ import { connect as tlsConnect, ConnectionOptions, TLSSocket } from 'tls'
9
9
  import { createConnection } from 'net'
10
10
  import Timeout = NodeJS.Timeout
11
11
  import { TlsOptionsFactory } from './tls-options-factory'
12
+ import { describeConnectionOptions, describeNegotiated, describePeerCertificate } from './tls-diagnostics'
12
13
  import { inject, injectable } from 'tsyringe'
13
14
  import { DITokens } from '../../runtime/di-tokens'
14
15
  import { ITcpTransportDescription } from './tcp-transport-description'
@@ -67,8 +68,13 @@ export class TcpInitiator extends FixInitiator {
67
68
  this.logger.info(`connecting with timeout ${timeoutSeconds}`)
68
69
  this.tryConnect()
69
70
  .then((t: MsgTransport) => { resolve(t) })
70
- .catch((_: Error) => {
71
- this.repeatConnect(timeoutSeconds)
71
+ .catch((first: Error) => {
72
+ // carry the first failure into repeatConnect. it used to be discarded, so a
73
+ // connect() whose timeout expires before the first retry interval fires - the
74
+ // default case, timeout and reconnectSeconds are both 5 - reported a generic
75
+ // "timeout whilst connecting" and threw away the reason. see #94.
76
+ this.logger.info(`first connect attempt failed: ${first.message}`)
77
+ this.repeatConnect(timeoutSeconds, first)
72
78
  .then((t: MsgTransport) => { resolve(t) })
73
79
  .catch((e: Error) => { reject(e) })
74
80
  })
@@ -120,15 +126,26 @@ export class TcpInitiator extends FixInitiator {
120
126
  const connectionOptions: ConnectionOptions | null = tcp ? TlsOptionsFactory.getTlsConnectionOptions(tcp) : null
121
127
  if (connectionOptions) {
122
128
  try {
129
+ this.logger.info(`tls connect with ${describeConnectionOptions(connectionOptions)}`)
123
130
  tlsSocket = tlsConnect(connectionOptions, () => {
124
131
  if (!tlsSocket) return null
125
132
  this.logger.info(`client connected ${tlsSocket.authorized ? 'authorized' : 'unauthorized'}`)
126
- if (!tlsSocket.authorized) {
133
+ this.logger.info(`tls negotiated ${describeNegotiated(tlsSocket)}`)
134
+ this.logger.info(`tls peer certificate ${describePeerCertificate(tlsSocket)}`)
135
+ // https://github.com/TimelordUK/jspurefix/issues/94 - honour rejectUnauthorized:
136
+ // false rather than always rejecting on an unauthorized cert. Node's tls.connect()
137
+ // never throws for an unauthorized peer by itself; it's the caller's job to decide
138
+ // what to do with tlsSocket.authorized, and rejectUnauthorized is how a caller opts
139
+ // out of that check - so only reject here when the caller didn't opt out.
140
+ if (!tlsSocket.authorized && connectionOptions.rejectUnauthorized !== false) {
127
141
  const error = tlsSocket.authorizationError
128
142
  this.logger.warning(`rejecting from state ${this.state} authorizationError ${error}`)
129
143
  tlsSocket.end()
130
144
  reject(error)
131
145
  } else {
146
+ if (!tlsSocket.authorized) {
147
+ this.logger.warning(`proceeding despite unauthorized TLS peer (rejectUnauthorized: false): ${tlsSocket.authorizationError}`)
148
+ }
132
149
  tlsSocket.setEncoding('utf8')
133
150
  const tlsDuplex = new TcpDuplex(tlsSocket)
134
151
  if (tcp?.tls?.enableTrace) {
@@ -140,6 +157,9 @@ export class TcpInitiator extends FixInitiator {
140
157
  }
141
158
  })
142
159
  tlsSocket.on('error', (err) => {
160
+ // previously silent - the socket error is the real reason a tls session failed,
161
+ // and without it the caller only ever sees repeatConnect's generic timeout.
162
+ this.logger.warning(`tls socket error from state ${this.state}: ${err.message}`)
143
163
  reject(err)
144
164
  })
145
165
  } catch (e) {
@@ -171,14 +191,14 @@ export class TcpInitiator extends FixInitiator {
171
191
  }
172
192
  }
173
193
 
174
- private async repeatConnect (timeoutSeconds: number): Promise < MsgTransport > {
194
+ private async repeatConnect (timeoutSeconds: number, initialError?: Error): Promise < MsgTransport > {
175
195
  return await new Promise<MsgTransport>(async (resolve, reject) => {
176
196
  const application = this.application
177
197
  const promisify = util.promisify
178
198
  const timeoutPromise = promisify(setTimeout)
179
199
  const reconnectSeconds = application?.reconnectSeconds ?? 5
180
200
  let retries = 0
181
- let lastError: Error
201
+ let lastError: Error | undefined = initialError
182
202
  const name = application?.name ?? 'initiator'
183
203
  this.th = setInterval(() => {
184
204
  ++retries
@@ -196,6 +216,7 @@ export class TcpInitiator extends FixInitiator {
196
216
  this.clearTimer()
197
217
  this.state = InitiatorState.Stopped
198
218
  const e = lastError ?? new Error(`${name}: timeout of ${timeoutSeconds} whilst connecting`)
219
+ this.logger.warning(`${name}: giving up after ${timeoutSeconds}s and ${retries} retries, last error: ${e.message}`)
199
220
  reject(e)
200
221
  }).catch(e => {
201
222
  reject(e)
@@ -4,4 +4,11 @@ export interface ITcpTransportDescription {
4
4
  readonly port: number
5
5
  readonly host: string
6
6
  readonly tls?: ITlsOptions
7
+ /**
8
+ * TCP keep-alive probe delay in milliseconds for connections accepted by this
9
+ * listener. Keep-alive is what lets the OS notice a peer that has vanished
10
+ * without FIN or RST - the half open socket in issue #153. Set 0 to disable.
11
+ * Default 30000.
12
+ */
13
+ readonly keepAliveMs?: number
7
14
  }
@@ -0,0 +1,76 @@
1
+ import { ConnectionOptions, TLSSocket, TlsOptions } from 'tls'
2
+
3
+ /*
4
+ tls failures are routinely reported as "it just times out" - see issues #94 and #151. the
5
+ session either connects or it does not, and nothing in the log says which options were
6
+ actually applied or what the peer presented. enableTrace exists but is node's raw handshake
7
+ trace: all or nothing, written to stderr rather than through IJsFixLogger.
8
+
9
+ these helpers render the small number of facts needed to diagnose a handshake, and are
10
+ deliberately conservative about what they include - never key material, never a full
11
+ certificate, only the identifying fields of one.
12
+ */
13
+
14
+ function describeCommonOptions (options: ConnectionOptions | TlsOptions): string[] {
15
+ const parts: string[] = []
16
+ // presence only - the values are private keys and certificate bodies.
17
+ parts.push(`key ${options.key ? 'supplied' : 'none'}`)
18
+ parts.push(`cert ${options.cert ? 'supplied' : 'none'}`)
19
+ const ca = options.ca
20
+ const caCount = Array.isArray(ca) ? ca.length : (ca ? 1 : 0)
21
+ parts.push(`ca ${caCount}`)
22
+ // the whole point of #94/#151 - make it unambiguous which value won.
23
+ parts.push(`rejectUnauthorized ${String(options.rejectUnauthorized ?? '(default true)')}`)
24
+ parts.push(`requestCert ${String(options.requestCert ?? '(default false)')}`)
25
+ if (options.minVersion) parts.push(`minVersion ${options.minVersion}`)
26
+ if (options.maxVersion) parts.push(`maxVersion ${options.maxVersion}`)
27
+ return parts
28
+ }
29
+
30
+ export function describeConnectionOptions (options: ConnectionOptions): string {
31
+ const parts: string[] = [`host ${options.host ?? '?'}`, `port ${options.port ?? '?'}`]
32
+ if (options.servername) parts.push(`servername ${options.servername}`)
33
+ return parts.concat(describeCommonOptions(options)).join(', ')
34
+ }
35
+
36
+ export function describeServerOptions (options: TlsOptions): string {
37
+ return describeCommonOptions(options).join(', ')
38
+ }
39
+
40
+ /*
41
+ what the handshake actually negotiated. the maintainer's suggestion in #94 was to reproduce
42
+ the connection in powershell to see the negotiated suite - this puts the same facts in our
43
+ own log instead.
44
+ */
45
+ export function describeNegotiated (socket: TLSSocket): string {
46
+ const parts: string[] = []
47
+ parts.push(`protocol ${socket.getProtocol() ?? 'none'}`)
48
+ const cipher = socket.getCipher()
49
+ parts.push(`cipher ${cipher ? `${cipher.name} (${cipher.version})` : 'none'}`)
50
+ parts.push(`authorized ${socket.authorized}`)
51
+ if (!socket.authorized && socket.authorizationError) {
52
+ parts.push(`authorizationError ${String(socket.authorizationError)}`)
53
+ }
54
+ return parts.join(', ')
55
+ }
56
+
57
+ /*
58
+ identifying fields of the peer certificate only. getPeerCertificate() returns the full DER
59
+ in .raw plus the public key - none of that belongs in a log, so pick fields explicitly.
60
+ */
61
+ export function describePeerCertificate (socket: TLSSocket): string {
62
+ const cert = socket.getPeerCertificate(false)
63
+ if (!cert || Object.keys(cert).length === 0) {
64
+ return 'peer presented no certificate'
65
+ }
66
+ const subject = cert.subject ? (cert.subject.CN ?? JSON.stringify(cert.subject)) : '?'
67
+ const issuer = cert.issuer ? (cert.issuer.CN ?? JSON.stringify(cert.issuer)) : '?'
68
+ const parts: string[] = [
69
+ `subject ${subject}`,
70
+ `issuer ${issuer}`,
71
+ `valid ${cert.valid_from ?? '?'} to ${cert.valid_to ?? '?'}`
72
+ ]
73
+ if (cert.subjectaltname) parts.push(`san ${cert.subjectaltname}`)
74
+ if (cert.fingerprint256) parts.push(`sha256 ${cert.fingerprint256}`)
75
+ return parts.join(', ')
76
+ }
@@ -5,6 +5,22 @@ const path = require('path')
5
5
  const fs = require('fs')
6
6
 
7
7
  export class TlsOptionsFactory {
8
+ /*
9
+ session descriptions are routinely hand written json, where a boolean flag is easily
10
+ quoted by mistake i.e. "rejectUnauthorized": "false". node treats that string as truthy
11
+ and so does exactly the opposite of what was intended, silently. accept the two string
12
+ spellings rather than inverting the caller's meaning - anything else is left alone so it
13
+ shows up as an obviously wrong value rather than being guessed at.
14
+ */
15
+ static asBoolean (value: any): any {
16
+ if (typeof value === 'string') {
17
+ const lowered = value.toLowerCase()
18
+ if (lowered === 'true') return true
19
+ if (lowered === 'false') return false
20
+ }
21
+ return value
22
+ }
23
+
8
24
  static read (filePath: string): string {
9
25
  const root: string = path.join(__dirname, '../../../')
10
26
  const fullPath = path.join(root, filePath)
@@ -18,8 +34,8 @@ export class TlsOptionsFactory {
18
34
  let tlsOptions: TlsOptions | null = null
19
35
  if (tls) {
20
36
  tlsOptions = {
21
- requestCert: tls.requestCert,
22
- rejectUnauthorized: tls.rejectUnauthorized
37
+ requestCert: TlsOptionsFactory.asBoolean(tls.requestCert),
38
+ rejectUnauthorized: TlsOptionsFactory.asBoolean(tls.rejectUnauthorized)
23
39
  } as TlsOptions
24
40
 
25
41
  if (tls.key) {
@@ -62,11 +78,27 @@ export class TlsOptionsFactory {
62
78
  if (tcp.tls.sessionTimeout) {
63
79
  connectionOptions.sessionTimeout = tcp.tls.sessionTimeout
64
80
  }
81
+ // https://github.com/TimelordUK/jspurefix/issues/151 - ITlsOptions declares these and
82
+ // getTlsOptions honours them on the server path, so map them here too rather than making
83
+ // nodeTlsConnectionOptions the only spelling that works for an initiator. Applied before
84
+ // the spread below so an explicit nodeTlsConnectionOptions value still wins.
85
+ if (tls.requestCert !== undefined) {
86
+ connectionOptions.requestCert = TlsOptionsFactory.asBoolean(tls.requestCert)
87
+ }
88
+ if (tls.rejectUnauthorized !== undefined) {
89
+ connectionOptions.rejectUnauthorized = TlsOptionsFactory.asBoolean(tls.rejectUnauthorized)
90
+ }
65
91
  if (tls.nodeTlsConnectionOptions) {
66
92
  connectionOptions = {
67
93
  ...connectionOptions,
68
94
  ...tls.nodeTlsConnectionOptions
69
95
  }
96
+ if (connectionOptions.rejectUnauthorized !== undefined) {
97
+ connectionOptions.rejectUnauthorized = TlsOptionsFactory.asBoolean(connectionOptions.rejectUnauthorized)
98
+ }
99
+ if (connectionOptions.requestCert !== undefined) {
100
+ connectionOptions.requestCert = TlsOptionsFactory.asBoolean(connectionOptions.requestCert)
101
+ }
70
102
  }
71
103
  }
72
104
  return connectionOptions