@tetherto/mdk-core 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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +572 -0
  3. package/index.js +519 -0
  4. package/lib/address-policy.js +124 -0
  5. package/lib/local-discovery.js +321 -0
  6. package/lib/services/actions.service.js +68 -0
  7. package/lib/services/alerts.service.js +207 -0
  8. package/lib/services/comments.service.js +118 -0
  9. package/lib/services/log-history.service.js +307 -0
  10. package/lib/services/logs.service.js +249 -0
  11. package/lib/services/pool-utils/constants.js +20 -0
  12. package/lib/services/pool-utils/time.js +49 -0
  13. package/lib/services/pool.service.js +397 -0
  14. package/lib/services/provisioning.service.js +420 -0
  15. package/lib/services/settings.service.js +26 -0
  16. package/lib/services/snaps.service.js +181 -0
  17. package/lib/services/stats.service.js +205 -0
  18. package/lib/stats/README.md +80 -0
  19. package/lib/stats/index.js +57 -0
  20. package/lib/stats/ops/alerts_aggr.js +40 -0
  21. package/lib/stats/ops/alerts_group_cnt.js +43 -0
  22. package/lib/stats/ops/arr_concat.js +27 -0
  23. package/lib/stats/ops/array_obj_calc.js +41 -0
  24. package/lib/stats/ops/avg.js +42 -0
  25. package/lib/stats/ops/cnt.js +19 -0
  26. package/lib/stats/ops/group.js +38 -0
  27. package/lib/stats/ops/group_avg.js +53 -0
  28. package/lib/stats/ops/group_cnt.js +38 -0
  29. package/lib/stats/ops/group_max.js +40 -0
  30. package/lib/stats/ops/group_multiple_stats.js +40 -0
  31. package/lib/stats/ops/group_sum.js +40 -0
  32. package/lib/stats/ops/index.js +35 -0
  33. package/lib/stats/ops/nested_obj_concat.js +54 -0
  34. package/lib/stats/ops/obj_concat.js +33 -0
  35. package/lib/stats/ops/sum.js +27 -0
  36. package/lib/stats/utils.js +99 -0
  37. package/lib/templates/alerts.js +128 -0
  38. package/lib/templates/stats.js +476 -0
  39. package/lib/things/constants.js +65 -0
  40. package/lib/things/container.js +84 -0
  41. package/lib/things/miner.js +190 -0
  42. package/lib/things/powermeter.js +30 -0
  43. package/lib/things/sensor.js +18 -0
  44. package/lib/things/thing.js +93 -0
  45. package/lib/utils.js +603 -0
  46. package/lib/worker-infra.js +221 -0
  47. package/package.json +64 -1
  48. package/services.js +246 -0
  49. package/utils/compose-yaml.js +56 -0
  50. package/utils/constants.js +30 -0
  51. package/utils/index.js +29 -0
  52. package/utils/initialize.js +148 -0
  53. package/utils/service-bootstrap.js +271 -0
  54. package/worker.js +9 -0
package/index.js ADDED
@@ -0,0 +1,519 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const fs = require('fs')
5
+ const crypto = require('crypto')
6
+ const os = require('os')
7
+ const debug = require('debug')('mdk:lifecycle')
8
+ const { setTimeout: sleep } = require('timers/promises')
9
+ const { KernelManager } = require('@tetherto/mdk-kernel')
10
+ const { MDK_STORE } = require('./utils/constants')
11
+ const initialize = require('./utils/initialize')
12
+ const { startServices } = require('./services')
13
+ const {
14
+ keysDir, discoverWorkerKeys, trustedKeysDir, publishTrustedKey, admitTrustedKeys, KERNEL_KEY_NAME
15
+ } = require('./lib/local-discovery')
16
+
17
+ const defaultRoot = path.join(os.homedir(), '.mdk')
18
+
19
+ // The gateway package, which startGateway boots.
20
+ const GATEWAY_PKG = path.dirname(require.resolve('@tetherto/mdk-gateway/package.json'))
21
+
22
+ // Well-known paths used as defaults when running without explicit configuration.
23
+ // Override via opts.topicFile / opts.keyFile if co-locating multiple instances.
24
+ const DEFAULT_TOPIC_FILE = path.join(defaultRoot, '.dht-topic')
25
+ const DEFAULT_KEY_FILE = path.join(defaultRoot, '.kernel-key')
26
+ // Default rendezvous for caller/worker keys (fail-closed RPC admission); a
27
+ // getKernel() with the default root uses the same dir.
28
+ const DEFAULT_TRUSTED_KEYS_DIR = trustedKeysDir(defaultRoot)
29
+
30
+ function _ensureDirs (...dirs) {
31
+ for (const d of dirs) fs.mkdirSync(d, { recursive: true, mode: 0o700 })
32
+ }
33
+
34
+ // Publish the Kernel HRPC public key to a well-known file so out-of-process
35
+ // clients (gateway, CLIs) can connect without configuration. The file is not
36
+ // removed on shutdown: the key is stable across restarts (seeds persist in the
37
+ // kernel store), so a leftover file stays correct for the same storeDir.
38
+ function _writeKernelKeyFile (kernel, keyFile) {
39
+ const key = kernel.getPublicKey()
40
+ if (!key) return
41
+ _ensureDirs(path.dirname(keyFile))
42
+ fs.writeFileSync(keyFile, key.toString('hex'), 'utf8')
43
+ debug('kernel key file written: %s', keyFile)
44
+ }
45
+
46
+ // Resolve the Kernel HRPC key for startGateway before any boot side effects,
47
+ // so a missing key fails fast. `kernelKey: false` means run without a Kernel
48
+ // connection (mdkClient stays null in the gateway worker).
49
+ function _resolveKernelKey (opts) {
50
+ if (opts.kernelKey === false) return null
51
+ if (opts.kernelKey) return Buffer.isBuffer(opts.kernelKey) ? opts.kernelKey.toString('hex') : opts.kernelKey
52
+ if (opts.kernel && typeof opts.kernel.getPublicKey === 'function') {
53
+ const key = opts.kernel.getPublicKey()
54
+ if (key) return key.toString('hex')
55
+ }
56
+ const keyFile = opts.keyFile || DEFAULT_KEY_FILE
57
+ if (fs.existsSync(keyFile)) {
58
+ const key = fs.readFileSync(keyFile, 'utf8').trim()
59
+ if (key) return key
60
+ }
61
+ throw new Error(`ERR_KERNEL_KEY_FILE_NOT_FOUND: no kernel key at ${keyFile} — start the Kernel first, pass opts.kernelKey/opts.kernel, or set kernelKey: false to run without a Kernel connection`)
62
+ }
63
+
64
+ function _deepMerge (base, override) {
65
+ const result = Object.assign({}, base)
66
+ for (const key of Object.keys(override)) {
67
+ if (
68
+ typeof override[key] === 'object' && override[key] !== null && !Array.isArray(override[key]) &&
69
+ typeof result[key] === 'object' && result[key] !== null && !Array.isArray(result[key])
70
+ ) {
71
+ result[key] = _deepMerge(result[key], override[key])
72
+ } else {
73
+ result[key] = override[key]
74
+ }
75
+ }
76
+ return result
77
+ }
78
+
79
+ /**
80
+ * Write a config file merging example defaults with user overrides.
81
+ * If the dest file already exists and no overrides are given, it is left untouched
82
+ * so user-edited configs remain as authoritative fallbacks.
83
+ */
84
+ function _writeConfigWithOverride (examplePath, destPath, overrides) {
85
+ const hasOverrides = Object.keys(overrides).length > 0
86
+ if (fs.existsSync(destPath) && !hasOverrides) return
87
+
88
+ let base = {}
89
+ let hasBase = false
90
+ if (fs.existsSync(destPath)) {
91
+ try { base = JSON.parse(fs.readFileSync(destPath, 'utf8')); hasBase = true } catch {}
92
+ } else {
93
+ // prefer .example; fall back to plain file (e.g. logging.config.json has no .example)
94
+ const src = fs.existsSync(examplePath) ? examplePath : examplePath.replace(/\.example$/, '')
95
+ if (fs.existsSync(src)) {
96
+ try { base = JSON.parse(fs.readFileSync(src, 'utf8')); hasBase = true } catch {}
97
+ }
98
+ }
99
+
100
+ // No template, no existing file, no overrides → leave the file absent so
101
+ // facilities (e.g. @tetherto/svc-facs-logging) can write their own defaults
102
+ // in init() without being preempted by an empty {} on disk.
103
+ if (!hasBase && !hasOverrides) return
104
+
105
+ fs.writeFileSync(destPath, JSON.stringify(_deepMerge(base, overrides), null, 2), 'utf8')
106
+ }
107
+
108
+ /**
109
+ * Start the Kernel (Orchestration Kernel).
110
+ *
111
+ * When called with no arguments the kernel reads the DHT topic from
112
+ * DEFAULT_TOPIC_FILE (written by a standalone worker boot)
113
+ * and publishes its HRPC public key to DEFAULT_KEY_FILE so clients can
114
+ * connect without any configuration.
115
+ *
116
+ * @param {object} [opts]
117
+ * @param {string} [opts.root] - Data root dir (default: ~/.mdk)
118
+ * @param {string} [opts.storeDir] - Override store dir
119
+ * @param {string} [opts.topic] - DHT topic hex (default: read from topicFile)
120
+ * @param {string} [opts.topicFile] - Path to topic file (default: DEFAULT_TOPIC_FILE)
121
+ * @param {string|boolean} [opts.keyFile] - Path for the HRPC key file (default: DEFAULT_KEY_FILE); `false` to disable
122
+ * @param {object|false} [opts.hrpc] - HRPC listener config, or `false` to disable it. Admission is
123
+ * fail-closed: only listed callers are admitted.
124
+ * @param {Array<string|{key: string, roles: string[]}>} [opts.hrpc.whitelist] - Caller allowlist: a hex
125
+ * DHT public key (full access) or `{ key, roles }` with roles from
126
+ * 'read' | 'write' | 'admin'. Default `[]` = nobody until admitted via
127
+ * kernel.allowKey(), startGateway({ kernel }) or the trusted-keys dir.
128
+ * @param {boolean} [opts.hrpc.allowAll] - Admit every DHT peer that knows the Kernel key. Development
129
+ * only; logs a warning. Never the default.
130
+ * @param {Array} [opts.hrpc.bootstrap] - DHT bootstrap nodes (testnets)
131
+ * @param {string|false} [opts.trustedKeysDir] - Same-machine key rendezvous (default <root>/.trusted-keys).
132
+ * The Kernel publishes kernel.pub (its worker-dialing key,
133
+ * kernel.getClientPublicKey()) there and admits every other
134
+ * `<name>.pub` as a full-access caller. `false` disables both.
135
+ * @param {object} [opts.discovery] - Discovery config: { mode: 'dht' | 'local', dir?, workerKeys?, allowAnyWorker? }.
136
+ * Default 'dht' (Hyperswarm topic; works on one host or
137
+ * across machines). 'local' is a faster same-machine option:
138
+ * the Kernel registers workers by the RPC keys they publish to a
139
+ * shared dir (default: <root>/.worker-keys), skipping the topic.
140
+ * Topic admission is fail-closed: only `workerKeys` (or
141
+ * kernel.allowWorkerKey()) register from the topic, unless
142
+ * `allowAnyWorker: true` (development only; logs a warning).
143
+ * kernel.registerWorker() skips the topic allowlist.
144
+ * @param {boolean} [opts.handleSignals] - Install SIGINT/SIGTERM handlers that drain kernel._cleanup,
145
+ * stop the Kernel and call process.exit(0). Default false
146
+ * (library use): stop it yourself with shutdown(kernel).
147
+ */
148
+ async function getKernel (opts = {}) {
149
+ const root = opts.root || defaultRoot
150
+ const storeDir = opts.storeDir || path.join(root, MDK_STORE, 'kernel-db')
151
+ _ensureDirs(storeDir)
152
+
153
+ const mode = (opts.discovery && opts.discovery.mode) || 'dht'
154
+
155
+ let topic
156
+ let localKeysDir
157
+ if (mode === 'local') {
158
+ localKeysDir = opts.discovery.dir || keysDir(root)
159
+ } else {
160
+ topic = opts.topic
161
+ if (!topic) {
162
+ const topicFile = opts.topicFile || DEFAULT_TOPIC_FILE
163
+ topic = fs.existsSync(topicFile)
164
+ ? fs.readFileSync(topicFile, 'utf8').trim()
165
+ : crypto.randomBytes(32).toString('hex')
166
+ }
167
+ }
168
+
169
+ const conf = { kernel: {} }
170
+ // Propagate `false` explicitly — the manager treats an absent hrpc conf as enabled.
171
+ // An empty whitelist admits nobody (fail closed); callers are added below.
172
+ conf.kernel.hrpc = opts.hrpc === false ? false : { whitelist: [], ...(opts.hrpc || {}) }
173
+ if (opts.telemetryPullMs) conf.kernel.telemetryPullMs = opts.telemetryPullMs
174
+ if (opts.healthPingMs) conf.kernel.healthPingMs = opts.healthPingMs
175
+ if (opts.statePullMs) conf.kernel.statePullMs = opts.statePullMs
176
+ if (opts.actionIntvlMs) conf.kernel.actionIntvlMs = opts.actionIntvlMs
177
+ conf.kernel.discovery = mode === 'local'
178
+ ? { mode: 'local', dir: localKeysDir }
179
+ : { ...(opts.discovery || {}), topic: (opts.discovery && opts.discovery.topic) || topic }
180
+
181
+ const kernel = new KernelManager(conf, { storeDir, root, loadConf: () => {} })
182
+ await kernel.init()
183
+ await kernel.start()
184
+
185
+ if (opts.keyFile !== false) _writeKernelKeyFile(kernel, opts.keyFile || DEFAULT_KEY_FILE)
186
+
187
+ kernel.topic = topic
188
+ kernel._cleanup = []
189
+
190
+ // Trusted-keys rendezvous: publish the key workers must allow, and admit the
191
+ // callers (Gateway, MCP, CLI) that other processes publish.
192
+ kernel.trustedKeysDir = null
193
+ if (opts.trustedKeysDir !== false && kernel.getClientPublicKey()) {
194
+ const dir = opts.trustedKeysDir || trustedKeysDir(root)
195
+ publishTrustedKey(dir, KERNEL_KEY_NAME, kernel.getClientPublicKey())
196
+ const admitter = admitTrustedKeys(kernel, dir)
197
+ kernel._cleanup.push(async () => admitter.stop())
198
+ kernel.trustedKeysDir = dir
199
+ }
200
+
201
+ // Local mode: register workers by the RPC keys they publish to the shared dir,
202
+ // skipping the DHT topic — immediate discovery for same-machine setups.
203
+ if (mode === 'local') {
204
+ const watcher = discoverWorkerKeys(kernel, localKeysDir)
205
+ kernel._cleanup.push(async () => watcher.stop())
206
+ }
207
+
208
+ // Library default: leave the process's signals alone (tear down with
209
+ // shutdown(kernel)). Standalone processes opt in with handleSignals.
210
+ if (opts.handleSignals === true) {
211
+ onShutdown(async () => {
212
+ for (const fn of kernel._cleanup) {
213
+ try { await fn() } catch {}
214
+ }
215
+ await kernel.stop()
216
+ })
217
+ }
218
+
219
+ return kernel
220
+ }
221
+
222
+ /**
223
+ * Start the Kernel with explicit configuration (backward-compatible).
224
+ * Prefer `getKernel()` for new code.
225
+ */
226
+ async function startKernel (opts = {}) {
227
+ return await getKernel(opts)
228
+ }
229
+
230
+ const APP_NAME_RE = /^[a-z][a-z0-9-]*$/
231
+ // A literal path segment: no Fastify `:param` / `*` wildcard, no `.` or `..`.
232
+ const MOUNT_SEGMENT_RE = /^(?!\.\.?$)[A-Za-z0-9._~-]+$/
233
+
234
+ // Checked before any boot side effect. Paths are resolved now: the process may
235
+ // chdir before a request reads them.
236
+ function _normalizeApps (apps = []) {
237
+ if (!Array.isArray(apps)) throw new Error('ERR_GATEWAY_APPS_INVALID: apps must be an array')
238
+ const names = new Set()
239
+ const mounts = new Map()
240
+ return apps.map((app, i) => {
241
+ const { name, appDir, keyFile, mount } = app || {}
242
+ if (typeof name !== 'string' || !APP_NAME_RE.test(name)) throw new Error(`ERR_GATEWAY_APP_NAME_INVALID: apps[${i}].name must match ${APP_NAME_RE}`)
243
+ if (names.has(name)) throw new Error(`ERR_GATEWAY_APP_DUPLICATE: ${name}`)
244
+ names.add(name)
245
+ if (typeof appDir !== 'string' || !appDir) throw new Error(`ERR_GATEWAY_APP_DIR_REQUIRED: ${name}`)
246
+ if (typeof keyFile !== 'string' || !keyFile) throw new Error(`ERR_GATEWAY_APP_KEY_REQUIRED: ${name} needs a keyFile`)
247
+ const out = { name, appDir: path.resolve(appDir), keyFile: path.resolve(keyFile) }
248
+ if (mount != null) {
249
+ if (typeof mount !== 'string' || !mount.startsWith('/')) throw new Error(`ERR_GATEWAY_APP_MOUNT_INVALID: ${name}: mount must start with "/"`)
250
+ const segments = mount.split('/').filter(Boolean)
251
+ if (!segments.every((s) => MOUNT_SEGMENT_RE.test(s))) {
252
+ throw new Error(`ERR_GATEWAY_APP_MOUNT_INVALID: ${name}: mount "${mount}" must be literal path segments (no ":", "*", "." or "..")`)
253
+ }
254
+ out.mount = '/' + segments.join('/')
255
+ }
256
+ const effective = out.mount || `/apps/${name}`
257
+ if (mounts.has(effective) && effective !== '/') {
258
+ throw new Error(`ERR_GATEWAY_APP_MOUNT_DUPLICATE: ${name} and ${mounts.get(effective)} are both mounted at ${effective}`)
259
+ }
260
+ mounts.set(effective, name)
261
+ return out
262
+ })
263
+ }
264
+
265
+ /**
266
+ * Start the gateway HTTP server programmatically.
267
+ *
268
+ * Writes config files under `opts.root` using example defaults deep-merged
269
+ * with any overrides supplied in opts. Files that already exist and have no
270
+ * corresponding override are left untouched — they remain user-editable
271
+ * fallbacks, consistent with the rest of the MDK architecture.
272
+ *
273
+ * @param {object} [opts]
274
+ * @param {string} [opts.root] - Config/data root dir (default: ~/.mdk/gateway)
275
+ * @param {number} [opts.port] - HTTP port (default: 3000)
276
+ * @param {string} [opts.env] - Environment string (default: 'development')
277
+ * @param {string} [opts.tmpdir] - Isolate the corestore under this dir instead of a
278
+ * CWD-relative path (defaults to root when env==='test')
279
+ * @param {object} [opts.kernel] - Kernel instance; cleanup is registered on opts.kernel._cleanup
280
+ * @param {*} [opts.kernelKey] - Kernel HRPC listener public key (hex/Buffer); `false` to run
281
+ * without a Kernel connection. Default: resolved from opts.kernel,
282
+ * then the key file.
283
+ * @param {string} [opts.keyFile] - Key file to resolve the Kernel key from (default: DEFAULT_KEY_FILE)
284
+ * @param {Array} [opts.bootstrap] - DHT bootstrap nodes for the Client and the app runtime client (testnets)
285
+ * @param {Array} [opts.apps] - The apps this gateway mounts — all of them. Each runs in its own
286
+ * runtime process (@tetherto/mdk-app-runtime); the gateway reads only
287
+ * its route table and forwards every request over HRPC.
288
+ * Entry: `{ name, appDir, keyFile, mount? }` — `keyFile` is where the
289
+ * runtime publishes its key (re-read per request; 503 until then),
290
+ * `mount` defaults to `/apps/<name>` (`/` mounts at the root).
291
+ * @param {string} [opts.gatewayKeyFile] - Where the gateway writes its runtime-client public key once
292
+ * serving: the key app runtimes allow-list (`allowedKeyFiles`).
293
+ * @param {object} [opts.common] - Overrides for common.json
294
+ * @param {object} [opts.httpd] - Overrides for httpd.config.json
295
+ * @param {object} [opts.net] - Overrides for net.config.json
296
+ * @param {object} [opts.store] - Overrides for store.config.json
297
+ * @param {object} [opts.logging] - Overrides for logging.config.json
298
+ * @param {Array} [opts.additionalRoutes] - Extra Fastify routes to register
299
+ * @param {Function} [opts.authVerifier] - `async (request, route) => identity | false`. Gates every
300
+ * app route whose manifest says `auth: true` (401 when absent
301
+ * or falsy); the identity becomes the controller's `req._info`.
302
+ * Routes without `auth: true` stay open.
303
+ * @param {string[]} [opts.allowedHosts] - Extra Host header values to serve (`name` or `name:port`);
304
+ * loopback on `port` is always allowed. `['*']` disables the check.
305
+ * @param {*} [opts.trustProxy] - Fastify trustProxy (default: false)
306
+ * @param {number} [opts.bodyLimit] - Max request body in bytes (default: 1048576)
307
+ * @param {object|false} [opts.rateLimit] - @fastify/rate-limit options (default: 300 per minute per IP);
308
+ * false disables
309
+ * @param {object|false} [opts.helmet] - @fastify/helmet options; false disables
310
+ * @param {string|false} [opts.trustedKeysDir] - Where to publish the gateway's Kernel/Worker client key
311
+ * (status.rpcClientKey) as `<trustedKeyName>.pub`, so a Kernel and
312
+ * workers in other processes admit it. Default: opts.kernel's dir,
313
+ * else DEFAULT_TRUSTED_KEYS_DIR. `false` disables. With opts.kernel
314
+ * the key is also admitted in-process (kernel.allowKey).
315
+ * @param {string} [opts.trustedKeyName] - File name for that key (default 'gateway')
316
+ * @param {boolean} [opts.handleSignals] - Standalone gateway (no opts.kernel) only: install
317
+ * SIGINT/SIGTERM handlers that stop it and call
318
+ * process.exit(0). Default false — use shutdown(hnd).
319
+ * @returns {Promise<object>} WrkServerHttp instance (already started)
320
+ */
321
+ async function startGateway (opts = {}) {
322
+ if (opts.extraPluginDirs !== undefined) {
323
+ throw new Error('ERR_GATEWAY_EXTRA_PLUGIN_DIRS_UNSUPPORTED: extraPluginDirs is not supported; run each app with @tetherto/mdk-app-runtime and pass it in apps: [{ name, appDir, keyFile, mount? }]')
324
+ }
325
+ const apps = _normalizeApps(opts.apps)
326
+ const kernelKey = _resolveKernelKey(opts)
327
+ const root = opts.root || path.join(defaultRoot, 'gateway')
328
+ const facsDir = path.join(root, 'config', 'facs')
329
+ const facsExampleDir = path.join(GATEWAY_PKG, 'config', 'facs')
330
+
331
+ _ensureDirs(facsDir, path.join(root, 'db'), path.join(root, 'status'), path.join(root, 'store'), path.join(root, 'workers'))
332
+
333
+ // Stub so that code paths using bfx-svc-boot-js worker path resolution still work
334
+ const wrkStubPath = path.join(root, 'workers', 'http.node.wrk.js')
335
+ if (!fs.existsSync(wrkStubPath)) {
336
+ fs.writeFileSync(
337
+ wrkStubPath,
338
+ `'use strict'\nmodule.exports = require(${JSON.stringify(path.join(GATEWAY_PKG, 'workers', 'http.node.wrk'))})\n`
339
+ )
340
+ }
341
+
342
+ _writeConfigWithOverride(
343
+ path.join(GATEWAY_PKG, 'config', 'common.json.example'),
344
+ path.join(root, 'config', 'common.json'),
345
+ opts.common || {}
346
+ )
347
+
348
+ const facMappings = [
349
+ ['httpd.config.json', opts.httpd || {}],
350
+ ['net.config.json', opts.net || {}],
351
+ ['store.config.json', opts.store || {}],
352
+ ['logging.config.json', opts.logging || {}]
353
+ ]
354
+
355
+ for (const [filename, overrides] of facMappings) {
356
+ _writeConfigWithOverride(
357
+ path.join(facsExampleDir, `${filename}.example`),
358
+ path.join(facsDir, filename),
359
+ overrides
360
+ )
361
+ }
362
+
363
+ // require() resolves relative to the WrkServerHttp file itself so its dependencies
364
+ // in gateway/node_modules are found by Node's normal resolution algorithm.
365
+ const WrkServerHttp = require('@tetherto/mdk-gateway/workers/http.node.wrk')
366
+
367
+ const ctx = {
368
+ root,
369
+ wtype: 'wrk-node-http',
370
+ env: opts.env || 'development',
371
+ port: opts.port || 3000,
372
+ shard: opts.shard || 0
373
+ }
374
+ // The gateway keeps its corestore at `<tmpdir || root>/store/http`, never
375
+ // relative to the working directory, so gateways sharing a working dir don't
376
+ // contend for one RocksDB lock (ERR: "File descriptor could not be locked" →
377
+ // a half-open corestore that later surfaces as "Corestore is closed"). Honour
378
+ // an explicit tmpdir, and in test env default it to root so each instance is
379
+ // hermetic under its own data dir.
380
+ if (opts.tmpdir) ctx.tmpdir = opts.tmpdir
381
+ else if (ctx.env === 'test') ctx.tmpdir = root
382
+ if (opts.kernel) ctx.kernel = opts.kernel
383
+ if (kernelKey) ctx.kernelKey = kernelKey
384
+ if (opts.bootstrap) ctx.kernelBootstrap = opts.bootstrap
385
+ if (apps.length) ctx.apps = apps
386
+ if (opts.gatewayKeyFile) ctx.gatewayKeyFile = path.resolve(opts.gatewayKeyFile)
387
+ if (opts.additionalRoutes) ctx.additionalRoutes = opts.additionalRoutes
388
+ // HTTP hardening (see @tetherto/mdk-gateway workers/lib/security.js); unset
389
+ // keys fall back to common.json, then to the gateway's safe defaults.
390
+ for (const key of ['authVerifier', 'allowedHosts', 'trustProxy', 'bodyLimit', 'rateLimit', 'helmet']) {
391
+ if (opts[key] !== undefined) ctx[key] = opts[key]
392
+ }
393
+
394
+ // WrkServerHttp constructor calls this.init() + this.start() automatically.
395
+ // bfx-wrk-base emits 'started' (via process.nextTick) when start() completes.
396
+ const hnd = new WrkServerHttp({}, ctx)
397
+ await new Promise((resolve) => hnd.once('started', resolve))
398
+
399
+ _trustGatewayKey(hnd, opts)
400
+
401
+ if (opts.kernel && Array.isArray(opts.kernel._cleanup)) {
402
+ opts.kernel._cleanup.push(() => new Promise((resolve) => hnd.stop(resolve)))
403
+ } else if (!opts.kernel && opts.handleSignals === true) {
404
+ // Standalone gateway that opted in: own SIGINT/SIGTERM. Otherwise the
405
+ // caller stops it via shutdown(hnd) / hnd.stop().
406
+ onShutdown(() => new Promise((resolve) => hnd.stop(resolve)))
407
+ }
408
+
409
+ return hnd
410
+ }
411
+
412
+ // The Kernel and workers are fail-closed, so the gateway's client key (the
413
+ // one plugin clients and worker clients dial with) must be admitted: directly
414
+ // on an in-process Kernel (which mirrors it onto in-process workers, see
415
+ // trustKernel), and through the trusted-keys dir for other processes.
416
+ function _trustGatewayKey (hnd, opts) {
417
+ const key = hnd.status && hnd.status.rpcClientKey
418
+ if (!key) return
419
+ if (opts.kernel && typeof opts.kernel.allowKey === 'function') opts.kernel.allowKey(key)
420
+ if (opts.trustedKeysDir === false) return
421
+ const dir = opts.trustedKeysDir || (opts.kernel && opts.kernel.trustedKeysDir) ||
422
+ (opts.kernel ? null : DEFAULT_TRUSTED_KEYS_DIR)
423
+ if (dir) publishTrustedKey(dir, opts.trustedKeyName || 'gateway', key)
424
+ }
425
+
426
+ // Wait until the registry holds `minWorkers` READY workers (with ≥1 device unless
427
+ // requireDevices is false); return the worker list (current list on timeout).
428
+ // Back-compat: a numeric second arg is read as timeoutMs.
429
+ async function waitForDiscovery (kernel, opts = {}) {
430
+ const o = (typeof opts === 'number') ? { timeoutMs: opts } : opts
431
+ const { minWorkers = 1, requireDevices = true, timeoutMs = 30000, intervalMs = 500 } = o
432
+ const ready = () => kernel.registry.listWorkers()
433
+ .filter(w => w.state === 'READY' && (!requireDevices || (w.deviceIds || []).length > 0))
434
+ const start = Date.now()
435
+ while (Date.now() - start < timeoutMs) {
436
+ if (ready().length >= minWorkers) return kernel.registry.listWorkers()
437
+ await sleep(intervalMs)
438
+ }
439
+ return kernel.registry.listWorkers()
440
+ }
441
+
442
+ // Run cleanupFn once on the first of `signals`, then exit. The unref'd force
443
+ // timer bounds a hung cleanup. Idempotent; returns the handler so tests can
444
+ // invoke or removeListener it.
445
+ function onShutdown (cleanupFn, { signals = ['SIGINT', 'SIGTERM'], forceMs = 3000 } = {}) {
446
+ let ran = false
447
+ const handler = async () => {
448
+ if (ran) return
449
+ ran = true
450
+ const force = setTimeout(() => process.exit(0), forceMs).unref()
451
+ try { await cleanupFn() } catch (err) { debug('shutdown cleanup error: %s', err && err.message) }
452
+ clearTimeout(force)
453
+ process.exit(0)
454
+ }
455
+ for (const sig of signals) process.once(sig, handler)
456
+ return handler
457
+ }
458
+
459
+ // Tear down any boot handle without the caller knowing its shape: drain
460
+ // _cleanup, then stop via the handle's top-level stop() (kernel/gateway/
461
+ // runtime worker). Idempotent and partial-handle safe.
462
+ async function shutdown (handle) {
463
+ if (!handle || handle.__mdkShutdownDone) return
464
+
465
+ if (Array.isArray(handle._cleanup)) {
466
+ for (const fn of handle._cleanup) {
467
+ try { await fn() } catch (err) { debug('cleanup error: %s', err && err.message) }
468
+ }
469
+ }
470
+
471
+ if (typeof handle.stop === 'function') {
472
+ // kernel.stop() resolves a promise; gateway hnd.stop(cb) calls back.
473
+ await new Promise((resolve) => {
474
+ const r = handle.stop(resolve)
475
+ if (r && typeof r.then === 'function') r.then(resolve, resolve)
476
+ })
477
+ }
478
+
479
+ handle.__mdkShutdownDone = true
480
+ }
481
+
482
+ module.exports = {
483
+ initialize,
484
+ getKernel,
485
+ startKernel,
486
+ startGateway,
487
+ waitForDiscovery,
488
+ onShutdown,
489
+ shutdown,
490
+ DEFAULT_TOPIC_FILE,
491
+ DEFAULT_KEY_FILE,
492
+ DEFAULT_TRUSTED_KEYS_DIR,
493
+ startServices,
494
+
495
+ // Worker-side infra: injectable services, device layer, and templates
496
+ LogsService: require('./lib/services/logs.service'),
497
+ LogHistoryService: require('./lib/services/log-history.service'),
498
+ SettingsService: require('./lib/services/settings.service'),
499
+ CommentsService: require('./lib/services/comments.service'),
500
+ AlertsService: require('./lib/services/alerts.service'),
501
+ StatsService: require('./lib/services/stats.service'),
502
+ SnapsService: require('./lib/services/snaps.service'),
503
+ ActionsService: require('./lib/services/actions.service'),
504
+ DeviceProvisioningService: require('./lib/services/provisioning.service'),
505
+ PoolService: require('./lib/services/pool.service'),
506
+ Thing: require('./lib/things/thing'),
507
+ Miner: require('./lib/things/miner'),
508
+ Container: require('./lib/things/container'),
509
+ PowerMeter: require('./lib/things/powermeter'),
510
+ Sensor: require('./lib/things/sensor'),
511
+ constants: require('./lib/things/constants'),
512
+ templates: {
513
+ alerts: require('./lib/templates/alerts'),
514
+ stats: require('./lib/templates/stats')
515
+ },
516
+ // Telemetry aggregation ops (applyStats/tallyStats, groupBy, getVal)
517
+ libStats: require('./lib/stats'),
518
+ utils: require('./lib/utils')
519
+ }
@@ -0,0 +1,124 @@
1
+ 'use strict'
2
+
3
+ const net = require('net')
4
+ const dns = require('dns')
5
+
6
+ // Device addresses come from remote registerThing/updateThing callers and the
7
+ // worker later dials them with the stored credentials, so an unchecked
8
+ // address is both an SSRF primitive and a way to redirect device passwords.
9
+ // Default policy: refuse loopback, unspecified, link-local (which covers the
10
+ // 169.254.169.254 cloud metadata endpoint), multicast/broadcast and the other
11
+ // well-known metadata addresses. An operator CIDR allowlist replaces the
12
+ // default policy entirely: when set, only addresses inside it are accepted.
13
+
14
+ const BLOCKED_V4 = [
15
+ ['0.0.0.0', 8], // unspecified / "this network"
16
+ ['127.0.0.0', 8], // loopback
17
+ ['169.254.0.0', 16], // link-local, incl. 169.254.169.254 metadata
18
+ ['100.100.100.200', 32], // Alibaba Cloud metadata
19
+ ['224.0.0.0', 4], // multicast
20
+ ['255.255.255.255', 32] // broadcast
21
+ ]
22
+ const BLOCKED_V6 = [
23
+ ['::', 128], // unspecified
24
+ ['::1', 128], // loopback
25
+ ['fe80::', 10], // link-local
26
+ ['fd00:ec2::254', 128], // AWS IPv6 metadata
27
+ ['ff00::', 8] // multicast
28
+ ]
29
+ const BLOCKED_HOSTNAMES = /^(localhost|.*\.localhost|metadata|metadata\.google\.internal|instance-data|instance-data\.ec2\.internal)\.?$/i
30
+ const HOSTNAME_RE = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.?$/i
31
+
32
+ const DEFAULT_BLOCKLIST = new net.BlockList()
33
+ for (const [ip, prefix] of BLOCKED_V4) DEFAULT_BLOCKLIST.addSubnet(ip, prefix, 'ipv4')
34
+ for (const [ip, prefix] of BLOCKED_V6) DEFAULT_BLOCKLIST.addSubnet(ip, prefix, 'ipv6')
35
+
36
+ function _ipType (ip) {
37
+ const v = net.isIP(ip)
38
+ return v === 4 ? 'ipv4' : v === 6 ? 'ipv6' : null
39
+ }
40
+
41
+ // ::ffff:a.b.c.d must be judged by its embedded IPv4 address.
42
+ function _normalizeIp (ip) {
43
+ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip)
44
+ return m ? m[1] : ip
45
+ }
46
+
47
+ /**
48
+ * Builds a net.BlockList from CIDR strings ("10.0.0.0/8", "fd00::/8", or a
49
+ * bare IP). Throws ERR_ADDRESS_ALLOWLIST_INVALID on a malformed entry.
50
+ */
51
+ function buildCidrList (cidrs) {
52
+ const list = new net.BlockList()
53
+ for (const entry of cidrs) {
54
+ if (typeof entry !== 'string') throw new Error('ERR_ADDRESS_ALLOWLIST_INVALID')
55
+ const [ip, prefixStr] = entry.trim().split('/')
56
+ const type = _ipType(ip)
57
+ if (!type) throw new Error(`ERR_ADDRESS_ALLOWLIST_INVALID: ${entry}`)
58
+ const max = type === 'ipv4' ? 32 : 128
59
+ const prefix = prefixStr === undefined ? max : Number(prefixStr)
60
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > max) {
61
+ throw new Error(`ERR_ADDRESS_ALLOWLIST_INVALID: ${entry}`)
62
+ }
63
+ list.addSubnet(ip, prefix, type)
64
+ }
65
+ return list
66
+ }
67
+
68
+ function _isIpAllowed (ip, allowlist) {
69
+ const addr = _normalizeIp(ip)
70
+ const type = _ipType(addr)
71
+ if (!type) return false
72
+ if (allowlist) return allowlist.check(addr, type)
73
+ return !DEFAULT_BLOCKLIST.check(addr, type)
74
+ }
75
+
76
+ /**
77
+ * Validates a device `port`: an integer 1..65535 (a numeric string is
78
+ * accepted). Returns the port as a number; undefined passes through.
79
+ */
80
+ function validatePort (port) {
81
+ if (port === undefined || port === null) return port
82
+ const n = typeof port === 'string' && /^\d+$/.test(port) ? Number(port) : port
83
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error('ERR_THING_PORT_INVALID')
84
+ return n
85
+ }
86
+
87
+ /**
88
+ * Validates a device `address` against the policy. `opts.allowedCidrs`
89
+ * (array of CIDR strings) replaces the default blocklist with a positive
90
+ * allowlist. Hostnames are resolved (opts.lookup, default dns.promises.lookup)
91
+ * and every resolved address must pass. Throws ERR_THING_ADDRESS_*.
92
+ */
93
+ async function validateAddress (address, opts = {}) {
94
+ if (typeof address !== 'string' || !address.length || address.length > 253) {
95
+ throw new Error('ERR_THING_ADDRESS_INVALID')
96
+ }
97
+ const allowlist = Array.isArray(opts.allowedCidrs) && opts.allowedCidrs.length
98
+ ? buildCidrList(opts.allowedCidrs)
99
+ : null
100
+
101
+ if (_ipType(address)) {
102
+ if (!_isIpAllowed(address, allowlist)) throw new Error(`ERR_THING_ADDRESS_FORBIDDEN: ${address}`)
103
+ return address
104
+ }
105
+
106
+ if (!HOSTNAME_RE.test(address)) throw new Error('ERR_THING_ADDRESS_INVALID')
107
+ if (!allowlist && BLOCKED_HOSTNAMES.test(address)) throw new Error(`ERR_THING_ADDRESS_FORBIDDEN: ${address}`)
108
+
109
+ const lookup = opts.lookup || ((host) => dns.promises.lookup(host, { all: true, verbatim: true }))
110
+ let resolved
111
+ try {
112
+ resolved = await lookup(address)
113
+ } catch (e) {
114
+ throw new Error(`ERR_THING_ADDRESS_UNRESOLVABLE: ${address}`)
115
+ }
116
+ const ips = (Array.isArray(resolved) ? resolved : [resolved]).map((r) => (typeof r === 'string' ? r : r && r.address))
117
+ if (!ips.length) throw new Error(`ERR_THING_ADDRESS_UNRESOLVABLE: ${address}`)
118
+ for (const ip of ips) {
119
+ if (!_isIpAllowed(ip, allowlist)) throw new Error(`ERR_THING_ADDRESS_FORBIDDEN: ${address} -> ${ip}`)
120
+ }
121
+ return address
122
+ }
123
+
124
+ module.exports = { validateAddress, validatePort, buildCidrList }