@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.
- package/LICENSE +202 -0
- package/README.md +572 -0
- package/index.js +519 -0
- package/lib/address-policy.js +124 -0
- package/lib/local-discovery.js +321 -0
- package/lib/services/actions.service.js +68 -0
- package/lib/services/alerts.service.js +207 -0
- package/lib/services/comments.service.js +118 -0
- package/lib/services/log-history.service.js +307 -0
- package/lib/services/logs.service.js +249 -0
- package/lib/services/pool-utils/constants.js +20 -0
- package/lib/services/pool-utils/time.js +49 -0
- package/lib/services/pool.service.js +397 -0
- package/lib/services/provisioning.service.js +420 -0
- package/lib/services/settings.service.js +26 -0
- package/lib/services/snaps.service.js +181 -0
- package/lib/services/stats.service.js +205 -0
- package/lib/stats/README.md +80 -0
- package/lib/stats/index.js +57 -0
- package/lib/stats/ops/alerts_aggr.js +40 -0
- package/lib/stats/ops/alerts_group_cnt.js +43 -0
- package/lib/stats/ops/arr_concat.js +27 -0
- package/lib/stats/ops/array_obj_calc.js +41 -0
- package/lib/stats/ops/avg.js +42 -0
- package/lib/stats/ops/cnt.js +19 -0
- package/lib/stats/ops/group.js +38 -0
- package/lib/stats/ops/group_avg.js +53 -0
- package/lib/stats/ops/group_cnt.js +38 -0
- package/lib/stats/ops/group_max.js +40 -0
- package/lib/stats/ops/group_multiple_stats.js +40 -0
- package/lib/stats/ops/group_sum.js +40 -0
- package/lib/stats/ops/index.js +35 -0
- package/lib/stats/ops/nested_obj_concat.js +54 -0
- package/lib/stats/ops/obj_concat.js +33 -0
- package/lib/stats/ops/sum.js +27 -0
- package/lib/stats/utils.js +99 -0
- package/lib/templates/alerts.js +128 -0
- package/lib/templates/stats.js +476 -0
- package/lib/things/constants.js +65 -0
- package/lib/things/container.js +84 -0
- package/lib/things/miner.js +190 -0
- package/lib/things/powermeter.js +30 -0
- package/lib/things/sensor.js +18 -0
- package/lib/things/thing.js +93 -0
- package/lib/utils.js +603 -0
- package/lib/worker-infra.js +221 -0
- package/package.json +64 -1
- package/services.js +246 -0
- package/utils/compose-yaml.js +56 -0
- package/utils/constants.js +30 -0
- package/utils/index.js +29 -0
- package/utils/initialize.js +148 -0
- package/utils/service-bootstrap.js +271 -0
- 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 }
|