@tetherto/mdk-worker 0.0.0 → 0.12.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/README.md +207 -0
- package/device.js +15 -0
- package/index.js +12 -0
- package/lib/contract-loader.js +74 -0
- package/lib/contract-validator.js +114 -0
- package/lib/instance-loader.js +107 -0
- package/lib/module-context.js +104 -0
- package/lib/param-validator.js +54 -0
- package/lib/plugin-loader.js +94 -0
- package/lib/service-builtins.js +268 -0
- package/lib/worker-runtime-v2.js +113 -0
- package/lib/worker-runtime.js +589 -0
- package/mdk-contract.schema.json +138 -0
- package/package.json +59 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const HyperswarmRPC = require('@hyperswarm/rpc')
|
|
7
|
+
const Hyperswarm = require('hyperswarm')
|
|
8
|
+
const DHT = require('hyperdht')
|
|
9
|
+
const debug = require('debug')('mdk:worker:runtime')
|
|
10
|
+
const { ACTIONS } = require('@tetherto/mdk-kernel/protocol/actions')
|
|
11
|
+
const { buildResponse, serialize, deserialize, validateFull: validateEnvelope } = require('@tetherto/mdk-kernel/protocol/envelope')
|
|
12
|
+
const { loadPlugin } = require('./plugin-loader')
|
|
13
|
+
const { telemetryBuiltin, commandBuiltin, mergeBuiltinCommands, builtinCommandParams } = require('./service-builtins')
|
|
14
|
+
const { validateParams } = require('./param-validator')
|
|
15
|
+
|
|
16
|
+
// Errors that already carry a protocol code (ERR_*) are part of the wire
|
|
17
|
+
// contract and go back as-is; anything else (a stack-less TypeError from a
|
|
18
|
+
// handler, a driver message with an address or a credential in it) is logged
|
|
19
|
+
// here and replaced by the fallback code before it crosses the RPC boundary.
|
|
20
|
+
const ERR_CODE = /^ERR_[A-Z0-9_]+/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generic host for a Worker Plugin: N same-type devices behind one HRPC
|
|
24
|
+
* channel to the Kernel. The plugin is loaded (never subclassed); every
|
|
25
|
+
* command/telemetry handler is invoked with the addressed device's context
|
|
26
|
+
* ({ deviceId, device, config }) and its return value is wrapped into the
|
|
27
|
+
* MDK Protocol envelope here. Handlers never see envelopes or transport.
|
|
28
|
+
*
|
|
29
|
+
* The device list is fixed at construction. A device whose connect() fails
|
|
30
|
+
* is held as `offline` (requests to it return ERR_DEVICE_UNAVAILABLE) so one
|
|
31
|
+
* unreachable unit cannot take down its siblings.
|
|
32
|
+
*
|
|
33
|
+
* `opts.services` (optional) injects worker-infra services (logs, comments,
|
|
34
|
+
* settings, stats, provisioning, write-call approval). When present, the
|
|
35
|
+
* runtime serves the legacy adapter surface from them as built-ins (see
|
|
36
|
+
* service-builtins.js) and exposes the same object to handlers as
|
|
37
|
+
* ctx.services. Services are process-owned, never plugin-owned.
|
|
38
|
+
*
|
|
39
|
+
* DHT/RPC identity: pass `opts.store` (Hyperbee-style) or `opts.storeDir`
|
|
40
|
+
* (directory of seed files) so the public key is stable across restarts.
|
|
41
|
+
* Without either, seeds are random per boot.
|
|
42
|
+
*
|
|
43
|
+
* RPC admission is fail closed: `opts.allowedKeys` (hex/Buffer public keys —
|
|
44
|
+
* the Kernel's kernel.getClientPublicKey()) or runtime allowKey() name who may
|
|
45
|
+
* call; start() throws ERR_WORKER_ALLOWED_KEYS_REQUIRED with none, unless
|
|
46
|
+
* `opts.allowAll: true` (development only, logs a warning). Every request is
|
|
47
|
+
* envelope-validated (validateFull) before dispatch.
|
|
48
|
+
*/
|
|
49
|
+
class WorkerRuntime {
|
|
50
|
+
constructor (plugin, opts) {
|
|
51
|
+
opts = opts || {}
|
|
52
|
+
if (!opts.workerId || typeof opts.workerId !== 'string') throw new Error('ERR_WORKER_ID_REQUIRED')
|
|
53
|
+
|
|
54
|
+
this._plugin = loadPlugin(plugin)
|
|
55
|
+
this.workerId = opts.workerId
|
|
56
|
+
this.kernelTopic = opts.kernelTopic || null
|
|
57
|
+
this.store = opts.store || null
|
|
58
|
+
this.storeDir = opts.storeDir || null
|
|
59
|
+
this.services = opts.services || null
|
|
60
|
+
this._publishedContract = mergeBuiltinCommands(this._plugin.publishedContract, this.services)
|
|
61
|
+
|
|
62
|
+
this._commandParamNames = new Map()
|
|
63
|
+
this._commandParams = new Map()
|
|
64
|
+
for (const entry of this._plugin.contract.capabilities.commands || []) {
|
|
65
|
+
this._commandParamNames.set(entry.name, (entry.params || []).map((p) => p.name))
|
|
66
|
+
this._commandParams.set(entry.name, Array.isArray(entry.params) ? entry.params : [])
|
|
67
|
+
}
|
|
68
|
+
// Optional DHT bootstrap override. Defaults to hyperdht's public bootstrap;
|
|
69
|
+
// set only for a custom DHT network or hermetic tests (hyperdht/testnet).
|
|
70
|
+
this._bootstrap = opts.bootstrap || null
|
|
71
|
+
|
|
72
|
+
// RPC caller allowlist (fail closed). Only these public keys — normally
|
|
73
|
+
// the Kernel's client key, kernel.getClientPublicKey() — may connect;
|
|
74
|
+
// start() refuses to run with none unless allowAll is set explicitly.
|
|
75
|
+
this._allowedKeys = new Set()
|
|
76
|
+
for (const key of opts.allowedKeys || []) this.allowKey(key)
|
|
77
|
+
this._allowAll = opts.allowAll === true
|
|
78
|
+
|
|
79
|
+
this._devices = new Map()
|
|
80
|
+
for (const spec of this._validateDeviceSpecs(opts.devices, opts.allowEmptyDevices)) {
|
|
81
|
+
this._devices.set(spec.deviceId, {
|
|
82
|
+
deviceId: spec.deviceId,
|
|
83
|
+
config: spec.config,
|
|
84
|
+
device: null,
|
|
85
|
+
ctx: null,
|
|
86
|
+
status: 'offline',
|
|
87
|
+
error: null
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this._rpc = null
|
|
92
|
+
this._server = null
|
|
93
|
+
this._swarm = null
|
|
94
|
+
this._discovery = null
|
|
95
|
+
this._announceTimer = null
|
|
96
|
+
this._dht = null
|
|
97
|
+
this._confBee = null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// allowEmpty supports provisioning-first bootstrap: a fresh worker boots
|
|
101
|
+
// with no devices, receives registerThing writes, and is restarted with
|
|
102
|
+
// the provisioned set. Without the flag an empty list stays an error.
|
|
103
|
+
_validateDeviceSpecs (devices, allowEmpty) {
|
|
104
|
+
if (allowEmpty && (!devices || (Array.isArray(devices) && devices.length === 0))) return []
|
|
105
|
+
if (!Array.isArray(devices) || devices.length === 0) throw new Error('ERR_DEVICES_REQUIRED')
|
|
106
|
+
const seen = new Set()
|
|
107
|
+
return devices.map((spec) => {
|
|
108
|
+
if (!spec || typeof spec.deviceId !== 'string' || !spec.deviceId) throw new Error('ERR_DEVICE_ID_MISSING')
|
|
109
|
+
if (seen.has(spec.deviceId)) throw new Error(`ERR_DEVICE_ID_DUPLICATE: ${spec.deviceId}`)
|
|
110
|
+
seen.add(spec.deviceId)
|
|
111
|
+
if (spec.config !== undefined && (typeof spec.config !== 'object' || spec.config === null)) {
|
|
112
|
+
throw new Error(`ERR_DEVICE_CONFIG_INVALID: ${spec.deviceId}`)
|
|
113
|
+
}
|
|
114
|
+
return { deviceId: spec.deviceId, config: spec.config || {} }
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async start () {
|
|
119
|
+
if (this._allowAll) {
|
|
120
|
+
console.warn(`[mdk-worker] WARNING: worker ${this.workerId} runs with allowAll — any DHT peer that learns its key can command its devices. Never use this outside local development.`)
|
|
121
|
+
} else if (this._allowedKeys.size === 0) {
|
|
122
|
+
throw new Error('ERR_WORKER_ALLOWED_KEYS_REQUIRED: pass opts.allowedKeys (the Kernel client key, kernel.getClientPublicKey()) or opts.allowAll: true for local development')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await this._openContexts()
|
|
126
|
+
|
|
127
|
+
const seedDht = await this._getOrCreateSeed('seedDht')
|
|
128
|
+
const seedRpc = await this._getOrCreateSeed('seedRpc')
|
|
129
|
+
|
|
130
|
+
this._dht = new DHT({ keyPair: DHT.keyPair(seedDht), ...(this._bootstrap ? { bootstrap: this._bootstrap } : {}) })
|
|
131
|
+
this._rpc = new HyperswarmRPC({ dht: this._dht, seed: seedRpc })
|
|
132
|
+
this._server = this._rpc.createServer({ firewall: (remotePublicKey) => !this._isAllowed(remotePublicKey) })
|
|
133
|
+
|
|
134
|
+
this._server.respond('mdk', async (reqBuf, rpc) => {
|
|
135
|
+
try {
|
|
136
|
+
// Re-checked per request so revokeKey() takes effect on open connections.
|
|
137
|
+
const remote = rpc && rpc.stream && rpc.stream.remotePublicKey
|
|
138
|
+
if (!remote || !this._isAllowed(remote)) return serialize({ error: 'ERR_WORKER_CALLER_NOT_ALLOWED' })
|
|
139
|
+
const envelope = deserialize(reqBuf)
|
|
140
|
+
const validation = validateEnvelope(envelope)
|
|
141
|
+
if (!validation.valid) return serialize({ error: validation.error })
|
|
142
|
+
const result = await this.handleRequest(envelope)
|
|
143
|
+
return serialize(result)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return serialize({ error: _publicError(err, 'ERR_REQUEST_FAILED') })
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
await this._server.listen()
|
|
150
|
+
debug('runtime RPC listening (key: %s...)', this._server.publicKey.toString('hex').slice(0, 16))
|
|
151
|
+
|
|
152
|
+
if (this.kernelTopic) {
|
|
153
|
+
await this._joinDiscoveryTopic()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { publicKey: this._server.publicKey }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async stop () {
|
|
160
|
+
if (this._announceTimer) { clearInterval(this._announceTimer); this._announceTimer = null }
|
|
161
|
+
this._discovery = null
|
|
162
|
+
if (this._swarm) { await this._swarm.destroy(); this._swarm = null }
|
|
163
|
+
if (this._server) { await this._server.close(); this._server = null }
|
|
164
|
+
if (this._rpc) { await this._rpc.destroy(); this._rpc = null }
|
|
165
|
+
if (this._dht) { await this._dht.destroy(); this._dht = null }
|
|
166
|
+
await this._closeContexts()
|
|
167
|
+
debug('worker runtime stopped')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Admit an RPC caller (hex or Buffer public key), e.g. a Kernel's
|
|
172
|
+
* getClientPublicKey(). Works before or after start().
|
|
173
|
+
*/
|
|
174
|
+
allowKey (key) {
|
|
175
|
+
const hex = _normalizeKey(key)
|
|
176
|
+
this._allowedKeys.add(hex)
|
|
177
|
+
return hex
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
revokeKey (key) {
|
|
181
|
+
return this._allowedKeys.delete(_normalizeKey(key))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
getAllowedKeys () {
|
|
185
|
+
return [...this._allowedKeys]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_isAllowed (remotePublicKey) {
|
|
189
|
+
if (this._allowAll) return true
|
|
190
|
+
const ok = this._allowedKeys.has(remotePublicKey.toString('hex'))
|
|
191
|
+
if (!ok) debug('blocked caller: %s...', remotePublicKey.toString('hex').slice(0, 16))
|
|
192
|
+
return ok
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getPublicKey () {
|
|
196
|
+
return this._server ? this._server.publicKey : null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Read access to a device's frozen handler context ({ deviceId, device,
|
|
200
|
+
// config, services }) for the process that owns the runtime — e.g. to wire
|
|
201
|
+
// a snap-collection service to the device clients. Null while offline.
|
|
202
|
+
getDeviceContext (deviceId) {
|
|
203
|
+
const entry = this._devices.get(deviceId)
|
|
204
|
+
return entry && entry.status === 'online' ? entry.ctx : null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async _openContexts () {
|
|
208
|
+
for (const entry of this._devices.values()) {
|
|
209
|
+
if (entry.status === 'online') continue
|
|
210
|
+
try {
|
|
211
|
+
entry.device = await this._plugin.connect(entry.config, { deviceId: entry.deviceId })
|
|
212
|
+
entry.ctx = Object.freeze({ deviceId: entry.deviceId, device: entry.device, config: entry.config, services: this.services })
|
|
213
|
+
entry.status = 'online'
|
|
214
|
+
entry.error = null
|
|
215
|
+
debug('device context opened: %s', entry.deviceId)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
entry.error = err.message
|
|
218
|
+
debug('connect failed for %s: %s', entry.deviceId, err.message)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async _closeContexts () {
|
|
224
|
+
if (!this._plugin.disconnect) return
|
|
225
|
+
for (const entry of this._devices.values()) {
|
|
226
|
+
if (entry.status !== 'online') continue
|
|
227
|
+
try {
|
|
228
|
+
await this._plugin.disconnect(entry.device, { deviceId: entry.deviceId })
|
|
229
|
+
} catch (err) {
|
|
230
|
+
debug('disconnect failed for %s: %s', entry.deviceId, err.message)
|
|
231
|
+
}
|
|
232
|
+
entry.status = 'offline'
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async _getOrCreateSeed (name) {
|
|
237
|
+
if (this.store) {
|
|
238
|
+
if (!this._confBee) {
|
|
239
|
+
this._confBee = this.store.getBee
|
|
240
|
+
? this.store.getBee({ name: 'workerConf' }, { keyEncoding: 'utf-8' })
|
|
241
|
+
: this.store.sub('workerConf')
|
|
242
|
+
if (this._confBee.ready) await this._confBee.ready()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const existing = await this._confBee.get(name)
|
|
246
|
+
if (existing && existing.value) {
|
|
247
|
+
return Buffer.isBuffer(existing.value) ? existing.value : Buffer.from(existing.value)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const seed = crypto.randomBytes(32)
|
|
251
|
+
await this._confBee.put(name, seed)
|
|
252
|
+
debug('created persistent seed: %s', name)
|
|
253
|
+
return seed
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (this.storeDir) return this._getOrCreateSeedFile(name)
|
|
257
|
+
|
|
258
|
+
return crypto.randomBytes(32)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// File-backed seeds for hosts that own a directory but not a Hyperbee store
|
|
262
|
+
// (WorkerRuntimeV2 / CLI). Same 32-byte seedDht/seedRpc
|
|
263
|
+
// names the Hyperbee path uses, so the RPC public key stays stable across
|
|
264
|
+
// process restarts without pulling in worker-infra.
|
|
265
|
+
//
|
|
266
|
+
// The seed IS the worker's identity, so this fails closed: a malformed
|
|
267
|
+
// seed is an error (never silently replaced, which would rotate the key the
|
|
268
|
+
// Kernel knows), a symlink is refused, a file owned by another user is
|
|
269
|
+
// refused, and group/other permission bits are stripped. A new seed is
|
|
270
|
+
// created exclusively (O_EXCL, so a planted file or link is never written
|
|
271
|
+
// through) with mode 0600.
|
|
272
|
+
_getOrCreateSeedFile (name) {
|
|
273
|
+
fs.mkdirSync(this.storeDir, { recursive: true, mode: 0o700 })
|
|
274
|
+
const seedPath = path.join(this.storeDir, name)
|
|
275
|
+
|
|
276
|
+
const existing = _readSeedFile(seedPath)
|
|
277
|
+
if (existing) return existing
|
|
278
|
+
|
|
279
|
+
const seed = crypto.randomBytes(32)
|
|
280
|
+
const { O_WRONLY, O_CREAT, O_EXCL } = fs.constants
|
|
281
|
+
const fd = fs.openSync(seedPath, O_WRONLY | O_CREAT | O_EXCL, 0o600)
|
|
282
|
+
try {
|
|
283
|
+
fs.writeSync(fd, seed)
|
|
284
|
+
fs.fsyncSync(fd)
|
|
285
|
+
} finally {
|
|
286
|
+
fs.closeSync(fd)
|
|
287
|
+
}
|
|
288
|
+
debug('created persistent seed file: %s', name)
|
|
289
|
+
return seed
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async _joinDiscoveryTopic () {
|
|
293
|
+
this._swarm = new Hyperswarm({ dht: this._dht })
|
|
294
|
+
const topicBuf = Buffer.isBuffer(this.kernelTopic)
|
|
295
|
+
? this.kernelTopic
|
|
296
|
+
: Buffer.from(this.kernelTopic, 'hex')
|
|
297
|
+
|
|
298
|
+
// Attach the handler BEFORE join. A Hyperswarm connection is persistent and
|
|
299
|
+
// its `connection` event fires once, immediately on open — if the Kernel client
|
|
300
|
+
// connects before this is attached (e.g. during an awaited flush), the event
|
|
301
|
+
// is missed and the worker never writes its key on that long-lived socket,
|
|
302
|
+
// so the Kernel times out and discovery hangs.
|
|
303
|
+
this._swarm.on('connection', (stream) => {
|
|
304
|
+
stream.write(this._server.publicKey)
|
|
305
|
+
stream.on('error', () => {})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// Server join: announce this keypair under the topic. `discovery.flushed()`
|
|
309
|
+
// is the server-mode wait — it resolves once we are announced on the DHT and
|
|
310
|
+
// reachable (swarm.flush() is the client "connect to pending peers" wait, the
|
|
311
|
+
// wrong semantics here). Hold the handle so we can re-announce and leave it.
|
|
312
|
+
this._discovery = this._swarm.join(topicBuf, { server: true, client: false })
|
|
313
|
+
await this._discovery.flushed()
|
|
314
|
+
|
|
315
|
+
// Announce/lookup is an ongoing process; re-announce periodically so a Kernel
|
|
316
|
+
// that joins later (or after our announce expires) still finds us.
|
|
317
|
+
this._announceTimer = setInterval(() => {
|
|
318
|
+
if (this._discovery) this._discovery.refresh({ server: true }).catch(() => {})
|
|
319
|
+
}, 30000)
|
|
320
|
+
this._announceTimer.unref()
|
|
321
|
+
|
|
322
|
+
debug('joined discovery topic: %s... (announced)', topicBuf.toString('hex').slice(0, 16))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async handleRequest (envelope) {
|
|
326
|
+
switch (envelope.action) {
|
|
327
|
+
case ACTIONS.IDENTITY_REQUEST:
|
|
328
|
+
return this._handleIdentity(envelope)
|
|
329
|
+
case ACTIONS.CAPABILITY_REQUEST:
|
|
330
|
+
return this._handleCapability(envelope)
|
|
331
|
+
case ACTIONS.TELEMETRY_PULL:
|
|
332
|
+
return this._handleTelemetry(envelope)
|
|
333
|
+
case ACTIONS.COMMAND_REQUEST:
|
|
334
|
+
return this._handleCommand(envelope)
|
|
335
|
+
case ACTIONS.HEALTH_PING:
|
|
336
|
+
return this._handleHealthPing(envelope)
|
|
337
|
+
case ACTIONS.STATE_PULL:
|
|
338
|
+
return this._handleStatePull(envelope)
|
|
339
|
+
case ACTIONS.WRITE_CALLS_REQUEST:
|
|
340
|
+
if (this.services && this.services.actions) return this._handleWriteCalls(envelope)
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return buildResponse(envelope, envelope.action, {
|
|
345
|
+
error: `ERR_UNKNOWN_ACTION: ${envelope.action}`
|
|
346
|
+
}, this.workerId)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async _handleWriteCalls (envelope) {
|
|
350
|
+
const res = await this.services.actions.getWriteCalls(envelope.payload || {})
|
|
351
|
+
return buildResponse(envelope, ACTIONS.WRITE_CALLS_RESPONSE, res, this.workerId)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_handleIdentity (envelope) {
|
|
355
|
+
return buildResponse(envelope, ACTIONS.IDENTITY_RESPONSE, {
|
|
356
|
+
workerId: this.workerId,
|
|
357
|
+
devices: [...this._devices.keys()].map((deviceId) => ({ deviceId }))
|
|
358
|
+
}, this.workerId)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_handleCapability (envelope) {
|
|
362
|
+
return buildResponse(envelope, ACTIONS.CAPABILITY_RESPONSE, {
|
|
363
|
+
contract: this._publishedContract
|
|
364
|
+
}, this.workerId)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async _handleTelemetry (envelope) {
|
|
368
|
+
const query = (envelope.payload && envelope.payload.query) || {}
|
|
369
|
+
const deviceId = envelope.deviceId || (envelope.payload && envelope.payload.deviceId) || null
|
|
370
|
+
const { type = 'metrics', ...params } = query
|
|
371
|
+
|
|
372
|
+
let result
|
|
373
|
+
const builtin = telemetryBuiltin(this.services, type)
|
|
374
|
+
if (builtin) {
|
|
375
|
+
// Adapter parity: worker-infra queries answer for absent/offline
|
|
376
|
+
// devices too (the record lives in the store, not on the device), and
|
|
377
|
+
// their errors come back inside the telemetry payload.
|
|
378
|
+
try {
|
|
379
|
+
result = await builtin.handle(this.services, deviceId, params, {
|
|
380
|
+
workerId: this.workerId,
|
|
381
|
+
metadata: this._publishedContract.metadata,
|
|
382
|
+
deviceIds: [...this._devices.keys()]
|
|
383
|
+
})
|
|
384
|
+
} catch (err) {
|
|
385
|
+
result = { error: _publicError(err, 'ERR_TELEMETRY_FAILED') }
|
|
386
|
+
}
|
|
387
|
+
} else if (type === 'list') {
|
|
388
|
+
result = { devices: [...this._devices.values()].map((e) => ({ deviceId: e.deviceId, status: e.status })) }
|
|
389
|
+
} else if (!deviceId) {
|
|
390
|
+
if (type !== 'metrics') {
|
|
391
|
+
result = { error: `ERR_DEVICE_ID_REQUIRED: ${type}` }
|
|
392
|
+
} else {
|
|
393
|
+
const devices = []
|
|
394
|
+
for (const entry of this._devices.values()) {
|
|
395
|
+
devices.push({ deviceId: entry.deviceId, ...(await this._collectMetrics(entry, params)) })
|
|
396
|
+
}
|
|
397
|
+
result = { devices }
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
const entry = this._devices.get(deviceId)
|
|
401
|
+
if (!entry) {
|
|
402
|
+
result = { error: `ERR_DEVICE_NOT_FOUND: ${deviceId}` }
|
|
403
|
+
} else if (type === 'metrics') {
|
|
404
|
+
result = await this._collectMetrics(entry, params)
|
|
405
|
+
} else if (this._plugin.handlers.telemetry.has(type)) {
|
|
406
|
+
result = await this._pullChannel(entry, type, params)
|
|
407
|
+
} else {
|
|
408
|
+
result = { error: `ERR_UNKNOWN_QUERY_TYPE: ${type}` }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return buildResponse(envelope, ACTIONS.TELEMETRY_RESPONSE, {
|
|
413
|
+
deviceId,
|
|
414
|
+
...result,
|
|
415
|
+
timestamp: Date.now()
|
|
416
|
+
}, this.workerId)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async _collectMetrics (entry, params) {
|
|
420
|
+
if (entry.status !== 'online') return { error: `ERR_DEVICE_UNAVAILABLE: ${entry.deviceId}` }
|
|
421
|
+
|
|
422
|
+
const metrics = {}
|
|
423
|
+
for (const [name, fn] of this._plugin.handlers.telemetry) {
|
|
424
|
+
try {
|
|
425
|
+
metrics[name] = await fn(entry.ctx, params)
|
|
426
|
+
} catch (err) {
|
|
427
|
+
metrics[name] = { error: _publicError(err, 'ERR_TELEMETRY_FAILED') }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { metrics }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async _pullChannel (entry, name, params) {
|
|
434
|
+
if (entry.status !== 'online') return { error: `ERR_DEVICE_UNAVAILABLE: ${entry.deviceId}` }
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
return { name, value: await this._plugin.handlers.telemetry.get(name)(entry.ctx, params) }
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return { error: _publicError(err, 'ERR_TELEMETRY_FAILED') }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async _handleCommand (envelope) {
|
|
444
|
+
const { commandId, command, params } = envelope.payload
|
|
445
|
+
const deviceId = envelope.deviceId
|
|
446
|
+
const fail = (error) => buildResponse(envelope, ACTIONS.COMMAND_RESULT, {
|
|
447
|
+
commandId,
|
|
448
|
+
status: 'FAILED',
|
|
449
|
+
error
|
|
450
|
+
}, this.workerId)
|
|
451
|
+
|
|
452
|
+
// Built-ins run before the device checks: provisioning commands arrive
|
|
453
|
+
// Worker-scoped (deviceId null) and store-backed commands must work for
|
|
454
|
+
// devices the runtime holds offline.
|
|
455
|
+
const builtin = commandBuiltin(this.services, command)
|
|
456
|
+
if (builtin) {
|
|
457
|
+
const invalid = validateParams(builtinCommandParams(command), params || {})
|
|
458
|
+
if (invalid) return fail(invalid)
|
|
459
|
+
try {
|
|
460
|
+
const result = await builtin.handle(this.services, deviceId, params || {})
|
|
461
|
+
return buildResponse(envelope, ACTIONS.COMMAND_RESULT, {
|
|
462
|
+
commandId,
|
|
463
|
+
status: 'SUCCESS',
|
|
464
|
+
result: result === undefined ? {} : result
|
|
465
|
+
}, this.workerId)
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return fail(_publicError(err, 'ERR_COMMAND_FAILED'))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!deviceId) return fail('ERR_DEVICE_ID_REQUIRED')
|
|
472
|
+
const entry = this._devices.get(deviceId)
|
|
473
|
+
if (!entry) return fail(`ERR_DEVICE_NOT_FOUND: ${deviceId}`)
|
|
474
|
+
if (entry.status !== 'online') return fail(`ERR_DEVICE_UNAVAILABLE: ${deviceId}`)
|
|
475
|
+
const handler = this._plugin.handlers.commands.get(command)
|
|
476
|
+
if (!handler) return fail(`ERR_UNKNOWN_COMMAND: ${command}`)
|
|
477
|
+
|
|
478
|
+
// Declared params are checked here, not left to each handler: the worker
|
|
479
|
+
// is reachable on its own RPC key, so the Kernel's check is not enough.
|
|
480
|
+
const normalized = this._normalizeParams(command, params)
|
|
481
|
+
const invalid = validateParams(this._commandParams.get(command), normalized)
|
|
482
|
+
if (invalid) return fail(invalid)
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const result = await handler(entry.ctx, normalized)
|
|
486
|
+
return buildResponse(envelope, ACTIONS.COMMAND_RESULT, {
|
|
487
|
+
commandId,
|
|
488
|
+
status: 'SUCCESS',
|
|
489
|
+
result: result === undefined ? {} : result
|
|
490
|
+
}, this.workerId)
|
|
491
|
+
} catch (err) {
|
|
492
|
+
return fail(_publicError(err, 'ERR_COMMAND_FAILED'))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// The Kernel's action-approver dispatches write actions positionally —
|
|
497
|
+
// `{ value: x }` for one argument, `{ args: [...] }` for several (the
|
|
498
|
+
// legacy applyThings consumed Object.values). Map those onto the
|
|
499
|
+
// contract-declared param names so handlers only ever see named params.
|
|
500
|
+
// Params that already look named (or commands without declared params)
|
|
501
|
+
// pass through untouched.
|
|
502
|
+
_normalizeParams (command, params) {
|
|
503
|
+
const declared = this._commandParamNames.get(command)
|
|
504
|
+
if (!declared || !declared.length) return params || {}
|
|
505
|
+
|
|
506
|
+
let positional = null
|
|
507
|
+
if (Array.isArray(params)) {
|
|
508
|
+
positional = params
|
|
509
|
+
} else if (params && Array.isArray(params.args) && Object.keys(params).length === 1 && !declared.includes('args')) {
|
|
510
|
+
positional = params.args
|
|
511
|
+
} else if (params && Object.keys(params).length === 1 && 'value' in params && !declared.includes('value')) {
|
|
512
|
+
positional = [params.value]
|
|
513
|
+
}
|
|
514
|
+
if (!positional) return params || {}
|
|
515
|
+
|
|
516
|
+
const named = {}
|
|
517
|
+
declared.forEach((name, i) => {
|
|
518
|
+
if (positional[i] !== undefined) named[name] = positional[i]
|
|
519
|
+
})
|
|
520
|
+
return named
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
_handleHealthPing (envelope) {
|
|
524
|
+
return buildResponse(envelope, ACTIONS.HEALTH_PONG, {
|
|
525
|
+
status: 'OK'
|
|
526
|
+
}, this.workerId)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
_handleStatePull (envelope) {
|
|
530
|
+
const state = {}
|
|
531
|
+
for (const entry of this._devices.values()) {
|
|
532
|
+
state[entry.deviceId] = { status: entry.status }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return buildResponse(envelope, ACTIONS.STATE_RESPONSE, {
|
|
536
|
+
state,
|
|
537
|
+
deviceCount: this._devices.size,
|
|
538
|
+
workerId: this.workerId
|
|
539
|
+
}, this.workerId)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Returns the 32-byte seed at seedPath, or null when there is none yet.
|
|
544
|
+
function _readSeedFile (seedPath) {
|
|
545
|
+
let lst
|
|
546
|
+
try {
|
|
547
|
+
lst = fs.lstatSync(seedPath)
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (err.code === 'ENOENT') return null
|
|
550
|
+
throw err
|
|
551
|
+
}
|
|
552
|
+
if (lst.isSymbolicLink()) throw new Error(`ERR_SEED_FILE_SYMLINK: ${seedPath}`)
|
|
553
|
+
if (!lst.isFile()) throw new Error(`ERR_SEED_FILE_INVALID: ${seedPath}`)
|
|
554
|
+
|
|
555
|
+
// O_NOFOLLOW closes the window between the lstat above and the open.
|
|
556
|
+
const fd = fs.openSync(seedPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
|
|
557
|
+
try {
|
|
558
|
+
const st = fs.fstatSync(fd)
|
|
559
|
+
if (!st.isFile()) throw new Error(`ERR_SEED_FILE_INVALID: ${seedPath}`)
|
|
560
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
|
|
561
|
+
throw new Error(`ERR_SEED_FILE_OWNER: ${seedPath}`)
|
|
562
|
+
}
|
|
563
|
+
if (process.platform !== 'win32' && (st.mode & 0o077)) {
|
|
564
|
+
fs.fchmodSync(fd, 0o600)
|
|
565
|
+
debug('tightened seed file permissions to 0600: %s', seedPath)
|
|
566
|
+
}
|
|
567
|
+
const buf = Buffer.alloc(33)
|
|
568
|
+
const n = fs.readSync(fd, buf, 0, 33, 0)
|
|
569
|
+
if (n !== 32) throw new Error(`ERR_SEED_FILE_MALFORMED: ${seedPath}`)
|
|
570
|
+
return buf.subarray(0, 32)
|
|
571
|
+
} finally {
|
|
572
|
+
fs.closeSync(fd)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function _normalizeKey (key) {
|
|
577
|
+
const hex = Buffer.isBuffer(key) ? key.toString('hex') : (typeof key === 'string' ? key.trim().toLowerCase() : '')
|
|
578
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error('ERR_WORKER_ALLOWED_KEY_INVALID: expected a 32-byte hex public key')
|
|
579
|
+
return hex
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function _publicError (err, fallback) {
|
|
583
|
+
const message = err && typeof err.message === 'string' ? err.message : String(err)
|
|
584
|
+
if (ERR_CODE.test(message)) return message
|
|
585
|
+
debug('%s: %s', fallback, (err && err.stack) || message)
|
|
586
|
+
return fallback
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
module.exports = WorkerRuntime
|