dsh-device-fleet 0.0.0-stage → 0.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/cordis.patch.yml +24 -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/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 +45 -6
- 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 +276 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Relay client: keeps a control connection to the self-hosted relay and opens
|
|
2
|
+
// spliced data connections to other fleet devices.
|
|
3
|
+
import { EventEmitter } from 'node:events'
|
|
4
|
+
import net from 'node:net'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { relayAuthToken } from './identity.js'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
|
9
|
+
|
|
10
|
+
export class RelayClient extends EventEmitter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.host relay host
|
|
14
|
+
* @param {number} options.port relay control port
|
|
15
|
+
* @param {number} options.dataPort relay data port
|
|
16
|
+
* @param {string} options.fleetId
|
|
17
|
+
* @param {string} options.deviceId
|
|
18
|
+
* @param {string} options.fleetKey
|
|
19
|
+
* @param {{initialDelayMs?: number, maxDelayMs?: number}} [options.reconnect]
|
|
20
|
+
*/
|
|
21
|
+
constructor({ host, port, dataPort, fleetId, deviceId, fleetKey, reconnect = {} }) {
|
|
22
|
+
super()
|
|
23
|
+
this.host = host
|
|
24
|
+
this.port = port
|
|
25
|
+
this.dataPort = dataPort
|
|
26
|
+
this.fleetId = fleetId
|
|
27
|
+
this.deviceId = deviceId
|
|
28
|
+
this.fleetKey = fleetKey
|
|
29
|
+
this.reconnect = { initialDelayMs: 500, maxDelayMs: 10_000, ...reconnect }
|
|
30
|
+
|
|
31
|
+
this.socket = null
|
|
32
|
+
this.buffer = ''
|
|
33
|
+
this.online = false
|
|
34
|
+
this.closed = false
|
|
35
|
+
this.attempt = 0
|
|
36
|
+
this.reconnectTimer = null
|
|
37
|
+
this.waiters = new Set()
|
|
38
|
+
this.readyWaiters = new Map()
|
|
39
|
+
this.controllers = new Map()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Start connecting; resolves once registered (or rejects on timeout). */
|
|
43
|
+
async start(timeoutMs = 5_000) {
|
|
44
|
+
if (this.socket) return
|
|
45
|
+
this.connect()
|
|
46
|
+
await this.whenOnline(timeoutMs)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
whenOnline(timeoutMs = 5_000) {
|
|
50
|
+
if (this.online) return Promise.resolve()
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const waiter = { resolve, reject }
|
|
53
|
+
this.waiters.add(waiter)
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
this.waiters.delete(waiter)
|
|
56
|
+
reject(new Error('relay connection timed out'))
|
|
57
|
+
}, timeoutMs)
|
|
58
|
+
timer.unref?.()
|
|
59
|
+
waiter.timer = timer
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
connect() {
|
|
64
|
+
if (this.closed) return
|
|
65
|
+
const socket = net.connect({ host: this.host, port: this.port })
|
|
66
|
+
this.socket = socket
|
|
67
|
+
this.buffer = ''
|
|
68
|
+
socket.setNoDelay?.(true)
|
|
69
|
+
socket.once('connect', () => {
|
|
70
|
+
const ts = Date.now()
|
|
71
|
+
this.send({
|
|
72
|
+
t: 'register',
|
|
73
|
+
fleetId: this.fleetId,
|
|
74
|
+
deviceId: this.deviceId,
|
|
75
|
+
ts,
|
|
76
|
+
token: relayAuthToken({ fleetKey: this.fleetKey, fleetId: this.fleetId, deviceId: this.deviceId, ts }),
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
socket.on('data', (chunk) => this.onData(chunk))
|
|
80
|
+
socket.on('error', () => {})
|
|
81
|
+
socket.once('close', () => this.onClose(socket))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
send(message) {
|
|
85
|
+
if (!this.socket || this.socket.destroyed || !this.socket.writable) return false
|
|
86
|
+
this.socket.write(JSON.stringify(message) + '\n')
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onData(chunk) {
|
|
91
|
+
this.buffer += chunk.toString('utf8')
|
|
92
|
+
let newline = this.buffer.indexOf('\n')
|
|
93
|
+
while (newline >= 0) {
|
|
94
|
+
const line = this.buffer.slice(0, newline)
|
|
95
|
+
this.buffer = this.buffer.slice(newline + 1)
|
|
96
|
+
if (line.trim() !== '') this.onMessage(line)
|
|
97
|
+
newline = this.buffer.indexOf('\n')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onMessage(line) {
|
|
102
|
+
let message
|
|
103
|
+
try {
|
|
104
|
+
message = JSON.parse(line)
|
|
105
|
+
} catch {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
switch (message?.t) {
|
|
109
|
+
case 'register.ok': {
|
|
110
|
+
this.online = true
|
|
111
|
+
this.attempt = 0
|
|
112
|
+
for (const waiter of this.waiters) {
|
|
113
|
+
this.waiters.delete(waiter)
|
|
114
|
+
clearTimeout(waiter.timer)
|
|
115
|
+
waiter.resolve()
|
|
116
|
+
}
|
|
117
|
+
this.emit('online', { deviceId: this.deviceId })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
case 'error': {
|
|
121
|
+
this.emit('relay-error', message)
|
|
122
|
+
if (message.code === 'unauthorized' || message.code === 'unknown-fleet' || message.code === 'replaced') {
|
|
123
|
+
this.closed = true
|
|
124
|
+
this.socket?.destroy()
|
|
125
|
+
}
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
case 'incoming': {
|
|
129
|
+
const { reqId, fromDeviceId } = message
|
|
130
|
+
const controller = {
|
|
131
|
+
reqId,
|
|
132
|
+
fromDeviceId,
|
|
133
|
+
decided: false,
|
|
134
|
+
accept: () => this.acceptIncoming(controller),
|
|
135
|
+
reject: () => {
|
|
136
|
+
controller.decided = true
|
|
137
|
+
this.controllers.delete(reqId)
|
|
138
|
+
this.send({ t: 'reject', reqId })
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
this.controllers.set(reqId, controller)
|
|
142
|
+
this.emit('incoming', controller)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
case 'connect.pending':
|
|
146
|
+
return
|
|
147
|
+
case 'connect.ready': {
|
|
148
|
+
const waiter = this.readyWaiters.get(message.reqId)
|
|
149
|
+
if (!waiter) return
|
|
150
|
+
this.readyWaiters.delete(message.reqId)
|
|
151
|
+
clearTimeout(waiter.timer)
|
|
152
|
+
waiter.resolve({ dataPort: message.dataPort, token: message.token })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
case 'connect.fail': {
|
|
156
|
+
const waiter = this.readyWaiters.get(message.reqId)
|
|
157
|
+
if (waiter) {
|
|
158
|
+
this.readyWaiters.delete(message.reqId)
|
|
159
|
+
clearTimeout(waiter.timer)
|
|
160
|
+
waiter.reject(Object.assign(new Error(`relay connect failed: ${message.reason}`), { code: message.reason }))
|
|
161
|
+
}
|
|
162
|
+
const connectWaiter = this.connectWaiters?.get(message.reqId)
|
|
163
|
+
if (connectWaiter) {
|
|
164
|
+
this.connectWaiters.delete(message.reqId)
|
|
165
|
+
clearTimeout(connectWaiter.timer)
|
|
166
|
+
connectWaiter.reject(Object.assign(new Error(`relay connect failed: ${message.reason}`), { code: message.reason }))
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
waitReady(reqId, timeoutMs = 15_000) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
this.readyWaiters.delete(reqId)
|
|
179
|
+
reject(Object.assign(new Error('relay data path timed out'), { code: 'data-timeout' }))
|
|
180
|
+
}, timeoutMs)
|
|
181
|
+
timer.unref?.()
|
|
182
|
+
this.readyWaiters.set(reqId, { resolve, reject, timer })
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async acceptIncoming(controller) {
|
|
187
|
+
if (controller.decided) throw new Error('incoming request already settled')
|
|
188
|
+
controller.decided = true
|
|
189
|
+
this.controllers.delete(controller.reqId)
|
|
190
|
+
const ready = this.waitReady(controller.reqId)
|
|
191
|
+
this.send({ t: 'accept', reqId: controller.reqId })
|
|
192
|
+
const { dataPort, token } = await ready
|
|
193
|
+
return this.openDataSocket(controller.reqId, token, dataPort)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async requestConnect(targetDeviceId, timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS) {
|
|
197
|
+
if (!this.online) await this.whenOnline(Math.min(timeoutMs, 5_000))
|
|
198
|
+
const reqId = randomUUID()
|
|
199
|
+
const ready = this.waitReady(reqId, timeoutMs)
|
|
200
|
+
this.send({ t: 'connect', reqId, targetDeviceId })
|
|
201
|
+
const { dataPort, token } = await ready
|
|
202
|
+
return this.openDataSocket(reqId, token, dataPort)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
openDataSocket(reqId, token, dataPort = this.dataPort) {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const socket = net.connect({ host: this.host, port: dataPort })
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
socket.destroy()
|
|
210
|
+
reject(Object.assign(new Error('relay data connection timed out'), { code: 'data-timeout' }))
|
|
211
|
+
}, DEFAULT_CONNECT_TIMEOUT_MS)
|
|
212
|
+
timer.unref?.()
|
|
213
|
+
socket.setNoDelay?.(true)
|
|
214
|
+
socket.once('connect', () => {
|
|
215
|
+
clearTimeout(timer)
|
|
216
|
+
socket.write(
|
|
217
|
+
JSON.stringify({ fleetId: this.fleetId, deviceId: this.deviceId, reqId, token }) + '\n',
|
|
218
|
+
)
|
|
219
|
+
resolve(socket)
|
|
220
|
+
})
|
|
221
|
+
socket.once('error', (error) => {
|
|
222
|
+
clearTimeout(timer)
|
|
223
|
+
reject(error)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
onClose(socket) {
|
|
229
|
+
if (this.socket !== socket) return
|
|
230
|
+
this.socket = null
|
|
231
|
+
const wasOnline = this.online
|
|
232
|
+
this.online = false
|
|
233
|
+
if (wasOnline) this.emit('offline')
|
|
234
|
+
for (const [reqId, waiter] of this.readyWaiters) {
|
|
235
|
+
this.readyWaiters.delete(reqId)
|
|
236
|
+
clearTimeout(waiter.timer)
|
|
237
|
+
waiter.reject(Object.assign(new Error('relay connection lost'), { code: 'relay-offline' }))
|
|
238
|
+
}
|
|
239
|
+
if (this.closed) return
|
|
240
|
+
const delay = Math.min(
|
|
241
|
+
this.reconnect.maxDelayMs,
|
|
242
|
+
this.reconnect.initialDelayMs * 2 ** Math.min(this.attempt++, 5),
|
|
243
|
+
)
|
|
244
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay)
|
|
245
|
+
this.reconnectTimer.unref?.()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
close() {
|
|
249
|
+
this.closed = true
|
|
250
|
+
clearTimeout(this.reconnectTimer)
|
|
251
|
+
try {
|
|
252
|
+
this.socket?.destroy()
|
|
253
|
+
} catch {
|
|
254
|
+
/* ignore */
|
|
255
|
+
}
|
|
256
|
+
this.socket = null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Self-hosted relay for fleet devices behind NAT (v2).
|
|
2
|
+
//
|
|
3
|
+
// Control plane: TCP with newline-delimited JSON.
|
|
4
|
+
// register -> register.ok
|
|
5
|
+
// connect(reqId,target) -> connect.pending; target gets incoming(reqId,from)
|
|
6
|
+
// accept/reject on the target side -> connect.ready (both sides, with data token)
|
|
7
|
+
// Data plane: a second TCP listener. Each side connects with a one-line header
|
|
8
|
+
// {fleetId, deviceId, reqId, token}\n
|
|
9
|
+
// and the relay splices the two raw sockets together. Payload bytes are the
|
|
10
|
+
// end-to-end encrypted frames from secure-channel.js, so the relay cannot read them.
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { EventEmitter } from 'node:events'
|
|
13
|
+
import net from 'node:net'
|
|
14
|
+
import { verifyRelayAuthToken } from './identity.js'
|
|
15
|
+
|
|
16
|
+
const MAX_CONTROL_LINE = 64 * 1024
|
|
17
|
+
const MAX_CLOCK_SKEW_MS = 120_000
|
|
18
|
+
const ACCEPT_TIMEOUT_MS = 20_000
|
|
19
|
+
const PAIR_TIMEOUT_MS = 20_000
|
|
20
|
+
|
|
21
|
+
function randomToken() {
|
|
22
|
+
return randomBytes(24).toString('base64url')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeFleets(fleets) {
|
|
26
|
+
const map = new Map()
|
|
27
|
+
if (fleets instanceof Map) {
|
|
28
|
+
for (const [fleetId, key] of fleets) map.set(fleetId, key)
|
|
29
|
+
return map
|
|
30
|
+
}
|
|
31
|
+
for (const [fleetId, key] of Object.entries(fleets ?? {})) map.set(fleetId, key)
|
|
32
|
+
return map
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function listen(server, port, host) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const onError = (error) => {
|
|
38
|
+
server.off('listening', onListening)
|
|
39
|
+
reject(error)
|
|
40
|
+
}
|
|
41
|
+
const onListening = () => {
|
|
42
|
+
server.off('error', onError)
|
|
43
|
+
const address = server.address()
|
|
44
|
+
resolve(address?.port ?? port)
|
|
45
|
+
}
|
|
46
|
+
server.once('error', onError)
|
|
47
|
+
server.once('listening', onListening)
|
|
48
|
+
server.listen(port, host)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{host?: string, port?: number, dataHost?: string, dataPort?: number, fleets?: Record<string,string>|Map<string,string>}} [options]
|
|
54
|
+
*/
|
|
55
|
+
export function createRelayServer({
|
|
56
|
+
host = '127.0.0.1',
|
|
57
|
+
dataHost = host,
|
|
58
|
+
port = 0,
|
|
59
|
+
dataPort = 0,
|
|
60
|
+
fleets = {},
|
|
61
|
+
} = {}) {
|
|
62
|
+
const fleetKeys = normalizeFleets(fleets)
|
|
63
|
+
if (fleetKeys.size === 0) throw new Error('relay needs at least one fleet key')
|
|
64
|
+
const relay = new EventEmitter()
|
|
65
|
+
/** @type {Map<string, {deviceId: string, fleetId: string, socket: net.Socket}>} */
|
|
66
|
+
const devices = new Map()
|
|
67
|
+
/** @type {Map<string, any>} */
|
|
68
|
+
const pending = new Map()
|
|
69
|
+
const state = { port: null, dataPort: null, closed: false }
|
|
70
|
+
const trackedSockets = new Set()
|
|
71
|
+
|
|
72
|
+
const send = (socket, message) => {
|
|
73
|
+
if (!socket || socket.destroyed || !socket.writable) return
|
|
74
|
+
try {
|
|
75
|
+
socket.write(JSON.stringify(message) + '\n')
|
|
76
|
+
} catch {
|
|
77
|
+
/* socket is going away */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cleanupPending = (reqId, reason, notify = true) => {
|
|
82
|
+
const entry = pending.get(reqId)
|
|
83
|
+
if (!entry) return
|
|
84
|
+
pending.delete(reqId)
|
|
85
|
+
clearTimeout(entry.acceptTimer)
|
|
86
|
+
clearTimeout(entry.pairTimer)
|
|
87
|
+
for (const socket of Object.values(entry.sockets ?? {})) {
|
|
88
|
+
try {
|
|
89
|
+
socket.destroy()
|
|
90
|
+
} catch {
|
|
91
|
+
/* ignore */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!notify) return
|
|
95
|
+
const fromDevice = devices.get(entry.fromDeviceId)
|
|
96
|
+
const targetDevice = devices.get(entry.targetDeviceId)
|
|
97
|
+
send(fromDevice?.socket, { t: 'connect.fail', reqId, reason })
|
|
98
|
+
send(targetDevice?.socket, { t: 'connect.fail', reqId, reason })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const removeDevice = (deviceId, socket) => {
|
|
102
|
+
const record = devices.get(deviceId)
|
|
103
|
+
if (!record || record.socket !== socket) return
|
|
104
|
+
devices.delete(deviceId)
|
|
105
|
+
for (const [reqId, entry] of pending) {
|
|
106
|
+
if (entry.fromDeviceId === deviceId || entry.targetDeviceId === deviceId) {
|
|
107
|
+
cleanupPending(reqId, 'peer-offline')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handleControlLine = (conn, line) => {
|
|
113
|
+
let message
|
|
114
|
+
try {
|
|
115
|
+
message = JSON.parse(line)
|
|
116
|
+
} catch {
|
|
117
|
+
send(conn.socket, { t: 'error', code: 'bad-json' })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
switch (message?.t) {
|
|
121
|
+
case 'register': {
|
|
122
|
+
const { fleetId, deviceId, ts, token } = message
|
|
123
|
+
if (typeof fleetId !== 'string' || typeof deviceId !== 'string') {
|
|
124
|
+
send(conn.socket, { t: 'error', code: 'bad-register' })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const fleetKey = fleetKeys.get(fleetId)
|
|
128
|
+
if (!fleetKey) {
|
|
129
|
+
send(conn.socket, { t: 'error', code: 'unknown-fleet' })
|
|
130
|
+
conn.socket.destroy()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (typeof ts !== 'number' || Math.abs(Date.now() - ts) > MAX_CLOCK_SKEW_MS) {
|
|
134
|
+
send(conn.socket, { t: 'error', code: 'stale-token' })
|
|
135
|
+
conn.socket.destroy()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (!verifyRelayAuthToken(token, { fleetKey, fleetId, deviceId, ts })) {
|
|
139
|
+
send(conn.socket, { t: 'error', code: 'unauthorized' })
|
|
140
|
+
conn.socket.destroy()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const previous = devices.get(deviceId)
|
|
144
|
+
if (previous && previous.socket !== conn.socket) {
|
|
145
|
+
send(previous.socket, { t: 'error', code: 'replaced' })
|
|
146
|
+
previous.socket.destroy()
|
|
147
|
+
}
|
|
148
|
+
devices.set(deviceId, { deviceId, fleetId, socket: conn.socket })
|
|
149
|
+
conn.registered = { deviceId, fleetId }
|
|
150
|
+
send(conn.socket, { t: 'register.ok', deviceId, server: 'dsh-device-fleet-relay', v: 1 })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
case 'ping': {
|
|
154
|
+
send(conn.socket, { t: 'pong' })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
case 'connect': {
|
|
158
|
+
if (!conn.registered) {
|
|
159
|
+
send(conn.socket, { t: 'connect.fail', reqId: message.reqId, reason: 'not-registered' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const { reqId, targetDeviceId } = message
|
|
163
|
+
if (typeof reqId !== 'string' || typeof targetDeviceId !== 'string') {
|
|
164
|
+
send(conn.socket, { t: 'connect.fail', reqId: message.reqId, reason: 'bad-request' })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const target = devices.get(targetDeviceId)
|
|
168
|
+
if (!target || target.fleetId !== conn.registered.fleetId || target.socket === conn.socket) {
|
|
169
|
+
send(conn.socket, { t: 'connect.fail', reqId, reason: 'offline' })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (pending.has(reqId)) {
|
|
173
|
+
send(conn.socket, { t: 'connect.fail', reqId, reason: 'duplicate-request' })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const entry = {
|
|
177
|
+
reqId,
|
|
178
|
+
fleetId: conn.registered.fleetId,
|
|
179
|
+
fromDeviceId: conn.registered.deviceId,
|
|
180
|
+
targetDeviceId,
|
|
181
|
+
sockets: {},
|
|
182
|
+
tokens: null,
|
|
183
|
+
accepted: false,
|
|
184
|
+
acceptTimer: setTimeout(() => cleanupPending(reqId, 'accept-timeout'), ACCEPT_TIMEOUT_MS),
|
|
185
|
+
pairTimer: null,
|
|
186
|
+
}
|
|
187
|
+
entry.acceptTimer.unref?.()
|
|
188
|
+
pending.set(reqId, entry)
|
|
189
|
+
send(target.socket, { t: 'incoming', reqId, fromDeviceId: entry.fromDeviceId })
|
|
190
|
+
send(conn.socket, { t: 'connect.pending', reqId })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
case 'accept': {
|
|
194
|
+
if (!conn.registered) return
|
|
195
|
+
const entry = pending.get(message.reqId)
|
|
196
|
+
if (!entry || entry.targetDeviceId !== conn.registered.deviceId) {
|
|
197
|
+
send(conn.socket, { t: 'error', code: 'unknown-request', reqId: message.reqId })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (entry.accepted) return
|
|
201
|
+
entry.accepted = true
|
|
202
|
+
clearTimeout(entry.acceptTimer)
|
|
203
|
+
entry.tokens = { [entry.fromDeviceId]: randomToken(), [entry.targetDeviceId]: randomToken() }
|
|
204
|
+
entry.pairTimer = setTimeout(() => cleanupPending(entry.reqId, 'data-timeout'), PAIR_TIMEOUT_MS)
|
|
205
|
+
entry.pairTimer.unref?.()
|
|
206
|
+
const from = devices.get(entry.fromDeviceId)
|
|
207
|
+
const target = devices.get(entry.targetDeviceId)
|
|
208
|
+
send(from?.socket, { t: 'connect.ready', reqId: entry.reqId, dataPort: state.dataPort, token: entry.tokens[entry.fromDeviceId] })
|
|
209
|
+
send(target?.socket, { t: 'connect.ready', reqId: entry.reqId, dataPort: state.dataPort, token: entry.tokens[entry.targetDeviceId] })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
case 'reject': {
|
|
213
|
+
const entry = pending.get(message.reqId)
|
|
214
|
+
if (!entry || entry.targetDeviceId !== conn.registered?.deviceId) return
|
|
215
|
+
cleanupPending(entry.reqId, 'rejected')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
default:
|
|
219
|
+
send(conn.socket, { t: 'error', code: 'unknown-message' })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const onControlSocket = (socket) => {
|
|
224
|
+
trackedSockets.add(socket)
|
|
225
|
+
socket.setNoDelay?.(true)
|
|
226
|
+
const conn = { socket, registered: null, buffer: '' }
|
|
227
|
+
socket.on('data', (chunk) => {
|
|
228
|
+
conn.buffer += chunk.toString('utf8')
|
|
229
|
+
if (conn.buffer.length > MAX_CONTROL_LINE * 8) {
|
|
230
|
+
socket.destroy()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
let newline = conn.buffer.indexOf('\n')
|
|
234
|
+
while (newline >= 0) {
|
|
235
|
+
const line = conn.buffer.slice(0, newline)
|
|
236
|
+
conn.buffer = conn.buffer.slice(newline + 1)
|
|
237
|
+
if (line.trim() !== '') handleControlLine(conn, line)
|
|
238
|
+
newline = conn.buffer.indexOf('\n')
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
socket.on('error', () => {})
|
|
242
|
+
socket.on('close', () => {
|
|
243
|
+
trackedSockets.delete(socket)
|
|
244
|
+
if (conn.registered) removeDevice(conn.registered.deviceId, socket)
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pairDataSockets = (reqId) => {
|
|
249
|
+
const entry = pending.get(reqId)
|
|
250
|
+
if (!entry) return
|
|
251
|
+
const entries = Object.entries(entry.sockets)
|
|
252
|
+
if (entries.length !== 2) return
|
|
253
|
+
pending.delete(reqId)
|
|
254
|
+
clearTimeout(entry.pairTimer)
|
|
255
|
+
clearTimeout(entry.acceptTimer)
|
|
256
|
+
const [[sideA, socketA], [sideB, socketB]] = entries
|
|
257
|
+
for (const socket of [socketA, socketB]) trackedSockets.delete(socket)
|
|
258
|
+
const destroyBoth = () => {
|
|
259
|
+
for (const socket of [socketA, socketB]) {
|
|
260
|
+
try {
|
|
261
|
+
socket.destroy()
|
|
262
|
+
} catch {
|
|
263
|
+
/* ignore */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
socketA.on('error', destroyBoth)
|
|
268
|
+
socketB.on('error', destroyBoth)
|
|
269
|
+
socketA.on('close', () => socketB.destroy())
|
|
270
|
+
socketB.on('close', () => socketA.destroy())
|
|
271
|
+
socketA.pipe(socketB)
|
|
272
|
+
socketB.pipe(socketA)
|
|
273
|
+
socketA.resume()
|
|
274
|
+
socketB.resume()
|
|
275
|
+
relay.emit('spliced', {
|
|
276
|
+
reqId,
|
|
277
|
+
fleetId: entry.fleetId,
|
|
278
|
+
fromDeviceId: entry.fromDeviceId,
|
|
279
|
+
targetDeviceId: entry.targetDeviceId,
|
|
280
|
+
sides: [sideA, sideB],
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const onDataSocket = (socket) => {
|
|
285
|
+
trackedSockets.add(socket)
|
|
286
|
+
socket.setNoDelay?.(true)
|
|
287
|
+
let headerBuffer = Buffer.alloc(0)
|
|
288
|
+
const onData = (chunk) => {
|
|
289
|
+
headerBuffer = Buffer.concat([headerBuffer, chunk])
|
|
290
|
+
const newline = headerBuffer.indexOf(0x0a)
|
|
291
|
+
if (newline < 0) {
|
|
292
|
+
if (headerBuffer.length > MAX_CONTROL_LINE) socket.destroy()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
socket.off('data', onData)
|
|
296
|
+
let header
|
|
297
|
+
try {
|
|
298
|
+
header = JSON.parse(headerBuffer.subarray(0, newline).toString('utf8'))
|
|
299
|
+
} catch {
|
|
300
|
+
socket.destroy()
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
const leftover = headerBuffer.subarray(newline + 1)
|
|
304
|
+
const entry = pending.get(header?.reqId)
|
|
305
|
+
if (!entry || !entry.tokens) {
|
|
306
|
+
socket.destroy()
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
const side =
|
|
310
|
+
header.deviceId === entry.fromDeviceId ? entry.fromDeviceId : header.deviceId === entry.targetDeviceId ? entry.targetDeviceId : null
|
|
311
|
+
if (!side || header.fleetId !== entry.fleetId || header.token !== entry.tokens[side]) {
|
|
312
|
+
socket.destroy()
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (entry.sockets[side]) {
|
|
316
|
+
socket.destroy()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
socket.pause()
|
|
320
|
+
entry.sockets[side] = socket
|
|
321
|
+
if (leftover.length > 0) socket.unshift(leftover)
|
|
322
|
+
pairDataSockets(entry.reqId)
|
|
323
|
+
}
|
|
324
|
+
socket.on('data', onData)
|
|
325
|
+
socket.once('close', () => {
|
|
326
|
+
trackedSockets.delete(socket)
|
|
327
|
+
for (const [reqId, entry] of pending) {
|
|
328
|
+
if (entry.sockets && Object.values(entry.sockets).includes(socket)) cleanupPending(reqId, 'data-closed')
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
socket.on('error', () => {})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const controlServer = net.createServer(onControlSocket)
|
|
335
|
+
const dataServer = net.createServer(onDataSocket)
|
|
336
|
+
|
|
337
|
+
const ready = (async () => {
|
|
338
|
+
state.dataPort = await listen(dataServer, dataPort, dataHost)
|
|
339
|
+
state.port = await listen(controlServer, port, host)
|
|
340
|
+
return { port: state.port, dataPort: state.dataPort }
|
|
341
|
+
})()
|
|
342
|
+
ready.catch(() => {})
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
ready,
|
|
346
|
+
state,
|
|
347
|
+
on: relay.on.bind(relay),
|
|
348
|
+
once: relay.once.bind(relay),
|
|
349
|
+
close: async () => {
|
|
350
|
+
if (state.closed) return
|
|
351
|
+
state.closed = true
|
|
352
|
+
for (const [reqId] of pending) cleanupPending(reqId, 'server-closing', false)
|
|
353
|
+
for (const socket of trackedSockets) {
|
|
354
|
+
try {
|
|
355
|
+
socket.destroy()
|
|
356
|
+
} catch {
|
|
357
|
+
/* ignore */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
await Promise.all([
|
|
361
|
+
new Promise((resolve) => (controlServer.listening ? controlServer.close(() => resolve()) : resolve())),
|
|
362
|
+
new Promise((resolve) => (dataServer.listening ? dataServer.close(() => resolve()) : resolve())),
|
|
363
|
+
])
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
}
|