dsh-device-fleet 0.0.0-stage → 0.1.0-dev.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.
- package/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +28 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +42 -3
- package/src/host/direct-call.js +190 -0
- package/src/host/execution-slot.js +82 -0
- package/src/host/index.js +6 -0
- package/src/host/store.js +128 -0
- package/src/host/tool-policy.js +150 -0
- package/src/host/tools.js +215 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +414 -0
- package/src/net/identity.js +156 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- package/src/plugin/index.js +285 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// FleetNode: one device's networking facade.
|
|
2
|
+
//
|
|
3
|
+
// It owns the identity, the fleet roster, a direct listener, optional LAN
|
|
4
|
+
// discovery, an optional relay client, and the RPC layer that runs over
|
|
5
|
+
// authenticated SecureChannels.
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import { createDirectServer, connectDirect } from './direct.js'
|
|
9
|
+
import { LanDiscovery } from './discovery.js'
|
|
10
|
+
import { RelayClient } from './relay-client.js'
|
|
11
|
+
import { SecureChannel } from './secure-channel.js'
|
|
12
|
+
import {
|
|
13
|
+
FRAME,
|
|
14
|
+
PROTOCOL_VERSION,
|
|
15
|
+
eventFrame,
|
|
16
|
+
rpcError,
|
|
17
|
+
rpcRequest,
|
|
18
|
+
rpcResult,
|
|
19
|
+
serializeError,
|
|
20
|
+
} from './protocol.js'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 12_000
|
|
23
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
|
24
|
+
|
|
25
|
+
export class FleetNode extends EventEmitter {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {object} options.identity device identity (generateIdentity)
|
|
29
|
+
* @param {{fleetId: string, fleetKey: string, name?: string}} options.fleet
|
|
30
|
+
* @param {string} [options.name]
|
|
31
|
+
* @param {string|null} [options.accountFingerprint]
|
|
32
|
+
* @param {{host?: string, port?: number}|false} [options.direct] direct listener (false disables)
|
|
33
|
+
* @param {false|{group?: string, port?: number, intervalMs?: number}} [options.discovery]
|
|
34
|
+
* @param {null|{host: string, port: number, dataPort: number, reconnect?: object}} [options.relay]
|
|
35
|
+
* @param {Array<{deviceId: string, edPublic: string, xPublic: string, name?: string}>} [options.roster]
|
|
36
|
+
* @param {boolean} [options.trustOnFirstUse]
|
|
37
|
+
* @param {boolean} [options.acceptRelayConnections]
|
|
38
|
+
*/
|
|
39
|
+
constructor({
|
|
40
|
+
identity,
|
|
41
|
+
fleet,
|
|
42
|
+
name = identity?.name ?? 'device',
|
|
43
|
+
accountFingerprint = null,
|
|
44
|
+
direct = { host: '127.0.0.1', port: 0 },
|
|
45
|
+
discovery = false,
|
|
46
|
+
relay = null,
|
|
47
|
+
roster = [],
|
|
48
|
+
trustOnFirstUse = true,
|
|
49
|
+
acceptRelayConnections = true,
|
|
50
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
51
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
52
|
+
pluginVersion = '0.1.0',
|
|
53
|
+
dshVersion = 'unknown',
|
|
54
|
+
capabilities = [],
|
|
55
|
+
} = {}) {
|
|
56
|
+
super()
|
|
57
|
+
if (!identity?.deviceId || !identity.edPrivate || !identity.edPublic) throw new Error('FleetNode needs an identity')
|
|
58
|
+
if (!fleet?.fleetId || !fleet?.fleetKey) throw new Error('FleetNode needs a fleet')
|
|
59
|
+
this.identity = identity
|
|
60
|
+
this.fleet = fleet
|
|
61
|
+
this.name = name
|
|
62
|
+
this.accountFingerprint = accountFingerprint
|
|
63
|
+
this.directConfig = direct
|
|
64
|
+
this.discoveryConfig = discovery
|
|
65
|
+
this.relayConfig = relay
|
|
66
|
+
this.trustOnFirstUse = trustOnFirstUse
|
|
67
|
+
this.acceptRelayConnections = acceptRelayConnections
|
|
68
|
+
this.connectTimeoutMs = connectTimeoutMs
|
|
69
|
+
this.requestTimeoutMs = requestTimeoutMs
|
|
70
|
+
this.pluginVersion = pluginVersion
|
|
71
|
+
this.dshVersion = dshVersion
|
|
72
|
+
this.capabilities = capabilities
|
|
73
|
+
|
|
74
|
+
this.roster = new Map()
|
|
75
|
+
for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
|
|
76
|
+
this.discovered = new Map()
|
|
77
|
+
this.channels = new Map()
|
|
78
|
+
this.pendingRpc = new Map()
|
|
79
|
+
this.requestHandler = null
|
|
80
|
+
this.eventHandler = null
|
|
81
|
+
|
|
82
|
+
this.directServer = null
|
|
83
|
+
this.discovery = null
|
|
84
|
+
this.relayClient = null
|
|
85
|
+
this.started = false
|
|
86
|
+
this.closed = false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get directPort() {
|
|
90
|
+
return this.directAddress?.port ?? null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
rosterEntries() {
|
|
94
|
+
return [...this.roster.values()]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
upsertRoster(entry) {
|
|
98
|
+
const existing = this.roster.get(entry.deviceId)
|
|
99
|
+
this.roster.set(entry.deviceId, { ...existing, name: 'device', ...entry })
|
|
100
|
+
this.emit('roster', this.rosterEntries())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onRequest(handler) {
|
|
104
|
+
this.requestHandler = handler
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
onEvent(handler) {
|
|
108
|
+
this.eventHandler = handler
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async start() {
|
|
112
|
+
if (this.started) return this
|
|
113
|
+
this.started = true
|
|
114
|
+
|
|
115
|
+
if (this.directConfig !== false) {
|
|
116
|
+
this.directServer = createDirectServer({
|
|
117
|
+
host: this.directConfig.host ?? '0.0.0.0',
|
|
118
|
+
port: this.directConfig.port ?? 0,
|
|
119
|
+
})
|
|
120
|
+
this.directAddress = await this.directServer.ready
|
|
121
|
+
this.directServer.server.on('connection', (socket) => this.acceptInbound(socket, 'direct'))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.discoveryConfig !== false && this.discoveryConfig !== undefined) {
|
|
125
|
+
if (!this.directAddress) throw new Error('discovery needs the direct listener')
|
|
126
|
+
const config = this.discoveryConfig === true ? {} : this.discoveryConfig
|
|
127
|
+
this.discovery = new LanDiscovery({
|
|
128
|
+
fleetId: this.fleet.fleetId,
|
|
129
|
+
deviceId: this.identity.deviceId,
|
|
130
|
+
name: this.name,
|
|
131
|
+
directPort: this.directAddress.port,
|
|
132
|
+
group: config.group,
|
|
133
|
+
port: config.port,
|
|
134
|
+
intervalMs: config.intervalMs,
|
|
135
|
+
})
|
|
136
|
+
this.discovery.on('peer', (peer) => {
|
|
137
|
+
this.discovered.set(peer.deviceId, peer)
|
|
138
|
+
this.emit('discovered', peer)
|
|
139
|
+
})
|
|
140
|
+
this.discovery.on('error', (error) => this.emit('warning', error))
|
|
141
|
+
this.discovery.start()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (this.relayConfig) {
|
|
145
|
+
this.relayClient = new RelayClient({
|
|
146
|
+
host: this.relayConfig.host,
|
|
147
|
+
port: this.relayConfig.port,
|
|
148
|
+
dataPort: this.relayConfig.dataPort,
|
|
149
|
+
fleetId: this.fleet.fleetId,
|
|
150
|
+
deviceId: this.identity.deviceId,
|
|
151
|
+
fleetKey: this.fleet.fleetKey,
|
|
152
|
+
reconnect: this.relayConfig.reconnect,
|
|
153
|
+
})
|
|
154
|
+
this.relayClient.on('online', () => this.emit('relay-online'))
|
|
155
|
+
this.relayClient.on('offline', () => this.emit('relay-offline'))
|
|
156
|
+
this.relayClient.on('relay-error', (error) => this.emit('warning', error))
|
|
157
|
+
this.relayClient.on('incoming', (controller) => this.acceptIncomingRelay(controller))
|
|
158
|
+
try {
|
|
159
|
+
await this.relayClient.start(5_000)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.emit('warning', error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.emit('started', { direct: this.directAddress ?? null })
|
|
166
|
+
return this
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async acceptIncomingRelay(controller) {
|
|
170
|
+
if (!this.acceptRelayConnections) {
|
|
171
|
+
controller.reject()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const socket = await controller.accept()
|
|
176
|
+
this.acceptInbound(socket, 'relay')
|
|
177
|
+
} catch (error) {
|
|
178
|
+
this.emit('warning', error)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
acceptInbound(socket, via) {
|
|
183
|
+
const channel = new SecureChannel({
|
|
184
|
+
socket,
|
|
185
|
+
identity: this.identity,
|
|
186
|
+
fleet: this.fleet,
|
|
187
|
+
role: 'responder',
|
|
188
|
+
accountFingerprint: this.accountFingerprint,
|
|
189
|
+
verifyPeer: (peer) => this.authorizePeer(peer),
|
|
190
|
+
pluginVersion: this.pluginVersion,
|
|
191
|
+
dshVersion: this.dshVersion,
|
|
192
|
+
capabilities: this.capabilities,
|
|
193
|
+
})
|
|
194
|
+
this.attachChannel(channel, via)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
attachChannel(channel, via) {
|
|
198
|
+
channel.on('error', (error) => this.emit('warning', error))
|
|
199
|
+
channel.on('ready', (peer) => {
|
|
200
|
+
this.registerChannel(peer, channel, via)
|
|
201
|
+
})
|
|
202
|
+
channel.on('close', () => {
|
|
203
|
+
const current = this.channels.get(channel.peer?.deviceId)
|
|
204
|
+
if (current === channel) this.channels.delete(channel.peer.deviceId)
|
|
205
|
+
this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
|
|
206
|
+
this.emit('peer-disconnected', { deviceId: channel.peer?.deviceId, via })
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
registerChannel(peer, channel, via) {
|
|
211
|
+
this.channels.set(peer.deviceId, channel)
|
|
212
|
+
channel.on('frame', (message) => this.onFrame(peer.deviceId, message, channel))
|
|
213
|
+
this.emit('peer', { deviceId: peer.deviceId, name: peer.name, via, channel })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
authorizePeer(peer) {
|
|
217
|
+
const existing = this.roster.get(peer.deviceId)
|
|
218
|
+
if (existing) {
|
|
219
|
+
return existing.edPublic === peer.edPublic && existing.xPublic === peer.xPublic
|
|
220
|
+
}
|
|
221
|
+
if (!this.trustOnFirstUse) return false
|
|
222
|
+
this.roster.set(peer.deviceId, {
|
|
223
|
+
deviceId: peer.deviceId,
|
|
224
|
+
name: peer.name ?? 'device',
|
|
225
|
+
edPublic: peer.edPublic,
|
|
226
|
+
xPublic: peer.xPublic,
|
|
227
|
+
addedAt: Date.now(),
|
|
228
|
+
})
|
|
229
|
+
this.emit('roster', this.rosterEntries())
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async connect(deviceId, { host, port, timeoutMs = this.connectTimeoutMs } = {}) {
|
|
234
|
+
if (typeof deviceId !== 'string' || deviceId === '') throw new Error('connect needs a device id')
|
|
235
|
+
if (deviceId === this.identity.deviceId) throw new Error('cannot connect to self')
|
|
236
|
+
const existing = this.channels.get(deviceId)
|
|
237
|
+
if (existing && !existing.closed) return existing
|
|
238
|
+
|
|
239
|
+
const expected = this.roster.get(deviceId) ?? { deviceId }
|
|
240
|
+
const discovered = this.discovered.get(deviceId)
|
|
241
|
+
const endpoint = host && port ? { host, port } : discovered ? { host: discovered.host, port: discovered.port } : null
|
|
242
|
+
let directError = null
|
|
243
|
+
if (endpoint) {
|
|
244
|
+
try {
|
|
245
|
+
const socket = await connectDirect({ host: endpoint.host, port: endpoint.port, timeoutMs })
|
|
246
|
+
const channel = this.createInitiator(socket, expected)
|
|
247
|
+
await this.waitReady(channel, timeoutMs)
|
|
248
|
+
return channel
|
|
249
|
+
} catch (error) {
|
|
250
|
+
directError = error
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (this.relayClient) {
|
|
254
|
+
try {
|
|
255
|
+
const socket = await this.relayClient.requestConnect(deviceId, timeoutMs)
|
|
256
|
+
const channel = this.createInitiator(socket, expected)
|
|
257
|
+
await this.waitReady(channel, timeoutMs)
|
|
258
|
+
return channel
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw Object.assign(new Error(`cannot reach ${deviceId} via relay: ${String(error.message)}`), {
|
|
261
|
+
code: 'unreachable',
|
|
262
|
+
cause: error,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw Object.assign(
|
|
267
|
+
new Error(`cannot reach ${deviceId}${directError ? `: ${String(directError.message)}` : ' (no endpoint)'}`),
|
|
268
|
+
{ code: 'unreachable', cause: directError ?? undefined },
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
createInitiator(socket, expectedPeer) {
|
|
273
|
+
const channel = new SecureChannel({
|
|
274
|
+
socket,
|
|
275
|
+
identity: this.identity,
|
|
276
|
+
fleet: this.fleet,
|
|
277
|
+
role: 'initiator',
|
|
278
|
+
expectedPeer,
|
|
279
|
+
accountFingerprint: this.accountFingerprint,
|
|
280
|
+
verifyPeer: (peer) => this.authorizePeer(peer),
|
|
281
|
+
pluginVersion: this.pluginVersion,
|
|
282
|
+
dshVersion: this.dshVersion,
|
|
283
|
+
capabilities: this.capabilities,
|
|
284
|
+
})
|
|
285
|
+
this.attachChannel(channel, 'outbound')
|
|
286
|
+
return channel
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
waitReady(channel, timeoutMs) {
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
if (channel.authenticated) return resolve(channel)
|
|
292
|
+
const timer = setTimeout(() => {
|
|
293
|
+
cleanup()
|
|
294
|
+
reject(Object.assign(new Error('secure channel handshake timed out'), { code: 'handshake-timeout' }))
|
|
295
|
+
channel.destroy()
|
|
296
|
+
}, timeoutMs)
|
|
297
|
+
timer.unref?.()
|
|
298
|
+
const onReady = () => {
|
|
299
|
+
cleanup()
|
|
300
|
+
resolve(channel)
|
|
301
|
+
}
|
|
302
|
+
const onClose = () => {
|
|
303
|
+
cleanup()
|
|
304
|
+
reject(Object.assign(new Error('secure channel closed during handshake'), { code: 'handshake-closed' }))
|
|
305
|
+
}
|
|
306
|
+
const cleanup = () => {
|
|
307
|
+
clearTimeout(timer)
|
|
308
|
+
channel.off('ready', onReady)
|
|
309
|
+
channel.off('close', onClose)
|
|
310
|
+
}
|
|
311
|
+
channel.once('ready', onReady)
|
|
312
|
+
channel.once('close', onClose)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
onFrame(deviceId, message, channel) {
|
|
317
|
+
switch (message.t) {
|
|
318
|
+
case FRAME.RPC_REQUEST: {
|
|
319
|
+
const handler = this.requestHandler
|
|
320
|
+
if (typeof handler !== 'function') {
|
|
321
|
+
channel.sendFrame(rpcError({ id: message.id, code: 'no-handler', message: 'no request handler' }))
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
Promise.resolve()
|
|
325
|
+
.then(() => handler({ deviceId, method: message.method, params: message.params }))
|
|
326
|
+
.then((result) => channel.sendFrame(rpcResult({ id: message.id, result })))
|
|
327
|
+
.catch((error) => {
|
|
328
|
+
channel.sendFrame(rpcError({ id: message.id, ...serializeError(error) }))
|
|
329
|
+
})
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
case FRAME.RPC_RESULT: {
|
|
333
|
+
const pending = this.pendingRpc.get(message.id)
|
|
334
|
+
if (!pending) return
|
|
335
|
+
this.pendingRpc.delete(message.id)
|
|
336
|
+
clearTimeout(pending.timer)
|
|
337
|
+
pending.resolve(message.result)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
case FRAME.RPC_ERROR: {
|
|
341
|
+
const pending = this.pendingRpc.get(message.id)
|
|
342
|
+
if (!pending) return
|
|
343
|
+
this.pendingRpc.delete(message.id)
|
|
344
|
+
clearTimeout(pending.timer)
|
|
345
|
+
const error = new Error(message.error?.message ?? 'remote error')
|
|
346
|
+
error.code = message.error?.code ?? 'remote-error'
|
|
347
|
+
error.data = message.error?.data ?? null
|
|
348
|
+
pending.reject(error)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
case FRAME.EVENT: {
|
|
352
|
+
this.emit('event', { deviceId, topic: message.topic, data: message.data })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
default:
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
rejectPendingForChannel(channel, error) {
|
|
361
|
+
for (const [id, pending] of this.pendingRpc) {
|
|
362
|
+
if (pending.channel === channel) {
|
|
363
|
+
this.pendingRpc.delete(id)
|
|
364
|
+
clearTimeout(pending.timer)
|
|
365
|
+
pending.reject(error)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async request(deviceId, method, params, { timeoutMs = this.requestTimeoutMs } = {}) {
|
|
371
|
+
const channel = await this.connect(deviceId)
|
|
372
|
+
const id = randomUUID()
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const timer = setTimeout(() => {
|
|
375
|
+
this.pendingRpc.delete(id)
|
|
376
|
+
reject(Object.assign(new Error(`request ${method} to ${deviceId} timed out`), { code: 'timeout' }))
|
|
377
|
+
}, timeoutMs)
|
|
378
|
+
timer.unref?.()
|
|
379
|
+
this.pendingRpc.set(id, { resolve, reject, timer, channel })
|
|
380
|
+
try {
|
|
381
|
+
channel.sendFrame(rpcRequest({ id, method, params }))
|
|
382
|
+
} catch (error) {
|
|
383
|
+
this.pendingRpc.delete(id)
|
|
384
|
+
clearTimeout(timer)
|
|
385
|
+
reject(error)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async sendEvent(deviceId, topic, data) {
|
|
391
|
+
const channel = await this.connect(deviceId)
|
|
392
|
+
channel.sendFrame(eventFrame({ topic, data }))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
peerIds() {
|
|
396
|
+
return [...this.channels.keys()]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async close() {
|
|
400
|
+
this.closed = true
|
|
401
|
+
for (const channel of this.channels.values()) channel.close()
|
|
402
|
+
this.channels.clear()
|
|
403
|
+
for (const [id, pending] of this.pendingRpc) {
|
|
404
|
+
this.pendingRpc.delete(id)
|
|
405
|
+
clearTimeout(pending.timer)
|
|
406
|
+
pending.reject(Object.assign(new Error('node closed'), { code: 'closed' }))
|
|
407
|
+
}
|
|
408
|
+
this.relayClient?.close()
|
|
409
|
+
this.discovery?.close()
|
|
410
|
+
if (this.directServer) await this.directServer.close()
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export { PROTOCOL_VERSION }
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Device identity, fleet invites and membership proofs.
|
|
2
|
+
//
|
|
3
|
+
// Every device owns an Ed25519 keypair (authentication) and an X25519 keypair
|
|
4
|
+
// (key agreement). A Fleet is identified by `fleetId` and a shared 32-byte
|
|
5
|
+
// `fleetKey` carried by invites; the key proves membership and bootstraps trust
|
|
6
|
+
// on first contact (TOFU), after which peer public keys are pinned in the roster.
|
|
7
|
+
import {
|
|
8
|
+
createHash,
|
|
9
|
+
createHmac,
|
|
10
|
+
createPrivateKey,
|
|
11
|
+
createPublicKey,
|
|
12
|
+
generateKeyPairSync,
|
|
13
|
+
randomBytes,
|
|
14
|
+
randomUUID,
|
|
15
|
+
} from 'node:crypto'
|
|
16
|
+
|
|
17
|
+
const INVITE_PREFIX = 'DSHF1.'
|
|
18
|
+
const PROOF_PREFIX = 'member'
|
|
19
|
+
const RELAY_PREFIX = 'relay-register'
|
|
20
|
+
const ACCOUNT_PREFIX = 'account'
|
|
21
|
+
|
|
22
|
+
export function exportKey(key) {
|
|
23
|
+
return key.export({ type: key.type === 'private' ? 'pkcs8' : 'spki', format: 'pem' }).toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function importPrivateKey(pem) {
|
|
27
|
+
return createPrivateKey(pem)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function importPublicKey(pem) {
|
|
31
|
+
return createPublicKey(pem)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function randomBase64Url(bytes) {
|
|
35
|
+
return randomBytes(bytes).toString('base64url')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A fresh device identity. Caller persists it (e.g. `$DSH_HOME/device-fleet/identity.json`). */
|
|
39
|
+
export function generateIdentity({ name = 'device' } = {}) {
|
|
40
|
+
const ed = generateKeyPairSync('ed25519')
|
|
41
|
+
const x = generateKeyPairSync('x25519')
|
|
42
|
+
return {
|
|
43
|
+
deviceId: randomUUID(),
|
|
44
|
+
name,
|
|
45
|
+
createdAt: Date.now(),
|
|
46
|
+
edPrivate: exportKey(ed.privateKey),
|
|
47
|
+
edPublic: exportKey(ed.publicKey),
|
|
48
|
+
xPrivate: exportKey(x.privateKey),
|
|
49
|
+
xPublic: exportKey(x.publicKey),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Public half of an identity, safe to put in a roster/invite. */
|
|
54
|
+
export function publicIdentity(identity) {
|
|
55
|
+
return {
|
|
56
|
+
deviceId: identity.deviceId,
|
|
57
|
+
name: identity.name,
|
|
58
|
+
edPublic: identity.edPublic,
|
|
59
|
+
xPublic: identity.xPublic,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A new fleet. `fleetKey` is the fleet-wide membership secret carried by invites. */
|
|
64
|
+
export function createFleet({ name = 'fleet' } = {}) {
|
|
65
|
+
return {
|
|
66
|
+
fleetId: randomUUID(),
|
|
67
|
+
fleetKey: randomBase64Url(32),
|
|
68
|
+
name,
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function encodeInvite({ fleet, accountFingerprint = null, relay = null }) {
|
|
74
|
+
const payload = {
|
|
75
|
+
v: 1,
|
|
76
|
+
fleetId: fleet.fleetId,
|
|
77
|
+
fleetKey: fleet.fleetKey,
|
|
78
|
+
name: fleet.name,
|
|
79
|
+
accountFingerprint,
|
|
80
|
+
relay,
|
|
81
|
+
createdAt: Date.now(),
|
|
82
|
+
}
|
|
83
|
+
return INVITE_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function decodeInvite(code) {
|
|
87
|
+
if (typeof code !== 'string' || !code.startsWith(INVITE_PREFIX)) throw new Error('invalid invite')
|
|
88
|
+
const raw = code.slice(INVITE_PREFIX.length)
|
|
89
|
+
let payload
|
|
90
|
+
try {
|
|
91
|
+
payload = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error('invalid invite payload')
|
|
94
|
+
}
|
|
95
|
+
if (payload?.v !== 1 || typeof payload.fleetId !== 'string' || typeof payload.fleetKey !== 'string') {
|
|
96
|
+
throw new Error('unsupported invite')
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
fleet: { fleetId: payload.fleetId, fleetKey: payload.fleetKey, name: payload.name ?? 'fleet' },
|
|
100
|
+
accountFingerprint: payload.accountFingerprint ?? null,
|
|
101
|
+
relay: payload.relay ?? null,
|
|
102
|
+
createdAt: payload.createdAt ?? null,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Account fingerprint: proves "same account" inside the fleet without exposing the account id. */
|
|
107
|
+
export function accountFingerprint(fleetKey, accountUserId) {
|
|
108
|
+
if (!accountUserId) return null
|
|
109
|
+
return createHmac('sha256', fleetKey).update(`${ACCOUNT_PREFIX}|${accountUserId}`).digest('base64url')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** HMAC proof that this device belongs to the fleet (checked by peers during the handshake). */
|
|
113
|
+
export function membershipProof({ fleetKey, fleetId, deviceId, edPublic, xPublic, accountFingerprint = null }) {
|
|
114
|
+
return createHmac('sha256', fleetKey)
|
|
115
|
+
.update(`${PROOF_PREFIX}|${fleetId}|${deviceId}|${edPublic}|${xPublic}|${accountFingerprint ?? ''}`)
|
|
116
|
+
.digest('base64url')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function verifyMembershipProof(proof, fields) {
|
|
120
|
+
const expected = membershipProof(fields)
|
|
121
|
+
const a = Buffer.from(String(proof))
|
|
122
|
+
const b = Buffer.from(expected)
|
|
123
|
+
return a.length === b.length && a.equals(b)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `fleetId` hash advertised in LAN announcements (the full id stays off the wire). */
|
|
127
|
+
export function fleetHash(fleetId) {
|
|
128
|
+
return createHash('sha256').update(`fleet|${fleetId}`).digest('hex').slice(0, 16)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function shortFingerprint(edPublic, xPublic) {
|
|
132
|
+
return createHash('sha256')
|
|
133
|
+
.update(`${edPublic}|${xPublic}`)
|
|
134
|
+
.digest('hex')
|
|
135
|
+
.slice(0, 16)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Token proving fleet membership to a self-hosted relay. */
|
|
139
|
+
export function relayAuthToken({ fleetKey, fleetId, deviceId, ts }) {
|
|
140
|
+
return createHmac('sha256', fleetKey)
|
|
141
|
+
.update(`${RELAY_PREFIX}|${fleetId}|${deviceId}|${ts}`)
|
|
142
|
+
.digest('base64url')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function verifyRelayAuthToken(token, { fleetKey, fleetId, deviceId, ts }) {
|
|
146
|
+
const expected = relayAuthToken({ fleetKey, fleetId, deviceId, ts })
|
|
147
|
+
const a = Buffer.from(String(token))
|
|
148
|
+
const b = Buffer.from(expected)
|
|
149
|
+
return a.length === b.length && a.equals(b)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ephemeral X25519 pair for one handshake. */
|
|
153
|
+
export function generateEphemeral() {
|
|
154
|
+
const pair = generateKeyPairSync('x25519')
|
|
155
|
+
return { privateKey: pair.privateKey, publicKey: exportKey(pair.publicKey), privateKeyPem: exportKey(pair.privateKey) }
|
|
156
|
+
}
|
package/src/net/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Network layer exports.
|
|
2
|
+
export * from './identity.js'
|
|
3
|
+
export * from './protocol.js'
|
|
4
|
+
export * from './secure-channel.js'
|
|
5
|
+
export * from './direct.js'
|
|
6
|
+
export * from './discovery.js'
|
|
7
|
+
export * from './relay-server.js'
|
|
8
|
+
export * from './relay-client.js'
|
|
9
|
+
export * from './fleet-node.js'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Wire protocol constants, canonical JSON and RPC envelope helpers.
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL_VERSION = 1
|
|
4
|
+
|
|
5
|
+
/** Largest accepted frame payload (ciphertext bytes, excluding the length prefix). */
|
|
6
|
+
export const MAX_FRAME_BYTES = 32 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
/** Handshake messages are newline-delimited JSON; encrypted frames are length-prefixed. */
|
|
9
|
+
export const HANDSHAKE_TIMEOUT_MS = 10_000
|
|
10
|
+
|
|
11
|
+
export const FRAME = Object.freeze({
|
|
12
|
+
READY: 'ready',
|
|
13
|
+
RPC_REQUEST: 'rpc.req',
|
|
14
|
+
RPC_RESULT: 'rpc.res',
|
|
15
|
+
RPC_ERROR: 'rpc.err',
|
|
16
|
+
EVENT: 'event',
|
|
17
|
+
PING: 'ping',
|
|
18
|
+
PONG: 'pong',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export function stableStringify(value) {
|
|
22
|
+
return JSON.stringify(sortValue(value))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sortValue(value) {
|
|
26
|
+
if (Array.isArray(value)) return value.map(sortValue)
|
|
27
|
+
if (value && typeof value === 'object') {
|
|
28
|
+
const out = {}
|
|
29
|
+
for (const key of Object.keys(value).sort()) out[key] = sortValue(value[key])
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function rpcRequest({ id, method, params }) {
|
|
36
|
+
return { t: FRAME.RPC_REQUEST, v: PROTOCOL_VERSION, id, method, params: params ?? null }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rpcResult({ id, result }) {
|
|
40
|
+
return { t: FRAME.RPC_RESULT, v: PROTOCOL_VERSION, id, result: result ?? null }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function rpcError({ id, code, message, data = null }) {
|
|
44
|
+
return { t: FRAME.RPC_ERROR, v: PROTOCOL_VERSION, id, error: { code, message, data } }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function eventFrame({ topic, data }) {
|
|
48
|
+
return { t: FRAME.EVENT, v: PROTOCOL_VERSION, topic, data: data ?? null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pingFrame() {
|
|
52
|
+
return { t: FRAME.PING, v: PROTOCOL_VERSION }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pongFrame() {
|
|
56
|
+
return { t: FRAME.PONG, v: PROTOCOL_VERSION }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function serializeError(error) {
|
|
60
|
+
if (!error) return { code: 'internal', message: 'unknown error', data: null }
|
|
61
|
+
return {
|
|
62
|
+
code: typeof error.code === 'string' ? error.code : 'internal',
|
|
63
|
+
message: String(error.message ?? error),
|
|
64
|
+
data: error.data ?? null,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeError(code, message, data = null) {
|
|
69
|
+
const error = new Error(message)
|
|
70
|
+
error.code = code
|
|
71
|
+
error.data = data
|
|
72
|
+
return error
|
|
73
|
+
}
|