@source-repo/rpc 3.0.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/LICENSE +21 -0
- package/README.md +1208 -0
- package/dist/Logging/ILogger.d.ts +7 -0
- package/dist/Logging/ILogger.d.ts.map +1 -0
- package/dist/Logging/ILogger.js +2 -0
- package/dist/Logging/ILogger.js.map +1 -0
- package/dist/NodeRpcServer.d.ts +55 -0
- package/dist/NodeRpcServer.d.ts.map +1 -0
- package/dist/NodeRpcServer.js +46 -0
- package/dist/NodeRpcServer.js.map +1 -0
- package/dist/RPC/Auth.d.ts +98 -0
- package/dist/RPC/Auth.d.ts.map +1 -0
- package/dist/RPC/Auth.js +14 -0
- package/dist/RPC/Auth.js.map +1 -0
- package/dist/RPC/Codec.d.ts +24 -0
- package/dist/RPC/Codec.d.ts.map +1 -0
- package/dist/RPC/Codec.js +21 -0
- package/dist/RPC/Codec.js.map +1 -0
- package/dist/RPC/Compatibility.d.ts +39 -0
- package/dist/RPC/Compatibility.d.ts.map +1 -0
- package/dist/RPC/Compatibility.js +195 -0
- package/dist/RPC/Compatibility.js.map +1 -0
- package/dist/RPC/Core.d.ts +188 -0
- package/dist/RPC/Core.d.ts.map +1 -0
- package/dist/RPC/Core.js +354 -0
- package/dist/RPC/Core.js.map +1 -0
- package/dist/RPC/Expose.d.ts +90 -0
- package/dist/RPC/Expose.d.ts.map +1 -0
- package/dist/RPC/Expose.js +110 -0
- package/dist/RPC/Expose.js.map +1 -0
- package/dist/RPC/Idempotency.d.ts +90 -0
- package/dist/RPC/Idempotency.d.ts.map +1 -0
- package/dist/RPC/Idempotency.js +46 -0
- package/dist/RPC/Idempotency.js.map +1 -0
- package/dist/RPC/Introspection.d.ts +92 -0
- package/dist/RPC/Introspection.d.ts.map +1 -0
- package/dist/RPC/Introspection.js +188 -0
- package/dist/RPC/Introspection.js.map +1 -0
- package/dist/RPC/Introspection.types.json +1093 -0
- package/dist/RPC/Messages.d.ts +108 -0
- package/dist/RPC/Messages.d.ts.map +1 -0
- package/dist/RPC/Messages.js +30 -0
- package/dist/RPC/Messages.js.map +1 -0
- package/dist/RPC/Rpc.d.ts +51 -0
- package/dist/RPC/Rpc.d.ts.map +1 -0
- package/dist/RPC/Rpc.js +54 -0
- package/dist/RPC/Rpc.js.map +1 -0
- package/dist/RPC/RpcClientHandler.d.ts +129 -0
- package/dist/RPC/RpcClientHandler.d.ts.map +1 -0
- package/dist/RPC/RpcClientHandler.js +267 -0
- package/dist/RPC/RpcClientHandler.js.map +1 -0
- package/dist/RPC/RpcServerHandler.d.ts +226 -0
- package/dist/RPC/RpcServerHandler.d.ts.map +1 -0
- package/dist/RPC/RpcServerHandler.js +676 -0
- package/dist/RPC/RpcServerHandler.js.map +1 -0
- package/dist/RPC/Schema.d.ts +150 -0
- package/dist/RPC/Schema.d.ts.map +1 -0
- package/dist/RPC/Schema.js +198 -0
- package/dist/RPC/Schema.js.map +1 -0
- package/dist/RPC/Signing.d.ts +118 -0
- package/dist/RPC/Signing.d.ts.map +1 -0
- package/dist/RPC/Signing.js +171 -0
- package/dist/RPC/Signing.js.map +1 -0
- package/dist/RPC/Tokens.d.ts +49 -0
- package/dist/RPC/Tokens.d.ts.map +1 -0
- package/dist/RPC/Tokens.js +45 -0
- package/dist/RPC/Tokens.js.map +1 -0
- package/dist/RpcClient.d.ts +91 -0
- package/dist/RpcClient.d.ts.map +1 -0
- package/dist/RpcClient.js +145 -0
- package/dist/RpcClient.js.map +1 -0
- package/dist/RpcServer.d.ts +191 -0
- package/dist/RpcServer.d.ts.map +1 -0
- package/dist/RpcServer.js +293 -0
- package/dist/RpcServer.js.map +1 -0
- package/dist/Transports/Mqtt5Frame.d.ts +107 -0
- package/dist/Transports/Mqtt5Frame.d.ts.map +1 -0
- package/dist/Transports/Mqtt5Frame.js +162 -0
- package/dist/Transports/Mqtt5Frame.js.map +1 -0
- package/dist/Transports/MqttTransport.d.ts +279 -0
- package/dist/Transports/MqttTransport.d.ts.map +1 -0
- package/dist/Transports/MqttTransport.js +843 -0
- package/dist/Transports/MqttTransport.js.map +1 -0
- package/dist/Transports/Presence.d.ts +68 -0
- package/dist/Transports/Presence.d.ts.map +1 -0
- package/dist/Transports/Presence.js +21 -0
- package/dist/Transports/Presence.js.map +1 -0
- package/dist/Transports/SocketIoClientTransport.d.ts +89 -0
- package/dist/Transports/SocketIoClientTransport.d.ts.map +1 -0
- package/dist/Transports/SocketIoClientTransport.js +280 -0
- package/dist/Transports/SocketIoClientTransport.js.map +1 -0
- package/dist/Transports/SocketIoServerTransport.d.ts +135 -0
- package/dist/Transports/SocketIoServerTransport.d.ts.map +1 -0
- package/dist/Transports/SocketIoServerTransport.js +505 -0
- package/dist/Transports/SocketIoServerTransport.js.map +1 -0
- package/dist/Utilities/Converters.d.ts +22 -0
- package/dist/Utilities/Converters.d.ts.map +1 -0
- package/dist/Utilities/Converters.js +58 -0
- package/dist/Utilities/Converters.js.map +1 -0
- package/dist/Utilities/Filter.d.ts +7 -0
- package/dist/Utilities/Filter.d.ts.map +1 -0
- package/dist/Utilities/Filter.js +14 -0
- package/dist/Utilities/Filter.js.map +1 -0
- package/dist/Utilities/ReadableName.d.ts +38 -0
- package/dist/Utilities/ReadableName.d.ts.map +1 -0
- package/dist/Utilities/ReadableName.js +58 -0
- package/dist/Utilities/ReadableName.js.map +1 -0
- package/dist/Utilities/Switch.d.ts +21 -0
- package/dist/Utilities/Switch.d.ts.map +1 -0
- package/dist/Utilities/Switch.js +70 -0
- package/dist/Utilities/Switch.js.map +1 -0
- package/dist/Utilities/TryCatch.d.ts +11 -0
- package/dist/Utilities/TryCatch.d.ts.map +1 -0
- package/dist/Utilities/TryCatch.js +13 -0
- package/dist/Utilities/TryCatch.js.map +1 -0
- package/dist/index-web.d.ts +25 -0
- package/dist/index-web.d.ts.map +1 -0
- package/dist/index-web.js +28 -0
- package/dist/index-web.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import * as mqtt from 'mqtt';
|
|
2
|
+
import { stringToUint8Array, uint8ArrayToBase64 } from 'uint8array-extras';
|
|
3
|
+
import { GenericModule, TransportEvent } from '../RPC/Core.js';
|
|
4
|
+
import { jsonCodec, msgPackCodec } from '../RPC/Codec.js';
|
|
5
|
+
import { canonicalSignedBytes, canonicalSignedBytesV5, createNonce, ReplayGuard } from '../RPC/Signing.js';
|
|
6
|
+
import { correlationToBytes, correlationToString, FRAME_VERSION, fromInboundFrame, isRequestKind, MR, readControlProperties, SUPPORTED_FRAME_VERSIONS, toOutboundFrame } from './Mqtt5Frame.js';
|
|
7
|
+
/** v1 is the $-header layout; v2 is the MQTT 5 property layout, so the two never share a topic. */
|
|
8
|
+
export const defaultTopicPrefix = { 4: 'msgrpc/v1', 5: 'msgrpc/v2' };
|
|
9
|
+
const PRESENCE_ONLINE = 'online';
|
|
10
|
+
const PRESENCE_OFFLINE = 'offline';
|
|
11
|
+
/** MQTT 5 reason code 0x8E, sent to the peer whose session a new connection has just claimed. */
|
|
12
|
+
const SESSION_TAKEN_OVER = 0x8e;
|
|
13
|
+
/** A connection that lasts this long was doing its job, whatever ended it afterwards. */
|
|
14
|
+
const STABLE_CONNECTION_MS = 5000;
|
|
15
|
+
/** How many connections must die young in a row before it stops looking like bad luck. */
|
|
16
|
+
const SUSPICIOUS_RECONNECTS = 3;
|
|
17
|
+
/**
|
|
18
|
+
* Wildcards, control characters and (unless this is a multi-level prefix) the level separator.
|
|
19
|
+
* Any of these would let a name change the shape of the topic it is interpolated into.
|
|
20
|
+
*/
|
|
21
|
+
const hasUnsafeTopicCharacter = (value, allowSeparator) => {
|
|
22
|
+
for (const character of value) {
|
|
23
|
+
const code = character.codePointAt(0) ?? 0;
|
|
24
|
+
if (code < 0x20 || code === 0x7f)
|
|
25
|
+
return true;
|
|
26
|
+
if (character === '#' || character === '+')
|
|
27
|
+
return true;
|
|
28
|
+
if (!allowSeparator && character === '/')
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A peer name is interpolated into a topic, so it must not be able to change that topic's shape.
|
|
35
|
+
* A peer named '#' would otherwise subscribe to every other peer's traffic, and one named '+'
|
|
36
|
+
* would do the same one level down.
|
|
37
|
+
*/
|
|
38
|
+
export const isSafeTopicSegment = (value) => typeof value === 'string' && value.length > 0 && value.length <= 128 && !hasUnsafeTopicCharacter(value, false);
|
|
39
|
+
/** A prefix may span levels, so '/' is allowed inside it, but wildcards still are not. */
|
|
40
|
+
export const isSafeTopicPrefix = (value) => typeof value === 'string' &&
|
|
41
|
+
value.length > 0 &&
|
|
42
|
+
value.length <= 256 &&
|
|
43
|
+
!hasUnsafeTopicCharacter(value, true) &&
|
|
44
|
+
!value.startsWith('/') &&
|
|
45
|
+
!value.endsWith('/');
|
|
46
|
+
/**
|
|
47
|
+
* A topic something may be published to. Spans levels, but wildcards are only meaningful in a
|
|
48
|
+
* subscription and `$` opens the broker's own namespace, so neither belongs in a destination that
|
|
49
|
+
* arrived from somewhere else.
|
|
50
|
+
*/
|
|
51
|
+
export const isSafeTopicName = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 && !hasUnsafeTopicCharacter(value, true) && !value.startsWith('$');
|
|
52
|
+
export class MqttTransport extends GenericModule {
|
|
53
|
+
url;
|
|
54
|
+
client;
|
|
55
|
+
connected = false;
|
|
56
|
+
/** Owned here rather than by a converter above, so the transport decides its own wire form. */
|
|
57
|
+
codec = msgPackCodec;
|
|
58
|
+
prefix;
|
|
59
|
+
topic;
|
|
60
|
+
qos;
|
|
61
|
+
presence;
|
|
62
|
+
persistentSession;
|
|
63
|
+
mqttOptions;
|
|
64
|
+
protocol;
|
|
65
|
+
requestExpirySeconds;
|
|
66
|
+
channels;
|
|
67
|
+
sharedGroup;
|
|
68
|
+
allowResponseTopic;
|
|
69
|
+
tap;
|
|
70
|
+
replicaId;
|
|
71
|
+
sessionExpirySeconds;
|
|
72
|
+
/** A replica must not speak for the whole group; see the constructor. */
|
|
73
|
+
announcePresence;
|
|
74
|
+
sign;
|
|
75
|
+
verify;
|
|
76
|
+
replayGuard;
|
|
77
|
+
/** Peer name -> identity established by verifying that peer's signature. */
|
|
78
|
+
peerIdentities = new Map();
|
|
79
|
+
/**
|
|
80
|
+
* Correlation -> where a request asked for its reply and in what encoding it arrived, so the
|
|
81
|
+
* answer goes back where it was asked for and in a form the caller can read. A third party that
|
|
82
|
+
* speaks JSON must not be answered in msgpack, and one that subscribed to a topic of its own
|
|
83
|
+
* choosing must not be answered on a topic we invented for it. Bounded, since the keys come off
|
|
84
|
+
* the wire.
|
|
85
|
+
*/
|
|
86
|
+
pendingReplies = new Map();
|
|
87
|
+
maxTrackedReplies = 1000;
|
|
88
|
+
constructor(name, url, options = {}, sources) {
|
|
89
|
+
super(name, sources);
|
|
90
|
+
this.url = url;
|
|
91
|
+
this.protocol = options.protocol ?? 5;
|
|
92
|
+
this.requestExpirySeconds = options.requestExpirySeconds ?? 30;
|
|
93
|
+
this.channels = options.channels ?? ['req', 'rsp', 'evt'];
|
|
94
|
+
this.prefix = options.prefix ?? defaultTopicPrefix[this.protocol];
|
|
95
|
+
this.topic = options.topic ?? this.name;
|
|
96
|
+
this.qos = options.qos ?? 1;
|
|
97
|
+
this.presence = options.presence ?? true;
|
|
98
|
+
this.tap = options.tap ?? false;
|
|
99
|
+
this.persistentSession = options.persistentSession ?? false;
|
|
100
|
+
this.sharedGroup = options.sharedGroup;
|
|
101
|
+
this.allowResponseTopic = options.allowResponseTopic;
|
|
102
|
+
this.replicaId = options.replicaId ?? uint8ArrayToBase64(globalThis.crypto.getRandomValues(new Uint8Array(6)));
|
|
103
|
+
// A replica keeps no session: its share of the queue would never be drained if it stayed
|
|
104
|
+
// down, and the broker would hold messages for a process that is not coming back.
|
|
105
|
+
this.sessionExpirySeconds = options.sessionExpirySeconds ?? (this.sharedGroup ? 0 : this.persistentSession ? 3600 : 60);
|
|
106
|
+
// Presence describes one connection. A replica's will would announce the whole shared name
|
|
107
|
+
// as offline when a single process stops, and its siblings' retained 'online' would fight
|
|
108
|
+
// with it. Replicas therefore observe presence without announcing their own.
|
|
109
|
+
this.announcePresence = this.presence && !this.sharedGroup;
|
|
110
|
+
this.mqttOptions = options.allowInsecureTls ? { rejectUnauthorized: false, ...options.mqtt } : (options.mqtt ?? {});
|
|
111
|
+
if (options.allowInsecureTls && /^(mqtts|wss|ssl|tls):/.test(this.url))
|
|
112
|
+
console.warn(`source-rpc: '${name}' is connecting to ${this.url} with allowInsecureTls, so the broker's certificate is not checked. ` +
|
|
113
|
+
"Anything able to answer on that address can read and rewrite this peer's traffic. Use it for a development broker, not a plant.");
|
|
114
|
+
this.sign = options.sign;
|
|
115
|
+
this.verify = options.verify;
|
|
116
|
+
this.replayGuard = new ReplayGuard(options.maxClockSkew ?? 60000, options.maxTrackedNonces ?? 5000);
|
|
117
|
+
// Rejected at construction rather than at publish time, so a misconfigured peer fails
|
|
118
|
+
// loudly instead of quietly subscribing to more than it should.
|
|
119
|
+
if (!isSafeTopicPrefix(this.prefix))
|
|
120
|
+
throw new Error(`MqttTransport: unsafe topic prefix '${this.prefix}'`);
|
|
121
|
+
if (!isSafeTopicSegment(this.name))
|
|
122
|
+
throw new Error(`MqttTransport: unsafe peer name '${this.name}'`);
|
|
123
|
+
if (!isSafeTopicSegment(this.topic))
|
|
124
|
+
throw new Error(`MqttTransport: unsafe topic '${this.topic}'`);
|
|
125
|
+
if (this.sharedGroup !== undefined && !isSafeTopicSegment(this.sharedGroup))
|
|
126
|
+
throw new Error(`MqttTransport: unsafe shared group '${this.sharedGroup}'`);
|
|
127
|
+
if (this.sharedGroup && this.protocol !== 5)
|
|
128
|
+
throw new Error('MqttTransport: shared subscriptions need protocol 5');
|
|
129
|
+
// Deferred by a microtask so whatever constructs this transport can finish wiring it
|
|
130
|
+
// before the link comes up. A resumed MQTT session is delivered its queued messages the
|
|
131
|
+
// instant it connects, and a frame arriving before the RPC handler is piped in would find
|
|
132
|
+
// no target and be dropped. A fresh session never exposes this, because nothing arrives
|
|
133
|
+
// that early.
|
|
134
|
+
queueMicrotask(() => void this.open().catch((e) => this.emit(TransportEvent.transportError, e)));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The broker connection, or an error saying there is none.
|
|
138
|
+
*
|
|
139
|
+
* Publishing used to go through `this.client?.publishAsync(...)`, which resolves to undefined
|
|
140
|
+
* when the transport is closed or has not opened yet - so an outgoing call was dropped on the
|
|
141
|
+
* floor and its caller learned nothing until the call timed out. A frame that cannot be sent is
|
|
142
|
+
* a failure worth reporting at once.
|
|
143
|
+
*/
|
|
144
|
+
requireClient() {
|
|
145
|
+
if (!this.client)
|
|
146
|
+
throw new Error(`MqttTransport '${this.name}': no connection to ${this.url}`);
|
|
147
|
+
return this.client;
|
|
148
|
+
}
|
|
149
|
+
rpcTopic(peer) {
|
|
150
|
+
return `${this.prefix}/rpc/${peer}`;
|
|
151
|
+
}
|
|
152
|
+
channelTopic(channel, peer) {
|
|
153
|
+
return `${this.prefix}/${channel}/${peer}`;
|
|
154
|
+
}
|
|
155
|
+
presenceTopic(peer) {
|
|
156
|
+
return `${this.prefix}/presence/${peer}`;
|
|
157
|
+
}
|
|
158
|
+
get presenceRoot() {
|
|
159
|
+
return `${this.prefix}/presence/`;
|
|
160
|
+
}
|
|
161
|
+
async open() {
|
|
162
|
+
// Idempotent for the same reason as the socket.io client transport: the constructor opens
|
|
163
|
+
// and RpcClient.init() opens again, which would leave a second broker connection behind.
|
|
164
|
+
if (this.client)
|
|
165
|
+
return;
|
|
166
|
+
this.client = mqtt.connect(this.url, {
|
|
167
|
+
// A stable clientId is what lets the broker recognise this peer across a reconnect.
|
|
168
|
+
// It used to be random per connection, so no session could ever be resumed.
|
|
169
|
+
clientId: this.sharedGroup ? `msgrpc-${this.name}-${this.replicaId}` : `msgrpc-${this.name}`,
|
|
170
|
+
protocolVersion: this.protocol,
|
|
171
|
+
...this.mqttOptions,
|
|
172
|
+
// MQTT 5 bounds a retained session with an expiry, so a client can queue across a blip
|
|
173
|
+
// without leaving state on the broker forever. 3.1.1 has no expiry, so it stays with
|
|
174
|
+
// the blunt choice between queueing forever and not queueing at all.
|
|
175
|
+
clean: this.mqttOptions.clean ?? (this.sharedGroup ? true : this.protocol === 5 ? false : !this.persistentSession),
|
|
176
|
+
...(this.protocol === 5
|
|
177
|
+
? { properties: { sessionExpiryInterval: this.sessionExpirySeconds, ...this.mqttOptions.properties } }
|
|
178
|
+
: {}),
|
|
179
|
+
will: this.announcePresence
|
|
180
|
+
? { topic: this.presenceTopic(this.name), payload: Buffer.from(PRESENCE_OFFLINE), qos: this.qos, retain: true }
|
|
181
|
+
: this.mqttOptions.will
|
|
182
|
+
});
|
|
183
|
+
// Both listeners catch: an async listener's rejection is unhandled by construction, and
|
|
184
|
+
// Node's default is to end the process on one. A single malformed frame from one peer -
|
|
185
|
+
// or a stray JSON payload published to the rpc topic by any tool that can reach the broker
|
|
186
|
+
// - would otherwise take down a server answering everybody else.
|
|
187
|
+
this.client.on('message', (topic, messageBuffer, packet) => void this.onBrokerMessage(topic, messageBuffer, packet).catch((e) => this.emit(TransportEvent.rejected, { source: 'unknown', reason: `failed to handle message on '${topic}': ${String(e)}`, error: e })));
|
|
188
|
+
// mqtt.js reconnects on its own and re-emits 'connect', so subscriptions are renewed on
|
|
189
|
+
// every transition.
|
|
190
|
+
this.client.on('connect', () => void this.onConnect().catch((e) => this.emit(TransportEvent.transportError, e)));
|
|
191
|
+
this.client.on('close', () => {
|
|
192
|
+
const wasConnected = this.connected;
|
|
193
|
+
this.connected = false;
|
|
194
|
+
this.readyFlag = false;
|
|
195
|
+
if (wasConnected) {
|
|
196
|
+
this.noteConnectionLength();
|
|
197
|
+
this.emit(TransportEvent.disconnected, 'close');
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
// Without a listener here Node throws on the emitter's unhandled 'error', so a rejected
|
|
201
|
+
// broker connection would take the process down. Not re-emitted as 'error' for the same
|
|
202
|
+
// reason.
|
|
203
|
+
this.client.on('error', (e) => this.emit(TransportEvent.transportError, e));
|
|
204
|
+
// A name collision on MQTT needs no detection of its own: the clientId is derived from the
|
|
205
|
+
// peer name, so a second peer using it makes the broker hand the session over and tell the
|
|
206
|
+
// incumbent why. Same outcome as socket.io - the newcomer takes the address - reported from
|
|
207
|
+
// the other end, because here it is the displaced peer that finds out rather than a server.
|
|
208
|
+
this.client.on('disconnect', (packet) => {
|
|
209
|
+
if (packet?.reasonCode === SESSION_TAKEN_OVER)
|
|
210
|
+
this.warnAboutDisplacement();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
connectedSince = 0;
|
|
214
|
+
/** Consecutive connections that did not survive long enough to be doing anything useful. */
|
|
215
|
+
shortConnections = 0;
|
|
216
|
+
/**
|
|
217
|
+
* The 3.1.1 half of collision reporting, which has to be inferred rather than read.
|
|
218
|
+
*
|
|
219
|
+
* MQTT 5 says why it disconnected you; 3.1.1 has no reason codes, so a session taken over looks
|
|
220
|
+
* exactly like the link dropping. What it does not look like is a *stable* connection: two
|
|
221
|
+
* peers sharing a client id evict each other on sight, so both sit in a reconnect loop where no
|
|
222
|
+
* connection outlives the next one's arrival. A blip reconnects once and stays.
|
|
223
|
+
*
|
|
224
|
+
* Reported as a suspicion rather than a fact, because a network flapping this hard would look
|
|
225
|
+
* the same - and either way it is worth saying out loud.
|
|
226
|
+
*/
|
|
227
|
+
noteConnectionLength() {
|
|
228
|
+
const lifetime = Date.now() - this.connectedSince;
|
|
229
|
+
this.shortConnections = lifetime < STABLE_CONNECTION_MS ? this.shortConnections + 1 : 0;
|
|
230
|
+
if (this.shortConnections >= SUSPICIOUS_RECONNECTS)
|
|
231
|
+
this.warnAboutFlapping();
|
|
232
|
+
}
|
|
233
|
+
warnedAboutFlapping = false;
|
|
234
|
+
warnAboutFlapping() {
|
|
235
|
+
if (this.warnedAboutFlapping)
|
|
236
|
+
return;
|
|
237
|
+
this.warnedAboutFlapping = true;
|
|
238
|
+
console.warn(`source-rpc: '${this.name}' has lost its broker connection ${this.shortConnections} times in a row without staying up. ` +
|
|
239
|
+
'The usual cause is a second peer running under this name, since both use the same client id and evict each other. ' +
|
|
240
|
+
'MQTT 3.1.1 gives no reason for a disconnect, so this is a guess - MQTT 5 would say so outright.');
|
|
241
|
+
}
|
|
242
|
+
/** Said once: mqtt.js reconnects on its own, and two peers sharing a name take turns forever. */
|
|
243
|
+
warnedAboutDisplacement = false;
|
|
244
|
+
warnAboutDisplacement() {
|
|
245
|
+
this.emit(TransportEvent.peerDisplaced, this.name);
|
|
246
|
+
if (this.warnedAboutDisplacement)
|
|
247
|
+
return;
|
|
248
|
+
this.warnedAboutDisplacement = true;
|
|
249
|
+
console.warn(`source-rpc: '${this.name}' was disconnected because another connection claimed its broker session, which means a second peer is running under this name. ` +
|
|
250
|
+
'Both will keep taking the connection from each other, and calls to either will reach whichever holds it. Give them distinct names.');
|
|
251
|
+
}
|
|
252
|
+
async onConnect() {
|
|
253
|
+
this.connected = true;
|
|
254
|
+
this.connectedSince = Date.now();
|
|
255
|
+
try {
|
|
256
|
+
// A tap watches every peer's channel rather than its own, and only that: subscribing to
|
|
257
|
+
// both would overlap, and a broker may deliver a message once per matching subscription.
|
|
258
|
+
const watched = this.tap ? '+' : this.topic;
|
|
259
|
+
if (this.protocol === 5) {
|
|
260
|
+
for (const channel of this.channels) {
|
|
261
|
+
const topic = this.channelTopic(channel, watched);
|
|
262
|
+
// Only requests are shared: replies and events must reach one specific peer.
|
|
263
|
+
const filter = channel === 'req' && this.sharedGroup && !this.tap ? `$share/${this.sharedGroup}/${topic}` : topic;
|
|
264
|
+
await this.client?.subscribeAsync(filter, { qos: this.qos });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else
|
|
268
|
+
await this.client?.subscribeAsync(this.rpcTopic(watched), { qos: this.qos });
|
|
269
|
+
if (this.presence) {
|
|
270
|
+
// Observed even by replicas, which still need to know when their own peers depart.
|
|
271
|
+
await this.client?.subscribeAsync(this.presenceTopic('+'), { qos: this.qos });
|
|
272
|
+
if (this.announcePresence)
|
|
273
|
+
await this.client?.publishAsync(this.presenceTopic(this.name), PRESENCE_ONLINE, { qos: this.qos, retain: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
this.emit(TransportEvent.transportError, e);
|
|
278
|
+
}
|
|
279
|
+
// Only now is inbound traffic actually reachable. Announcing earlier would let a client
|
|
280
|
+
// replay its subscriptions before this transport could receive the answers.
|
|
281
|
+
this.readyFlag = true;
|
|
282
|
+
this.emit(TransportEvent.connected);
|
|
283
|
+
}
|
|
284
|
+
async onBrokerMessage(topic, messageBuffer, packet) {
|
|
285
|
+
if (this.presence && topic.startsWith(this.presenceRoot)) {
|
|
286
|
+
const peer = topic.slice(this.presenceRoot.length);
|
|
287
|
+
// Retained presence means a late subscriber also learns about peers that already left.
|
|
288
|
+
if (!peer || peer === this.name)
|
|
289
|
+
return;
|
|
290
|
+
// Presence this transport published on a proxied peer's behalf comes straight back to
|
|
291
|
+
// it. Acting on it would register that peer as living on the broker and break the route
|
|
292
|
+
// home, since it actually lives on whichever transport asked for the forwarding.
|
|
293
|
+
if (this.proxied.has(peer))
|
|
294
|
+
return;
|
|
295
|
+
const state = messageBuffer.toString();
|
|
296
|
+
if (state === PRESENCE_OFFLINE) {
|
|
297
|
+
this.peerIdentities.delete(peer);
|
|
298
|
+
this.emit(TransportEvent.peerGone, peer);
|
|
299
|
+
}
|
|
300
|
+
else if (state === PRESENCE_ONLINE) {
|
|
301
|
+
// Retained, so a subscriber learns about every peer already online the moment it
|
|
302
|
+
// subscribes. That is the whole of peer discovery.
|
|
303
|
+
// Registered as well as announced: presence is how this transport knows a peer
|
|
304
|
+
// exists, and a bridge has to be able to route to it without having heard from it
|
|
305
|
+
// first. Without this a peer discovered over the broker was visible but unreachable.
|
|
306
|
+
this.setKnownSource(peer);
|
|
307
|
+
this.emit(TransportEvent.peerOnline, peer);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (this.protocol === 5)
|
|
312
|
+
return await this.receiveV5(topic, messageBuffer, packet);
|
|
313
|
+
const frame = new Uint8Array(messageBuffer.buffer, messageBuffer.byteOffset, messageBuffer.byteLength);
|
|
314
|
+
const [header, payload, reason] = this.extractHeader(frame);
|
|
315
|
+
if (!header) {
|
|
316
|
+
// Reported rather than dropped in silence. Anything at all can be published to an rpc
|
|
317
|
+
// topic, and "the calls just time out" is the hardest kind of problem to diagnose.
|
|
318
|
+
this.emit(TransportEvent.rejected, { source: 'unknown', reason: reason ?? 'no msgrpc header' });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!isSafeTopicSegment(header.source)) {
|
|
322
|
+
// Replies are addressed by source, so an unsafe one cannot be answered anyway.
|
|
323
|
+
this.emit(TransportEvent.rejected, { source: header.source, reason: 'unsafe peer name' });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Before the signature check and instead of delivery: a tap holds no key for a conversation
|
|
327
|
+
// it is not part of, and it must not act on what it is only watching.
|
|
328
|
+
if (this.tap)
|
|
329
|
+
return this.report(payload, header.source, this.topicAddressee(topic) ?? header.target);
|
|
330
|
+
if (this.verify) {
|
|
331
|
+
const rejection = await this.verifyFrame(header, payload);
|
|
332
|
+
if (rejection) {
|
|
333
|
+
this.emit(TransportEvent.rejected, { source: header.source, reason: rejection });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
let message;
|
|
338
|
+
try {
|
|
339
|
+
message = this.codec.decode(payload);
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
this.emit(TransportEvent.rejected, { source: header.source, reason: `undecodable frame: ${String(e)}` });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
await this.deliver(message, header.source, header.target);
|
|
346
|
+
}
|
|
347
|
+
async receiveV5(topic, messageBuffer, packet) {
|
|
348
|
+
const properties = packet?.properties;
|
|
349
|
+
const control = readControlProperties(properties?.userProperties);
|
|
350
|
+
if ('duplicate' in control) {
|
|
351
|
+
this.emit(TransportEvent.rejected, { source: 'unknown', reason: `repeated control property ${control.duplicate}` });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const values = control.values;
|
|
355
|
+
const source = values[MR.source];
|
|
356
|
+
if (!isSafeTopicSegment(source)) {
|
|
357
|
+
this.emit(TransportEvent.rejected, { source, reason: 'missing or unsafe peer name' });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// An unknown content type used to fall back to msgpack, which is a guess about how to read
|
|
361
|
+
// bytes somebody else chose - and the guess decides what the values mean.
|
|
362
|
+
const declared = properties?.contentType;
|
|
363
|
+
if (declared && declared !== msgPackCodec.contentType && declared !== jsonCodec.contentType) {
|
|
364
|
+
this.emit(TransportEvent.rejected, { source, reason: `unknown content type '${declared}'` });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const body = new Uint8Array(messageBuffer.buffer, messageBuffer.byteOffset, messageBuffer.byteLength);
|
|
368
|
+
const correlation = correlationToString(properties?.correlationData);
|
|
369
|
+
// Only a request may say where its answer goes; a reply or an event carrying the property is
|
|
370
|
+
// saying nothing this peer acts on. Read before the policy is applied, because the signature
|
|
371
|
+
// covers what arrived either way.
|
|
372
|
+
const responseTopic = isRequestKind(values[MR.kind]) ? properties?.responseTopic : undefined;
|
|
373
|
+
// The policy is about publishing there, so it applies to a peer that will answer and not to
|
|
374
|
+
// a tap, whose job is to show what is on the wire rather than to have opinions about it.
|
|
375
|
+
if (responseTopic !== undefined && !this.tap) {
|
|
376
|
+
const refusal = this.refuseResponseTopic(responseTopic, source);
|
|
377
|
+
if (refusal) {
|
|
378
|
+
// Refused rather than quietly answered on the derived topic: a caller that named a
|
|
379
|
+
// topic is waiting on that topic, and a reply it never sees is a call that hangs.
|
|
380
|
+
this.emit(TransportEvent.rejected, { source, reason: refusal });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (this.verify) {
|
|
385
|
+
const rejection = await this.verifyV5({ topic, responseTopic, values, correlation, body, contentType: properties?.contentType });
|
|
386
|
+
if (rejection) {
|
|
387
|
+
this.emit(TransportEvent.rejected, { source, reason: rejection });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
let decoded;
|
|
392
|
+
try {
|
|
393
|
+
decoded = messageBuffer.length ? this.codecFor(properties?.contentType).decode(body) : undefined;
|
|
394
|
+
}
|
|
395
|
+
catch (e) {
|
|
396
|
+
this.emit(TransportEvent.rejected, { source, reason: `undecodable payload: ${String(e)}` });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Recorded before dispatch so the reply can mirror it. Not for a tap, which replies to
|
|
400
|
+
// nothing and would otherwise keep a note about every request on the network.
|
|
401
|
+
if (correlation && !this.tap)
|
|
402
|
+
this.rememberReply(correlation, properties?.contentType, responseTopic);
|
|
403
|
+
const message = fromInboundFrame({
|
|
404
|
+
kind: values[MR.kind],
|
|
405
|
+
correlation,
|
|
406
|
+
path: values[MR.path],
|
|
407
|
+
method: values[MR.method],
|
|
408
|
+
event: values[MR.event],
|
|
409
|
+
code: values[MR.code],
|
|
410
|
+
version: values[MR.contractVersion],
|
|
411
|
+
ttl: this.remainingTtl(values[MR.ttl], properties?.messageExpiryInterval),
|
|
412
|
+
idempotencyKey: values[MR.idempotencyKey],
|
|
413
|
+
body: decoded
|
|
414
|
+
});
|
|
415
|
+
if (!message) {
|
|
416
|
+
this.emit(TransportEvent.rejected, { source, reason: `unrecognised frame kind '${values[MR.kind]}'` });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// Watched rather than acted on: see the note in the v1 path above.
|
|
420
|
+
if (this.tap) {
|
|
421
|
+
if (this.listenerCount(TransportEvent.relayed))
|
|
422
|
+
this.emit(TransportEvent.relayed, { source, target: this.topicAddressee(topic) ?? this.name, message });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
this.setKnownSource(source);
|
|
426
|
+
// The addressee is in the topic under the MQTT 5 layout. It is this peer for everything it
|
|
427
|
+
// subscribed to for itself, and someone else for a topic it watches on their behalf.
|
|
428
|
+
await this.deliver(message, source, this.topicAddressee(topic) ?? this.name);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* A frame this transport is only watching, decoded and announced.
|
|
432
|
+
*
|
|
433
|
+
* The v1 layout carries no properties, so the payload has to be decoded here before anything can
|
|
434
|
+
* be said about it - which is also the only work a tap does per frame, and it is skipped
|
|
435
|
+
* entirely when nothing is listening.
|
|
436
|
+
*/
|
|
437
|
+
report(payload, source, target) {
|
|
438
|
+
if (!this.listenerCount(TransportEvent.relayed))
|
|
439
|
+
return;
|
|
440
|
+
let message;
|
|
441
|
+
try {
|
|
442
|
+
message = this.codec.decode(payload);
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
this.emit(TransportEvent.rejected, { source, reason: `undecodable frame: ${String(e)}` });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.emit(TransportEvent.relayed, { source, target, message });
|
|
449
|
+
}
|
|
450
|
+
/** The peer a topic addresses: <prefix>/<channel>/<peer>. */
|
|
451
|
+
topicAddressee(topic) {
|
|
452
|
+
if (!topic.startsWith(`${this.prefix}/`))
|
|
453
|
+
return undefined;
|
|
454
|
+
const rest = topic.slice(this.prefix.length + 1);
|
|
455
|
+
const slash = rest.indexOf('/');
|
|
456
|
+
return slash < 0 ? undefined : rest.slice(slash + 1);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Hand a decoded frame to this peer's own handler, or on to whichever transport carries its
|
|
460
|
+
* addressee. The second case is a bridge: this transport is subscribed to a topic belonging to
|
|
461
|
+
* a peer that lives on another link, and its job is to pass the frame along unchanged - the
|
|
462
|
+
* source and any signature stay as the original sender wrote them.
|
|
463
|
+
*/
|
|
464
|
+
async deliver(message, source, target) {
|
|
465
|
+
if (target !== this.name) {
|
|
466
|
+
const carrier = this.peerRegistry.get(target);
|
|
467
|
+
if (carrier && carrier !== this && carrier.isTransport()) {
|
|
468
|
+
await carrier.receive(message, source, target);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (this.targetExists(target)) {
|
|
473
|
+
await this.send(message, source, target);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.emit(TransportEvent.unroutable, { source, target });
|
|
477
|
+
}
|
|
478
|
+
/** Peers this transport collects answers for, so the subscriptions are made once and dropped once. */
|
|
479
|
+
proxied = new Set();
|
|
480
|
+
async watchOnBehalfOf(peer) {
|
|
481
|
+
if (this.proxied.has(peer) || peer === this.name || !isSafeTopicSegment(peer))
|
|
482
|
+
return;
|
|
483
|
+
this.proxied.add(peer);
|
|
484
|
+
try {
|
|
485
|
+
if (this.protocol === 5) {
|
|
486
|
+
for (const channel of ['rsp', 'evt'])
|
|
487
|
+
await this.client?.subscribeAsync(this.channelTopic(channel, peer), { qos: this.qos });
|
|
488
|
+
}
|
|
489
|
+
else
|
|
490
|
+
await this.client?.subscribeAsync(this.rpcTopic(peer), { qos: this.qos });
|
|
491
|
+
// Presence for it too. A server drops a departed peer's event subscriptions when its
|
|
492
|
+
// presence goes offline, and a peer that only exists on the other side of this bridge
|
|
493
|
+
// has no other way to say it left - its subscriptions would sit there forever, with
|
|
494
|
+
// every emit producing a frame nobody collects.
|
|
495
|
+
if (this.presence)
|
|
496
|
+
await this.client?.publishAsync(this.presenceTopic(peer), PRESENCE_ONLINE, { qos: this.qos, retain: true });
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
this.proxied.delete(peer);
|
|
500
|
+
this.emit(TransportEvent.transportError, e);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Stop collecting for a peer that has gone, so a departed browser leaves no subscription behind. */
|
|
504
|
+
async stopWatchingFor(peer) {
|
|
505
|
+
if (!this.proxied.delete(peer))
|
|
506
|
+
return;
|
|
507
|
+
try {
|
|
508
|
+
if (this.presence) {
|
|
509
|
+
// Offline first, so a server holding its subscriptions releases them, then cleared
|
|
510
|
+
// so the peer leaves no retained state behind - the same pair this transport
|
|
511
|
+
// publishes for itself on a clean shutdown.
|
|
512
|
+
await this.client?.publishAsync(this.presenceTopic(peer), PRESENCE_OFFLINE, { qos: this.qos, retain: true });
|
|
513
|
+
await this.client?.publishAsync(this.presenceTopic(peer), '', { qos: this.qos, retain: true });
|
|
514
|
+
}
|
|
515
|
+
if (this.protocol === 5) {
|
|
516
|
+
for (const channel of ['rsp', 'evt'])
|
|
517
|
+
await this.client?.unsubscribeAsync(this.channelTopic(channel, peer));
|
|
518
|
+
}
|
|
519
|
+
else
|
|
520
|
+
await this.client?.unsubscribeAsync(this.rpcTopic(peer));
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
this.emit(TransportEvent.transportError, e);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* A reason to refuse a response topic, or undefined to publish the answer there.
|
|
528
|
+
*
|
|
529
|
+
* The topic comes from whoever sent the request, and this peer is the one that will publish to
|
|
530
|
+
* it, so without a rule a caller could have a server publish anywhere the broker's ACLs let it -
|
|
531
|
+
* over another peer's presence, into `$SYS`, or onto a retained topic something else reads. The
|
|
532
|
+
* default rule is the transport's own prefix, since that is the boundary an operator already
|
|
533
|
+
* draws ACLs on.
|
|
534
|
+
*/
|
|
535
|
+
refuseResponseTopic(topic, source) {
|
|
536
|
+
if (!isSafeTopicName(topic))
|
|
537
|
+
return `unusable response topic '${String(topic).slice(0, 64)}'`;
|
|
538
|
+
if (this.allowResponseTopic)
|
|
539
|
+
return this.allowResponseTopic(topic, source) ? undefined : `response topic '${topic}' is not allowed here`;
|
|
540
|
+
return topic.startsWith(`${this.prefix}/`) ? undefined : `response topic '${topic}' is outside '${this.prefix}/'`;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* What is left of the caller's stated budget.
|
|
544
|
+
*
|
|
545
|
+
* MQTT 5 requires a server to hand on the Message Expiry Interval it received minus the time
|
|
546
|
+
* the message spent waiting in it, so for a request that was queued this is the caller's budget
|
|
547
|
+
* with the queueing already deducted - measured by the broker, with no clock of ours entering
|
|
548
|
+
* into it. Taken as an upper bound only: expiry is whole seconds and a broker of unknown
|
|
549
|
+
* quality set it, so it may shorten what the caller signed and never lengthen it.
|
|
550
|
+
*/
|
|
551
|
+
remainingTtl(stated, expirySeconds) {
|
|
552
|
+
if (stated === undefined)
|
|
553
|
+
return undefined;
|
|
554
|
+
const ttl = Number(stated);
|
|
555
|
+
if (!Number.isFinite(ttl) || ttl < 0)
|
|
556
|
+
return undefined;
|
|
557
|
+
if (typeof expirySeconds !== 'number' || !Number.isFinite(expirySeconds))
|
|
558
|
+
return ttl;
|
|
559
|
+
return Math.min(ttl, Math.max(0, expirySeconds) * 1000);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Seconds the broker should hold a request: what its caller said it would wait, rounded up.
|
|
563
|
+
*
|
|
564
|
+
* Clamped to the four-byte field MQTT gives it - about 136 years - because the ttl comes from
|
|
565
|
+
* whatever number a caller set as its timeout, and a value that does not fit would be a packet
|
|
566
|
+
* the broker refuses rather than a request that waits a long time.
|
|
567
|
+
*/
|
|
568
|
+
expiryFor(ttl) {
|
|
569
|
+
if (ttl === undefined || !Number.isFinite(ttl) || ttl <= 0)
|
|
570
|
+
return this.requestExpirySeconds;
|
|
571
|
+
return Math.min(Math.max(1, Math.ceil(ttl / 1000)), 0xffffffff);
|
|
572
|
+
}
|
|
573
|
+
rememberReply(correlation, contentType, topic) {
|
|
574
|
+
// Only what differs from what would be derived anyway, so an ordinary exchange between two
|
|
575
|
+
// of our own peers adds nothing to this map.
|
|
576
|
+
const encoding = contentType && contentType !== this.codec.contentType ? contentType : undefined;
|
|
577
|
+
if (!encoding && !topic)
|
|
578
|
+
return;
|
|
579
|
+
this.pendingReplies.set(correlation, { contentType: encoding, topic });
|
|
580
|
+
while (this.pendingReplies.size > this.maxTrackedReplies) {
|
|
581
|
+
const oldest = this.pendingReplies.keys().next();
|
|
582
|
+
if (oldest.done)
|
|
583
|
+
break;
|
|
584
|
+
this.pendingReplies.delete(oldest.value);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** One request, one answer: taking it also forgets it. */
|
|
588
|
+
takeReply(correlation) {
|
|
589
|
+
const pending = this.pendingReplies.get(correlation);
|
|
590
|
+
if (pending)
|
|
591
|
+
this.pendingReplies.delete(correlation);
|
|
592
|
+
return pending;
|
|
593
|
+
}
|
|
594
|
+
/** A peer may speak JSON while this one defaults to msgpack; contentType says which. */
|
|
595
|
+
codecFor(contentType) {
|
|
596
|
+
if (contentType && contentType !== this.codec.contentType)
|
|
597
|
+
return contentType === jsonCodec.contentType ? jsonCodec : msgPackCodec;
|
|
598
|
+
return this.codec;
|
|
599
|
+
}
|
|
600
|
+
async verifyV5(frame) {
|
|
601
|
+
const { topic, values, correlation, body, contentType } = frame;
|
|
602
|
+
const signature = values[MR.signature];
|
|
603
|
+
const nonce = values[MR.nonce];
|
|
604
|
+
const timestamp = Number(values[MR.timestamp]);
|
|
605
|
+
if (!signature || !nonce)
|
|
606
|
+
return 'unsigned';
|
|
607
|
+
// The version gate belongs here rather than on the receive path, and the distinction is the
|
|
608
|
+
// whole design of this change. An unsigned frame's version says nothing about security, and
|
|
609
|
+
// refusing one would break plain MQTT 5 peers written against version 1 - which is a feature
|
|
610
|
+
// worth keeping. A *signed* frame announcing version 1 is different: version 1 left the
|
|
611
|
+
// content type, the error code and the contract version out of the signature, so accepting
|
|
612
|
+
// one would leave exactly the hole this closes, and let a sender choose the weaker form.
|
|
613
|
+
const announced = values[MR.version] ?? FRAME_VERSION;
|
|
614
|
+
if (!SUPPORTED_FRAME_VERSIONS.has(announced))
|
|
615
|
+
return `signed frame version '${announced}', which this build does not accept`;
|
|
616
|
+
if (!this.replayGuard.accept(nonce, timestamp))
|
|
617
|
+
return 'stale or replayed';
|
|
618
|
+
const source = values[MR.source];
|
|
619
|
+
const canonical = canonicalSignedBytesV5({
|
|
620
|
+
version: values[MR.version] ?? FRAME_VERSION,
|
|
621
|
+
topic,
|
|
622
|
+
responseTopic: frame.responseTopic ?? '',
|
|
623
|
+
source,
|
|
624
|
+
kind: values[MR.kind] ?? '',
|
|
625
|
+
path: values[MR.path] ?? '',
|
|
626
|
+
methodOrEvent: values[MR.method] ?? values[MR.event] ?? '',
|
|
627
|
+
correlation: correlation ?? '',
|
|
628
|
+
// Rebuilt from what arrived, so tampering with any of them fails the signature rather
|
|
629
|
+
// than changing how the payload is read, where the answer is sent, what the caller does
|
|
630
|
+
// about a failure, or whether a command that is already too late still runs.
|
|
631
|
+
contentType: contentType ?? '',
|
|
632
|
+
code: values[MR.code] ?? '',
|
|
633
|
+
contractVersion: values[MR.contractVersion] ?? '',
|
|
634
|
+
ttl: values[MR.ttl] ?? '',
|
|
635
|
+
idempotencyKey: values[MR.idempotencyKey] ?? '',
|
|
636
|
+
timestamp,
|
|
637
|
+
nonce,
|
|
638
|
+
payload: body
|
|
639
|
+
});
|
|
640
|
+
let identity;
|
|
641
|
+
try {
|
|
642
|
+
identity = await this.verify(canonical, signature, { source });
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return 'verifier error';
|
|
646
|
+
}
|
|
647
|
+
if (!identity)
|
|
648
|
+
return 'bad signature';
|
|
649
|
+
if (identity.name !== source)
|
|
650
|
+
return 'identity does not match source';
|
|
651
|
+
this.peerIdentities.set(source, identity);
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Returns a reason to reject, or undefined when the frame is authentic. Every check is a
|
|
656
|
+
* separate failure mode worth naming, because "message dropped" with no reason is the hardest
|
|
657
|
+
* kind of problem to diagnose on a plant network.
|
|
658
|
+
*/
|
|
659
|
+
async verifyFrame(header, payload) {
|
|
660
|
+
// An unsigned frame is not a valid frame once verification is on, or signing would be
|
|
661
|
+
// trivially bypassed by omitting the signature.
|
|
662
|
+
if (!header.sig || !header.nonce)
|
|
663
|
+
return 'unsigned';
|
|
664
|
+
if (!this.replayGuard.accept(header.nonce, header.time))
|
|
665
|
+
return 'stale or replayed';
|
|
666
|
+
const canonical = canonicalSignedBytes({
|
|
667
|
+
source: header.source,
|
|
668
|
+
target: header.target,
|
|
669
|
+
time: header.time,
|
|
670
|
+
seq: header.seq,
|
|
671
|
+
nonce: header.nonce,
|
|
672
|
+
payload: typeof payload === 'string' ? stringToUint8Array(payload) : payload
|
|
673
|
+
});
|
|
674
|
+
let identity;
|
|
675
|
+
try {
|
|
676
|
+
identity = await this.verify(canonical, header.sig, { source: header.source });
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// A verifier that throws rejects, for the same reason an authorizer that throws denies.
|
|
680
|
+
return 'verifier error';
|
|
681
|
+
}
|
|
682
|
+
if (!identity)
|
|
683
|
+
return 'bad signature';
|
|
684
|
+
// The same pinning rule the socket.io transport applies: a key authorises one name, so a
|
|
685
|
+
// peer cannot sign frames claiming to come from someone else.
|
|
686
|
+
if (identity.name !== header.source)
|
|
687
|
+
return 'identity does not match source';
|
|
688
|
+
this.peerIdentities.set(header.source, identity);
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
async receive(message, source, target) {
|
|
692
|
+
if (!isSafeTopicSegment(target)) {
|
|
693
|
+
this.emit(TransportEvent.unroutable, { source, target, reason: 'unsafe peer name' });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
// Publishing for a peer that is not this one means acting as its gateway onto the broker.
|
|
697
|
+
// Its replies and events are addressed to it, on topics this transport does not otherwise
|
|
698
|
+
// watch, so they have to be subscribed to or the call can only ever time out.
|
|
699
|
+
if (source !== this.name)
|
|
700
|
+
await this.watchOnBehalfOf(source);
|
|
701
|
+
if (this.protocol === 5)
|
|
702
|
+
return await this.publishV5(message, source, target);
|
|
703
|
+
const body = this.codec.encode(message);
|
|
704
|
+
const header = this.buildHeader(source, target, this.sign ? { nonce: createNonce() } : undefined);
|
|
705
|
+
if (this.sign) {
|
|
706
|
+
const canonical = canonicalSignedBytes({
|
|
707
|
+
source: header.source,
|
|
708
|
+
target: header.target,
|
|
709
|
+
time: header.time,
|
|
710
|
+
seq: header.seq,
|
|
711
|
+
nonce: header.nonce,
|
|
712
|
+
payload: body
|
|
713
|
+
});
|
|
714
|
+
header.sig = await this.sign(canonical, { source: header.source });
|
|
715
|
+
}
|
|
716
|
+
const framed = this.frameMessage(header, body);
|
|
717
|
+
const payload = typeof framed === 'string' ? framed : Buffer.from(framed.buffer, framed.byteOffset, framed.byteLength);
|
|
718
|
+
// Awaited, so at QoS > 0 a publish that never reaches the broker surfaces as a failed call
|
|
719
|
+
// rather than a silent drop followed by a timeout.
|
|
720
|
+
await this.requireClient().publishAsync(this.rpcTopic(target), payload, { qos: this.qos });
|
|
721
|
+
}
|
|
722
|
+
/** Maps an RPC message onto the MQTT 5 packet layout. See docs/mqtt5-frame-spec.md. */
|
|
723
|
+
async publishV5(message, source, target) {
|
|
724
|
+
const frame = toOutboundFrame(message);
|
|
725
|
+
if (!frame) {
|
|
726
|
+
this.emit(TransportEvent.unroutable, { source, target, reason: 'no MQTT 5 representation for this message' });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// A reply goes where its request asked and in the encoding it arrived in; anything else goes
|
|
730
|
+
// to the addressee's own channel in this peer's own encoding.
|
|
731
|
+
const pending = frame.channel === 'rsp' && frame.correlation ? this.takeReply(frame.correlation) : undefined;
|
|
732
|
+
const topic = pending?.topic ?? this.channelTopic(frame.channel, target);
|
|
733
|
+
const codec = pending?.contentType ? this.codecFor(pending.contentType) : this.codec;
|
|
734
|
+
const body = codec.encode(frame.body);
|
|
735
|
+
// Where this peer wants its own answer. Named explicitly rather than left to the far end to
|
|
736
|
+
// derive, and signed below, because the far end now publishes to it.
|
|
737
|
+
const responseTopic = frame.channel === 'req' ? this.channelTopic('rsp', source) : undefined;
|
|
738
|
+
const userProperties = {
|
|
739
|
+
[MR.version]: FRAME_VERSION,
|
|
740
|
+
[MR.source]: source,
|
|
741
|
+
[MR.kind]: frame.kind
|
|
742
|
+
};
|
|
743
|
+
if (frame.path)
|
|
744
|
+
userProperties[MR.path] = frame.path;
|
|
745
|
+
if (frame.method)
|
|
746
|
+
userProperties[MR.method] = frame.method;
|
|
747
|
+
if (frame.event)
|
|
748
|
+
userProperties[MR.event] = frame.event;
|
|
749
|
+
if (frame.code)
|
|
750
|
+
userProperties[MR.code] = frame.code;
|
|
751
|
+
if (frame.version)
|
|
752
|
+
userProperties[MR.contractVersion] = frame.version;
|
|
753
|
+
if (frame.ttl !== undefined)
|
|
754
|
+
userProperties[MR.ttl] = String(frame.ttl);
|
|
755
|
+
if (frame.idempotencyKey)
|
|
756
|
+
userProperties[MR.idempotencyKey] = frame.idempotencyKey;
|
|
757
|
+
if (this.sign) {
|
|
758
|
+
const nonce = createNonce();
|
|
759
|
+
const timestamp = Date.now();
|
|
760
|
+
const canonical = canonicalSignedBytesV5({
|
|
761
|
+
version: FRAME_VERSION,
|
|
762
|
+
topic,
|
|
763
|
+
responseTopic: responseTopic ?? '',
|
|
764
|
+
source,
|
|
765
|
+
kind: frame.kind,
|
|
766
|
+
path: frame.path ?? '',
|
|
767
|
+
methodOrEvent: frame.method ?? frame.event ?? '',
|
|
768
|
+
correlation: frame.correlation ?? '',
|
|
769
|
+
contentType: codec.contentType,
|
|
770
|
+
code: frame.code ?? '',
|
|
771
|
+
contractVersion: frame.version ?? '',
|
|
772
|
+
ttl: frame.ttl !== undefined ? String(frame.ttl) : '',
|
|
773
|
+
idempotencyKey: frame.idempotencyKey ?? '',
|
|
774
|
+
timestamp,
|
|
775
|
+
nonce,
|
|
776
|
+
payload: body
|
|
777
|
+
});
|
|
778
|
+
userProperties[MR.nonce] = nonce;
|
|
779
|
+
userProperties[MR.timestamp] = String(timestamp);
|
|
780
|
+
userProperties[MR.signature] = await this.sign(canonical, { source });
|
|
781
|
+
}
|
|
782
|
+
await this.requireClient().publishAsync(topic, Buffer.from(body), {
|
|
783
|
+
qos: this.qos,
|
|
784
|
+
properties: {
|
|
785
|
+
contentType: codec.contentType,
|
|
786
|
+
payloadFormatIndicator: codec.contentType === jsonCodec.contentType,
|
|
787
|
+
correlationData: frame.correlation ? Buffer.from(correlationToBytes(frame.correlation)) : undefined,
|
|
788
|
+
// Only a request expects an answer, and only a request should expire. The expiry is
|
|
789
|
+
// the caller's own remaining time, so the broker stops holding the request at the
|
|
790
|
+
// moment the caller stops waiting for it - the two used to be set independently,
|
|
791
|
+
// and a request outlived its caller's patience by twenty seconds by default.
|
|
792
|
+
...(responseTopic ? { responseTopic, messageExpiryInterval: this.expiryFor(frame.ttl) } : {}),
|
|
793
|
+
userProperties
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
async close() {
|
|
798
|
+
// GenericModule.close() is a no-op, so without this the broker connection stayed open and
|
|
799
|
+
// kept reconnecting after the transport was discarded.
|
|
800
|
+
const client = this.client;
|
|
801
|
+
this.client = undefined;
|
|
802
|
+
this.connected = false;
|
|
803
|
+
this.readyFlag = false;
|
|
804
|
+
if (!client)
|
|
805
|
+
return;
|
|
806
|
+
// Peers this transport was standing in for go with it. Its own will covers its own name;
|
|
807
|
+
// nothing covers theirs, so a bridge that is killed does leave their presence retained.
|
|
808
|
+
if (this.presence && client.connected)
|
|
809
|
+
for (const peer of [...this.proxied]) {
|
|
810
|
+
this.proxied.delete(peer);
|
|
811
|
+
try {
|
|
812
|
+
await client.publishAsync(this.presenceTopic(peer), PRESENCE_OFFLINE, { qos: this.qos, retain: true });
|
|
813
|
+
await client.publishAsync(this.presenceTopic(peer), '', { qos: this.qos, retain: true });
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// Going away regardless.
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (this.announcePresence && client.connected) {
|
|
820
|
+
const topic = this.presenceTopic(this.name);
|
|
821
|
+
try {
|
|
822
|
+
// A graceful goodbye, so peers release this one's subscriptions immediately instead
|
|
823
|
+
// of waiting for the broker to notice the connection is gone and publish the will.
|
|
824
|
+
await client.publishAsync(topic, PRESENCE_OFFLINE, { qos: this.qos, retain: true });
|
|
825
|
+
// Then clear the retained value. A peer that left cleanly has no state for a later
|
|
826
|
+
// subscriber to clean up, and leaving one behind per peer name accumulates on the
|
|
827
|
+
// broker forever. An ungraceful death keeps its retained will, which is the point.
|
|
828
|
+
await client.publishAsync(topic, '', { qos: this.qos, retain: true });
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
// Going away regardless; the will covers it.
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
await client.endAsync();
|
|
835
|
+
}
|
|
836
|
+
getIdentity(source) {
|
|
837
|
+
return this.peerIdentities.get(source);
|
|
838
|
+
}
|
|
839
|
+
isTransport() {
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
//# sourceMappingURL=MqttTransport.js.map
|