dsh-mesh-node 0.0.0-stage → 0.0.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 +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +340 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +313 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
package/src/core/jobs.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { randomBytes } from 'node:crypto'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { nowIso, readJsonFile, short } from './util.js'
|
|
13
|
+
|
|
14
|
+
export const TERMINAL_STATES = ['succeeded', 'failed', 'cancelled']
|
|
15
|
+
|
|
16
|
+
export function jobsDir(home) {
|
|
17
|
+
const dir = join(home, 'jobs')
|
|
18
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
19
|
+
return dir
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jobPath(home, id) {
|
|
23
|
+
return join(jobsDir(home), `${id}.json`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function newJobId() {
|
|
27
|
+
return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeJob(home, job) {
|
|
31
|
+
writeFileSync(jobPath(home, job.id), JSON.stringify(job, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJob(home, id) {
|
|
35
|
+
const p = jobPath(home, id)
|
|
36
|
+
if (!existsSync(p)) return null
|
|
37
|
+
try {
|
|
38
|
+
return readJsonFile(p)
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function allJobs(home) {
|
|
45
|
+
const dir = jobsDir(home)
|
|
46
|
+
const out = []
|
|
47
|
+
for (const name of readdirSync(dir)) {
|
|
48
|
+
if (!name.endsWith('.json')) continue
|
|
49
|
+
try {
|
|
50
|
+
out.push(readJsonFile(join(dir, name)))
|
|
51
|
+
} catch {
|
|
52
|
+
/* skip corrupt job file */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Trim old jobs so the jobs dir cannot grow without bound. */
|
|
59
|
+
export function pruneJobs(home, { ttlSec = 7 * 24 * 3600, maxJobs = 500 } = {}) {
|
|
60
|
+
const jobs = allJobs(home)
|
|
61
|
+
const cutoff = Date.now() - ttlSec * 1000
|
|
62
|
+
const keep = []
|
|
63
|
+
for (const job of jobs) {
|
|
64
|
+
const created = Date.parse(job.createdAt || 0)
|
|
65
|
+
const terminal = TERMINAL_STATES.includes(job.state)
|
|
66
|
+
if (terminal && Number.isFinite(created) && created < cutoff) {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(jobPath(home, job.id), { force: true })
|
|
69
|
+
} catch {
|
|
70
|
+
/* ignore */
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
keep.push(job)
|
|
75
|
+
}
|
|
76
|
+
for (const job of keep.slice(maxJobs)) {
|
|
77
|
+
if (!TERMINAL_STATES.includes(job.state)) continue
|
|
78
|
+
try {
|
|
79
|
+
rmSync(jobPath(home, job.id), { force: true })
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { scanned: jobs.length, kept: Math.min(keep.length, maxJobs) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Async job runner: `task_submit` persists a job, runs it in-process and lets the caller
|
|
89
|
+
* poll `task_status`. Jobs survive a server restart as records (running ones are marked
|
|
90
|
+
* interrupted on load, so a client never waits forever).
|
|
91
|
+
*
|
|
92
|
+
* Idempotency: `task_submit` with the same `idem` key returns the original job instead of
|
|
93
|
+
* creating a second one — that is what makes retries after a dropped connection safe.
|
|
94
|
+
*/
|
|
95
|
+
export class JobRunner {
|
|
96
|
+
constructor({ home, exec, audit, log = console, limits = {} }) {
|
|
97
|
+
this.home = home
|
|
98
|
+
this.exec = exec
|
|
99
|
+
this.audit = audit
|
|
100
|
+
this.log = log
|
|
101
|
+
this.limits = limits
|
|
102
|
+
this.running = new Map()
|
|
103
|
+
this.recoverInterrupted()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
recoverInterrupted() {
|
|
107
|
+
let recovered = 0
|
|
108
|
+
for (const job of allJobs(this.home)) {
|
|
109
|
+
if (job.state === 'running' || job.state === 'queued') {
|
|
110
|
+
job.state = 'failed'
|
|
111
|
+
job.error = { code: 'E_INTERRUPTED', message: 'node restarted while the job was running', retryable: true }
|
|
112
|
+
job.finishedAt = nowIso()
|
|
113
|
+
try {
|
|
114
|
+
writeJob(this.home, job)
|
|
115
|
+
recovered += 1
|
|
116
|
+
} catch {
|
|
117
|
+
/* ignore */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (recovered > 0) this.log?.info?.(`[dsh-mesh] marked ${recovered} interrupted job(s) as failed`)
|
|
122
|
+
return recovered
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
findByIdem(idem) {
|
|
126
|
+
if (!idem) return null
|
|
127
|
+
return allJobs(this.home).find((j) => j.idem === idem) || null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
submit({ tool, arguments: args = {}, idem, peer, timeoutMs }) {
|
|
131
|
+
if (idem) {
|
|
132
|
+
const existing = this.findByIdem(idem)
|
|
133
|
+
if (existing) return { job: existing, idempotent: true }
|
|
134
|
+
}
|
|
135
|
+
const job = {
|
|
136
|
+
id: newJobId(),
|
|
137
|
+
idem: idem || null,
|
|
138
|
+
tool,
|
|
139
|
+
arguments: args,
|
|
140
|
+
state: 'queued',
|
|
141
|
+
createdAt: nowIso(),
|
|
142
|
+
startedAt: null,
|
|
143
|
+
finishedAt: null,
|
|
144
|
+
peer: peer || null,
|
|
145
|
+
result: null,
|
|
146
|
+
error: null
|
|
147
|
+
}
|
|
148
|
+
writeJob(this.home, job)
|
|
149
|
+
this.#start(job, timeoutMs)
|
|
150
|
+
return { job: this.#public(job), idempotent: false }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#start(job, timeoutMs) {
|
|
154
|
+
const controller = new AbortController()
|
|
155
|
+
const timer = timeoutMs
|
|
156
|
+
? setTimeout(() => controller.abort(new Error(`job timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
157
|
+
: null
|
|
158
|
+
timer?.unref?.()
|
|
159
|
+
this.running.set(job.id, { controller, job })
|
|
160
|
+
|
|
161
|
+
setImmediate(async () => {
|
|
162
|
+
job.state = 'running'
|
|
163
|
+
job.startedAt = nowIso()
|
|
164
|
+
try {
|
|
165
|
+
writeJob(this.home, job)
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const result = await this.exec(job.tool, job.arguments, { signal: controller.signal, jobId: job.id })
|
|
171
|
+
job.result = result
|
|
172
|
+
if (result?.isError) {
|
|
173
|
+
job.state = controller.signal.aborted ? 'cancelled' : 'failed'
|
|
174
|
+
try {
|
|
175
|
+
job.error = JSON.parse(result.content?.[0]?.text || '{}')
|
|
176
|
+
} catch {
|
|
177
|
+
job.error = { code: 'E_INTERNAL', message: 'tool returned an error' }
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
job.state = controller.signal.aborted ? 'cancelled' : 'succeeded'
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
job.state = controller.signal.aborted ? 'cancelled' : 'failed'
|
|
184
|
+
job.error = { code: controller.signal.aborted ? 'E_CANCELLED' : 'E_INTERNAL', message: short(error?.message || error) }
|
|
185
|
+
} finally {
|
|
186
|
+
if (timer) clearTimeout(timer)
|
|
187
|
+
job.finishedAt = nowIso()
|
|
188
|
+
this.running.delete(job.id)
|
|
189
|
+
try {
|
|
190
|
+
writeJob(this.home, job)
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
this.audit?.({
|
|
195
|
+
peer: job.peer,
|
|
196
|
+
tool: job.tool,
|
|
197
|
+
jobId: job.id,
|
|
198
|
+
ok: job.state === 'succeeded',
|
|
199
|
+
state: job.state,
|
|
200
|
+
args: Object.keys(job.arguments || {})
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get(id) {
|
|
207
|
+
const job = readJob(this.home, id)
|
|
208
|
+
return job ? this.#public(job) : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
list({ state, limit = 20 } = {}) {
|
|
212
|
+
let jobs = allJobs(this.home)
|
|
213
|
+
if (state) jobs = jobs.filter((j) => j.state === state)
|
|
214
|
+
return jobs.slice(0, Math.min(Math.max(1, limit), 100)).map((j) => this.#public(j))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
cancel(id) {
|
|
218
|
+
const job = readJob(this.home, id)
|
|
219
|
+
if (!job) return { ok: false, code: 'E_NOT_FOUND', message: `no such job: ${id}` }
|
|
220
|
+
if (TERMINAL_STATES.includes(job.state)) {
|
|
221
|
+
return { ok: false, code: 'E_INVALID_ARG', message: `job already ${job.state}` }
|
|
222
|
+
}
|
|
223
|
+
const live = this.running.get(id)
|
|
224
|
+
if (live) {
|
|
225
|
+
live.controller.abort(new Error('cancelled by client'))
|
|
226
|
+
return { ok: true, job: this.#public({ ...job, cancelRequested: true }) }
|
|
227
|
+
}
|
|
228
|
+
job.state = 'cancelled'
|
|
229
|
+
job.finishedAt = nowIso()
|
|
230
|
+
writeJob(this.home, job)
|
|
231
|
+
return { ok: true, job: this.#public(job) }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
stats() {
|
|
235
|
+
const jobs = allJobs(this.home)
|
|
236
|
+
const byState = {}
|
|
237
|
+
for (const job of jobs) byState[job.state] = (byState[job.state] || 0) + 1
|
|
238
|
+
return { total: jobs.length, byState, running: this.running.size, dir: jobsDir(this.home) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#public(job) {
|
|
242
|
+
return {
|
|
243
|
+
id: job.id,
|
|
244
|
+
idem: job.idem || null,
|
|
245
|
+
tool: job.tool,
|
|
246
|
+
arguments: job.arguments,
|
|
247
|
+
state: job.state,
|
|
248
|
+
createdAt: job.createdAt,
|
|
249
|
+
startedAt: job.startedAt,
|
|
250
|
+
finishedAt: job.finishedAt,
|
|
251
|
+
durationMs:
|
|
252
|
+
job.startedAt && job.finishedAt ? Date.parse(job.finishedAt) - Date.parse(job.startedAt) : null,
|
|
253
|
+
cancelRequested: Boolean(job.cancelRequested),
|
|
254
|
+
error: job.error,
|
|
255
|
+
result: job.result,
|
|
256
|
+
resultBytes: job.result ? Buffer.byteLength(JSON.stringify(job.result), 'utf8') : 0
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { meshHome, pairingPath, peersPath, ensureHome } from './config.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-time pairing codes.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. On the node being reached: `dsh-mesh pair --code` → "7KQ2-M9XD" (TTL 5 min, one use).
|
|
10
|
+
* 2. The human tells the code to the peer machine.
|
|
11
|
+
* 3. The peer runs `dsh-mesh peer add --url <url> --code 7KQ2-M9XD` → POST /pair.
|
|
12
|
+
* 4. The node verifies the code and hands back a *unique* bearer token for that peer.
|
|
13
|
+
*
|
|
14
|
+
* Only the sha256 of the code is stored, so a leaked pairing.json cannot be replayed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Crockford-ish alphabet: no 0/O/1/I/L to keep it readable when typed by a human. */
|
|
18
|
+
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
|
19
|
+
const CODE_LEN = 8
|
|
20
|
+
|
|
21
|
+
export function normalizeCode(raw) {
|
|
22
|
+
const compact = String(raw || '')
|
|
23
|
+
.toUpperCase()
|
|
24
|
+
.replace(/[^A-Z0-9]/g, '')
|
|
25
|
+
return compact
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hashCode(code) {
|
|
29
|
+
return createHash('sha256').update(`dsh-mesh-pair:${normalizeCode(code)}`).digest('hex')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatCode(compact) {
|
|
33
|
+
const s = normalizeCode(compact)
|
|
34
|
+
return `${s.slice(0, 4)}-${s.slice(4)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function randomCode() {
|
|
38
|
+
let out = ''
|
|
39
|
+
for (let i = 0; i < CODE_LEN; i += 1) out += ALPHABET[randomInt(ALPHABET.length)]
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeJson0600(file, value) {
|
|
44
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
45
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
46
|
+
renameSync(tmp, file)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readJsonSafe(file) {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(file)) return null
|
|
52
|
+
const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
53
|
+
return JSON.parse(text)
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create (and persist) a one-time code. Only one code is valid at a time:
|
|
61
|
+
* creating a new one invalidates the previous one.
|
|
62
|
+
*/
|
|
63
|
+
export function createPairingCode({ home = meshHome(), ttlSec = 300, note } = {}) {
|
|
64
|
+
ensureHome(home)
|
|
65
|
+
const compact = randomCode()
|
|
66
|
+
const now = Date.now()
|
|
67
|
+
const record = {
|
|
68
|
+
version: 1,
|
|
69
|
+
codeHash: hashCode(compact),
|
|
70
|
+
createdAt: new Date(now).toISOString(),
|
|
71
|
+
expiresAt: new Date(now + Math.max(30, ttlSec) * 1000).toISOString(),
|
|
72
|
+
attempts: 0,
|
|
73
|
+
used: false,
|
|
74
|
+
note: note || undefined
|
|
75
|
+
}
|
|
76
|
+
writeJson0600(pairingPath(home), record)
|
|
77
|
+
return { code: formatCode(compact), expiresAt: record.expiresAt, ttlSec }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readPairingState(home = meshHome()) {
|
|
81
|
+
const record = readJsonSafe(pairingPath(home))
|
|
82
|
+
if (!record) return null
|
|
83
|
+
return {
|
|
84
|
+
createdAt: record.createdAt,
|
|
85
|
+
expiresAt: record.expiresAt,
|
|
86
|
+
attempts: record.attempts || 0,
|
|
87
|
+
used: record.used === true,
|
|
88
|
+
expired: new Date(record.expiresAt).getTime() <= Date.now(),
|
|
89
|
+
note: record.note
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sameHash(a, b) {
|
|
94
|
+
const ab = Buffer.from(String(a), 'hex')
|
|
95
|
+
const bb = Buffer.from(String(b), 'hex')
|
|
96
|
+
if (ab.length !== bb.length || ab.length === 0) return false
|
|
97
|
+
return timingSafeEqual(ab, bb)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify a presented code and consume it.
|
|
102
|
+
* Returns { ok:true } once; every failure carries a stable code so the server can
|
|
103
|
+
* answer 401 without leaking whether the code exists (all failures look the same
|
|
104
|
+
* except E_MAX_ATTEMPTS, which is only reachable by someone who keeps guessing).
|
|
105
|
+
*/
|
|
106
|
+
export function claimPairingCode({ home = meshHome(), code, maxAttempts = 5 } = {}) {
|
|
107
|
+
const record = readJsonSafe(pairingPath(home))
|
|
108
|
+
if (!record) return { ok: false, reason: 'E_NO_CODE' }
|
|
109
|
+
if (record.used) return { ok: false, reason: 'E_CODE_USED' }
|
|
110
|
+
if (new Date(record.expiresAt).getTime() <= Date.now()) return { ok: false, reason: 'E_CODE_EXPIRED' }
|
|
111
|
+
if ((record.attempts || 0) >= maxAttempts) return { ok: false, reason: 'E_MAX_ATTEMPTS' }
|
|
112
|
+
|
|
113
|
+
if (!sameHash(hashCode(code), record.codeHash)) {
|
|
114
|
+
record.attempts = (record.attempts || 0) + 1
|
|
115
|
+
if (record.attempts >= maxAttempts) {
|
|
116
|
+
record.used = true // burn it: no more guesses
|
|
117
|
+
writeJson0600(pairingPath(home), record)
|
|
118
|
+
return { ok: false, reason: 'E_MAX_ATTEMPTS', attemptsLeft: 0 }
|
|
119
|
+
}
|
|
120
|
+
writeJson0600(pairingPath(home), record)
|
|
121
|
+
return { ok: false, reason: 'E_BAD_CODE', attemptsLeft: Math.max(0, maxAttempts - record.attempts) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
record.used = true
|
|
125
|
+
record.usedAt = new Date().toISOString()
|
|
126
|
+
writeJson0600(pairingPath(home), record)
|
|
127
|
+
return { ok: true }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Peer bookkeeping on the *client* side: who we can talk to, and with which token. */
|
|
131
|
+
export function loadPeers(home = meshHome()) {
|
|
132
|
+
const raw = readJsonSafe(peersPath(home))
|
|
133
|
+
return raw && Array.isArray(raw.items) ? raw : { version: 1, items: [] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function savePeers(peers, home = meshHome()) {
|
|
137
|
+
ensureHome(home)
|
|
138
|
+
const file = peersPath(home)
|
|
139
|
+
writeJson0600(file, peers)
|
|
140
|
+
return file
|
|
141
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound peer client (M7 step 4: cluster_exec).
|
|
3
|
+
*
|
|
4
|
+
* `cluster_status` only probes peers over /health; this module is what lets a
|
|
5
|
+
* node ACT on a paired peer — call one MCP tool on the other machine. The trust
|
|
6
|
+
* model stays TOFU: the peer's stored certificate pin is checked on EVERY https
|
|
7
|
+
* connection (we cannot rely on NODE_EXTRA_CA_CERTS at runtime), so a changed
|
|
8
|
+
* certificate fails loudly instead of silently accepting a new one.
|
|
9
|
+
*
|
|
10
|
+
* The MCP SDK's transport takes a `fetch` option; we supply a small node:https
|
|
11
|
+
* based implementation because Node's global fetch cannot talk to a self-signed
|
|
12
|
+
* peer without adding its CA to the process trust store.
|
|
13
|
+
*/
|
|
14
|
+
import { request as httpRequest } from 'node:http'
|
|
15
|
+
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'
|
|
16
|
+
import { Client } from '@modelcontextprotocol/client'
|
|
17
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
18
|
+
import { MESH_VERSION } from './config.js'
|
|
19
|
+
import { assertFingerprint, fingerprintOfSocket, normalizeFingerprint } from './tls.js'
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 30000
|
|
22
|
+
|
|
23
|
+
export function peerError(code, message, extra = {}) {
|
|
24
|
+
const error = new Error(message)
|
|
25
|
+
error.code = code
|
|
26
|
+
Object.assign(error, extra)
|
|
27
|
+
return error
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `https://host:port/mcp` -> `https://host:port` (peer records store the base URL). */
|
|
31
|
+
export function baseUrlOf(url) {
|
|
32
|
+
return String(url || '')
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/\/+$/, '')
|
|
35
|
+
.replace(/\/mcp$/, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the piece of a `mesh-peer pair` output file that mesh needs to call the
|
|
40
|
+
* peer: the bearer token and base URL live in the generated DSH overlay (the
|
|
41
|
+
* `<name>.json` metadata intentionally has no token). Also accepts the metadata
|
|
42
|
+
* file for URL/fingerprint hints.
|
|
43
|
+
*/
|
|
44
|
+
export function parsePeerOverlay(text, { meta = null } = {}) {
|
|
45
|
+
const source = String(text || '')
|
|
46
|
+
const tokenMatch = source.match(/Authorization:\s*['"]?Bearer\s+([A-Za-z0-9._~+/=-]+)['"]?/)
|
|
47
|
+
const token = tokenMatch ? tokenMatch[1] : ''
|
|
48
|
+
if (!token) {
|
|
49
|
+
throw peerError('E_INVALID_ARG', 'no Bearer token found — pass the <name>.yml overlay written by `mesh-peer pair`')
|
|
50
|
+
}
|
|
51
|
+
const urlMatch = source.match(/^\s*url:\s*['"]?([^'"\s]+)['"]?\s*$/m)
|
|
52
|
+
const nameMatch = source.match(/^\s*serverName:\s*['"]?([^'"\s]+)['"]?\s*$/m)
|
|
53
|
+
const rawUrl = meta?.url || (urlMatch ? urlMatch[1] : '')
|
|
54
|
+
if (!rawUrl) throw peerError('E_INVALID_ARG', 'no peer url found — pass --url or the <name>.json metadata')
|
|
55
|
+
const fallbackName = meta?.node || ''
|
|
56
|
+
const serverName = nameMatch ? nameMatch[1] : ''
|
|
57
|
+
const suggestedName = String(fallbackName || serverName || 'peer')
|
|
58
|
+
.replace(/-mesh$/, '')
|
|
59
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
60
|
+
.slice(0, 40)
|
|
61
|
+
const certFingerprint = meta?.certFingerprint || null
|
|
62
|
+
return {
|
|
63
|
+
token,
|
|
64
|
+
url: baseUrlOf(rawUrl),
|
|
65
|
+
tokenId: meta?.tokenId || suggestedName,
|
|
66
|
+
certFingerprint,
|
|
67
|
+
suggestedName
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* node:https implementation of the WHATWG fetch subset the MCP transport needs.
|
|
73
|
+
* TLS verification is replaced by the stored pin: `rejectUnauthorized:false` is
|
|
74
|
+
* only acceptable because the socket certificate is compared to the pin before
|
|
75
|
+
* a single byte of the response is handed to the SDK.
|
|
76
|
+
*/
|
|
77
|
+
export function makePinnedFetch({ certFingerprint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
78
|
+
const expected = certFingerprint ? normalizeFingerprint(certFingerprint) : null
|
|
79
|
+
// No TLS session caching: a resumed session would skip the certificate check.
|
|
80
|
+
const pinnedAgent = new HttpsAgent({ keepAlive: false, maxCachedSessions: 0, rejectUnauthorized: false })
|
|
81
|
+
|
|
82
|
+
return function pinnedFetch(input, init = {}) {
|
|
83
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
84
|
+
let url
|
|
85
|
+
try {
|
|
86
|
+
url = new URL(String(input))
|
|
87
|
+
} catch {
|
|
88
|
+
rejectPromise(peerError('E_INVALID_ARG', `invalid peer url: ${input}`))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const secure = url.protocol === 'https:'
|
|
92
|
+
if (secure && !expected) {
|
|
93
|
+
rejectPromise(peerError('E_PEER_NO_PIN', 'refusing to talk to an https peer without a stored certificate fingerprint'))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const body = init.body
|
|
97
|
+
// The SDK passes a Headers instance; spreading it would silently drop every
|
|
98
|
+
// header (including Authorization) because Headers is not enumerable.
|
|
99
|
+
const rawHeaders = init.headers
|
|
100
|
+
const headers =
|
|
101
|
+
typeof Headers !== 'undefined' && rawHeaders instanceof Headers
|
|
102
|
+
? Object.fromEntries(rawHeaders.entries())
|
|
103
|
+
: Array.isArray(rawHeaders)
|
|
104
|
+
? Object.fromEntries(rawHeaders)
|
|
105
|
+
: { ...(rawHeaders || {}) }
|
|
106
|
+
const request = (secure ? httpsRequest : httpRequest)(
|
|
107
|
+
{
|
|
108
|
+
protocol: url.protocol,
|
|
109
|
+
hostname: url.hostname,
|
|
110
|
+
port: url.port || (secure ? 443 : 80),
|
|
111
|
+
path: `${url.pathname}${url.search}`,
|
|
112
|
+
method: init.method || 'GET',
|
|
113
|
+
headers,
|
|
114
|
+
...(secure ? { rejectUnauthorized: false, agent: pinnedAgent } : {}),
|
|
115
|
+
timeout: timeoutMs
|
|
116
|
+
},
|
|
117
|
+
(res) => {
|
|
118
|
+
const status = res.statusCode || 0
|
|
119
|
+
const stream = new ReadableStream({
|
|
120
|
+
start(controller) {
|
|
121
|
+
res.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)))
|
|
122
|
+
res.on('end', () => controller.close())
|
|
123
|
+
res.on('error', (error) => controller.error(error))
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
if (status === 204 || status === 304) {
|
|
127
|
+
res.resume()
|
|
128
|
+
resolvePromise(new Response(null, { status, statusText: res.statusMessage || '' }))
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
resolvePromise(new Response(stream, { status, statusText: res.statusMessage || '', headers: res.headers }))
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
if (secure) {
|
|
135
|
+
// Verify the pin at handshake time. Checking it on the response is too late
|
|
136
|
+
// and unreliable: by then the socket may already be destroyed/kept alive.
|
|
137
|
+
request.on('socket', (socket) => {
|
|
138
|
+
const verify = () => {
|
|
139
|
+
try {
|
|
140
|
+
assertFingerprint(expected, fingerprintOfSocket(socket))
|
|
141
|
+
} catch (error) {
|
|
142
|
+
request.destroy(
|
|
143
|
+
peerError('E_PEER_PIN_MISMATCH', `peer certificate does not match the stored fingerprint (${error?.code || 'unknown'})`, {
|
|
144
|
+
cause: error
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (socket.connecting || socket.secureConnecting) socket.once('secureConnect', verify)
|
|
150
|
+
else verify()
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
request.on('timeout', () => request.destroy(peerError('E_PEER_TIMEOUT', `peer did not answer within ${timeoutMs} ms`)))
|
|
154
|
+
request.on('error', (error) => rejectPromise(error))
|
|
155
|
+
if (init.signal) {
|
|
156
|
+
if (init.signal.aborted) {
|
|
157
|
+
request.destroy(peerError('E_PEER_ABORTED', 'request aborted'))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
init.signal.addEventListener('abort', () => request.destroy(peerError('E_PEER_ABORTED', 'request aborted')), { once: true })
|
|
161
|
+
}
|
|
162
|
+
if (body !== undefined && body !== null) {
|
|
163
|
+
if (typeof body === 'string' || body instanceof Uint8Array || body instanceof URLSearchParams) request.write(body)
|
|
164
|
+
else if (body instanceof ArrayBuffer) request.write(new Uint8Array(body))
|
|
165
|
+
else request.write(String(body))
|
|
166
|
+
}
|
|
167
|
+
request.end()
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Close a client + transport pair; the connection is already unusable if this fails. */
|
|
173
|
+
async function closeConnection(client, transport) {
|
|
174
|
+
try {
|
|
175
|
+
await client.close()
|
|
176
|
+
} catch {
|
|
177
|
+
/* already closed */
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await transport.close?.()
|
|
181
|
+
} catch {
|
|
182
|
+
/* already closed */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Keep the peer's own error code (E_PEER_PIN_MISMATCH and friends) so callers can
|
|
188
|
+
* react per failure mode; only map the transport's HTTP 401 to E_PEER_AUTH.
|
|
189
|
+
*/
|
|
190
|
+
function peerFailureCode(error, fallback) {
|
|
191
|
+
const message = error?.message || String(error)
|
|
192
|
+
const ownCode = typeof error?.code === 'string' && /^E_[A-Z0-9_]+$/.test(error.code) ? error.code : null
|
|
193
|
+
return ownCode || (/401|unauthori[sz]ed/i.test(message) ? 'E_PEER_AUTH' : fallback)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Open a pinned MCP connection to a paired peer and keep it for several calls
|
|
198
|
+
* (tools/list followed by tools/call). TLS trust comes from the stored pin, not
|
|
199
|
+
* from NODE_EXTRA_CA_CERTS, so this works in any process — including a stdio
|
|
200
|
+
* bridge child that DSH spawned with a scrubbed environment. The caller owns
|
|
201
|
+
* `close()`.
|
|
202
|
+
*/
|
|
203
|
+
export async function connectPeer(peer, { timeoutMs = DEFAULT_TIMEOUT_MS, clientName = 'dsh-mesh-peer' } = {}) {
|
|
204
|
+
const name = String(peer?.name || peer?.id || 'peer')
|
|
205
|
+
if (!peer?.url) throw peerError('E_INVALID_ARG', `peer "${name}" has no url`)
|
|
206
|
+
if (!peer?.token) throw peerError('E_PEER_NO_TOKEN', `peer "${name}" has no outbound token — run \`node src/cli.js peer import --file <name>.yml\``)
|
|
207
|
+
const endpoint = `${baseUrlOf(peer.url)}/mcp`
|
|
208
|
+
const client = new Client({ name: clientName, version: MESH_VERSION })
|
|
209
|
+
const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
|
|
210
|
+
fetch: makePinnedFetch({ certFingerprint: peer.certFingerprint, timeoutMs }),
|
|
211
|
+
requestInit: { headers: { Authorization: `Bearer ${peer.token}` } }
|
|
212
|
+
})
|
|
213
|
+
try {
|
|
214
|
+
await client.connect(transport)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
await closeConnection(client, transport)
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
return { peer: name, client, transport, close: () => closeConnection(client, transport) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Call one tool on a paired peer. `peer` = {name, url, token, certFingerprint}
|
|
224
|
+
* (as stored by `peer import`). Returns a compact result — never the token.
|
|
225
|
+
*/
|
|
226
|
+
export async function callPeerTool(peer, { tool, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
227
|
+
const name = String(peer?.name || peer?.id || 'peer')
|
|
228
|
+
const started = Date.now()
|
|
229
|
+
let connection
|
|
230
|
+
try {
|
|
231
|
+
connection = await connectPeer(peer, { timeoutMs, clientName: 'dsh-mesh-cluster-exec' })
|
|
232
|
+
const result = await connection.client.callTool({ name: tool, arguments: args })
|
|
233
|
+
return {
|
|
234
|
+
peer: name,
|
|
235
|
+
tool,
|
|
236
|
+
ok: result?.isError !== true,
|
|
237
|
+
ms: Date.now() - started,
|
|
238
|
+
result
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const message = error?.message || String(error)
|
|
242
|
+
throw peerError(peerFailureCode(error, 'E_PEER_CALL_FAILED'), `peer "${name}" tool "${tool}" failed: ${message}`)
|
|
243
|
+
} finally {
|
|
244
|
+
if (connection) await connection.close()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* List the tools a paired peer exposes (same pinned transport as callPeerTool).
|
|
250
|
+
* Used by `peer tools`/`peer call` and by the stdio bridge, which republishes
|
|
251
|
+
* this list locally so the tool surface stays in sync with the peer.
|
|
252
|
+
*/
|
|
253
|
+
export async function listPeerTools(peer, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
254
|
+
const name = String(peer?.name || peer?.id || 'peer')
|
|
255
|
+
const started = Date.now()
|
|
256
|
+
let connection
|
|
257
|
+
try {
|
|
258
|
+
connection = await connectPeer(peer, { timeoutMs, clientName: 'dsh-mesh-peer-tools' })
|
|
259
|
+
const listed = await connection.client.listTools()
|
|
260
|
+
const tools = listed?.tools ?? []
|
|
261
|
+
return {
|
|
262
|
+
peer: name,
|
|
263
|
+
ok: true,
|
|
264
|
+
ms: Date.now() - started,
|
|
265
|
+
count: tools.length,
|
|
266
|
+
tools: tools.map((tool) => ({ name: tool.name, description: tool.description || '' }))
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
throw peerError(peerFailureCode(error, 'E_PEER_CALL_FAILED'), `peer "${name}" tools/list failed: ${error?.message || error}`)
|
|
270
|
+
} finally {
|
|
271
|
+
if (connection) await connection.close()
|
|
272
|
+
}
|
|
273
|
+
}
|