@tetherto/mdk-gateway 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 +306 -0
- package/config/common.json.example +15 -0
- package/config/facs/httpd.config.json.example +4 -0
- package/config/facs/net.config.json.example +5 -0
- package/config/facs/store.config.json.example +0 -0
- package/package.json +69 -1
- package/plugin.js +16 -0
- package/worker.js +5 -0
- package/workers/http.node.wrk.js +175 -0
- package/workers/lib/constants.js +7 -0
- package/workers/lib/logger.js +70 -0
- package/workers/lib/plugin-adapter.js +189 -0
- package/workers/lib/plugin-gateway.js +67 -0
- package/workers/lib/plugin-loader.js +6 -0
- package/workers/lib/security.js +252 -0
- package/workers/lib/server/lib/cachedRoute.js +48 -0
- package/workers/lib/server/lib/send200.js +9 -0
- package/workers/lib/utils.js +26 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const async = require('async')
|
|
7
|
+
const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether')
|
|
8
|
+
const createLogger = require('debug')
|
|
9
|
+
const debug = createLogger('store:aggr')
|
|
10
|
+
const { createRuntimeClient, getOrCreateSeed, resolvePluginDir } = require('@tetherto/mdk-app-runtime')
|
|
11
|
+
const { loadPlugin } = require('@tetherto/mdk-app-runtime/lib/plugin-loader')
|
|
12
|
+
const { buildFastifyRoutes, errorResponse, resolveAppTimeout } = require('./lib/plugin-adapter')
|
|
13
|
+
const { fastifyLoggerOptions, gatewayLogger } = require('./lib/logger')
|
|
14
|
+
const { resolveSecurityOptions, buildHostAllowlist, createHostCheck } = require('./lib/security')
|
|
15
|
+
const { resolveStoreDir } = require('./lib/utils')
|
|
16
|
+
|
|
17
|
+
// Publishes the key app runtimes allow-list. The directory is private to this
|
|
18
|
+
// user and the file is not group/other-writable (runtimes refuse one that is),
|
|
19
|
+
// and it is replaced atomically so a runtime never reads half a key.
|
|
20
|
+
function writeGatewayKeyFile (file, key) {
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
22
|
+
const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(tmp, key, { mode: 0o644, flag: 'wx' })
|
|
25
|
+
fs.chmodSync(tmp, 0o644) // the umask may have narrowed it; never widened past 0o644
|
|
26
|
+
fs.renameSync(tmp, file)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
29
|
+
throw err
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class WrkServerHttp extends TetherWrkBase {
|
|
34
|
+
constructor (conf, ctx) {
|
|
35
|
+
super(conf, ctx)
|
|
36
|
+
|
|
37
|
+
if (!ctx.port) {
|
|
38
|
+
throw new Error('ERR_HTTP_PORT_INVALID')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.storeDir = 'http'
|
|
42
|
+
this.prefix = `${this.wtype}-${ctx.port}`
|
|
43
|
+
this.queuedRequests = new Map()
|
|
44
|
+
|
|
45
|
+
this.init()
|
|
46
|
+
this.start()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
init () {
|
|
50
|
+
super.init()
|
|
51
|
+
|
|
52
|
+
const storeFac = (this.conf.init?.facilities || []).find(([, mod, name]) => mod === '@tetherto/hp-svc-facs-store' && name === 's0')
|
|
53
|
+
if (storeFac) storeFac[4] = { ...storeFac[4], storeDir: resolveStoreDir(storeFac[4].storeDir, this.ctx) }
|
|
54
|
+
|
|
55
|
+
// Validated up front so a bad option fails the boot, not the first request.
|
|
56
|
+
this._security = resolveSecurityOptions(this.conf, this.ctx)
|
|
57
|
+
resolveAppTimeout(this.conf)
|
|
58
|
+
|
|
59
|
+
this.setInitFacs([
|
|
60
|
+
['fac', '@bitfinex/bfx-facs-lru', '10s', '10s', { max: 10000, maxAge: 10000 }],
|
|
61
|
+
['fac', '@bitfinex/bfx-facs-lru', '15s', '15s', { max: 10000, maxAge: 15000 }],
|
|
62
|
+
['fac', '@bitfinex/bfx-facs-lru', '30s', '30s', { max: 10000, maxAge: 30000 }],
|
|
63
|
+
['fac', '@bitfinex/bfx-facs-lru', '1m', '1m', { max: 10000, maxAge: 60000 }],
|
|
64
|
+
['fac', '@bitfinex/bfx-facs-lru', '15m', '15m', { max: 10000, maxAge: 60000 * 15 }],
|
|
65
|
+
['fac', '@tetherto/svc-facs-httpd', 'h0', 'h0', {
|
|
66
|
+
// Static serving is opt-in: only when common.json sets staticRootPath.
|
|
67
|
+
staticRootPath: this.conf.staticRootPath,
|
|
68
|
+
staticOn404File: 'index.html',
|
|
69
|
+
port: this.ctx.port,
|
|
70
|
+
logger: fastifyLoggerOptions(this.conf),
|
|
71
|
+
// No GET /echo: the gateway serves only what its plugins and caller declare.
|
|
72
|
+
addDefaultRoutes: false,
|
|
73
|
+
trustProxy: this._security.trustProxy,
|
|
74
|
+
bodyLimit: this._security.bodyLimit
|
|
75
|
+
}, 0]
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
// ctx.apps (`{ name, appDir, keyFile, mount? }`) is the complete inventory
|
|
79
|
+
// of a gateway's app routes. Each app runs in its own runtime process: only
|
|
80
|
+
// its route table is read here, and every request is forwarded over HRPC.
|
|
81
|
+
this._apps = (this.ctx.apps || []).map(app => ({
|
|
82
|
+
...app,
|
|
83
|
+
routes: loadPlugin(resolvePluginDir(app.appDir), undefined, { handlers: false }).routes
|
|
84
|
+
}))
|
|
85
|
+
this._runtimeClient = null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
debugGeneric (msg) {
|
|
89
|
+
debug(`[HTTP/${this.ctx.shard}]`, ...arguments)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_start (cb) {
|
|
93
|
+
async.series([
|
|
94
|
+
next => { super._start(next) },
|
|
95
|
+
async () => {
|
|
96
|
+
await this.net_r0.startRpcServer()
|
|
97
|
+
|
|
98
|
+
const httpd = this.httpd_h0
|
|
99
|
+
const security = this._security
|
|
100
|
+
const log = gatewayLogger(this.conf)
|
|
101
|
+
|
|
102
|
+
// Registered before any route so their onRoute/onRequest hooks cover
|
|
103
|
+
// every plugin and additional route.
|
|
104
|
+
if (security.helmet) httpd.addPlugin([require('@fastify/helmet'), security.helmet])
|
|
105
|
+
else log.warn('security headers (helmet) are disabled')
|
|
106
|
+
if (security.rateLimit) httpd.addPlugin([require('@fastify/rate-limit'), security.rateLimit])
|
|
107
|
+
else log.warn('rate limiting is disabled')
|
|
108
|
+
|
|
109
|
+
const hostAllowlist = buildHostAllowlist(security.allowedHosts, {
|
|
110
|
+
port: String(this.ctx.port),
|
|
111
|
+
host: httpd.opts.host || httpd.conf?.host
|
|
112
|
+
})
|
|
113
|
+
if (hostAllowlist) httpd.addHook('onRequest', createHostCheck(hostAllowlist))
|
|
114
|
+
else log.warn('allowedHosts contains "*": the Host check is off, so DNS rebinding is not blocked')
|
|
115
|
+
|
|
116
|
+
this.ctx.additionalRoutes?.forEach(r => {
|
|
117
|
+
httpd.addRoute(r)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (this._apps.length || this.ctx.gatewayKeyFile) {
|
|
121
|
+
// One stable key, across restarts, for every app runtime to allow-list.
|
|
122
|
+
const seed = getOrCreateSeed(path.join(this.ctx.root, 'identity', 'seedRuntimeClient'))
|
|
123
|
+
this._runtimeClient = createRuntimeClient({ seed, bootstrap: this.ctx.kernelBootstrap })
|
|
124
|
+
await this._runtimeClient.connect()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const app of this._apps) {
|
|
128
|
+
buildFastifyRoutes(app, this._runtimeClient, this, { authVerifier: security.authVerifier }).forEach(r => {
|
|
129
|
+
httpd.addRoute(r)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!security.authVerifier) {
|
|
134
|
+
const closed = this._apps.flatMap(a => a.routes.filter(r => r.auth === true).map(r => r.id))
|
|
135
|
+
if (closed.length) log.warn(`no authVerifier configured: ${closed.length} auth: true route(s) answer 401 (${closed.join(', ')})`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
httpd.addErrorHandler((err, request, reply) => {
|
|
139
|
+
const body = errorResponse(err, request.log)
|
|
140
|
+
reply.status(body.statusCode).send(body)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await httpd.startServer()
|
|
144
|
+
|
|
145
|
+
// The net facility's DHT client key; startGateway publishes it to the
|
|
146
|
+
// trusted-keys dir as gateway.pub. App routes use appClientKey below.
|
|
147
|
+
this.status.rpcClientKey = this.net_r0.dht.defaultKeyPair.publicKey.toString('hex')
|
|
148
|
+
|
|
149
|
+
// The key app runtimes allow-list (their allowedKeyFiles), published
|
|
150
|
+
// once the listener is up.
|
|
151
|
+
if (this._runtimeClient) {
|
|
152
|
+
this.status.appClientKey = this._runtimeClient.publicKey.toString('hex')
|
|
153
|
+
if (this.ctx.gatewayKeyFile) {
|
|
154
|
+
writeGatewayKeyFile(this.ctx.gatewayKeyFile, this.status.appClientKey)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.saveStatus()
|
|
158
|
+
}
|
|
159
|
+
], cb)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_stop (cb) {
|
|
163
|
+
async.series([
|
|
164
|
+
next => { super._stop(next) },
|
|
165
|
+
async () => {
|
|
166
|
+
const client = this._runtimeClient
|
|
167
|
+
this._runtimeClient = null
|
|
168
|
+
if (client) await client.close()
|
|
169
|
+
}
|
|
170
|
+
], cb)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = WrkServerHttp
|
|
175
|
+
module.exports.writeGatewayKeyFile = writeGatewayKeyFile
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const pino = require('pino')
|
|
4
|
+
|
|
5
|
+
// Everything the gateway prints — Fastify's request lines and every plugin's
|
|
6
|
+
// logger — goes through one destination, so a plugin failure is formatted and
|
|
7
|
+
// ordered like the rest of the log instead of arriving as a bare console line.
|
|
8
|
+
|
|
9
|
+
let destination = null
|
|
10
|
+
|
|
11
|
+
function _destination () {
|
|
12
|
+
if (destination) return destination
|
|
13
|
+
|
|
14
|
+
// Human-readable in a terminal; NDJSON when stdout is piped, which is what
|
|
15
|
+
// Docker, systemd and log shippers expect to parse.
|
|
16
|
+
destination = process.stdout.isTTY
|
|
17
|
+
? require('pino-pretty')({
|
|
18
|
+
colorize: true,
|
|
19
|
+
translateTime: 'HH:MM:ss',
|
|
20
|
+
ignore: 'pid,hostname',
|
|
21
|
+
singleLine: true
|
|
22
|
+
})
|
|
23
|
+
: pino.destination(1)
|
|
24
|
+
|
|
25
|
+
return destination
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `debug: 0` in the gateway's common.json is the default, so debug lines are
|
|
29
|
+
// opt-in the same way they are for the other facilities.
|
|
30
|
+
function _level (conf) {
|
|
31
|
+
return conf?.debug ? 'debug' : 'info'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fastify announces every address it binds, and logs an "incoming request" line
|
|
35
|
+
// paired with each "request completed". The CLI already prints the gateway URL,
|
|
36
|
+
// and one line per request is what an operator can actually read.
|
|
37
|
+
const hooks = {
|
|
38
|
+
logMethod (args, method) {
|
|
39
|
+
const msg = typeof args[0] === 'string' ? args[0] : typeof args[1] === 'string' ? args[1] : ''
|
|
40
|
+
if (msg.startsWith('Server listening at') || msg === 'incoming request') return
|
|
41
|
+
return method.apply(this, args)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fastifyLoggerOptions (conf) {
|
|
46
|
+
return { level: _level(conf), stream: _destination(), hooks }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let root = null
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The logger a plugin sees as `logger` on its ambient context: pino's
|
|
53
|
+
* error/warn/info/debug/trace/fatal plus child(), tagged with the plugin name so
|
|
54
|
+
* a line names who wrote it.
|
|
55
|
+
*/
|
|
56
|
+
function pluginLogger (name, conf) {
|
|
57
|
+
if (!root) root = pino({ level: _level(conf) }, _destination())
|
|
58
|
+
return root.child({ name })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same logger for the few things the gateway itself has to tell an operator
|
|
63
|
+
* — a plugin's `onReady` callback throwing, say. Tagged like a plugin's so the
|
|
64
|
+
* line names its source, and on the one stream, so it is ordered with the rest.
|
|
65
|
+
*/
|
|
66
|
+
function gatewayLogger (conf) {
|
|
67
|
+
return pluginLogger('gateway', conf)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { fastifyLoggerOptions, pluginLogger, gatewayLogger }
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const { STATUS_CODES } = require('http')
|
|
6
|
+
const { pipeToResponse } = require('@tetherto/mdk-app-runtime')
|
|
7
|
+
const { cachedRoute } = require('./server/lib/cachedRoute')
|
|
8
|
+
const { send200 } = require('./server/lib/send200')
|
|
9
|
+
const { createAuthHook, canOverwriteCache } = require('./security')
|
|
10
|
+
|
|
11
|
+
// Only ERR_* codes and Fastify's own 4xx errors (validation, content type,
|
|
12
|
+
// body size) describe the request; any other message can carry internals, so
|
|
13
|
+
// the client gets the status text and the log gets the error.
|
|
14
|
+
function errorResponse (err, log) {
|
|
15
|
+
const statusCode = Number.isInteger(err.statusCode) && err.statusCode >= 400 ? err.statusCode : 400
|
|
16
|
+
const error = STATUS_CODES[statusCode] || 'Bad Request'
|
|
17
|
+
const isSafe = /^ERR_/.test(err.message) || (/^FST_/.test(err.code) && statusCode < 500)
|
|
18
|
+
if (!isSafe && !err.remote) log?.error(err)
|
|
19
|
+
return { statusCode, error, message: isSafe ? err.message : error }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _extractCacheKeyParts (fields, req) {
|
|
23
|
+
return fields.map(field => {
|
|
24
|
+
const parts = field.split('.')
|
|
25
|
+
return parts.reduce((obj, key) => (obj != null ? obj[key] : undefined), req)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// JSON with object keys sorted, so the same identity always hashes the same
|
|
30
|
+
// whatever order the verifier built it in.
|
|
31
|
+
function _stableStringify (value) {
|
|
32
|
+
if (Array.isArray(value)) return `[${value.map(v => _stableStringify(v ?? null)).join(',')}]`
|
|
33
|
+
if (value && typeof value === 'object' && typeof value.toJSON !== 'function') {
|
|
34
|
+
return `{${Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => `${JSON.stringify(k)}:${_stableStringify(value[k])}`).join(',')}}`
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(value) ?? 'null'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A cache key component for the verified caller of an `auth: true` route, so
|
|
40
|
+
// two identities never share a cached response or an in-flight call.
|
|
41
|
+
function _identityCacheKey (req) {
|
|
42
|
+
return 'id:' + crypto.createHash('sha256').update(_stableStringify(req._info || {})).digest('hex')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Bounds a unary call, and the wait for a stream route's response head, so a
|
|
46
|
+
// hung controller cannot hold the HTTP request open forever (common.json `appTimeoutMs`).
|
|
47
|
+
const APP_TIMEOUT_MS = 60000
|
|
48
|
+
|
|
49
|
+
// A malformed value must fail the boot: one that is not a positive number would
|
|
50
|
+
// silently drop the bound. Digit strings are accepted, as for bodyLimit.
|
|
51
|
+
function resolveAppTimeout (conf = {}) {
|
|
52
|
+
let ms = conf.appTimeoutMs
|
|
53
|
+
if (ms === undefined || ms === null) return APP_TIMEOUT_MS
|
|
54
|
+
if (typeof ms === 'string' && /^\d+$/.test(ms)) ms = Number(ms)
|
|
55
|
+
if (!Number.isInteger(ms) || ms <= 0) {
|
|
56
|
+
throw new Error('ERR_GATEWAY_APP_TIMEOUT_INVALID: appTimeoutMs must be a positive integer (milliseconds)')
|
|
57
|
+
}
|
|
58
|
+
return ms
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Re-read on every request, so a runtime may start, or restart under a new
|
|
62
|
+
// key, after the Gateway. Until it has published a key its routes answer 503.
|
|
63
|
+
async function _runtimeKey (app) {
|
|
64
|
+
const key = await fs.promises.readFile(app.keyFile, 'utf8').then(s => s.trim(), () => '')
|
|
65
|
+
if (/^[0-9a-f]{64}$/i.test(key)) return key
|
|
66
|
+
const err = new Error(`ERR_APP_RUNTIME_UNAVAILABLE: ${app.name}`)
|
|
67
|
+
err.statusCode = 503
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The caller's credentials stay at the Gateway: the authVerifier already turned
|
|
72
|
+
// them into the identity, and app code must not be able to replay them.
|
|
73
|
+
const CREDENTIAL_HEADERS = ['authorization', 'proxy-authorization', 'cookie']
|
|
74
|
+
|
|
75
|
+
function _appRequest (req) {
|
|
76
|
+
const headers = { ...req.headers }
|
|
77
|
+
for (const name of CREDENTIAL_HEADERS) delete headers[name]
|
|
78
|
+
return { params: req.params, query: req.query, body: req.body, headers }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Aborts when the client goes away before its response finished, so a request
|
|
82
|
+
// nobody waits for is not forwarded.
|
|
83
|
+
function _disconnectSignal (rep) {
|
|
84
|
+
const ac = new AbortController()
|
|
85
|
+
rep.raw.once('close', () => { if (!rep.raw.writableFinished) ac.abort() })
|
|
86
|
+
return ac.signal
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One call to the app's runtime: `method` is 'invoke' (unary) or 'stream'. The
|
|
90
|
+
// runtime takes `{ params, query, body, headers }` from `req`; the identity the
|
|
91
|
+
// authVerifier set (none on an open route) travels beside it.
|
|
92
|
+
async function _forward (client, app, route, method, req, timeoutMs, signal) {
|
|
93
|
+
const key = await _runtimeKey(app)
|
|
94
|
+
return client[method](key, route.id, _appRequest(req), { identity: req._info || {}, timeoutMs, signal })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Mounts an app's route table (`app.routes`) under `app.mount` (default
|
|
98
|
+
// `/apps/<name>`) and forwards every request to the app's runtime over HRPC.
|
|
99
|
+
//
|
|
100
|
+
// opts.authVerifier: async (request, route) => identity | false. Every
|
|
101
|
+
// `auth: true` route gets a route-level preParsing hook that runs it: after the
|
|
102
|
+
// Host check and rate limit (onRequest, so failed attempts are throttled too),
|
|
103
|
+
// before body parsing, validation, the cache and the forward. With no
|
|
104
|
+
// verifier those routes answer 401. Routes without `auth: true` stay open.
|
|
105
|
+
// The verified identity reaches the app runtime as the call's `identity`.
|
|
106
|
+
function buildFastifyRoutes (app, client, ctx, opts = {}) {
|
|
107
|
+
const mount = app.mount || `/apps/${app.name}`
|
|
108
|
+
const timeoutMs = resolveAppTimeout(ctx.conf)
|
|
109
|
+
const prefix = mount === '/' ? '' : mount
|
|
110
|
+
|
|
111
|
+
return app.routes.map(route => {
|
|
112
|
+
const fastifyRoute = {
|
|
113
|
+
method: route.method,
|
|
114
|
+
// A route at "/" answers at the mount itself (/apps/<name>), not only with a trailing slash.
|
|
115
|
+
url: prefix && route.path === '/' ? prefix : prefix + route.path
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (route.schema) {
|
|
119
|
+
fastifyRoute.schema = route.schema
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (route.auth === true) {
|
|
123
|
+
fastifyRoute.preParsing = createAuthHook(opts.authVerifier || null, Object.freeze({
|
|
124
|
+
id: route.id,
|
|
125
|
+
method: route.method,
|
|
126
|
+
path: route.path,
|
|
127
|
+
app: app.name,
|
|
128
|
+
permissions: Array.isArray(route.permissions) ? Object.freeze([...route.permissions]) : undefined
|
|
129
|
+
}))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A stream route owns the raw ServerResponse. The reply is hijacked so
|
|
133
|
+
// Fastify never serializes; the boundary catch is load-bearing — a
|
|
134
|
+
// rejection after hijack() is otherwise swallowed (reply.sent is true)
|
|
135
|
+
// and the socket stays open forever.
|
|
136
|
+
if (route.stream === true) {
|
|
137
|
+
fastifyRoute.handler = async (req, rep) => {
|
|
138
|
+
const signal = _disconnectSignal(rep)
|
|
139
|
+
rep.hijack()
|
|
140
|
+
const raw = rep.raw
|
|
141
|
+
try {
|
|
142
|
+
await pipeToResponse(await _forward(client, app, route, 'stream', req, timeoutMs, signal), raw)
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (!raw.headersSent) {
|
|
145
|
+
const body = errorResponse(err, req.log)
|
|
146
|
+
raw.writeHead(body.statusCode, { 'content-type': 'application/json' })
|
|
147
|
+
raw.end(JSON.stringify(body))
|
|
148
|
+
} else if (!raw.writableEnded) {
|
|
149
|
+
raw.end()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return fastifyRoute
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cacheFields = route.cache
|
|
157
|
+
|
|
158
|
+
fastifyRoute.handler = async (req, rep) => {
|
|
159
|
+
// A cached call is shared by every request waiting on its key, so one
|
|
160
|
+
// client leaving must not abort it.
|
|
161
|
+
const signal = Array.isArray(cacheFields) ? undefined : _disconnectSignal(rep)
|
|
162
|
+
const forward = () => _forward(client, app, route, 'invoke', req, timeoutMs, signal)
|
|
163
|
+
|
|
164
|
+
let result
|
|
165
|
+
if (cacheFields && Array.isArray(cacheFields)) {
|
|
166
|
+
const keyParts = [app.name, route.id, ..._extractCacheKeyParts(cacheFields, req)]
|
|
167
|
+
// Per-caller on an `auth: true` route: the response may depend on who asked.
|
|
168
|
+
if (route.auth === true) keyParts.push(_identityCacheKey(req))
|
|
169
|
+
result = await cachedRoute(
|
|
170
|
+
ctx,
|
|
171
|
+
keyParts,
|
|
172
|
+
route.path,
|
|
173
|
+
forward,
|
|
174
|
+
// A forced refresh is honoured only for a caller the authVerifier
|
|
175
|
+
// vouched for and granted OVERWRITE_CACHE_PERM; a bare header is not proof.
|
|
176
|
+
String(req.query?.overwriteCache) === 'true' && canOverwriteCache(req)
|
|
177
|
+
)
|
|
178
|
+
} else {
|
|
179
|
+
result = await forward()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return send200(rep, result)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return fastifyRoute
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { buildFastifyRoutes, errorResponse, resolveAppTimeout, runtimeKey: _runtimeKey }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { pluginLogger } = require('./logger')
|
|
5
|
+
|
|
6
|
+
// Builds what a plugin sees as require('@tetherto/mdk-gateway/plugin') — the
|
|
7
|
+
// contract between a plugin and the gateway. There are no phases and no worker
|
|
8
|
+
// internals to learn; the one moment in the gateway's life a plugin can name is
|
|
9
|
+
// onReady, and only because a plugin cannot observe it any other way.
|
|
10
|
+
//
|
|
11
|
+
// The full surface:
|
|
12
|
+
// gw.config gateway conf ⊕ the plugin's own config block
|
|
13
|
+
// (spec.gateway.plugins[].config wins key-by-key)
|
|
14
|
+
// gw.logger the gateway's logger, tagged with this plugin's name:
|
|
15
|
+
// error/warn/info/debug/trace/fatal + child()
|
|
16
|
+
// gw.onReady(fn) fn runs once this gateway is serving
|
|
17
|
+
//
|
|
18
|
+
// Everything else a plugin needs it owns: kernel and worker data — live or
|
|
19
|
+
// historical — go through the plugin's own mdk client, built from
|
|
20
|
+
// config.kernelKey/kernelKeyPair/kernelBootstrap (see the telemetry plugin's
|
|
21
|
+
// lib/client.js). kernelKeyPair is the persistent keypair status.rpcClientKey
|
|
22
|
+
// is read from, so plugin clients dial the Kernel with the allowlisted key.
|
|
23
|
+
|
|
24
|
+
// What a plugin's log lines are tagged with. The manifest name is the plugin's
|
|
25
|
+
// own identity; the scope is dropped because the tag prefixes every line.
|
|
26
|
+
function _pluginName (pluginDir) {
|
|
27
|
+
try {
|
|
28
|
+
const { name } = require(path.join(pluginDir, 'mdk-plugin.json'))
|
|
29
|
+
if (typeof name === 'string' && name) return name.replace(/^@[^/]+\//, '')
|
|
30
|
+
} catch {}
|
|
31
|
+
return path.basename(pluginDir)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildPluginContext (wrk, pluginDir, pluginConf) {
|
|
35
|
+
const kernelKey = Buffer.isBuffer(wrk.ctx.kernelKey) ? wrk.ctx.kernelKey.toString('hex') : wrk.ctx.kernelKey
|
|
36
|
+
|
|
37
|
+
const context = Object.freeze({
|
|
38
|
+
// The plugin's own config block (spec.gateway.plugins[].config) wins over
|
|
39
|
+
// gateway-wide conf, so a plugin's settings live with the plugin.
|
|
40
|
+
config: Object.freeze({
|
|
41
|
+
kernelKey: kernelKey || null,
|
|
42
|
+
// resolved lazily: net_r0.dht does not exist yet when plugins load; a
|
|
43
|
+
// host without one (the MCP server's shim) dials with an ephemeral key
|
|
44
|
+
kernelKeyPair: () => wrk.net_r0?.dht?.defaultKeyPair,
|
|
45
|
+
kernelBootstrap: wrk.ctx.kernelBootstrap || null,
|
|
46
|
+
...wrk.conf,
|
|
47
|
+
...pluginConf
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
// A plugin that prints with console has no level, no timestamp and no name;
|
|
51
|
+
// going through the gateway's logger puts its lines in the same stream and
|
|
52
|
+
// format as the requests it serves.
|
|
53
|
+
logger: pluginLogger(_pluginName(pluginDir), wrk.conf),
|
|
54
|
+
|
|
55
|
+
// Plugin modules are loaded from the worker's init(), which is strictly
|
|
56
|
+
// before _start() opens the listener, so a plugin doing anything at load
|
|
57
|
+
// time does it to a gateway that answers nothing yet — and a plugin whose
|
|
58
|
+
// work is a call to this same gateway fails for no reason but the order.
|
|
59
|
+
// onReady is that missing moment: fn runs once everything this gateway
|
|
60
|
+
// serves is up, and runs straight away for anything registered after that.
|
|
61
|
+
onReady: (fn) => wrk.onGatewayReady(fn)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return { context }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { buildPluginContext }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The loader lives in @tetherto/mdk-app-runtime, which loads and runs app
|
|
4
|
+
// handlers; the Gateway only reads route tables with it. This path stays as a
|
|
5
|
+
// re-export for existing callers.
|
|
6
|
+
module.exports = require('@tetherto/mdk-app-runtime/lib/plugin-loader')
|