dsh-mesh-node 0.0.0-stage → 0.0.2
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 +341 -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/sakura-api.js +103 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/tunnels.js +212 -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 +317 -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/acl.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ACL editing helpers. Pure functions so the CLI and the self-test share one truth:
|
|
2
|
+
// an upgraded config keeps its (possibly customised) allow list — new default allows
|
|
3
|
+
// are NOT merged in automatically, which is why `acl reset|allow|deny` exists.
|
|
4
|
+
import { DEFAULT_ALLOW } from './config.js'
|
|
5
|
+
|
|
6
|
+
function unique(list) {
|
|
7
|
+
return [...new Set(list.filter(name => typeof name === 'string' && name.trim()).map(name => name.trim()))]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function validate(names, known) {
|
|
11
|
+
if (!known) return names
|
|
12
|
+
const catalog = new Set(known)
|
|
13
|
+
const unknown = names.filter(name => !catalog.has(name))
|
|
14
|
+
if (unknown.length > 0) {
|
|
15
|
+
throw Object.assign(new Error(`unknown tool(s): ${unknown.join(', ')}`), { code: 'E_INVALID_ARG' })
|
|
16
|
+
}
|
|
17
|
+
return names
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute the next ACL from the current one.
|
|
22
|
+
* @param {{allow?: string[], deny?: string[]}} current
|
|
23
|
+
* @param {{allow?: string[], deny?: string[], reset?: boolean, known?: string[]}} change
|
|
24
|
+
* @returns {{allow: string[], deny: string[], changed: boolean, notes: string[]}}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveAclUpdate(current, change = {}) {
|
|
27
|
+
const notes = []
|
|
28
|
+
let allow = [...(current?.allow || [])]
|
|
29
|
+
let deny = [...(current?.deny || [])]
|
|
30
|
+
|
|
31
|
+
if (change.reset) {
|
|
32
|
+
allow = [...DEFAULT_ALLOW]
|
|
33
|
+
deny = []
|
|
34
|
+
notes.push('reset to DEFAULT_ALLOW')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toAllow = validate(unique(change.allow || []), change.known)
|
|
38
|
+
const toDeny = validate(unique(change.deny || []), change.known)
|
|
39
|
+
|
|
40
|
+
for (const name of toAllow) {
|
|
41
|
+
if (!allow.includes(name)) {
|
|
42
|
+
allow.push(name)
|
|
43
|
+
notes.push(`allow ${name}`)
|
|
44
|
+
}
|
|
45
|
+
if (deny.includes(name)) {
|
|
46
|
+
deny = deny.filter(n => n !== name)
|
|
47
|
+
notes.push(`un-deny ${name}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const name of toDeny) {
|
|
51
|
+
if (!deny.includes(name)) {
|
|
52
|
+
deny.push(name)
|
|
53
|
+
notes.push(`deny ${name}`)
|
|
54
|
+
}
|
|
55
|
+
if (allow.includes(name)) {
|
|
56
|
+
allow = allow.filter(n => n !== name)
|
|
57
|
+
notes.push(`un-allow ${name}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const changed =
|
|
62
|
+
allow.length !== (current?.allow || []).length ||
|
|
63
|
+
deny.length !== (current?.deny || []).length ||
|
|
64
|
+
allow.some(n => !(current?.allow || []).includes(n))
|
|
65
|
+
|
|
66
|
+
return { allow, deny, changed, notes }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Effective view: every tool with the flag that `mesh_self` reports. */
|
|
70
|
+
export function describeAcl(config, names = []) {
|
|
71
|
+
const allow = config?.acl?.allow || []
|
|
72
|
+
const deny = config?.acl?.deny || []
|
|
73
|
+
return {
|
|
74
|
+
allow,
|
|
75
|
+
deny,
|
|
76
|
+
tools: names.map(name => {
|
|
77
|
+
const denied = deny.includes(name)
|
|
78
|
+
return { name, allowed: denied ? false : allow.includes(name) }
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { nowIso, short, stripBom } from './util.js'
|
|
4
|
+
|
|
5
|
+
const MAX_AUDIT_BYTES = 5 * 1024 * 1024
|
|
6
|
+
const KEEP_TAIL_LINES = 400
|
|
7
|
+
|
|
8
|
+
export function auditPath(home) {
|
|
9
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
10
|
+
return join(home, 'audit.jsonl')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rotateIfNeeded(file) {
|
|
14
|
+
try {
|
|
15
|
+
if (!existsSync(file)) return
|
|
16
|
+
if (statSync(file).size < MAX_AUDIT_BYTES) return
|
|
17
|
+
renameSync(file, `${file}.1`)
|
|
18
|
+
} catch {
|
|
19
|
+
/* rotation is best-effort: never break a tool call because of it */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Audit writer. One JSON line per tool call: who (token id), what, result, duration.
|
|
25
|
+
* Never contains argument values (they may hold secrets); only argument key names.
|
|
26
|
+
*/
|
|
27
|
+
export function makeAudit({ home, log = console }) {
|
|
28
|
+
const file = auditPath(home)
|
|
29
|
+
return async function audit(event) {
|
|
30
|
+
const line = {
|
|
31
|
+
ts: nowIso(),
|
|
32
|
+
...event,
|
|
33
|
+
args: Array.isArray(event?.args) ? event.args : undefined
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
rotateIfNeeded(file)
|
|
37
|
+
appendFileSync(file, JSON.stringify(line) + '\n', { encoding: 'utf8' })
|
|
38
|
+
} catch (error) {
|
|
39
|
+
log?.error?.(`[dsh-mesh] audit write failed: ${error?.message || error}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read the last `limit` audit lines (newest last). */
|
|
45
|
+
export function tailAudit(home, { limit = 50, tool, ok } = {}) {
|
|
46
|
+
const file = auditPath(home)
|
|
47
|
+
if (!existsSync(file)) return { path: file, entries: [], total: 0 }
|
|
48
|
+
const text = stripBom(readFileSync(file, 'utf8'))
|
|
49
|
+
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
50
|
+
let entries = []
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
try {
|
|
53
|
+
entries.push(JSON.parse(stripBom(line)))
|
|
54
|
+
} catch {
|
|
55
|
+
/* skip corrupt line */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const total = entries.length
|
|
59
|
+
if (tool) entries = entries.filter((e) => e.tool === tool)
|
|
60
|
+
if (typeof ok === 'boolean') entries = entries.filter((e) => e.ok === ok)
|
|
61
|
+
const slice = entries.slice(-Math.min(Math.max(1, limit), KEEP_TAIL_LINES))
|
|
62
|
+
return { path: file, total, matched: entries.length, entries: slice }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function auditSummary(home) {
|
|
66
|
+
const file = auditPath(home)
|
|
67
|
+
if (!existsSync(file)) return { path: file, exists: false, sizeBytes: 0 }
|
|
68
|
+
let sizeBytes = 0
|
|
69
|
+
try {
|
|
70
|
+
sizeBytes = statSync(file).size
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore */
|
|
73
|
+
}
|
|
74
|
+
return { path: file, exists: true, sizeBytes, preview: short('', 0) }
|
|
75
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Autostart helper: keep the mesh endpoint alive across user logins (Windows Task Scheduler).
|
|
2
|
+
// Read-only by default: `status` only queries schtasks; `install`/`remove` need an explicit
|
|
3
|
+
// confirmation flag because creating a per-user scheduled task is a user-level config change.
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { dirname as dirnameOf, join as joinPath } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export const TASK_NAME = 'dsh-mesh-endpoint'
|
|
9
|
+
|
|
10
|
+
const CLI_PATH = joinPath(dirnameOf(fileURLToPath(import.meta.url)), '..', 'cli.js')
|
|
11
|
+
|
|
12
|
+
function exec(args) {
|
|
13
|
+
return new Promise((resolvePromise) => {
|
|
14
|
+
// schtasks writes the console code page (GBK on zh-CN Windows), so decode bytes ourselves.
|
|
15
|
+
execFile(
|
|
16
|
+
'schtasks',
|
|
17
|
+
args,
|
|
18
|
+
{ windowsHide: true, timeout: 15000, encoding: 'buffer' },
|
|
19
|
+
(error, stdout, stderr) => {
|
|
20
|
+
const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
21
|
+
resolvePromise({
|
|
22
|
+
code,
|
|
23
|
+
stdout: decodeText(stdout),
|
|
24
|
+
stderr: decodeText(stderr),
|
|
25
|
+
message: error ? String(error.message) : ''
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function decodeText(buf) {
|
|
33
|
+
if (!Buffer.isBuffer(buf)) return buf || ''
|
|
34
|
+
try {
|
|
35
|
+
return new TextDecoder('gbk').decode(buf)
|
|
36
|
+
} catch {
|
|
37
|
+
return buf.toString('utf8')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertWindows() {
|
|
42
|
+
if (process.platform !== 'win32') throw new Error('autostart is Windows-only (schtasks)')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse `schtasks /Query /V /FO LIST` into a flat object (localized labels are matched by suffix). */
|
|
46
|
+
function parseVerboseList(text) {
|
|
47
|
+
const out = {}
|
|
48
|
+
for (const line of text.split(/\r?\n/)) {
|
|
49
|
+
const i = line.indexOf(':')
|
|
50
|
+
if (i === -1) continue
|
|
51
|
+
const key = line.slice(0, i).trim()
|
|
52
|
+
const value = line.slice(i + 1).trim()
|
|
53
|
+
if (key && value) out[key] = value
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Inspect the task. Never throws for "not installed" — that is the normal state. */
|
|
59
|
+
export async function autostartStatus(name = TASK_NAME) {
|
|
60
|
+
assertWindows()
|
|
61
|
+
const res = await exec(['/Query', '/TN', name, '/V', '/FO', 'LIST'])
|
|
62
|
+
if (res.code !== 0) {
|
|
63
|
+
return { installed: false, name, detail: (res.stderr || res.message || '').trim() }
|
|
64
|
+
}
|
|
65
|
+
const fields = parseVerboseList(res.stdout)
|
|
66
|
+
// zh-CN Windows localizes these labels ("状态", "要运行的任务", …), so a suffix match on the
|
|
67
|
+
// English name alone silently returns undefined — match localized aliases too (2026-10-19).
|
|
68
|
+
const ALIASES = {
|
|
69
|
+
status: ['Status', '状态'],
|
|
70
|
+
state: ['ScheduledTaskState', '计划任务状态', '已计划的任务状态'],
|
|
71
|
+
taskToRun: ['TaskToRun', '要运行的任务'],
|
|
72
|
+
lastRun: ['LastRunTime', '上次运行时间'],
|
|
73
|
+
lastResult: ['LastResult', '上次结果'],
|
|
74
|
+
runAsUser: ['RunAsUser', '运行身份', '运行方式', '作为用户运行']
|
|
75
|
+
}
|
|
76
|
+
const pick = (aliases) => {
|
|
77
|
+
const squash = (value) => value.replace(/[\s.]/g, '')
|
|
78
|
+
const key = Object.keys(fields).find((candidate) => aliases.some((alias) => squash(candidate).endsWith(squash(alias)) && squash(candidate).length <= squash(alias).length + 2))
|
|
79
|
+
|| Object.keys(fields).find((candidate) => aliases.includes(candidate.trim()))
|
|
80
|
+
return key ? fields[key] : undefined
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
installed: true,
|
|
84
|
+
name,
|
|
85
|
+
status: pick(ALIASES.status),
|
|
86
|
+
state: pick(ALIASES.state),
|
|
87
|
+
taskToRun: pick(ALIASES.taskToRun),
|
|
88
|
+
lastRun: pick(ALIASES.lastRun),
|
|
89
|
+
lastResult: pick(ALIASES.lastResult),
|
|
90
|
+
runAsUser: pick(ALIASES.runAsUser)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The exact command a login-time task would run. */
|
|
95
|
+
export function autostartCommand(name = TASK_NAME, port) {
|
|
96
|
+
const portArg = port ? ` --port ${port}` : ''
|
|
97
|
+
return `"${process.execPath}" "${CLI_PATH}" serve${portArg}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create/replace a logon-triggered task that runs `serve`.
|
|
102
|
+
* Callers must pass `yes: true`; the CLI only forwards it after an explicit `--yes`.
|
|
103
|
+
*/
|
|
104
|
+
export async function autostartInstall({ name = TASK_NAME, command = autostartCommand(name), yes = false } = {}) {
|
|
105
|
+
assertWindows()
|
|
106
|
+
if (!yes) throw new Error('refusing to install without --yes (creates a per-user scheduled task)')
|
|
107
|
+
// /RL LIMITED: run at the user's normal integrity level, never elevated.
|
|
108
|
+
const res = await exec(['/Create', '/TN', name, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TR', command, '/F'])
|
|
109
|
+
if (res.code !== 0) throw new Error(`schtasks /Create failed (${res.code}): ${(res.stderr || res.message || '').trim()}`)
|
|
110
|
+
return { ok: true, name, command, stdout: res.stdout.trim() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function autostartRemove({ name = TASK_NAME, yes = false } = {}) {
|
|
114
|
+
assertWindows()
|
|
115
|
+
if (!yes) throw new Error('refusing to remove without --yes')
|
|
116
|
+
const res = await exec(['/Delete', '/TN', name, '/F'])
|
|
117
|
+
if (res.code !== 0) throw new Error(`schtasks /Delete failed (${res.code}): ${(res.stderr || res.message || '').trim()}`)
|
|
118
|
+
return { ok: true, name }
|
|
119
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster view (M7 step 4).
|
|
3
|
+
*
|
|
4
|
+
* A single read-only snapshot of everything this node can currently reach:
|
|
5
|
+
* - local: every DSH instance behind a host bridge (id / pid / tool count / live agents)
|
|
6
|
+
* - remote: every paired peer's `/health` (version / tools / uptime / TLS pin)
|
|
7
|
+
*
|
|
8
|
+
* The point is to answer "is the cluster healthy, and if not, which hop is broken"
|
|
9
|
+
* from one call, instead of hand-running `host status` plus curl on each peer.
|
|
10
|
+
* Nothing here mutates state; all probes are bounded by `timeoutMs`.
|
|
11
|
+
*/
|
|
12
|
+
import { request as httpRequest } from 'node:http'
|
|
13
|
+
import { request as httpsRequest } from 'node:https'
|
|
14
|
+
import { createHostClient, hostInstances } from './host-agent.js'
|
|
15
|
+
import { MESH_VERSION } from './config.js'
|
|
16
|
+
import { callPeerTool } from './peer-client.js'
|
|
17
|
+
import { fingerprintOfSocket, normalizeFingerprint } from './tls.js'
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 4000
|
|
20
|
+
|
|
21
|
+
function normalizeUrl(url) {
|
|
22
|
+
return String(url || '').replace(/\/+$/, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizePeerHealth(body) {
|
|
26
|
+
if (!body || body.service !== 'dsh-mesh') {
|
|
27
|
+
return { error: { code: 'E_PEER_BAD_RESPONSE', message: 'peer /health did not identify as dsh-mesh' } }
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
version: body.version,
|
|
31
|
+
node: body.node,
|
|
32
|
+
tools: body.tools,
|
|
33
|
+
pid: body.pid ?? null,
|
|
34
|
+
uptimeSec: body.uptimeSec,
|
|
35
|
+
tls: body.tls === true,
|
|
36
|
+
tunnels: body.tunnels || null,
|
|
37
|
+
pairing: body.pairing ? { enabled: body.pairing.enabled === true, codeActive: body.pairing.codeActive === true } : null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Probe one paired peer over HTTPS. Uses TOFU pinning (peers store the cert
|
|
43
|
+
* fingerprint captured at pairing time): the trust chain is not judged, the
|
|
44
|
+
* certificate is read off the socket and compared against the stored pin.
|
|
45
|
+
*/
|
|
46
|
+
export function probePeer(peer, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
47
|
+
const base = normalizeUrl(peer?.url)
|
|
48
|
+
const expected = peer?.certFingerprint ? normalizeFingerprint(peer.certFingerprint) : null
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
let settled = false
|
|
51
|
+
const startedAt = Date.now()
|
|
52
|
+
const finish = (value) => {
|
|
53
|
+
if (settled) return
|
|
54
|
+
settled = true
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
resolve({ id: peer?.id || null, name: peer?.name || null, url: base, ...value })
|
|
57
|
+
}
|
|
58
|
+
if (!base) {
|
|
59
|
+
return finish({ up: false, latencyMs: 0, error: { code: 'E_PEER_BAD_RESPONSE', message: 'peer has no url' } })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isHttps = base.startsWith('https:')
|
|
63
|
+
const request = isHttps ? httpsRequest : httpRequest
|
|
64
|
+
const options = { method: 'GET', agent: false, headers: { accept: 'application/json' } }
|
|
65
|
+
if (isHttps) options.rejectUnauthorized = false
|
|
66
|
+
|
|
67
|
+
const req = request(`${base}/health`, options, (res) => {
|
|
68
|
+
// Grab the certificate *inside* the response callback: by the time `end`
|
|
69
|
+
// fires the socket may already be gone (same trap as company-check.mjs).
|
|
70
|
+
let got = null
|
|
71
|
+
if (isHttps) {
|
|
72
|
+
try {
|
|
73
|
+
got = fingerprintOfSocket(res.socket)
|
|
74
|
+
} catch {
|
|
75
|
+
got = null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const chunks = []
|
|
79
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
80
|
+
res.on('end', () => {
|
|
81
|
+
const latencyMs = Date.now() - startedAt
|
|
82
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
83
|
+
let body
|
|
84
|
+
try {
|
|
85
|
+
body = JSON.parse(text)
|
|
86
|
+
} catch {
|
|
87
|
+
return finish({
|
|
88
|
+
up: false,
|
|
89
|
+
latencyMs,
|
|
90
|
+
fingerprint: got,
|
|
91
|
+
fingerprintMatch: got && expected ? normalizeFingerprint(got) === expected : null,
|
|
92
|
+
error: { code: 'E_PEER_BAD_RESPONSE', message: `peer /health returned non-JSON (${res.statusCode})` }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
const match = got && expected ? normalizeFingerprint(got) === expected : null
|
|
96
|
+
if (res.statusCode !== 200) {
|
|
97
|
+
return finish({
|
|
98
|
+
up: false,
|
|
99
|
+
latencyMs,
|
|
100
|
+
status: res.statusCode,
|
|
101
|
+
fingerprint: got,
|
|
102
|
+
fingerprintMatch: match,
|
|
103
|
+
error: { code: 'E_PEER_BAD_RESPONSE', message: `peer /health returned ${res.statusCode}` }
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
if (match === false) {
|
|
107
|
+
const health = normalizePeerHealth(body)
|
|
108
|
+
return finish({
|
|
109
|
+
up: true,
|
|
110
|
+
latencyMs,
|
|
111
|
+
fingerprint: got,
|
|
112
|
+
fingerprintMatch: false,
|
|
113
|
+
...health,
|
|
114
|
+
error: {
|
|
115
|
+
code: 'E_PEER_PIN_MISMATCH',
|
|
116
|
+
message: 'peer certificate does not match the fingerprint stored at pairing time — refuse to trust it'
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
const health = normalizePeerHealth(body)
|
|
121
|
+
return finish({ up: !health.error, latencyMs, fingerprint: got, fingerprintMatch: match, ...health })
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
try {
|
|
126
|
+
req.destroy(new Error('timeout'))
|
|
127
|
+
} catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
finish({
|
|
131
|
+
up: false,
|
|
132
|
+
latencyMs: Date.now() - startedAt,
|
|
133
|
+
error: { code: 'E_PEER_TIMEOUT', message: `peer did not answer within ${timeoutMs} ms` }
|
|
134
|
+
})
|
|
135
|
+
}, timeoutMs)
|
|
136
|
+
req.on('error', (error) => {
|
|
137
|
+
finish({
|
|
138
|
+
up: false,
|
|
139
|
+
latencyMs: Date.now() - startedAt,
|
|
140
|
+
error: {
|
|
141
|
+
code: error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED' ? 'E_PEER_OFFLINE' : 'E_PEER_TLS',
|
|
142
|
+
message: `${base} unreachable (${error?.code || error?.message || error})`,
|
|
143
|
+
detail: error?.code || null
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')))
|
|
148
|
+
req.end()
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Probe one local DSH instance through its host bridge. */
|
|
153
|
+
export async function probeInstance({ config, home, log, entry, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
154
|
+
const startedAt = Date.now()
|
|
155
|
+
const base = { id: entry.id, label: entry.label || entry.id, url: entry.url, profileDir: entry.profileDir || '' }
|
|
156
|
+
try {
|
|
157
|
+
const client = createHostClient({ config, home, log, instance: entry.id })
|
|
158
|
+
await client.ping()
|
|
159
|
+
const [self, tools, agents] = await Promise.all([client.self(), client.tools(), client.agents()])
|
|
160
|
+
const toolList = tools?.tools || []
|
|
161
|
+
const agentList = agents?.agents || []
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
up: true,
|
|
165
|
+
latencyMs: Date.now() - startedAt,
|
|
166
|
+
plugin: self?.plugin || null,
|
|
167
|
+
hostname: self?.node?.hostname || null,
|
|
168
|
+
platform: self?.node?.platform || null,
|
|
169
|
+
pid: self?.node?.pid || null,
|
|
170
|
+
toolCount: toolList.length,
|
|
171
|
+
agents: agentList.map((agent) => ({ id: agent.id, status: agent.status, title: agent.title || '' })),
|
|
172
|
+
runningAgents: agentList.filter((agent) => agent.status === 'running').length
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
up: false,
|
|
178
|
+
latencyMs: Date.now() - startedAt,
|
|
179
|
+
error: { code: error?.code || 'E_HOST_OFFLINE', message: error?.message || String(error) }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Probe this node's OWN serve endpoint (`/health`) — the hop every peer needs.
|
|
186
|
+
* The host-bridge probes below only prove the DSH instances are up; when the
|
|
187
|
+
* local mesh server dies they all still look fine and only the peers look
|
|
188
|
+
* offline. Reported 2026-10-23: the company endpoint had been down ~3 days
|
|
189
|
+
* while `self-update status --deep` read clean.
|
|
190
|
+
*/
|
|
191
|
+
export async function probeSelfEndpoint(config, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
192
|
+
const node = config?.node || {}
|
|
193
|
+
const scheme = node.tls?.enabled ? 'https' : 'http'
|
|
194
|
+
const url = `${scheme}://${node.bind || '127.0.0.1'}:${node.port || 8124}`
|
|
195
|
+
const probe = await probePeer({ id: 'self', name: 'self', url }, { timeoutMs })
|
|
196
|
+
return {
|
|
197
|
+
url,
|
|
198
|
+
up: probe.up === true,
|
|
199
|
+
version: probe.version || null,
|
|
200
|
+
pid: probe.pid ?? null,
|
|
201
|
+
tools: probe.tools ?? null,
|
|
202
|
+
uptimeSec: probe.uptimeSec ?? null,
|
|
203
|
+
error: probe.error || null
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Parse the JSON payload of an MCP tool result (remote tools return text content). */
|
|
208
|
+
function parsePeerToolPayload(result) {
|
|
209
|
+
const block = result?.content?.[0]
|
|
210
|
+
if (!block || block.type !== 'text') return null
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(block.text)
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Ask a paired peer for its OWN host instances through the MCP `host_instances`
|
|
220
|
+
* tool, so the cluster view can show all machines' DSH instances in one place.
|
|
221
|
+
* This is best-effort: a peer that denies the tool (or has no token) still gets
|
|
222
|
+
* its /health row — we just record `instancesError` instead of failing the view.
|
|
223
|
+
*/
|
|
224
|
+
async function probePeerInstances(peer, timeoutMs) {
|
|
225
|
+
if (!peer?.token) {
|
|
226
|
+
return { error: { code: 'E_PEER_NO_TOKEN', message: 'peer has no outbound token in peers.json — cannot list its host instances' } }
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const result = await callPeerTool(peer, { tool: 'host_instances', args: {}, timeoutMs })
|
|
230
|
+
const body = parsePeerToolPayload(result.result)
|
|
231
|
+
const rows = Array.isArray(body?.instances) ? body.instances : []
|
|
232
|
+
return {
|
|
233
|
+
instances: rows.map((row) => ({
|
|
234
|
+
id: row.id,
|
|
235
|
+
label: row.label || row.id,
|
|
236
|
+
url: row.url,
|
|
237
|
+
alive: row.alive === true
|
|
238
|
+
}))
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return { error: { code: error?.code || 'E_PEER_CALL_FAILED', message: error?.message || String(error) } }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {{ probeSelf?: boolean }} options `probeSelf: true` additionally probes
|
|
247
|
+
* this node's own serve endpoint, so a CLI running *outside* the server can tell
|
|
248
|
+
* "my host bridges are fine" apart from "my node is actually serving peers".
|
|
249
|
+
* Off by default: callers inside the server would only measure themselves, and
|
|
250
|
+
* tests use `node.port = 0`.
|
|
251
|
+
*/
|
|
252
|
+
export async function clusterReport({ config, home, log, peers = [], timeoutMs = DEFAULT_TIMEOUT_MS, probeSelf = false } = {}) {
|
|
253
|
+
const endpoint = probeSelf ? await probeSelfEndpoint(config, { timeoutMs }) : null
|
|
254
|
+
const instances = []
|
|
255
|
+
for (const entry of hostInstances(config)) {
|
|
256
|
+
if (entry.enabled === false) continue
|
|
257
|
+
instances.push(await probeInstance({ config, home, log, entry, timeoutMs }))
|
|
258
|
+
}
|
|
259
|
+
const peerReports = await Promise.all(
|
|
260
|
+
peers.map(async (peer) => {
|
|
261
|
+
const report = await probePeer(peer, { timeoutMs })
|
|
262
|
+
if (report.up === true && !report.error) {
|
|
263
|
+
const remote = await probePeerInstances(peer, timeoutMs)
|
|
264
|
+
report.instances = remote.instances || []
|
|
265
|
+
if (remote.error) report.instancesError = remote.error
|
|
266
|
+
}
|
|
267
|
+
return report
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const issues = []
|
|
272
|
+
if (endpoint && !endpoint.up) {
|
|
273
|
+
issues.push(`local endpoint ${endpoint.url} down: ${endpoint.error?.code || 'E_ENDPOINT_OFFLINE'}`)
|
|
274
|
+
}
|
|
275
|
+
for (const instance of instances) {
|
|
276
|
+
if (!instance.up) issues.push(`instance ${instance.id} down: ${instance.error?.code || 'E_HOST_OFFLINE'}`)
|
|
277
|
+
else if (instance.runningAgents > 0) issues.push(`instance ${instance.id} has ${instance.runningAgents} running agent(s)`)
|
|
278
|
+
}
|
|
279
|
+
for (const peer of peerReports) {
|
|
280
|
+
if (!peer.up) issues.push(`peer ${peer.name || peer.id || peer.url} down: ${peer.error?.code || 'E_PEER_OFFLINE'}`)
|
|
281
|
+
else if (peer.fingerprintMatch === false) issues.push(`peer ${peer.name || peer.id} fingerprint mismatch`)
|
|
282
|
+
else if (peer.instancesError) {
|
|
283
|
+
issues.push(`peer ${peer.name || peer.id} host instances unavailable: ${peer.instancesError.code}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Flatten remote instances next to local ones so one call shows the whole
|
|
288
|
+
// cluster (four DSH instances on two machines). `scope` says where a row lives.
|
|
289
|
+
const localRows = instances.map((instance) => ({
|
|
290
|
+
scope: 'local',
|
|
291
|
+
id: instance.id,
|
|
292
|
+
label: instance.label,
|
|
293
|
+
up: instance.up === true,
|
|
294
|
+
toolCount: instance.toolCount ?? null,
|
|
295
|
+
pid: instance.pid ?? null
|
|
296
|
+
}))
|
|
297
|
+
const remoteRows = []
|
|
298
|
+
for (const peer of peerReports) {
|
|
299
|
+
for (const row of peer.instances || []) {
|
|
300
|
+
remoteRows.push({
|
|
301
|
+
scope: peer.name || peer.id,
|
|
302
|
+
id: row.id,
|
|
303
|
+
label: row.label,
|
|
304
|
+
url: row.url,
|
|
305
|
+
up: row.alive === true,
|
|
306
|
+
remote: true
|
|
307
|
+
})
|
|
308
|
+
if (row.alive !== true) issues.push(`peer ${peer.name || peer.id} instance ${row.id} down`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const anyInstanceUp = instances.some((instance) => instance.up)
|
|
313
|
+
const remoteAllAlive = remoteRows.every((row) => row.up === true)
|
|
314
|
+
const allUp =
|
|
315
|
+
(!endpoint || endpoint.up) &&
|
|
316
|
+
instances.every((instance) => instance.up) &&
|
|
317
|
+
peerReports.every((peer) => peer.up && peer.fingerprintMatch !== false) &&
|
|
318
|
+
remoteAllAlive
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
node: { version: MESH_VERSION, home, instances: instances.length, peers: peerReports.length },
|
|
322
|
+
summary: { local: localRows.length, remote: remoteRows.length, total: localRows.length + remoteRows.length },
|
|
323
|
+
endpoint,
|
|
324
|
+
instances,
|
|
325
|
+
remoteInstances: remoteRows,
|
|
326
|
+
cluster: [...localRows, ...remoteRows],
|
|
327
|
+
peers: peerReports,
|
|
328
|
+
verdict:
|
|
329
|
+
endpoint && !endpoint.up
|
|
330
|
+
? 'down'
|
|
331
|
+
: instances.length === 0
|
|
332
|
+
? 'unknown'
|
|
333
|
+
: allUp
|
|
334
|
+
? 'ok'
|
|
335
|
+
: anyInstanceUp
|
|
336
|
+
? 'degraded'
|
|
337
|
+
: 'down',
|
|
338
|
+
issues
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|