@tetherto/mdk-app-runtime 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/LICENSE +202 -0
- package/README.md +106 -0
- package/bin/run-app.js +118 -0
- package/index.js +14 -0
- package/lib/client.js +297 -0
- package/lib/manifest.js +75 -0
- package/lib/plugin-loader.js +216 -0
- package/lib/protocol.js +184 -0
- package/lib/runtime.js +583 -0
- package/package.json +57 -1
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const EventEmitter = require('events')
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const http = require('http')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
const util = require('util')
|
|
9
|
+
const createDebug = require('debug')
|
|
10
|
+
const HyperswarmRPC = require('@hyperswarm/rpc')
|
|
11
|
+
const DHT = require('hyperdht')
|
|
12
|
+
const { loadPlugin } = require('./plugin-loader')
|
|
13
|
+
const { loadAppManifest, resolvePluginDir } = require('./manifest')
|
|
14
|
+
const protocol = require('./protocol')
|
|
15
|
+
|
|
16
|
+
const { METHODS, STREAM_MESSAGES: MSG, ERR } = protocol
|
|
17
|
+
|
|
18
|
+
const LEVELS = ['error', 'warn', 'info', 'debug', 'trace', 'fatal']
|
|
19
|
+
|
|
20
|
+
// Default logger: error/warn/info/debug/trace/fatal + child(). error, fatal and
|
|
21
|
+
// warn always reach stderr (a failing controller must not go unnoticed); the
|
|
22
|
+
// rest print only under DEBUG=mdk:app:<name> (or a wildcard). Accepts
|
|
23
|
+
// pino-style calls too (`logger.info({ k: 1 }, 'msg')`).
|
|
24
|
+
const ALWAYS = new Set(['error', 'fatal', 'warn'])
|
|
25
|
+
|
|
26
|
+
function _debugLogger (dbg) {
|
|
27
|
+
const logger = {}
|
|
28
|
+
for (const level of LEVELS) {
|
|
29
|
+
logger[level] = (...args) => {
|
|
30
|
+
if (!dbg.enabled) {
|
|
31
|
+
if (ALWAYS.has(level)) process.stderr.write(`${dbg.namespace} [${level}] ${util.format(...args)}\n`)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const [first, second, ...rest] = args
|
|
35
|
+
if (first && typeof first === 'object' && typeof second === 'string') {
|
|
36
|
+
dbg('[%s] %s %o', level, second, first, ...rest)
|
|
37
|
+
} else if (typeof first === 'string') {
|
|
38
|
+
dbg('[%s] ' + first, level, ...(second === undefined ? [] : [second]), ...rest)
|
|
39
|
+
} else {
|
|
40
|
+
dbg('[%s] %o', level, ...args)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
logger.child = (bindings) => {
|
|
45
|
+
const suffix = bindings && (bindings.name || bindings.component)
|
|
46
|
+
return suffix ? _debugLogger(dbg.extend(String(suffix))) : logger
|
|
47
|
+
}
|
|
48
|
+
return logger
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultLogger (name) {
|
|
52
|
+
return _debugLogger(createDebug(`mdk:app:${name}`))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Written whole (temp file + rename) so a reader never sees half a key. A new
|
|
56
|
+
// directory is owner-only, as the trusted-keys reader expects. The temp name is
|
|
57
|
+
// random and created exclusively ('wx'), so nothing planted at a guessable path
|
|
58
|
+
// (a symlink, another user's file) is written through or renamed into place.
|
|
59
|
+
function _writeKeyFile (file, key) {
|
|
60
|
+
const target = path.resolve(file)
|
|
61
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
|
|
62
|
+
const tmp = `${target}.${crypto.randomBytes(8).toString('hex')}.tmp`
|
|
63
|
+
try {
|
|
64
|
+
fs.writeFileSync(tmp, key.toString('hex'), { mode: 0o644, flag: 'wx' })
|
|
65
|
+
if (process.platform !== 'win32') fs.chmodSync(tmp, 0o644)
|
|
66
|
+
fs.renameSync(tmp, target)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Removes a key file only while it still names this runtime: a newer runtime
|
|
74
|
+
// may have replaced it.
|
|
75
|
+
function _removeKeyFile (file, key) {
|
|
76
|
+
try {
|
|
77
|
+
if (fs.readFileSync(file, 'utf8').trim() === key.toString('hex')) fs.unlinkSync(file)
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A 32-byte secret seed persisted at `file` (dir 0o700, file 0o600) so an
|
|
82
|
+
// identity survives restarts; a looser mode on an existing file is tightened.
|
|
83
|
+
function getOrCreateSeed (file) {
|
|
84
|
+
const dir = path.dirname(file)
|
|
85
|
+
|
|
86
|
+
const read = () => {
|
|
87
|
+
let seed
|
|
88
|
+
try {
|
|
89
|
+
seed = fs.readFileSync(file)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err.code === 'ENOENT') return null
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
if (seed.length !== 32) throw new Error(`ERR_APP_RUNTIME_SEED_INVALID: ${file} must hold exactly 32 bytes`)
|
|
95
|
+
if (process.platform !== 'win32' && (fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600)
|
|
96
|
+
return seed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const existing = read()
|
|
100
|
+
if (existing) return existing
|
|
101
|
+
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
103
|
+
const seed = crypto.randomBytes(32)
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(file, seed, { mode: 0o600, flag: 'wx' })
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Another process created it between the read and the write: use its seed.
|
|
108
|
+
if (err.code === 'EEXIST') return read()
|
|
109
|
+
throw err
|
|
110
|
+
}
|
|
111
|
+
return seed
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Seeds live in <storeDir>/identity. Without a storeDir every start gets a fresh random seed.
|
|
115
|
+
function _storeSeed (storeDir, name) {
|
|
116
|
+
return storeDir ? getOrCreateSeed(path.join(storeDir, 'identity', name)) : crypto.randomBytes(32)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The keypair handed to createMdkClient. The secret key is non-enumerable so
|
|
120
|
+
// that logging, spreading or serializing the config cannot leak it.
|
|
121
|
+
function _kernelKeyPair (seed) {
|
|
122
|
+
const { publicKey, secretKey } = DHT.keyPair(seed)
|
|
123
|
+
const keyPair = { publicKey }
|
|
124
|
+
Object.defineProperty(keyPair, 'secretKey', { value: secretKey, enumerable: false })
|
|
125
|
+
return Object.freeze(keyPair)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Firewall: hyperdht blocks a connection when this returns true (or throws).
|
|
129
|
+
// Unlike the Kernel listener, an empty allow set denies everyone: a runtime
|
|
130
|
+
// executes app code for whoever reaches it. Key files are re-read on every
|
|
131
|
+
// connection, so the Gateway may publish its key before or after this starts;
|
|
132
|
+
// an unreadable file contributes no keys.
|
|
133
|
+
//
|
|
134
|
+
// A key in an allowed key file is a full-trust caller (it skips the Gateway's
|
|
135
|
+
// auth and sets `identity`), so a file is only read when nobody else could have
|
|
136
|
+
// written it: a regular file, not a symlink, owned by this process's user and
|
|
137
|
+
// not group/world-writable (0644 is fine). Anything else is skipped with one
|
|
138
|
+
// warning, as mdk-core's trusted-keys reader does. Each entry must be a
|
|
139
|
+
// 32-byte key in hex; other tokens are ignored.
|
|
140
|
+
|
|
141
|
+
const KEY_HEX = /^[0-9a-f]{64}$/
|
|
142
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0
|
|
143
|
+
const O_NONBLOCK = fs.constants.O_NONBLOCK || 0
|
|
144
|
+
|
|
145
|
+
function _normalizeKey (k) {
|
|
146
|
+
if (Buffer.isBuffer(k)) return k.toString('hex')
|
|
147
|
+
return typeof k === 'string' ? k.trim().toLowerCase() : ''
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Why `st` may not be trusted as a key file, or null when it may.
|
|
151
|
+
function _untrustedReason (st) {
|
|
152
|
+
if (st.isSymbolicLink()) return 'is a symlink'
|
|
153
|
+
if (!st.isFile()) return 'is not a regular file'
|
|
154
|
+
if (process.platform !== 'win32') {
|
|
155
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return 'is owned by another user'
|
|
156
|
+
if (st.mode & 0o022) return 'is group/world-writable'
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Returns { keys } for a trusted file (keys = valid hex keys in it), or
|
|
162
|
+
// { reason } when the file is untrusted, or { keys: [] } when it is absent or unreadable.
|
|
163
|
+
async function _readKeyFile (file) {
|
|
164
|
+
let fh = null
|
|
165
|
+
try {
|
|
166
|
+
const st = await fs.promises.lstat(file)
|
|
167
|
+
const reason = _untrustedReason(st)
|
|
168
|
+
if (reason) return { reason }
|
|
169
|
+
// O_NOFOLLOW/O_NONBLOCK and the inode check close the gap between lstat and
|
|
170
|
+
// open: a file swapped for a symlink or a FIFO meanwhile is refused.
|
|
171
|
+
fh = await fs.promises.open(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK)
|
|
172
|
+
const fst = await fh.stat()
|
|
173
|
+
if (fst.dev !== st.dev || fst.ino !== st.ino) return { reason: 'changed while being read' }
|
|
174
|
+
const fstReason = _untrustedReason(fst)
|
|
175
|
+
if (fstReason) return { reason: fstReason }
|
|
176
|
+
const text = await fh.readFile('utf8')
|
|
177
|
+
return { keys: text.split(/\s+/).map(_normalizeKey).filter((k) => KEY_HEX.test(k)) }
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err && err.code === 'ELOOP') return { reason: 'is a symlink' }
|
|
180
|
+
return { keys: [] }
|
|
181
|
+
} finally {
|
|
182
|
+
if (fh) await fh.close().catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildFirewall ({ allowedKeys, allowedKeyFiles, logger }) {
|
|
187
|
+
const staticKeys = (allowedKeys || []).map(_normalizeKey).filter((k) => KEY_HEX.test(k))
|
|
188
|
+
const files = allowedKeyFiles || []
|
|
189
|
+
// file -> the reason last warned about, so a bad file warns once, not per connection.
|
|
190
|
+
const warned = new Map()
|
|
191
|
+
|
|
192
|
+
return async (remotePublicKey) => {
|
|
193
|
+
const hex = Buffer.from(remotePublicKey).toString('hex')
|
|
194
|
+
if (staticKeys.includes(hex)) return false
|
|
195
|
+
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
const { keys, reason } = await _readKeyFile(file)
|
|
198
|
+
if (reason) {
|
|
199
|
+
if (warned.get(file) !== reason) {
|
|
200
|
+
warned.set(file, reason)
|
|
201
|
+
logger.warn('ignoring allowed key file %s: it %s', file, reason)
|
|
202
|
+
}
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
warned.delete(file)
|
|
206
|
+
if (keys.includes(hex)) return false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
logger.warn('blocked connection from %s...', hex.slice(0, 16))
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The ServerResponse-like `raw` a stream handler receives: bridges
|
|
215
|
+
// writeHead/write/end onto the `app.stream` channel.
|
|
216
|
+
|
|
217
|
+
class StreamResponse extends EventEmitter {
|
|
218
|
+
constructor (channel) {
|
|
219
|
+
super()
|
|
220
|
+
this._channel = channel
|
|
221
|
+
this._headers = new Map()
|
|
222
|
+
this.statusCode = 200
|
|
223
|
+
this.headersSent = false
|
|
224
|
+
this.writableEnded = false
|
|
225
|
+
this.destroyed = false
|
|
226
|
+
this._closeEmitted = false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
get writableFinished () { return this.writableEnded }
|
|
230
|
+
get finished () { return this.writableEnded }
|
|
231
|
+
|
|
232
|
+
setHeader (name, value) { this._headers.set(String(name).toLowerCase(), value); return this }
|
|
233
|
+
getHeader (name) { return this._headers.get(String(name).toLowerCase()) }
|
|
234
|
+
hasHeader (name) { return this._headers.has(String(name).toLowerCase()) }
|
|
235
|
+
removeHeader (name) { this._headers.delete(String(name).toLowerCase()) }
|
|
236
|
+
getHeaders () { return Object.fromEntries(this._headers) }
|
|
237
|
+
|
|
238
|
+
writeHead (status, headers) {
|
|
239
|
+
if (this.headersSent) return this
|
|
240
|
+
if (Number.isInteger(status)) this.statusCode = status
|
|
241
|
+
if (headers && typeof headers === 'object') {
|
|
242
|
+
for (const [k, v] of Object.entries(headers)) this.setHeader(k, v)
|
|
243
|
+
}
|
|
244
|
+
this.headersSent = true
|
|
245
|
+
this._send(MSG.HEAD, protocol.encode({ statusCode: this.statusCode, headers: this.getHeaders() }))
|
|
246
|
+
return this
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
flushHeaders () {
|
|
250
|
+
if (!this.headersSent) this.writeHead(this.statusCode)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
write (chunk, encoding, cb) {
|
|
254
|
+
if (typeof encoding === 'function') { cb = encoding; encoding = undefined }
|
|
255
|
+
if (this.writableEnded || this.destroyed) return false
|
|
256
|
+
this.flushHeaders()
|
|
257
|
+
const buf = _toBuffer(chunk, encoding)
|
|
258
|
+
// false: the connection's outgoing buffer is full; 'drain' follows.
|
|
259
|
+
const ok = buf.length === 0 ? true : this._send(MSG.CHUNK, buf)
|
|
260
|
+
if (typeof cb === 'function') cb()
|
|
261
|
+
return ok
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
end (chunk, encoding, cb) {
|
|
265
|
+
if (typeof chunk === 'function') { cb = chunk; chunk = undefined; encoding = undefined }
|
|
266
|
+
if (typeof encoding === 'function') { cb = encoding; encoding = undefined }
|
|
267
|
+
if (this.writableEnded) return this
|
|
268
|
+
if (chunk !== undefined && chunk !== null) this.write(chunk, encoding)
|
|
269
|
+
if (this.destroyed) { this.writableEnded = true; return this }
|
|
270
|
+
this.flushHeaders()
|
|
271
|
+
this.writableEnded = true
|
|
272
|
+
this._send(MSG.END, Buffer.alloc(0))
|
|
273
|
+
// protomux keeps order, so the peer sees END before the close.
|
|
274
|
+
this._channel.close()
|
|
275
|
+
if (typeof cb === 'function') cb()
|
|
276
|
+
this.emit('finish')
|
|
277
|
+
return this
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
destroy () {
|
|
281
|
+
if (!this.destroyed) this._channel.close()
|
|
282
|
+
this._markClosed()
|
|
283
|
+
return this
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The channel (or the connection under it) is gone: remote disconnect, our
|
|
287
|
+
// own end(), or runtime shutdown. 'close' fires once, like ServerResponse's.
|
|
288
|
+
_markClosed () {
|
|
289
|
+
this.destroyed = true
|
|
290
|
+
if (this._closeEmitted) return
|
|
291
|
+
this._closeEmitted = true
|
|
292
|
+
this.emit('close')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// protomux's send() returns whether the connection's stream is drained.
|
|
296
|
+
_send (type, buf) {
|
|
297
|
+
if (this.destroyed || this._channel.closed) return false
|
|
298
|
+
try {
|
|
299
|
+
return this._channel.messages[type].send(buf) !== false
|
|
300
|
+
} catch {
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function _toBuffer (chunk, encoding) {
|
|
307
|
+
if (Buffer.isBuffer(chunk)) return chunk
|
|
308
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk)
|
|
309
|
+
if (typeof chunk === 'string') return Buffer.from(chunk, encoding)
|
|
310
|
+
return Buffer.from(String(chunk))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function _pluginReq (req, identity) {
|
|
314
|
+
const r = req && typeof req === 'object' ? req : {}
|
|
315
|
+
return {
|
|
316
|
+
params: r.params || {},
|
|
317
|
+
query: r.query || {},
|
|
318
|
+
body: r.body,
|
|
319
|
+
headers: r.headers || {},
|
|
320
|
+
_info: identity || {}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Runs one app's controllers and serves them over HRPC.
|
|
326
|
+
*
|
|
327
|
+
* Manifest, route table and controllers are loaded synchronously when this is
|
|
328
|
+
* called (so a broken app fails here, not on first request), through the
|
|
329
|
+
* Gateway's plugin loader; the network side only comes up in start(). onReady callbacks run once, after the first
|
|
330
|
+
* successful start(); a stop()/start() cycle does not re-run them.
|
|
331
|
+
*
|
|
332
|
+
* The ambient `config` carries `kernelKey`, `kernelBootstrap` and
|
|
333
|
+
* `kernelKeyPair` (the app's own Kernel identity, derived from a persisted
|
|
334
|
+
* seed), which is what `createMdkClient(config)` reads.
|
|
335
|
+
*/
|
|
336
|
+
function createAppRuntime (opts) {
|
|
337
|
+
opts = opts || {}
|
|
338
|
+
const { name, appDir } = opts
|
|
339
|
+
if (typeof name !== 'string' || !name) throw new Error('ERR_APP_RUNTIME_NAME_REQUIRED')
|
|
340
|
+
if (typeof appDir !== 'string' || !appDir) throw new Error('ERR_APP_RUNTIME_APP_DIR_REQUIRED')
|
|
341
|
+
|
|
342
|
+
const logger = opts.logger || defaultLogger(name)
|
|
343
|
+
const { manifest, dir } = loadAppManifest(appDir)
|
|
344
|
+
|
|
345
|
+
// Controllers load with the ambient config, so identity is derived first.
|
|
346
|
+
const storeDir = opts.storeDir || null
|
|
347
|
+
const seedRpc = _storeSeed(storeDir, 'seedRpc')
|
|
348
|
+
const kernelKeyPair = _kernelKeyPair(_storeSeed(storeDir, 'seedKernelClient'))
|
|
349
|
+
|
|
350
|
+
const kernelKey = Buffer.isBuffer(opts.kernelKey) ? opts.kernelKey.toString('hex') : opts.kernelKey
|
|
351
|
+
|
|
352
|
+
// The app's own writable directory, so relative paths never land in the
|
|
353
|
+
// project root or collide with another app's.
|
|
354
|
+
const dataDir = storeDir ? path.resolve(storeDir, 'data') : null
|
|
355
|
+
if (dataDir) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 })
|
|
356
|
+
|
|
357
|
+
// Operator config wins over the manifest's defaults. Which Kernel to dial, the
|
|
358
|
+
// identity to dial it with and the data dir are the runtime's own: neither
|
|
359
|
+
// layer of config can point the app elsewhere.
|
|
360
|
+
const config = Object.freeze({
|
|
361
|
+
...manifest.config,
|
|
362
|
+
...opts.config,
|
|
363
|
+
kernelKey: kernelKey || null,
|
|
364
|
+
kernelBootstrap: opts.kernelBootstrap || null,
|
|
365
|
+
dataDir,
|
|
366
|
+
kernelKeyPair
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const readyFns = []
|
|
370
|
+
let ready = false
|
|
371
|
+
const runReady = (fn) => {
|
|
372
|
+
const failed = (err) => logger.error('onReady callback failed: %s', err && err.message)
|
|
373
|
+
try {
|
|
374
|
+
const out = fn()
|
|
375
|
+
if (out && typeof out.catch === 'function') out.catch(failed)
|
|
376
|
+
} catch (err) {
|
|
377
|
+
failed(err)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const context = Object.freeze({
|
|
382
|
+
config,
|
|
383
|
+
logger,
|
|
384
|
+
// Runs fn once this runtime is serving; straight away if it already is.
|
|
385
|
+
onReady (fn) {
|
|
386
|
+
if (typeof fn !== 'function') throw new Error('ERR_APP_RUNTIME_ONREADY_FN_REQUIRED')
|
|
387
|
+
if (ready) runReady(fn)
|
|
388
|
+
else readyFns.push(fn)
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const { routes, load } = loadPlugin(resolvePluginDir(dir), context)
|
|
393
|
+
const routeById = new Map(routes.map(r => [r.id, r]))
|
|
394
|
+
|
|
395
|
+
// The app's background logic: `main` (`<file>[#export]`, beside the route
|
|
396
|
+
// table) is awaited with the config before the first start() serves, so no
|
|
397
|
+
// request reaches a controller whose state main has not set up yet.
|
|
398
|
+
const main = manifest.main ? load(manifest.main) : null
|
|
399
|
+
if (main !== null && typeof main !== 'function') throw new Error(`ERR_APP_MAIN_NOT_FUNCTION: ${manifest.main}`)
|
|
400
|
+
let mainDone = !main
|
|
401
|
+
|
|
402
|
+
const firewall = buildFirewall({
|
|
403
|
+
allowedKeys: opts.allowedKeys,
|
|
404
|
+
allowedKeyFiles: opts.allowedKeyFiles,
|
|
405
|
+
logger
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
let rpc = null
|
|
409
|
+
let server = null
|
|
410
|
+
let starting = null
|
|
411
|
+
let stopping = null
|
|
412
|
+
|
|
413
|
+
// Decodes a request envelope and finds its route; throws a protocol error otherwise.
|
|
414
|
+
function parseRequest (buf, wantStream) {
|
|
415
|
+
let msg
|
|
416
|
+
try {
|
|
417
|
+
msg = protocol.decode(buf)
|
|
418
|
+
} catch {
|
|
419
|
+
throw protocol.protocolError(400, ERR.BAD_REQUEST, 'malformed request')
|
|
420
|
+
}
|
|
421
|
+
const bad = protocol.checkRequest(msg)
|
|
422
|
+
if (bad) throw bad
|
|
423
|
+
const route = routeById.get(msg.routeId)
|
|
424
|
+
if (!route) throw protocol.protocolError(404, ERR.ROUTE_NOT_FOUND, msg.routeId)
|
|
425
|
+
if (wantStream && route.stream !== true) throw protocol.protocolError(400, ERR.ROUTE_NOT_STREAM, msg.routeId)
|
|
426
|
+
if (!wantStream && route.stream === true) throw protocol.protocolError(400, ERR.ROUTE_IS_STREAM, msg.routeId)
|
|
427
|
+
return { msg, route }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// An ERR_* failure is the controller's answer; anything else is a bug the
|
|
431
|
+
// caller only sees masked, so it is logged here with its stack.
|
|
432
|
+
function logFailure (routeId, err) {
|
|
433
|
+
if (protocol.isSafeError(err)) logger.debug('route %s failed: %s', routeId, err.message)
|
|
434
|
+
else logger.error('route %s failed: %s', routeId, (err && err.stack) || err)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function onInvoke (buf) {
|
|
438
|
+
let routeId = null
|
|
439
|
+
try {
|
|
440
|
+
const { msg, route } = parseRequest(buf, false)
|
|
441
|
+
routeId = route.id
|
|
442
|
+
const result = await route._handler(_pluginReq(msg.req, msg.identity))
|
|
443
|
+
return protocol.encode(protocol.okResponse(result))
|
|
444
|
+
} catch (err) {
|
|
445
|
+
logFailure(routeId, err)
|
|
446
|
+
return protocol.encode(protocol.toErrorResponse(err))
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Server side of one `app.stream` channel.
|
|
451
|
+
function onStreamChannel (mux) {
|
|
452
|
+
let channel = null
|
|
453
|
+
let raw = null
|
|
454
|
+
|
|
455
|
+
const sendError = (err) => {
|
|
456
|
+
try { channel.messages[MSG.ERROR].send(protocol.encode(protocol.toErrorResponse(err))) } catch {}
|
|
457
|
+
channel.close()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const onRequest = async (buf) => {
|
|
461
|
+
if (raw) return // one request per channel
|
|
462
|
+
let msg, route
|
|
463
|
+
try {
|
|
464
|
+
({ msg, route } = parseRequest(buf, true))
|
|
465
|
+
} catch (err) {
|
|
466
|
+
return sendError(err)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
raw = new StreamResponse(channel)
|
|
470
|
+
try {
|
|
471
|
+
await route._handler(_pluginReq(msg.req, msg.identity), raw)
|
|
472
|
+
} catch (err) {
|
|
473
|
+
// A stream handler owns the response, so a failure is reported in-band
|
|
474
|
+
// while headers are still unsent. Afterwards the channel closes without
|
|
475
|
+
// END, so the caller sees a truncated body as an error, not a success.
|
|
476
|
+
logFailure(route.id, err)
|
|
477
|
+
if (!raw.headersSent) {
|
|
478
|
+
const { status, message } = protocol.toErrorResponse(err)
|
|
479
|
+
raw.writeHead(status, { 'content-type': 'application/json' })
|
|
480
|
+
raw.end(JSON.stringify({ statusCode: status, error: http.STATUS_CODES[status] || 'Bad Request', message }))
|
|
481
|
+
} else if (!raw.writableEnded) {
|
|
482
|
+
raw.destroy()
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Index = STREAM_MESSAGES; only REQUEST is inbound.
|
|
488
|
+
const messages = [
|
|
489
|
+
{ onmessage: onRequest },
|
|
490
|
+
{}, {}, {}, {}
|
|
491
|
+
]
|
|
492
|
+
channel = mux.createChannel({
|
|
493
|
+
protocol: METHODS.STREAM,
|
|
494
|
+
messages,
|
|
495
|
+
ondrain: () => { if (raw && !raw.destroyed) raw.emit('drain') },
|
|
496
|
+
onclose: () => { if (raw) raw._markClosed() }
|
|
497
|
+
})
|
|
498
|
+
if (channel) channel.open()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function _start () {
|
|
502
|
+
if (!mainDone) {
|
|
503
|
+
await main(config, context)
|
|
504
|
+
mainDone = true
|
|
505
|
+
}
|
|
506
|
+
rpc = new HyperswarmRPC({ seed: seedRpc, bootstrap: opts.bootstrap || undefined })
|
|
507
|
+
server = rpc.createServer({ firewall })
|
|
508
|
+
server.respond(METHODS.INVOKE, onInvoke)
|
|
509
|
+
server.on('connection', (conn) => {
|
|
510
|
+
conn.mux.pair({ protocol: METHODS.STREAM }, () => onStreamChannel(conn.mux))
|
|
511
|
+
})
|
|
512
|
+
await server.listen()
|
|
513
|
+
|
|
514
|
+
const publicKey = server.publicKey
|
|
515
|
+
if (opts.keyFile) _writeKeyFile(opts.keyFile, publicKey)
|
|
516
|
+
|
|
517
|
+
// Public by nature, so world-readable but not writable: an operator copies
|
|
518
|
+
// it into the Kernel's auth.whitelist, or it lands in a trusted-keys dir,
|
|
519
|
+
// whose reader skips group/world-writable files.
|
|
520
|
+
try {
|
|
521
|
+
if (opts.kernelKeyFile) _writeKeyFile(opts.kernelKeyFile, kernelKeyPair.publicKey)
|
|
522
|
+
} catch (err) {
|
|
523
|
+
// A failed start must not leave the key the Gateway dials (it would
|
|
524
|
+
// wait out appTimeoutMs for a 502 instead of answering 503 at once).
|
|
525
|
+
if (opts.keyFile) _removeKeyFile(opts.keyFile, publicKey)
|
|
526
|
+
throw err
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
logger.info('app runtime "%s" serving (key %s...)', name, publicKey.toString('hex').slice(0, 16))
|
|
530
|
+
|
|
531
|
+
if (!ready) {
|
|
532
|
+
ready = true
|
|
533
|
+
for (const fn of readyFns.splice(0)) runReady(fn)
|
|
534
|
+
}
|
|
535
|
+
return { publicKey }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function start () {
|
|
539
|
+
if (stopping) await stopping
|
|
540
|
+
// `starting` first: _start() sets `server` before it is listening, so a
|
|
541
|
+
// second start() meanwhile must wait for the first, not report ready.
|
|
542
|
+
if (starting) return starting
|
|
543
|
+
if (server) return { publicKey: server.publicKey }
|
|
544
|
+
starting = _start().catch(async (err) => {
|
|
545
|
+
await _teardown()
|
|
546
|
+
throw err
|
|
547
|
+
}).finally(() => { starting = null })
|
|
548
|
+
return starting
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function _teardown () {
|
|
552
|
+
const s = server
|
|
553
|
+
const r = rpc
|
|
554
|
+
server = null
|
|
555
|
+
rpc = null
|
|
556
|
+
if (s) await s.close().catch(() => {})
|
|
557
|
+
if (r) await r.destroy().catch(() => {})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function stop () {
|
|
561
|
+
if (starting) await starting.catch(() => {})
|
|
562
|
+
if (!stopping) {
|
|
563
|
+
const publicKey = server && server.publicKey
|
|
564
|
+
stopping = _teardown().finally(() => { stopping = null })
|
|
565
|
+
// The Gateway reads a missing key file as "not started" (503) instead of
|
|
566
|
+
// dialling a runtime that is gone.
|
|
567
|
+
if (publicKey && opts.keyFile) _removeKeyFile(opts.keyFile, publicKey)
|
|
568
|
+
}
|
|
569
|
+
return stopping
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
name,
|
|
574
|
+
routes,
|
|
575
|
+
manifest,
|
|
576
|
+
start,
|
|
577
|
+
stop,
|
|
578
|
+
getPublicKey () { return server ? server.publicKey : null },
|
|
579
|
+
getKernelPublicKey () { return Buffer.from(kernelKeyPair.publicKey) }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module.exports = { createAppRuntime, getOrCreateSeed, buildFirewall, _writeKeyFile }
|
package/package.json
CHANGED
|
@@ -1 +1,57 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@tetherto/mdk-app-runtime",
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"description": "MDK app runtime: runs one app's controllers in its own process and serves them over HRPC",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mdk-app-runtime": "bin/run-app.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"tether",
|
|
11
|
+
"mdk",
|
|
12
|
+
"app"
|
|
13
|
+
],
|
|
14
|
+
"author": "Tether",
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"lint": "standard",
|
|
21
|
+
"lint:fix": "standard --fix",
|
|
22
|
+
"test": "npm run lint && npm run test:unit",
|
|
23
|
+
"test:unit": "NODE_ENV=test brittle tests/unit/*.test.js",
|
|
24
|
+
"test:coverage": "NODE_ENV=test brittle --coverage 'tests/**/*.test.js'"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@hyperswarm/rpc": "^3.5.0",
|
|
28
|
+
"@tetherto/mdk-worker": "^0.12.1",
|
|
29
|
+
"debug": "4.4.1",
|
|
30
|
+
"hyperdht": "6.34.1",
|
|
31
|
+
"yaml": "^2.9.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"brittle": "3.18.0",
|
|
35
|
+
"standard": "17.1.0"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/tetherto/mdk.git",
|
|
40
|
+
"directory": "backend/core/app-runtime"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"index.js",
|
|
47
|
+
"lib/",
|
|
48
|
+
"bin/",
|
|
49
|
+
"README.md"
|
|
50
|
+
],
|
|
51
|
+
"exports": {
|
|
52
|
+
".": "./index.js",
|
|
53
|
+
"./lib/plugin-loader": "./lib/plugin-loader.js",
|
|
54
|
+
"./bin/run-app.js": "./bin/run-app.js",
|
|
55
|
+
"./package.json": "./package.json"
|
|
56
|
+
}
|
|
57
|
+
}
|