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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnose one paired peer end to end: can this machine reach it, does a DSH
|
|
3
|
+
* profile mount it, and do the running DSH instances actually expose its tools?
|
|
4
|
+
*
|
|
5
|
+
* Every check corresponds to a failure mode observed in the field:
|
|
6
|
+
* - missing certificate pin / token -> refused handshake or 401;
|
|
7
|
+
* - https peer without NODE_EXTRA_CA_CERTS in the DSH process -> silent
|
|
8
|
+
* "mcp-client(home): server is disconnected" and zero tools;
|
|
9
|
+
* - overlay mounted but zero tools -> the in-DSH client failed to start
|
|
10
|
+
* (DSH hot-reloads cordis.patch.yml, so a restart is the last resort, not
|
|
11
|
+
* the first fix);
|
|
12
|
+
* - ACL/upgrade drift -> tools exist but calls return E_DENIED on the peer.
|
|
13
|
+
*
|
|
14
|
+
* The command is read-only: it never starts, stops, or reconfigures anything.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, join, resolve } from 'node:path'
|
|
19
|
+
import { loadConfig, peersPath } from './config.js'
|
|
20
|
+
import { loadPeers } from './pairing.js'
|
|
21
|
+
import { createHostClient, hostInstances } from './host-agent.js'
|
|
22
|
+
import { connectPeer } from './peer-client.js'
|
|
23
|
+
|
|
24
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
25
|
+
|
|
26
|
+
/** DSH state directories worth scanning, most specific first. */
|
|
27
|
+
export function dshHomeCandidates(env = process.env) {
|
|
28
|
+
const homes = []
|
|
29
|
+
const push = (value) => {
|
|
30
|
+
if (value && !homes.includes(value)) homes.push(value)
|
|
31
|
+
}
|
|
32
|
+
push(env.DSH_HOME)
|
|
33
|
+
for (const suffix of ['.dsh0', '.dsh1', '.dsh2', '.dsh3', '.dsh']) push(join(homedir(), suffix))
|
|
34
|
+
return homes
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Which transport the entry around `lineIndex` declares (search a small window). */
|
|
38
|
+
function transportFor(lines, lineIndex) {
|
|
39
|
+
for (let index = lineIndex; index >= 0 && index >= lineIndex - 25; index -= 1) {
|
|
40
|
+
const match = lines[index].match(/transport:\s*['"]?([A-Za-z-]+)/)
|
|
41
|
+
if (match) return match[1]
|
|
42
|
+
if (index < lineIndex && /^\s*-\s+insert:/.test(lines[index])) break
|
|
43
|
+
}
|
|
44
|
+
return 'unknown'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolve a path for comparison even when it does not exist yet (junction-aware). */
|
|
48
|
+
function canonicalPath(value) {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(value)
|
|
51
|
+
} catch {
|
|
52
|
+
return resolve(value)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when an overlay's patch file lives in exactly this DSH profile directory.
|
|
58
|
+
* An overlay found through another instance's home (or through a `~/.dsh`
|
|
59
|
+
* junction pointing at `~/.dsh0`) must never be credited to another instance.
|
|
60
|
+
*/
|
|
61
|
+
export function overlayMatchesProfile(overlay, profileDir) {
|
|
62
|
+
if (!overlay?.patchPath || !profileDir) return false
|
|
63
|
+
const overlayDir = canonicalPath(dirname(overlay.patchPath))
|
|
64
|
+
const wanted = canonicalPath(profileDir)
|
|
65
|
+
return process.platform === 'win32' ? overlayDir.toLowerCase() === wanted.toLowerCase() : overlayDir === wanted
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Find DSH profile overlays that mount this peer (matched by serverName).
|
|
70
|
+
* Pure filesystem read; returns one row per matching overlay.
|
|
71
|
+
*/
|
|
72
|
+
export function findPeerOverlays(peer, { env = process.env, dshHomes = null } = {}) {
|
|
73
|
+
const names = new Set([peer?.name, peer?.id, `${peer?.name}-mesh`, `${peer?.id}-mesh`].filter(Boolean))
|
|
74
|
+
const matches = []
|
|
75
|
+
const seen = new Set()
|
|
76
|
+
for (const dshHome of dshHomes ?? dshHomeCandidates(env)) {
|
|
77
|
+
const profilesDir = join(dshHome, 'profiles')
|
|
78
|
+
if (!existsSync(profilesDir)) continue
|
|
79
|
+
let profiles
|
|
80
|
+
try {
|
|
81
|
+
profiles = readdirSync(profilesDir, { withFileTypes: true })
|
|
82
|
+
} catch {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
for (const profile of profiles) {
|
|
86
|
+
if (!profile.isDirectory()) continue
|
|
87
|
+
const patchPath = join(profilesDir, profile.name, 'cordis.patch.yml')
|
|
88
|
+
if (!existsSync(patchPath)) continue
|
|
89
|
+
let text
|
|
90
|
+
try {
|
|
91
|
+
text = readFileSync(patchPath, 'utf8')
|
|
92
|
+
} catch {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (!text.includes('dsh-mcp-client')) continue
|
|
96
|
+
const lines = text.split(/\r?\n/)
|
|
97
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
98
|
+
const match = lines[index].match(/^\s*serverName:\s*['"]?([^'"\s]+)['"]?\s*$/)
|
|
99
|
+
if (!match || !names.has(match[1])) continue
|
|
100
|
+
const key = `${canonicalPath(patchPath)}::${match[1]}`
|
|
101
|
+
if (seen.has(key)) continue
|
|
102
|
+
seen.add(key)
|
|
103
|
+
matches.push({
|
|
104
|
+
dshHome,
|
|
105
|
+
profile: profile.name,
|
|
106
|
+
patchPath,
|
|
107
|
+
serverName: match[1],
|
|
108
|
+
transport: transportFor(lines, index)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return matches
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hintForConnectFailure(code) {
|
|
117
|
+
if (code === 'E_PEER_PIN_MISMATCH') {
|
|
118
|
+
return 'the peer certificate changed or the stored pin is wrong: re-pair with `peer add` and the fingerprint printed by `pair --code`'
|
|
119
|
+
}
|
|
120
|
+
if (code === 'E_PEER_NO_PIN') {
|
|
121
|
+
return 'https peer without a fingerprint: `peer import --file <name>.yml --fingerprint <sha256>`'
|
|
122
|
+
}
|
|
123
|
+
if (code === 'E_PEER_AUTH') {
|
|
124
|
+
return 'token rejected: re-pair (`peer add`), or the peer revoked the token (`revoke` on that node)'
|
|
125
|
+
}
|
|
126
|
+
if (code === 'E_PEER_TIMEOUT') {
|
|
127
|
+
return 'no answer in time: check the peer is up (`node src/cli.js cluster status`), the frp tunnel, and that `serve` runs there'
|
|
128
|
+
}
|
|
129
|
+
return 'check the peer node: `node src/cli.js cluster status` and the peer’s own mesh serve log'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run all checks and return {verdict, checks, nextSteps}. `verdict` is `fail`
|
|
134
|
+
* only when a hard requirement (peer record, pin, handshake) fails; mount and
|
|
135
|
+
* environment findings are `warn` unless they would make in-session tools
|
|
136
|
+
* silently absent without any explanation.
|
|
137
|
+
*/
|
|
138
|
+
export async function runPeerDoctor({ home, target = null, timeoutMs = 8000, env = process.env, dshHomes = null } = {}) {
|
|
139
|
+
const checks = []
|
|
140
|
+
const add = (id, status, detail, fix) => checks.push({ id, status, detail, ...(fix ? { fix } : {}) })
|
|
141
|
+
const peers = loadPeers(home)
|
|
142
|
+
const items = peers.items ?? []
|
|
143
|
+
add(
|
|
144
|
+
'peers-file',
|
|
145
|
+
items.length > 0 ? 'pass' : 'fail',
|
|
146
|
+
`${peersPath(home)}: ${items.length} peer record(s)`,
|
|
147
|
+
items.length > 0 ? undefined : 'node src/cli.js peer import --file <name>.yml'
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const peer = target
|
|
151
|
+
? items.find((item) => item.name === target || item.id === target)
|
|
152
|
+
: items.length === 1
|
|
153
|
+
? items[0]
|
|
154
|
+
: null
|
|
155
|
+
if (!peer) {
|
|
156
|
+
add(
|
|
157
|
+
'peer-selection',
|
|
158
|
+
'fail',
|
|
159
|
+
target
|
|
160
|
+
? `peer "${target}" is not in ${peersPath(home)}`
|
|
161
|
+
: `several peers are paired (${items.map((item) => item.name).join(', ')}); pass --to <name>`,
|
|
162
|
+
'node src/cli.js peer list'
|
|
163
|
+
)
|
|
164
|
+
return { verdict: 'fail', checks, nextSteps: ['pick a peer with --to <name>'] }
|
|
165
|
+
}
|
|
166
|
+
add('peer-selection', 'pass', `peer "${peer.name}" -> ${peer.url}`)
|
|
167
|
+
|
|
168
|
+
const secure = /^https:/i.test(peer.url)
|
|
169
|
+
if (secure) {
|
|
170
|
+
add(
|
|
171
|
+
'certificate-pin',
|
|
172
|
+
peer.certFingerprint ? 'pass' : 'fail',
|
|
173
|
+
peer.certFingerprint
|
|
174
|
+
? `pinned ${String(peer.certFingerprint).slice(0, 23)}…`
|
|
175
|
+
: 'https peer has no stored fingerprint; the pinned fetch refuses to connect',
|
|
176
|
+
peer.certFingerprint
|
|
177
|
+
? undefined
|
|
178
|
+
: 'node src/cli.js peer import --file <name>.yml --fingerprint <sha256 from `pair --code`>'
|
|
179
|
+
)
|
|
180
|
+
} else {
|
|
181
|
+
add('certificate-pin', 'pass', 'plain http peer — no TLS trust needed (not recommended across machines)')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const overlays = findPeerOverlays(peer, { env, dshHomes })
|
|
185
|
+
const httpOverlay = overlays.find((entry) => entry.transport !== 'stdio')
|
|
186
|
+
const overlayDetail = overlays.map((entry) => `${entry.dshHome}/profiles/${entry.profile} (${entry.transport})`).join('; ')
|
|
187
|
+
const caEnv = env.NODE_EXTRA_CA_CERTS || ''
|
|
188
|
+
if (secure && httpOverlay && !caEnv) {
|
|
189
|
+
add(
|
|
190
|
+
'dsh-ca-env',
|
|
191
|
+
'warn',
|
|
192
|
+
'NODE_EXTRA_CA_CERTS unset in this process and an overlay uses streamable-http: the in-DSH client cannot verify the self-signed peer and stays disconnected',
|
|
193
|
+
`switch it to the stdio bridge: node src/cli.js peer patch --transport stdio --to ${peer.name}`
|
|
194
|
+
)
|
|
195
|
+
} else if (secure) {
|
|
196
|
+
add(
|
|
197
|
+
'dsh-ca-env',
|
|
198
|
+
'pass',
|
|
199
|
+
caEnv ? `NODE_EXTRA_CA_CERTS=${caEnv}` : 'not needed: the stdio bridge pins the peer certificate itself'
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (overlays.length === 0) {
|
|
204
|
+
add(
|
|
205
|
+
'dsh-overlay',
|
|
206
|
+
'warn',
|
|
207
|
+
'no DSH profile overlay mounts this peer, so only CLI calls work',
|
|
208
|
+
`node src/cli.js peer patch --transport stdio --to ${peer.name}`
|
|
209
|
+
)
|
|
210
|
+
} else {
|
|
211
|
+
add('dsh-overlay', 'pass', overlayDetail)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Did the RUNNING instances actually register the peer tools? This is the only
|
|
215
|
+
// check that distinguishes "overlay is valid" from "overlay is live".
|
|
216
|
+
try {
|
|
217
|
+
const config = loadConfig(home)
|
|
218
|
+
const instances = hostInstances(config)
|
|
219
|
+
if (instances.length === 0) {
|
|
220
|
+
add('dsh-live-tools', 'warn', 'no host bridge configured, so loaded tools cannot be verified', 'node src/cli.js host install --profile <DSH profile>')
|
|
221
|
+
}
|
|
222
|
+
for (const entry of instances) {
|
|
223
|
+
const client = createHostClient({ config, home, log: quiet, instance: entry.id })
|
|
224
|
+
const alive = await client.ping().catch(() => false)
|
|
225
|
+
if (!alive) {
|
|
226
|
+
add(`dsh-${entry.id}`, 'warn', 'host bridge not reachable — its loaded tools cannot be verified')
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
const { tools } = await client.tools().catch(() => ({ tools: [] }))
|
|
230
|
+
const names = (tools ?? []).map((tool) => tool.name)
|
|
231
|
+
const mounted = names.filter((name) => name.startsWith(`mcp__${peer.name}__`) || name.startsWith(`mcp__${peer.name}-mesh__`))
|
|
232
|
+
const ownOverlays = entry.profileDir
|
|
233
|
+
? overlays.filter((overlay) => overlayMatchesProfile(overlay, entry.profileDir))
|
|
234
|
+
: overlays
|
|
235
|
+
if (mounted.length > 0) {
|
|
236
|
+
add(`dsh-${entry.id}`, 'pass', `${mounted.length} peer tool(s) registered (e.g. ${mounted[0]})`)
|
|
237
|
+
} else if (ownOverlays.length > 0) {
|
|
238
|
+
add(
|
|
239
|
+
`dsh-${entry.id}`,
|
|
240
|
+
'warn',
|
|
241
|
+
`overlay is mounted in ${entry.profileDir} but this running instance exposes 0 "${peer.name}" tools (${names.length} total)`,
|
|
242
|
+
'DSH hot-reloads cordis.patch.yml (HMR) within seconds; if the tools stay missing, check the mcp-client startup error in the DSH log — restart only as a last resort'
|
|
243
|
+
)
|
|
244
|
+
} else {
|
|
245
|
+
add(
|
|
246
|
+
`dsh-${entry.id}`,
|
|
247
|
+
'warn',
|
|
248
|
+
`no overlay in this instance's profile (${entry.profileDir || 'unknown'}), so it can never expose peer tools (${names.length} tools total)`,
|
|
249
|
+
entry.profileDir
|
|
250
|
+
? `add the peer insert block to ${join(entry.profileDir, 'cordis.patch.yml')} (HMR picks it up, no restart)`
|
|
251
|
+
: undefined
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
add('dsh-live-tools', 'warn', `could not inspect running instances: ${error?.message || error}`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let connection
|
|
260
|
+
const started = Date.now()
|
|
261
|
+
try {
|
|
262
|
+
connection = await connectPeer(peer, { timeoutMs })
|
|
263
|
+
const listed = await connection.client.listTools()
|
|
264
|
+
add('mcp-handshake', 'pass', `initialized and listed ${(listed.tools ?? []).length} tools in ${Date.now() - started} ms`)
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const code = typeof error?.code === 'string' ? error.code : null
|
|
267
|
+
add('mcp-handshake', 'fail', `${code ? `[${code}] ` : ''}${error?.message || error}`, hintForConnectFailure(code))
|
|
268
|
+
} finally {
|
|
269
|
+
if (connection) await connection.close()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const verdict = checks.some((check) => check.status === 'fail')
|
|
273
|
+
? 'fail'
|
|
274
|
+
: checks.some((check) => check.status === 'warn')
|
|
275
|
+
? 'warn'
|
|
276
|
+
: 'ok'
|
|
277
|
+
const nextSteps = []
|
|
278
|
+
const caMissing = secure && !caEnv
|
|
279
|
+
if (httpOverlay && caMissing) {
|
|
280
|
+
nextSteps.push(`switch the overlay to the stdio bridge: node src/cli.js peer patch --transport stdio --to ${peer.name}`)
|
|
281
|
+
}
|
|
282
|
+
if (overlays.length === 0) {
|
|
283
|
+
nextSteps.push(`mount the peer in DSH: node src/cli.js peer patch --transport stdio --to ${peer.name}`)
|
|
284
|
+
}
|
|
285
|
+
const staleLive = checks.filter(
|
|
286
|
+
(check) => check.id.startsWith('dsh-') && check.status === 'warn' && /overlay is mounted in/.test(check.detail)
|
|
287
|
+
)
|
|
288
|
+
if (staleLive.length > 0) {
|
|
289
|
+
nextSteps.push(
|
|
290
|
+
'DSH hot-reloads cordis.patch.yml (HMR): re-run this doctor after ~10 s; restart the instance only if the tools stay missing (read its mcp-client startup error first)'
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
return { verdict, peer: { name: peer.name, url: peer.url, secure }, checks, nextSteps }
|
|
294
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP(S) JSON POST used for peer pairing.
|
|
3
|
+
*
|
|
4
|
+
* 两个安全选项:
|
|
5
|
+
* - `caPath`:信任一张自签证书(标准信任链校验)。
|
|
6
|
+
* - `fingerprint`:TOFU 固定指纹 —— 先不判信任链(自签必然不过),抓 socket 上的
|
|
7
|
+
* 对端证书算 SHA-256,再和人工核对过的指纹比对;不一致直接 `E_PIN_MISMATCH`。
|
|
8
|
+
*
|
|
9
|
+
* 抽成独立文件是为了让 e2e 能直接测(cli.js 有 main 副作用,不适合被 import)。
|
|
10
|
+
*/
|
|
11
|
+
import { fingerprintOfSocket, assertFingerprint } from './tls.js'
|
|
12
|
+
|
|
13
|
+
export async function postJson(urlString, body, { caPath, fingerprint } = {}) {
|
|
14
|
+
const isHttps = String(urlString).startsWith('https:')
|
|
15
|
+
const { request } = await import(isHttps ? 'node:https' : 'node:http')
|
|
16
|
+
const { readFileSync } = await import('node:fs')
|
|
17
|
+
const options = { method: 'POST', headers: { 'content-type': 'application/json' } }
|
|
18
|
+
if (isHttps && caPath) options.ca = readFileSync(caPath)
|
|
19
|
+
// 固定指纹时先不判信任链(自签必然不过),拿到 socket 证书后自己比对指纹。
|
|
20
|
+
// 这是 TOFU 的标准做法:信任来自「配对码那条可信信道」里带出来的指纹,而不是 CA。
|
|
21
|
+
if (isHttps && fingerprint) {
|
|
22
|
+
options.rejectUnauthorized = false
|
|
23
|
+
// 关键:不能复用 keep-alive 连接 —— 复用时会话已建立,secureConnect 不会再触发,
|
|
24
|
+
// 指纹校验就会被跳过。每次固定指纹请求都开新连接。
|
|
25
|
+
options.agent = false
|
|
26
|
+
}
|
|
27
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
28
|
+
const req = request(urlString, options, (res) => {
|
|
29
|
+
let text = ''
|
|
30
|
+
res.setEncoding('utf8')
|
|
31
|
+
res.on('data', (chunk) => {
|
|
32
|
+
text += chunk
|
|
33
|
+
})
|
|
34
|
+
res.on('end', () => {
|
|
35
|
+
let parsed
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(text)
|
|
38
|
+
} catch {
|
|
39
|
+
parsed = { raw: text }
|
|
40
|
+
}
|
|
41
|
+
resolvePromise({ status: res.statusCode, body: parsed, fingerprint: req.__meshFingerprint })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
if (isHttps && fingerprint) {
|
|
45
|
+
req.on('socket', (socket) => {
|
|
46
|
+
socket.once('secureConnect', () => {
|
|
47
|
+
try {
|
|
48
|
+
const got = fingerprintOfSocket(socket)
|
|
49
|
+
assertFingerprint(fingerprint, got)
|
|
50
|
+
req.__meshFingerprint = got
|
|
51
|
+
} catch (error) {
|
|
52
|
+
req.destroy(error)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
req.setTimeout(20000, () => req.destroy(new Error('timeout')))
|
|
58
|
+
req.on('error', rejectPromise)
|
|
59
|
+
req.end(JSON.stringify(body ?? {}))
|
|
60
|
+
})
|
|
61
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure helpers for the repair toolkit (M7 step 3). Keeping the path/validation
|
|
5
|
+
* logic here means the MCP tool, the CLI and the selftest all agree on what a
|
|
6
|
+
* legal plugin payload is — writing into a DSH profile is the one operation that
|
|
7
|
+
* can brick an instance, so it gets validated in exactly one place.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const PLUGIN_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,63}$/i
|
|
11
|
+
|
|
12
|
+
export function repairError(code, message, extra = {}) {
|
|
13
|
+
const error = new Error(message)
|
|
14
|
+
error.code = code
|
|
15
|
+
Object.assign(error, extra)
|
|
16
|
+
return error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Directory a plugin package occupies inside a profile's node_modules. */
|
|
20
|
+
export function pluginRoot(profileDir, name) {
|
|
21
|
+
return join(profileDir, 'node_modules', ...String(name).split('/'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate and normalise a plugin file payload.
|
|
26
|
+
* `path` is always a forward-slash relative path inside the package directory.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizePluginFiles(files, { maxFiles = 20, maxFileBytes = 262144 } = {}) {
|
|
29
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
30
|
+
throw repairError('E_INVALID_ARG', 'files must be a non-empty array of {path, content}')
|
|
31
|
+
}
|
|
32
|
+
if (files.length > maxFiles) {
|
|
33
|
+
throw repairError('E_INVALID_ARG', `too many files: ${files.length} > ${maxFiles}`)
|
|
34
|
+
}
|
|
35
|
+
const seen = new Set()
|
|
36
|
+
return files.map((file, index) => {
|
|
37
|
+
const raw = typeof file?.path === 'string' ? file.path : ''
|
|
38
|
+
const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
39
|
+
if (!rel) throw repairError('E_INVALID_ARG', `files[${index}].path is required`)
|
|
40
|
+
if (isAbsolute(rel) || /^[a-z]:/i.test(rel)) {
|
|
41
|
+
throw repairError('E_INVALID_ARG', `files[${index}].path must be relative: ${raw}`)
|
|
42
|
+
}
|
|
43
|
+
const parts = rel.split('/').filter((part) => part.length > 0)
|
|
44
|
+
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
|
|
45
|
+
throw repairError('E_INVALID_ARG', `files[${index}].path escapes the package root: ${raw}`)
|
|
46
|
+
}
|
|
47
|
+
const content = typeof file?.content === 'string' ? file.content : null
|
|
48
|
+
if (content === null) throw repairError('E_INVALID_ARG', `files[${index}].content must be a string`)
|
|
49
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
50
|
+
if (bytes > maxFileBytes) {
|
|
51
|
+
throw repairError('E_INVALID_ARG', `files[${index}] is ${bytes} bytes > limit ${maxFileBytes}`)
|
|
52
|
+
}
|
|
53
|
+
const key = parts.join('/').toLowerCase()
|
|
54
|
+
if (seen.has(key)) throw repairError('E_INVALID_ARG', `duplicate file path: ${rel}`)
|
|
55
|
+
seen.add(key)
|
|
56
|
+
return { rel: parts.join('/'), abs: parts.join(sep), content, bytes }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when the profile patch already wires a plugin by name or id. */
|
|
61
|
+
export function patchHasPlugin(patchText, name) {
|
|
62
|
+
const text = String(patchText || '')
|
|
63
|
+
if (!text) return false
|
|
64
|
+
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
65
|
+
return new RegExp(`(^|[\\s-])(name|id):\\s*['"]?${escaped}['"]?\\s*$`, 'm').test(text)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildPatchBlock(name) {
|
|
69
|
+
return `- insert:\n - id: ${name}\n name: ${name}\n`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Append a `- insert:` block unless the plugin is already wired.
|
|
74
|
+
* A non-empty, non-`[]` patch is preserved and flagged for backup by the caller.
|
|
75
|
+
*/
|
|
76
|
+
export function applyPatchInsert(patchText, name) {
|
|
77
|
+
const current = String(patchText ?? '')
|
|
78
|
+
if (patchHasPlugin(current, name)) return { text: current, changed: false, backup: false }
|
|
79
|
+
const trimmed = current.trim()
|
|
80
|
+
const needsBackup = Boolean(trimmed && trimmed !== '[]')
|
|
81
|
+
const header = needsBackup ? `${current.replace(/\s+$/, '')}\n` : ''
|
|
82
|
+
return { text: header + buildPatchBlock(name), changed: true, backup: needsBackup }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve `candidate` and assert it stays inside `root` (profile confinement). */
|
|
86
|
+
export function assertInside(root, candidate) {
|
|
87
|
+
const base = resolve(root)
|
|
88
|
+
const target = resolve(candidate)
|
|
89
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
90
|
+
throw repairError('E_INVALID_ARG', `path escapes the allowed root: ${target}`)
|
|
91
|
+
}
|
|
92
|
+
return target
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @generated 2026-10-27 repair 重启命令的**运行期**白名单(不只是编辑期校验)
|
|
2
|
+
// [why] 2026-10-27 P2 安全收紧:`repair.restartScripts` 是「配置即白名单」,但配置可以被手改/被别的工具写。
|
|
3
|
+
// 如果只在写配置时校验,手改 config 仍能塞任意命令。所以 service_restart **执行前**也过同一套规则:
|
|
4
|
+
// 只允许调用 mesh 自己的重启脚本 / 看护脚本 / autostart 的 start-dshN.vbs|cmd,且禁止 shell 元字符。
|
|
5
|
+
// 归宿:reusable(dsh-mesh/src/core/)
|
|
6
|
+
export const RESTART_TARGET_RE = /\b(restart-dsh-instance\.mjs|instance-watchdog\.mjs|start-dsh\d*\.(vbs|cmd))\b/
|
|
7
|
+
export const MAX_RESTART_COMMAND_LEN = 600
|
|
8
|
+
/** Shell metacharacters that would let a "restart" command become arbitrary code. */
|
|
9
|
+
const FORBIDDEN_META_RE = /[|&;<>`$(){}]/
|
|
10
|
+
|
|
11
|
+
export function validateRestartCommand(command) {
|
|
12
|
+
const text = String(command || '').trim()
|
|
13
|
+
if (!text) return { ok: false, reason: 'command is empty' }
|
|
14
|
+
if (text.length > MAX_RESTART_COMMAND_LEN) return { ok: false, reason: `command longer than ${MAX_RESTART_COMMAND_LEN} chars` }
|
|
15
|
+
const meta = FORBIDDEN_META_RE.exec(text)
|
|
16
|
+
if (meta) return { ok: false, reason: `command contains shell metacharacter "${meta[0]}" — only plain quoted paths/args are allowed` }
|
|
17
|
+
if (!RESTART_TARGET_RE.test(text)) {
|
|
18
|
+
return { ok: false, reason: 'command must reference restart-dsh-instance.mjs / instance-watchdog.mjs / start-dsh<N>.vbs|cmd' }
|
|
19
|
+
}
|
|
20
|
+
return { ok: true }
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Uniform tool result helpers: text content only (M1 keeps no outputSchema, so no structuredContent). */
|
|
2
|
+
export function ok(data) {
|
|
3
|
+
return { content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function fail(code, message, extra = {}) {
|
|
7
|
+
return {
|
|
8
|
+
isError: true,
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: JSON.stringify({ code, message, retryable: false, ...extra }, null, 2)
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function denied(message, extra) {
|
|
19
|
+
return fail('E_DENIED', message, extra)
|
|
20
|
+
}
|
|
21
|
+
export function invalidArg(message, extra) {
|
|
22
|
+
return fail('E_INVALID_ARG', message, extra)
|
|
23
|
+
}
|
|
24
|
+
export function notFound(message, extra) {
|
|
25
|
+
return fail('E_NOT_FOUND', message, extra)
|
|
26
|
+
}
|
|
27
|
+
export function internal(message, extra) {
|
|
28
|
+
return fail('E_INTERNAL', message, { retryable: true, ...extra })
|
|
29
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SakuraFrp 账号级隧道状态(只读):读本机 Launcher token + 调 api.natfrp.com/v4/tunnels(+ /v4/nodes 解析节点域名)
|
|
2
|
+
// 用途:PC 端即可看到同账号下所有设备(含手机)的樱花隧道在线状态,不依赖对端上报。
|
|
3
|
+
// 安全:token 只在内存中使用,绝不写日志/输出;结果缓存 60s。
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
|
|
8
|
+
const API_BASE = 'https://api.natfrp.com'
|
|
9
|
+
// 2026-11-09:动态检测——默认 5s 重新拉取账号隧道状态(SAKURA_POLL_MS 可覆盖;<=3s 会被抬到 3s)
|
|
10
|
+
const CACHE_TTL_MS = Math.max(3000, Number(process.env.SAKURA_POLL_MS || 5000))
|
|
11
|
+
|
|
12
|
+
let cache = { at: 0, data: null }
|
|
13
|
+
|
|
14
|
+
/** 本机 Launcher 配置里的账号 token(Windows / Linux / macOS 常见路径,或 SAKURA_TOKEN 环境变量)。 */
|
|
15
|
+
function findToken() {
|
|
16
|
+
const candidates = [
|
|
17
|
+
'C:/ProgramData/SakuraFrpService/config.json',
|
|
18
|
+
'/var/lib/sakurafrp/config.json',
|
|
19
|
+
'/ProgramData/SakuraFrpService/config.json',
|
|
20
|
+
join(homedir(), '.config', 'sakura-frp', 'config.json'),
|
|
21
|
+
join(homedir(), '.config', 'sakurafrp', 'config.json')
|
|
22
|
+
]
|
|
23
|
+
for (const file of candidates) {
|
|
24
|
+
try {
|
|
25
|
+
const cfg = JSON.parse(readFileSync(file, 'utf8'))
|
|
26
|
+
if (cfg && cfg.token) return { token: String(cfg.token), source: file }
|
|
27
|
+
} catch {
|
|
28
|
+
/* try next */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (process.env.SAKURA_TOKEN) return { token: process.env.SAKURA_TOKEN, source: 'env:SAKURA_TOKEN' }
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function apiGet(path, token) {
|
|
36
|
+
const res = await fetch(API_BASE + path, {
|
|
37
|
+
headers: { Authorization: 'Bearer ' + token, accept: 'application/json', 'user-agent': 'dsh-mesh-sakura' },
|
|
38
|
+
signal: AbortSignal.timeout(12_000)
|
|
39
|
+
})
|
|
40
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
41
|
+
return res.json()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 隧道名 → 设备 key 的猜测映射(名字里含设备名时用)。 */
|
|
45
|
+
function deviceKeyOf(name) {
|
|
46
|
+
const n = String(name || '')
|
|
47
|
+
if (!n) return null
|
|
48
|
+
let h = 0
|
|
49
|
+
for (const ch of n) h = (h * 31 + ch.codePointAt(0)) >>> 0
|
|
50
|
+
return 'dev' + (h % 1000)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 账号级樱花隧道列表 + 节点域名。失败返回 { available:false, error },不抛异常。
|
|
55
|
+
* @param {{ttlMs?:number, token?:string}} [opts]
|
|
56
|
+
*/
|
|
57
|
+
export async function collectSakuraAccount({ ttlMs = CACHE_TTL_MS, token } = {}) {
|
|
58
|
+
if (!token && cache.data && Date.now() - cache.at < ttlMs) return cache.data
|
|
59
|
+
const found = token ? { token, source: 'arg' } : findToken()
|
|
60
|
+
if (!found) {
|
|
61
|
+
const data = { available: false, error: '本机未找到 Sakura Launcher token(config.json / SAKURA_TOKEN)', generatedAt: new Date().toISOString() }
|
|
62
|
+
cache = { at: Date.now(), data }
|
|
63
|
+
return data
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const [tunnels, nodes] = await Promise.all([apiGet('/v4/tunnels', found.token), apiGet('/v4/nodes', found.token)])
|
|
67
|
+
const list = (Array.isArray(tunnels) ? tunnels : []).map((t) => {
|
|
68
|
+
const node = nodes && nodes[String(t.node)] ? nodes[String(t.node)] : {}
|
|
69
|
+
const remote = t.remote == null ? '' : String(t.remote)
|
|
70
|
+
return {
|
|
71
|
+
id: t.id,
|
|
72
|
+
name: t.name,
|
|
73
|
+
type: t.type,
|
|
74
|
+
nodeId: t.node,
|
|
75
|
+
nodeName: node.name || '',
|
|
76
|
+
host: node.host || '',
|
|
77
|
+
remotePort: remote,
|
|
78
|
+
localIp: t.local_ip || '',
|
|
79
|
+
localPort: t.local_port == null ? null : Number(t.local_port),
|
|
80
|
+
online: t.online === true,
|
|
81
|
+
status: t.status == null ? null : Number(t.status),
|
|
82
|
+
statusReason: t.status_reason || '',
|
|
83
|
+
deviceKey: deviceKeyOf(t.name),
|
|
84
|
+
addr: (node.host || '') + (remote ? ':' + remote : '')
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
const data = {
|
|
88
|
+
available: true,
|
|
89
|
+
source: found.source,
|
|
90
|
+
generatedAt: new Date().toISOString(),
|
|
91
|
+
total: list.length,
|
|
92
|
+
online: list.filter((t) => t.online).length,
|
|
93
|
+
tunnels: list,
|
|
94
|
+
error: null
|
|
95
|
+
}
|
|
96
|
+
cache = { at: Date.now(), data }
|
|
97
|
+
return data
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const data = { available: false, error: String(error?.message || error), generatedAt: new Date().toISOString(), source: found.source }
|
|
100
|
+
cache = { at: Date.now(), data }
|
|
101
|
+
return data
|
|
102
|
+
}
|
|
103
|
+
}
|