dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10
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 +135 -2
- package/client.js +167 -0
- 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/approval-inbox-phase.md +31 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +67 -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/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +51 -3
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +209 -0
- package/src/host/execution-slot.js +96 -0
- package/src/host/index.js +10 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +156 -0
- package/src/host/tool-policy.js +151 -0
- package/src/host/tools.js +590 -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 +509 -0
- package/src/net/identity.js +167 -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 +380 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// LAN discovery over UDP multicast. Announcements carry only a fleet hash,
|
|
2
|
+
// device id, display name and the direct listening port; the real trust
|
|
3
|
+
// decision happens in the secure-channel handshake.
|
|
4
|
+
import dgram from 'node:dgram'
|
|
5
|
+
import { EventEmitter } from 'node:events'
|
|
6
|
+
import { fleetHash } from './identity.js'
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_DISCOVERY_GROUP = '239.255.42.99'
|
|
9
|
+
export const DEFAULT_DISCOVERY_PORT = 47833
|
|
10
|
+
|
|
11
|
+
export class LanDiscovery extends EventEmitter {
|
|
12
|
+
constructor({
|
|
13
|
+
fleetId,
|
|
14
|
+
deviceId,
|
|
15
|
+
name = 'device',
|
|
16
|
+
directPort,
|
|
17
|
+
group = DEFAULT_DISCOVERY_GROUP,
|
|
18
|
+
port = DEFAULT_DISCOVERY_PORT,
|
|
19
|
+
intervalMs = 3_000,
|
|
20
|
+
}) {
|
|
21
|
+
super()
|
|
22
|
+
this.fleetId = fleetId
|
|
23
|
+
this.deviceId = deviceId
|
|
24
|
+
this.name = name
|
|
25
|
+
this.directPort = directPort
|
|
26
|
+
this.group = group
|
|
27
|
+
this.port = port
|
|
28
|
+
this.intervalMs = intervalMs
|
|
29
|
+
this.socket = null
|
|
30
|
+
this.timer = null
|
|
31
|
+
this.closed = false
|
|
32
|
+
this.peers = new Map()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start() {
|
|
36
|
+
if (this.socket) return
|
|
37
|
+
this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
38
|
+
this.socket.on('message', (buffer, rinfo) => this.onMessage(buffer, rinfo))
|
|
39
|
+
this.socket.on('error', (error) => this.emit('error', error))
|
|
40
|
+
this.socket.bind(this.port, () => {
|
|
41
|
+
try {
|
|
42
|
+
this.socket.addMembership(this.group)
|
|
43
|
+
this.socket.setMulticastTTL(1)
|
|
44
|
+
this.socket.setMulticastLoopback(true)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.emit('error', error)
|
|
47
|
+
}
|
|
48
|
+
this.announce()
|
|
49
|
+
this.timer = setInterval(() => this.announce(), this.intervalMs)
|
|
50
|
+
this.timer.unref?.()
|
|
51
|
+
this.emit('listening', { group: this.group, port: this.port })
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
announce() {
|
|
56
|
+
if (!this.socket || this.closed) return
|
|
57
|
+
const payload = Buffer.from(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
v: 1,
|
|
60
|
+
fleetHash: fleetHash(this.fleetId),
|
|
61
|
+
deviceId: this.deviceId,
|
|
62
|
+
name: this.name,
|
|
63
|
+
port: this.directPort,
|
|
64
|
+
ts: Date.now(),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
this.socket.send(payload, 0, payload.length, this.port, this.group, () => {})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onMessage(buffer, rinfo) {
|
|
71
|
+
if (this.closed) return
|
|
72
|
+
let message
|
|
73
|
+
try {
|
|
74
|
+
message = JSON.parse(buffer.toString('utf8'))
|
|
75
|
+
} catch {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (message?.v !== 1) return
|
|
79
|
+
if (message.fleetHash !== fleetHash(this.fleetId)) return
|
|
80
|
+
if (typeof message.deviceId !== 'string' || message.deviceId === this.deviceId) return
|
|
81
|
+
if (typeof message.port !== 'number' || message.port <= 0) return
|
|
82
|
+
const peer = {
|
|
83
|
+
deviceId: message.deviceId,
|
|
84
|
+
name: typeof message.name === 'string' ? message.name : 'device',
|
|
85
|
+
host: rinfo.address,
|
|
86
|
+
port: message.port,
|
|
87
|
+
lastSeen: Date.now(),
|
|
88
|
+
}
|
|
89
|
+
this.peers.set(peer.deviceId, peer)
|
|
90
|
+
this.emit('peer', peer)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close() {
|
|
94
|
+
this.closed = true
|
|
95
|
+
clearInterval(this.timer)
|
|
96
|
+
if (this.socket) {
|
|
97
|
+
try {
|
|
98
|
+
this.socket.close()
|
|
99
|
+
} catch {
|
|
100
|
+
/* ignore */
|
|
101
|
+
}
|
|
102
|
+
this.socket = null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,509 @@
|
|
|
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
|
+
peerHints = [],
|
|
49
|
+
trustOnFirstUse = true,
|
|
50
|
+
acceptRelayConnections = true,
|
|
51
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
52
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
53
|
+
pluginVersion = '0.1.0',
|
|
54
|
+
dshVersion = 'unknown',
|
|
55
|
+
capabilities = [],
|
|
56
|
+
} = {}) {
|
|
57
|
+
super()
|
|
58
|
+
if (!identity?.deviceId || !identity.edPrivate || !identity.edPublic) throw new Error('FleetNode needs an identity')
|
|
59
|
+
if (!fleet?.fleetId || !fleet?.fleetKey) throw new Error('FleetNode needs a fleet')
|
|
60
|
+
this.identity = identity
|
|
61
|
+
this.fleet = fleet
|
|
62
|
+
this.name = name
|
|
63
|
+
this.accountFingerprint = accountFingerprint
|
|
64
|
+
this.directConfig = direct
|
|
65
|
+
this.discoveryConfig = discovery
|
|
66
|
+
this.relayConfig = relay
|
|
67
|
+
this.trustOnFirstUse = trustOnFirstUse
|
|
68
|
+
this.acceptRelayConnections = acceptRelayConnections
|
|
69
|
+
this.connectTimeoutMs = connectTimeoutMs
|
|
70
|
+
this.requestTimeoutMs = requestTimeoutMs
|
|
71
|
+
this.pluginVersion = pluginVersion
|
|
72
|
+
this.dshVersion = dshVersion
|
|
73
|
+
this.capabilities = capabilities
|
|
74
|
+
|
|
75
|
+
this.roster = new Map()
|
|
76
|
+
for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
|
|
77
|
+
this.discovered = new Map()
|
|
78
|
+
this.peerHints = []
|
|
79
|
+
for (const hint of peerHints) this.addPeerHint(hint)
|
|
80
|
+
this.hintTimer = null
|
|
81
|
+
this.hintAttempt = 0
|
|
82
|
+
this.channels = new Map()
|
|
83
|
+
this.pendingRpc = new Map()
|
|
84
|
+
this.requestHandler = null
|
|
85
|
+
this.eventHandler = null
|
|
86
|
+
|
|
87
|
+
this.directServer = null
|
|
88
|
+
this.discovery = null
|
|
89
|
+
this.relayClient = null
|
|
90
|
+
this.started = false
|
|
91
|
+
this.closed = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get directPort() {
|
|
95
|
+
return this.directAddress?.port ?? null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
rosterEntries() {
|
|
99
|
+
return [...this.roster.values()]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
upsertRoster(entry) {
|
|
103
|
+
const existing = this.roster.get(entry.deviceId)
|
|
104
|
+
this.roster.set(entry.deviceId, { ...existing, name: 'device', ...entry })
|
|
105
|
+
this.emit('roster', this.rosterEntries())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onRequest(handler) {
|
|
109
|
+
this.requestHandler = handler
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onEvent(handler) {
|
|
113
|
+
this.eventHandler = handler
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async start() {
|
|
117
|
+
if (this.started) return this
|
|
118
|
+
this.started = true
|
|
119
|
+
|
|
120
|
+
if (this.directConfig !== false) {
|
|
121
|
+
this.directServer = createDirectServer({
|
|
122
|
+
host: this.directConfig.host ?? '0.0.0.0',
|
|
123
|
+
port: this.directConfig.port ?? 0,
|
|
124
|
+
})
|
|
125
|
+
this.directAddress = await this.directServer.ready
|
|
126
|
+
this.directServer.server.on('connection', (socket) => this.acceptInbound(socket, 'direct'))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this.discoveryConfig !== false && this.discoveryConfig !== undefined) {
|
|
130
|
+
if (!this.directAddress) throw new Error('discovery needs the direct listener')
|
|
131
|
+
const config = this.discoveryConfig === true ? {} : this.discoveryConfig
|
|
132
|
+
this.discovery = new LanDiscovery({
|
|
133
|
+
fleetId: this.fleet.fleetId,
|
|
134
|
+
deviceId: this.identity.deviceId,
|
|
135
|
+
name: this.name,
|
|
136
|
+
directPort: this.directAddress.port,
|
|
137
|
+
group: config.group,
|
|
138
|
+
port: config.port,
|
|
139
|
+
intervalMs: config.intervalMs,
|
|
140
|
+
})
|
|
141
|
+
this.discovery.on('peer', (peer) => {
|
|
142
|
+
this.discovered.set(peer.deviceId, peer)
|
|
143
|
+
this.emit('discovered', peer)
|
|
144
|
+
})
|
|
145
|
+
this.discovery.on('error', (error) => this.emit('warning', error))
|
|
146
|
+
this.discovery.start()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.relayConfig) {
|
|
150
|
+
this.relayClient = new RelayClient({
|
|
151
|
+
host: this.relayConfig.host,
|
|
152
|
+
port: this.relayConfig.port,
|
|
153
|
+
dataPort: this.relayConfig.dataPort,
|
|
154
|
+
fleetId: this.fleet.fleetId,
|
|
155
|
+
deviceId: this.identity.deviceId,
|
|
156
|
+
fleetKey: this.fleet.fleetKey,
|
|
157
|
+
reconnect: this.relayConfig.reconnect,
|
|
158
|
+
})
|
|
159
|
+
this.relayClient.on('online', () => this.emit('relay-online'))
|
|
160
|
+
this.relayClient.on('offline', () => this.emit('relay-offline'))
|
|
161
|
+
this.relayClient.on('relay-error', (error) => this.emit('warning', error))
|
|
162
|
+
this.relayClient.on('incoming', (controller) => this.acceptIncomingRelay(controller))
|
|
163
|
+
try {
|
|
164
|
+
await this.relayClient.start(5_000)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.emit('warning', error)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.emit('started', { direct: this.directAddress ?? null })
|
|
171
|
+
this.scheduleHintConnects()
|
|
172
|
+
return this
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Remember an address hint for a peer (from an invite or a manual `fleet_connect`). */
|
|
176
|
+
addPeerHint(hint) {
|
|
177
|
+
if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
|
|
178
|
+
const entry = {
|
|
179
|
+
deviceId: typeof hint.deviceId === 'string' ? hint.deviceId : undefined,
|
|
180
|
+
host: hint.host,
|
|
181
|
+
port: hint.port,
|
|
182
|
+
}
|
|
183
|
+
if (entry.deviceId === this.identity.deviceId) return
|
|
184
|
+
const exists = this.peerHints.some(
|
|
185
|
+
(item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId,
|
|
186
|
+
)
|
|
187
|
+
if (!exists) this.peerHints.push(entry)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Connect to a raw address; learns the peer id from the handshake. */
|
|
191
|
+
async connectAddress(host, port, { expectedDeviceId = null, timeoutMs = this.connectTimeoutMs } = {}) {
|
|
192
|
+
if (!host || !Number.isInteger(port) || port <= 0) throw new Error('connectAddress needs host and port')
|
|
193
|
+
const expected = expectedDeviceId ? { deviceId: expectedDeviceId } : null
|
|
194
|
+
const socket = await connectDirect({ host, port, timeoutMs })
|
|
195
|
+
const channel = this.createInitiator(socket, expected)
|
|
196
|
+
await this.waitReady(channel, timeoutMs)
|
|
197
|
+
return channel.peer
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Retry the invite/manual hints in the background until one peer connects. */
|
|
201
|
+
scheduleHintConnects() {
|
|
202
|
+
if (this.hintTimer || this.closed) return
|
|
203
|
+
const attempt = async () => {
|
|
204
|
+
this.hintTimer = null
|
|
205
|
+
if (this.closed) return
|
|
206
|
+
if (this.peerHints.length === 0 || this.channels.size > 0) return
|
|
207
|
+
try {
|
|
208
|
+
for (const hint of this.peerHints) {
|
|
209
|
+
if (this.closed || this.channels.size > 0) return
|
|
210
|
+
try {
|
|
211
|
+
const peer = await this.connectAddress(hint.host, hint.port, { expectedDeviceId: hint.deviceId })
|
|
212
|
+
this.emit('hint-connected', { hint, peer })
|
|
213
|
+
return
|
|
214
|
+
} catch {
|
|
215
|
+
// try the next hint; the timer below retries
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
if (!this.closed && this.channels.size === 0) {
|
|
220
|
+
this.hintAttempt += 1
|
|
221
|
+
const delay = Math.min(10_000, 1000 * 2 ** Math.min(this.hintAttempt, 4))
|
|
222
|
+
this.hintTimer = setTimeout(() => void attempt(), delay)
|
|
223
|
+
this.hintTimer.unref?.()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.hintTimer = setTimeout(() => void attempt(), 1500)
|
|
228
|
+
this.hintTimer.unref?.()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
status() {
|
|
232
|
+
return {
|
|
233
|
+
deviceId: this.identity.deviceId,
|
|
234
|
+
name: this.name,
|
|
235
|
+
pluginVersion: this.pluginVersion,
|
|
236
|
+
dshVersion: this.dshVersion,
|
|
237
|
+
direct: this.directAddress ? { host: this.directAddress.host, port: this.directAddress.port } : null,
|
|
238
|
+
relay: this.relayClient ? { online: this.relayClient.online === true } : null,
|
|
239
|
+
discovery: this.discovery ? { enabled: true, peers: [...this.discovered.keys()] } : { enabled: false, peers: [] },
|
|
240
|
+
roster: this.rosterEntries().map((entry) => ({ deviceId: entry.deviceId, name: entry.name ?? 'device' })),
|
|
241
|
+
connected: this.peerIds(),
|
|
242
|
+
peerHints: [...this.peerHints],
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async acceptIncomingRelay(controller) {
|
|
247
|
+
if (!this.acceptRelayConnections) {
|
|
248
|
+
controller.reject()
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const socket = await controller.accept()
|
|
253
|
+
this.acceptInbound(socket, 'relay')
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this.emit('warning', error)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
acceptInbound(socket, via) {
|
|
260
|
+
const channel = new SecureChannel({
|
|
261
|
+
socket,
|
|
262
|
+
identity: this.identity,
|
|
263
|
+
fleet: this.fleet,
|
|
264
|
+
role: 'responder',
|
|
265
|
+
accountFingerprint: this.accountFingerprint,
|
|
266
|
+
verifyPeer: (peer) => this.authorizePeer(peer),
|
|
267
|
+
pluginVersion: this.pluginVersion,
|
|
268
|
+
dshVersion: this.dshVersion,
|
|
269
|
+
capabilities: this.capabilities,
|
|
270
|
+
})
|
|
271
|
+
this.attachChannel(channel, via)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
attachChannel(channel, via) {
|
|
275
|
+
channel.on('error', (error) => this.emit('warning', error))
|
|
276
|
+
channel.on('ready', (peer) => {
|
|
277
|
+
this.registerChannel(peer, channel, via)
|
|
278
|
+
})
|
|
279
|
+
channel.on('close', () => {
|
|
280
|
+
const current = this.channels.get(channel.peer?.deviceId)
|
|
281
|
+
if (current === channel) this.channels.delete(channel.peer.deviceId)
|
|
282
|
+
this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
|
|
283
|
+
this.emit('peer-disconnected', { deviceId: channel.peer?.deviceId, via })
|
|
284
|
+
// Recover automatically: the peer may have restarted or moved; retry the
|
|
285
|
+
// known addresses instead of waiting for the next process start.
|
|
286
|
+
if (!this.closed && this.channels.size === 0 && this.peerHints.length > 0) {
|
|
287
|
+
clearTimeout(this.hintTimer)
|
|
288
|
+
this.hintTimer = null
|
|
289
|
+
this.hintAttempt = 0
|
|
290
|
+
this.scheduleHintConnects()
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
registerChannel(peer, channel, via) {
|
|
296
|
+
this.channels.set(peer.deviceId, channel)
|
|
297
|
+
channel.on('frame', (message) => this.onFrame(peer.deviceId, message, channel))
|
|
298
|
+
this.emit('peer', { deviceId: peer.deviceId, name: peer.name, via, channel })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
authorizePeer(peer) {
|
|
302
|
+
const existing = this.roster.get(peer.deviceId)
|
|
303
|
+
if (existing) {
|
|
304
|
+
return existing.edPublic === peer.edPublic && existing.xPublic === peer.xPublic
|
|
305
|
+
}
|
|
306
|
+
if (!this.trustOnFirstUse) return false
|
|
307
|
+
this.roster.set(peer.deviceId, {
|
|
308
|
+
deviceId: peer.deviceId,
|
|
309
|
+
name: peer.name ?? 'device',
|
|
310
|
+
edPublic: peer.edPublic,
|
|
311
|
+
xPublic: peer.xPublic,
|
|
312
|
+
addedAt: Date.now(),
|
|
313
|
+
})
|
|
314
|
+
this.emit('roster', this.rosterEntries())
|
|
315
|
+
return true
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async connect(deviceId, { host, port, timeoutMs = this.connectTimeoutMs } = {}) {
|
|
319
|
+
if (typeof deviceId !== 'string' || deviceId === '') throw new Error('connect needs a device id')
|
|
320
|
+
if (deviceId === this.identity.deviceId) throw new Error('cannot connect to self')
|
|
321
|
+
const existing = this.channels.get(deviceId)
|
|
322
|
+
if (existing && !existing.closed) return existing
|
|
323
|
+
|
|
324
|
+
const expected = this.roster.get(deviceId) ?? { deviceId }
|
|
325
|
+
const discovered = this.discovered.get(deviceId)
|
|
326
|
+
const hinted = this.peerHints.find((entry) => entry.deviceId === deviceId)
|
|
327
|
+
const endpoint =
|
|
328
|
+
host && port
|
|
329
|
+
? { host, port }
|
|
330
|
+
: discovered
|
|
331
|
+
? { host: discovered.host, port: discovered.port }
|
|
332
|
+
: hinted
|
|
333
|
+
? { host: hinted.host, port: hinted.port }
|
|
334
|
+
: null
|
|
335
|
+
let directError = null
|
|
336
|
+
if (endpoint) {
|
|
337
|
+
try {
|
|
338
|
+
const socket = await connectDirect({ host: endpoint.host, port: endpoint.port, timeoutMs })
|
|
339
|
+
const channel = this.createInitiator(socket, expected)
|
|
340
|
+
await this.waitReady(channel, timeoutMs)
|
|
341
|
+
return channel
|
|
342
|
+
} catch (error) {
|
|
343
|
+
directError = error
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (this.relayClient) {
|
|
347
|
+
try {
|
|
348
|
+
const socket = await this.relayClient.requestConnect(deviceId, timeoutMs)
|
|
349
|
+
const channel = this.createInitiator(socket, expected)
|
|
350
|
+
await this.waitReady(channel, timeoutMs)
|
|
351
|
+
return channel
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw Object.assign(new Error(`cannot reach ${deviceId} via relay: ${String(error.message)}`), {
|
|
354
|
+
code: 'unreachable',
|
|
355
|
+
cause: error,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
throw Object.assign(
|
|
360
|
+
new Error(`cannot reach ${deviceId}${directError ? `: ${String(directError.message)}` : ' (no endpoint)'}`),
|
|
361
|
+
{ code: 'unreachable', cause: directError ?? undefined },
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
createInitiator(socket, expectedPeer) {
|
|
366
|
+
const channel = new SecureChannel({
|
|
367
|
+
socket,
|
|
368
|
+
identity: this.identity,
|
|
369
|
+
fleet: this.fleet,
|
|
370
|
+
role: 'initiator',
|
|
371
|
+
expectedPeer,
|
|
372
|
+
accountFingerprint: this.accountFingerprint,
|
|
373
|
+
verifyPeer: (peer) => this.authorizePeer(peer),
|
|
374
|
+
pluginVersion: this.pluginVersion,
|
|
375
|
+
dshVersion: this.dshVersion,
|
|
376
|
+
capabilities: this.capabilities,
|
|
377
|
+
})
|
|
378
|
+
this.attachChannel(channel, 'outbound')
|
|
379
|
+
return channel
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
waitReady(channel, timeoutMs) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
if (channel.authenticated) return resolve(channel)
|
|
385
|
+
const timer = setTimeout(() => {
|
|
386
|
+
cleanup()
|
|
387
|
+
reject(Object.assign(new Error('secure channel handshake timed out'), { code: 'handshake-timeout' }))
|
|
388
|
+
channel.destroy()
|
|
389
|
+
}, timeoutMs)
|
|
390
|
+
timer.unref?.()
|
|
391
|
+
const onReady = () => {
|
|
392
|
+
cleanup()
|
|
393
|
+
resolve(channel)
|
|
394
|
+
}
|
|
395
|
+
const onClose = () => {
|
|
396
|
+
cleanup()
|
|
397
|
+
reject(Object.assign(new Error('secure channel closed during handshake'), { code: 'handshake-closed' }))
|
|
398
|
+
}
|
|
399
|
+
const cleanup = () => {
|
|
400
|
+
clearTimeout(timer)
|
|
401
|
+
channel.off('ready', onReady)
|
|
402
|
+
channel.off('close', onClose)
|
|
403
|
+
}
|
|
404
|
+
channel.once('ready', onReady)
|
|
405
|
+
channel.once('close', onClose)
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
onFrame(deviceId, message, channel) {
|
|
410
|
+
switch (message.t) {
|
|
411
|
+
case FRAME.RPC_REQUEST: {
|
|
412
|
+
const handler = this.requestHandler
|
|
413
|
+
if (typeof handler !== 'function') {
|
|
414
|
+
channel.sendFrame(rpcError({ id: message.id, code: 'no-handler', message: 'no request handler' }))
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
Promise.resolve()
|
|
418
|
+
.then(() => handler({ deviceId, method: message.method, params: message.params }))
|
|
419
|
+
.then((result) => channel.sendFrame(rpcResult({ id: message.id, result })))
|
|
420
|
+
.catch((error) => {
|
|
421
|
+
channel.sendFrame(rpcError({ id: message.id, ...serializeError(error) }))
|
|
422
|
+
})
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
case FRAME.RPC_RESULT: {
|
|
426
|
+
const pending = this.pendingRpc.get(message.id)
|
|
427
|
+
if (!pending) return
|
|
428
|
+
this.pendingRpc.delete(message.id)
|
|
429
|
+
clearTimeout(pending.timer)
|
|
430
|
+
pending.resolve(message.result)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
case FRAME.RPC_ERROR: {
|
|
434
|
+
const pending = this.pendingRpc.get(message.id)
|
|
435
|
+
if (!pending) return
|
|
436
|
+
this.pendingRpc.delete(message.id)
|
|
437
|
+
clearTimeout(pending.timer)
|
|
438
|
+
const error = new Error(message.error?.message ?? 'remote error')
|
|
439
|
+
error.code = message.error?.code ?? 'remote-error'
|
|
440
|
+
error.data = message.error?.data ?? null
|
|
441
|
+
pending.reject(error)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
case FRAME.EVENT: {
|
|
445
|
+
this.emit('event', { deviceId, topic: message.topic, data: message.data })
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
default:
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
rejectPendingForChannel(channel, error) {
|
|
454
|
+
for (const [id, pending] of this.pendingRpc) {
|
|
455
|
+
if (pending.channel === channel) {
|
|
456
|
+
this.pendingRpc.delete(id)
|
|
457
|
+
clearTimeout(pending.timer)
|
|
458
|
+
pending.reject(error)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async request(deviceId, method, params, { timeoutMs = this.requestTimeoutMs } = {}) {
|
|
464
|
+
const channel = await this.connect(deviceId)
|
|
465
|
+
const id = randomUUID()
|
|
466
|
+
return new Promise((resolve, reject) => {
|
|
467
|
+
const timer = setTimeout(() => {
|
|
468
|
+
this.pendingRpc.delete(id)
|
|
469
|
+
reject(Object.assign(new Error(`request ${method} to ${deviceId} timed out`), { code: 'timeout' }))
|
|
470
|
+
}, timeoutMs)
|
|
471
|
+
timer.unref?.()
|
|
472
|
+
this.pendingRpc.set(id, { resolve, reject, timer, channel })
|
|
473
|
+
try {
|
|
474
|
+
channel.sendFrame(rpcRequest({ id, method, params }))
|
|
475
|
+
} catch (error) {
|
|
476
|
+
this.pendingRpc.delete(id)
|
|
477
|
+
clearTimeout(timer)
|
|
478
|
+
reject(error)
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async sendEvent(deviceId, topic, data) {
|
|
484
|
+
const channel = await this.connect(deviceId)
|
|
485
|
+
channel.sendFrame(eventFrame({ topic, data }))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
peerIds() {
|
|
489
|
+
return [...this.channels.keys()]
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async close() {
|
|
493
|
+
this.closed = true
|
|
494
|
+
clearTimeout(this.hintTimer)
|
|
495
|
+
this.hintTimer = null
|
|
496
|
+
for (const channel of this.channels.values()) channel.close()
|
|
497
|
+
this.channels.clear()
|
|
498
|
+
for (const [id, pending] of this.pendingRpc) {
|
|
499
|
+
this.pendingRpc.delete(id)
|
|
500
|
+
clearTimeout(pending.timer)
|
|
501
|
+
pending.reject(Object.assign(new Error('node closed'), { code: 'closed' }))
|
|
502
|
+
}
|
|
503
|
+
this.relayClient?.close()
|
|
504
|
+
this.discovery?.close()
|
|
505
|
+
if (this.directServer) await this.directServer.close()
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export { PROTOCOL_VERSION }
|