jspurefix 5.8.5 → 5.9.1
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/.gitattributes +4 -0
- package/BACKPORT_PLAN.md +71 -0
- package/DEMO_PORT_PLAN.md +10 -0
- package/README.md +3 -0
- package/dist/config/js-fix-config.d.ts +4 -0
- package/dist/config/js-fix-config.js.map +1 -1
- package/dist/runtime/session-launcher.js +1 -1
- package/dist/runtime/session-launcher.js.map +1 -1
- package/dist/runtime/session-scope.d.ts +3 -0
- package/dist/runtime/session-scope.js +47 -0
- package/dist/runtime/session-scope.js.map +1 -0
- package/dist/sample/http/oms/app.js +3 -3
- package/dist/sample/http/oms/app.js.map +1 -1
- package/dist/sample/tcp/qf-md/app.js +3 -3
- package/dist/sample/tcp/qf-md/app.js.map +1 -1
- package/dist/sample/tcp/skeleton/app.js +1 -1
- package/dist/sample/tcp/skeleton/app.js.map +1 -1
- package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
- package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
- package/dist/transport/ascii/ascii-session.d.ts +17 -3
- package/dist/transport/ascii/ascii-session.js +121 -8
- package/dist/transport/ascii/ascii-session.js.map +1 -1
- package/dist/transport/duplex/fix-duplex.d.ts +1 -0
- package/dist/transport/duplex/fix-duplex.js +3 -0
- package/dist/transport/duplex/fix-duplex.js.map +1 -1
- package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
- package/dist/transport/duplex/tcp-duplex.js +5 -0
- package/dist/transport/duplex/tcp-duplex.js.map +1 -1
- package/dist/transport/factory/msg-transport.d.ts +2 -0
- package/dist/transport/factory/msg-transport.js +17 -2
- package/dist/transport/factory/msg-transport.js.map +1 -1
- package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
- package/dist/transport/session/a-session-msg-factory.js +4 -0
- package/dist/transport/session/a-session-msg-factory.js.map +1 -1
- package/dist/transport/session/clone-session-description.d.ts +6 -0
- package/dist/transport/session/clone-session-description.js +11 -0
- package/dist/transport/session/clone-session-description.js.map +1 -0
- package/dist/transport/session/fix-session.d.ts +5 -1
- package/dist/transport/session/fix-session.js +93 -24
- package/dist/transport/session/fix-session.js.map +1 -1
- package/dist/transport/session/index.d.ts +2 -0
- package/dist/transport/session/index.js +2 -0
- package/dist/transport/session/index.js.map +1 -1
- package/dist/transport/session/session-msg-factory.d.ts +1 -0
- package/dist/transport/session/session-msg-factory.js.map +1 -1
- package/dist/transport/session/session-registry.d.ts +23 -0
- package/dist/transport/session/session-registry.js +59 -0
- package/dist/transport/session/session-registry.js.map +1 -0
- package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
- package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
- package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
- package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
- package/dist/transport/tcp/tcp-acceptor.js +74 -20
- package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
- package/dist/transport/tcp/tcp-initiator.js +15 -5
- package/dist/transport/tcp/tcp-initiator.js.map +1 -1
- package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
- package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
- package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
- package/dist/transport/tcp/tls-diagnostics.js +64 -0
- package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
- package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
- package/dist/transport/tcp/tls-options-factory.js +24 -2
- package/dist/transport/tcp/tls-options-factory.js.map +1 -1
- package/docs/acceptor.md +207 -0
- package/jsfix.test_client.txt +62 -75
- package/jsfix.test_server.txt +72 -72
- package/package.json +11 -10
- package/script/pack-local.js +68 -0
- package/src/config/js-fix-config.ts +8 -0
- package/src/runtime/session-launcher.ts +5 -1
- package/src/runtime/session-scope.ts +84 -0
- package/src/sample/http/oms/app.ts +3 -3
- package/src/sample/tcp/qf-md/app.ts +3 -3
- package/src/sample/tcp/skeleton/app.ts +1 -1
- package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
- package/src/transport/ascii/ascii-session.ts +186 -11
- package/src/transport/duplex/fix-duplex.ts +9 -0
- package/src/transport/duplex/tcp-duplex.ts +10 -0
- package/src/transport/factory/msg-transport.ts +20 -0
- package/src/transport/session/a-session-msg-factory.ts +18 -0
- package/src/transport/session/clone-session-description.ts +41 -0
- package/src/transport/session/fix-session.ts +136 -25
- package/src/transport/session/index.ts +2 -0
- package/src/transport/session/session-msg-factory.ts +13 -0
- package/src/transport/session/session-registry.ts +112 -0
- package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
- package/src/transport/tcp/tcp-acceptor.ts +92 -18
- package/src/transport/tcp/tcp-initiator.ts +26 -5
- package/src/transport/tcp/tcp-transport-description.ts +7 -0
- package/src/transport/tcp/tls-diagnostics.ts +76 -0
- package/src/transport/tcp/tls-options-factory.ts +34 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { SessionId } from '../../store/session-id'
|
|
2
|
+
import { IJsFixLogger } from '../../config/js-fix-logger'
|
|
3
|
+
import { JsFixLoggerFactory } from '../../config/js-fix-logger-factory'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The registry only ever needs to ask a session to stop, so it is typed against
|
|
7
|
+
* that capability rather than against FixSession. IJsFixConfig carries a registry,
|
|
8
|
+
* and FixSession is constructed from IJsFixConfig - depending on the concrete class
|
|
9
|
+
* here would close an import cycle.
|
|
10
|
+
*/
|
|
11
|
+
export interface IStoppableSession {
|
|
12
|
+
requestStop: (reason: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tracks the live session for each SessionId served by an acceptor.
|
|
17
|
+
*
|
|
18
|
+
* FIX allows exactly one active session per (BeginString, SenderCompID,
|
|
19
|
+
* TargetCompID). A counterparty whose socket has gone half open - no FIN, no RST,
|
|
20
|
+
* so node never raises 'end' - will happily open a second connection and log on
|
|
21
|
+
* again. Without a registry the acceptor then runs two sessions for one CompID
|
|
22
|
+
* pair, both writing to the same message store and both stamping sequence numbers,
|
|
23
|
+
* which corrupts the store and produces sequence errors the peer cannot recover
|
|
24
|
+
* from. See https://github.com/TimelordUK/jspurefix/issues/153.
|
|
25
|
+
*
|
|
26
|
+
* Ported from cspurefix PureFix.Transport/Session/SessionRegistry.cs.
|
|
27
|
+
*/
|
|
28
|
+
export interface ISessionRegistry {
|
|
29
|
+
/**
|
|
30
|
+
* Claim this SessionId for the given session. Any *different* session already
|
|
31
|
+
* holding it is stopped first, because its transport is by definition stale.
|
|
32
|
+
* @returns true when an existing session was stopped
|
|
33
|
+
*/
|
|
34
|
+
register: (sessionId: SessionId, session: IStoppableSession) => boolean
|
|
35
|
+
/**
|
|
36
|
+
* Release this SessionId. A no-op unless the registered session is this exact
|
|
37
|
+
* instance - a session which has already been replaced must not evict its
|
|
38
|
+
* successor on the way out.
|
|
39
|
+
*/
|
|
40
|
+
unregister: (sessionId: SessionId, session: IStoppableSession) => void
|
|
41
|
+
/** number of live sessions */
|
|
42
|
+
readonly count: number
|
|
43
|
+
/** the SessionId keys currently held, for logging and diagnostics */
|
|
44
|
+
keys: () => string[]
|
|
45
|
+
get: (sessionId: SessionId) => IStoppableSession | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const REPLACED_BY_NEW_CONNECTION =
|
|
49
|
+
'replaced by new connection - stopping old session with stale transport'
|
|
50
|
+
|
|
51
|
+
export class SessionRegistry implements ISessionRegistry {
|
|
52
|
+
private readonly sessions = new Map<string, IStoppableSession>()
|
|
53
|
+
private readonly logger: IJsFixLogger | null
|
|
54
|
+
|
|
55
|
+
constructor (logFactory?: JsFixLoggerFactory) {
|
|
56
|
+
this.logger = logFactory?.logger('SessionRegistry') ?? null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public get count (): number {
|
|
60
|
+
return this.sessions.size
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public keys (): string[] {
|
|
64
|
+
return Array.from(this.sessions.keys())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public get (sessionId: SessionId): IStoppableSession | null {
|
|
68
|
+
return this.sessions.get(sessionId.toString()) ?? null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public register (sessionId: SessionId, session: IStoppableSession): boolean {
|
|
72
|
+
const key = sessionId.toString()
|
|
73
|
+
const logger = this.logger
|
|
74
|
+
logger?.info(`register called for SessionId=${key}, activeSessions=${this.sessions.size}`)
|
|
75
|
+
|
|
76
|
+
const existing = this.sessions.get(key)
|
|
77
|
+
let stoppedOld = false
|
|
78
|
+
|
|
79
|
+
if (existing && existing !== session) {
|
|
80
|
+
logger?.info(`FOUND EXISTING SESSION for ${key} - this is a reconnection scenario`)
|
|
81
|
+
logger?.info('stopping old session to prevent stale transport writes')
|
|
82
|
+
this.sessions.set(key, session)
|
|
83
|
+
// stop after re-pointing the map so the old session's unregister (which runs
|
|
84
|
+
// synchronously inside requestStop) sees it is no longer the holder
|
|
85
|
+
existing.requestStop(REPLACED_BY_NEW_CONNECTION)
|
|
86
|
+
stoppedOld = true
|
|
87
|
+
logger?.info('old session stopped, new session registered')
|
|
88
|
+
} else if (existing === session) {
|
|
89
|
+
logger?.debug(`same session instance already registered for ${key}`)
|
|
90
|
+
} else {
|
|
91
|
+
logger?.info(`no existing session found - registering new session: ${key}`)
|
|
92
|
+
this.sessions.set(key, session)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
logger?.info(`register complete for ${key}: stoppedOldSession=${stoppedOld}, totalActiveSessions=${this.sessions.size}`)
|
|
96
|
+
return stoppedOld
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public unregister (sessionId: SessionId, session: IStoppableSession): void {
|
|
100
|
+
const key = sessionId.toString()
|
|
101
|
+
const logger = this.logger
|
|
102
|
+
logger?.info(`unregister called for SessionId=${key}`)
|
|
103
|
+
|
|
104
|
+
if (this.sessions.get(key) === session) {
|
|
105
|
+
this.sessions.delete(key)
|
|
106
|
+
logger?.info(`successfully unregistered session: ${key}, remainingActiveSessions=${this.sessions.size}`)
|
|
107
|
+
} else {
|
|
108
|
+
// expected when a new connection has already taken this SessionId over
|
|
109
|
+
logger?.info(`session NOT unregistered (already replaced by new connection or not found): ${key}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IJsFixConfig } from '../../config'
|
|
1
|
+
import { IJsFixConfig, IJsFixLogger } from '../../config'
|
|
2
2
|
import { FixAcceptor } from '../fix-acceptor'
|
|
3
3
|
import { TcpAcceptor } from './tcp-acceptor'
|
|
4
4
|
import { MsgTransport } from '../factory'
|
|
@@ -6,14 +6,23 @@ import { inject, injectable } from 'tsyringe'
|
|
|
6
6
|
import { FixSession } from '../session/fix-session'
|
|
7
7
|
import { DITokens } from '../../runtime/di-tokens'
|
|
8
8
|
import { FixEntity } from '../fix-entity'
|
|
9
|
+
import { ISessionRegistry, SessionRegistry } from '../session/session-registry'
|
|
9
10
|
|
|
10
11
|
@injectable()
|
|
11
12
|
export class TcpAcceptorListener extends FixEntity {
|
|
12
13
|
private acceptor: FixAcceptor | null = null
|
|
13
14
|
private resolveStart: ((value: any) => void) | null = null
|
|
15
|
+
/**
|
|
16
|
+
* One registry per listener, so a counterparty reconnecting onto a fresh socket
|
|
17
|
+
* replaces its previous session instead of running alongside it (issue #153).
|
|
18
|
+
* An application may supply its own via config.sessionRegistry.
|
|
19
|
+
*/
|
|
20
|
+
public readonly sessionRegistry: ISessionRegistry
|
|
14
21
|
|
|
15
22
|
constructor (@inject(DITokens.IJsFixConfig) public readonly config: IJsFixConfig) {
|
|
16
23
|
super(config)
|
|
24
|
+
this.sessionRegistry = config.sessionRegistry ?? new SessionRegistry(config.logFactory)
|
|
25
|
+
config.sessionRegistry = this.sessionRegistry
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
async start (): Promise<any> {
|
|
@@ -29,24 +38,53 @@ export class TcpAcceptorListener extends FixEntity {
|
|
|
29
38
|
this.acceptor = acceptor
|
|
30
39
|
|
|
31
40
|
acceptor.on('transport', (t: MsgTransport) => {
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
// resolve from the transport's own scope, not the listener's container, so
|
|
42
|
+
// this session gets the per-connection description, buffers and message
|
|
43
|
+
// factory created by TcpAcceptor. Falls back to the listener container for
|
|
44
|
+
// transports created outside the acceptor (tests, custom entities).
|
|
45
|
+
const scope = t.config?.sessionContainer ?? sessionContainer
|
|
46
|
+
const description = t.config?.description
|
|
47
|
+
logger.info(`transport ${t.id}: creating session via DI token ${DITokens.FixSession} ` +
|
|
48
|
+
`[${description?.SenderCompId ?? '?'} -> ${description?.TargetCompID ?? '?'}]`)
|
|
49
|
+
const acceptorSession = scope.resolve<FixSession>(DITokens.FixSession)
|
|
34
50
|
this.emit('session', acceptorSession, t)
|
|
51
|
+
// a wildcard acceptor has not claimed its SessionId yet - report again once
|
|
52
|
+
// the session ends, by which time the registry line is meaningful
|
|
53
|
+
this.census(logger)
|
|
35
54
|
// Run the session but do NOT close the listener when it ends.
|
|
36
55
|
// The acceptor keeps listening for new connections (reconnects).
|
|
37
56
|
// This matches the C# TcpAcceptorListener accept-loop pattern.
|
|
38
57
|
acceptorSession.run(t).then(() => {
|
|
39
|
-
logger.info(
|
|
58
|
+
logger.info(`transport ${t.id}: session ended normally, acceptor continues listening`)
|
|
59
|
+
this.census(logger)
|
|
40
60
|
}).catch((e: Error) => {
|
|
41
|
-
logger.warning(`session ended with error: ${e.message}, acceptor continues listening`)
|
|
61
|
+
logger.warning(`transport ${t.id}: session ended with error: ${e.message}, acceptor continues listening`)
|
|
62
|
+
this.census(logger)
|
|
42
63
|
})
|
|
43
64
|
})
|
|
44
65
|
|
|
66
|
+
acceptor.on('harvested', (tid: number, reason: string) => {
|
|
67
|
+
logger.info(`transport ${tid} harvested (${reason})`)
|
|
68
|
+
this.census(logger)
|
|
69
|
+
})
|
|
70
|
+
|
|
45
71
|
this.resolveStart = resolve
|
|
46
72
|
acceptor.listen()
|
|
47
73
|
})
|
|
48
74
|
}
|
|
49
75
|
|
|
76
|
+
/**
|
|
77
|
+
* What is actually connected, and which SessionIds are claimed. Emitted whenever
|
|
78
|
+
* the population changes so "how many clients do I have, and who are they" is
|
|
79
|
+
* answerable from the server log alone.
|
|
80
|
+
*/
|
|
81
|
+
private census (logger: IJsFixLogger): void {
|
|
82
|
+
const acceptor = this.acceptor as TcpAcceptor | null
|
|
83
|
+
const transports = acceptor?.liveTransports() ?? []
|
|
84
|
+
logger.info(`acceptor census: transports=${transports.length} [${transports.join(', ')}] ` +
|
|
85
|
+
`sessions=${this.sessionRegistry.count} [${this.sessionRegistry.keys().join(', ')}]`)
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
/**
|
|
51
89
|
* Explicitly stop the acceptor listener. Call this when the application
|
|
52
90
|
* is shutting down (e.g., timeout, SIGINT). Without this call, the
|
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.logger.
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
this.logger.info(`new transport id = ${tid} created total transports = ${
|
|
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
|
-
|
|
124
|
-
this.logger.info(`transport ${tid}
|
|
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((
|
|
71
|
-
|
|
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
|
-
|
|
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 = initialError as Error
|
|
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
|