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,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Delegation safety profile (2026-10-25).
|
|
6
|
+
*
|
|
7
|
+
* A peer must never inject a task into a session that owns a scheduled job:
|
|
8
|
+
* that mixes two contexts and pollutes the scheduled conversation (real
|
|
9
|
+
* incident: a `channel_wake` landed inside a company `定时-*` session).
|
|
10
|
+
*
|
|
11
|
+
* The profile joins two local sources of truth:
|
|
12
|
+
* - `<dshHome>/storages/schedule.json` schedules -> sessionId
|
|
13
|
+
* - `<fsRoot>/.mesh/tasks/<taskId>/request.json` delegations -> sessionId
|
|
14
|
+
*
|
|
15
|
+
* A session is *protected* when it owns at least one schedule record.
|
|
16
|
+
* Delegations are the sessions this node created for a peer, so they are
|
|
17
|
+
* identified explicitly instead of being guessed from titles.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const TITLE_SCHEDULE_HINT = /定时|班次|schedule|nightly/i
|
|
21
|
+
const EMPTY = Object.freeze([])
|
|
22
|
+
|
|
23
|
+
function readJson(file, fallback = null) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
26
|
+
} catch {
|
|
27
|
+
return fallback
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `<home>/profiles/web` -> `<home>`; empty/unknown profileDir -> ''. */
|
|
32
|
+
export function dshHomeOf(hostEntry) {
|
|
33
|
+
const dir = String(hostEntry?.profileDir || '')
|
|
34
|
+
if (!dir) return ''
|
|
35
|
+
return dirname(dirname(dir))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** sessionId -> [{id,title,kind,status,instance}] for one configured instance. */
|
|
39
|
+
export function readScheduleIndex(hostEntry) {
|
|
40
|
+
const home = dshHomeOf(hostEntry)
|
|
41
|
+
const file = home ? join(home, 'storages', 'schedule.json') : ''
|
|
42
|
+
const index = new Map()
|
|
43
|
+
const raw = file && existsSync(file) ? readJson(file) : null
|
|
44
|
+
const tasks = raw?.tables?.tasks || {}
|
|
45
|
+
for (const [id, entry] of Object.entries(tasks)) {
|
|
46
|
+
const sessionId = entry?.sessionId
|
|
47
|
+
if (!sessionId) continue
|
|
48
|
+
const record = entry.record || {}
|
|
49
|
+
const rows = index.get(sessionId) || []
|
|
50
|
+
rows.push({
|
|
51
|
+
id,
|
|
52
|
+
title: record.title || '',
|
|
53
|
+
kind: record.kind || '',
|
|
54
|
+
status: entry.status || '',
|
|
55
|
+
instance: hostEntry?.id || ''
|
|
56
|
+
})
|
|
57
|
+
index.set(sessionId, rows)
|
|
58
|
+
}
|
|
59
|
+
return index
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** sessionId -> delegation record from this node's `.mesh/tasks` mailbox. */
|
|
63
|
+
export function readDelegationIndex(root) {
|
|
64
|
+
const dir = root ? join(root, '.mesh', 'tasks') : ''
|
|
65
|
+
const index = new Map()
|
|
66
|
+
if (!dir || !existsSync(dir)) return index
|
|
67
|
+
let names = []
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(dir)
|
|
70
|
+
} catch {
|
|
71
|
+
return index
|
|
72
|
+
}
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
const request = readJson(join(dir, name, 'request.json'))
|
|
75
|
+
if (!request?.sessionId) continue
|
|
76
|
+
index.set(request.sessionId, {
|
|
77
|
+
taskId: request.taskId || name,
|
|
78
|
+
conversationId: request.conversationId || '',
|
|
79
|
+
from: request.from || '',
|
|
80
|
+
policy: request.sessionPolicy || '',
|
|
81
|
+
createdAt: request.requestedAt || ''
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
return index
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Join every configured instance's schedules plus this node's delegations. */
|
|
88
|
+
export function buildSessionProfile(config) {
|
|
89
|
+
const schedules = new Map()
|
|
90
|
+
for (const entry of config?.hosts || []) {
|
|
91
|
+
if (entry?.enabled === false) continue
|
|
92
|
+
for (const [sessionId, rows] of readScheduleIndex(entry)) {
|
|
93
|
+
const merged = schedules.get(sessionId) || []
|
|
94
|
+
merged.push(...rows.map((row) => ({ ...row, instance: entry.id })))
|
|
95
|
+
schedules.set(sessionId, merged)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { schedules, delegations: readDelegationIndex(config?.acl?.fsRoots?.[0] || '') }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Classify one host_sessions / delegate_candidates / listAgents row. */
|
|
102
|
+
export function classifySession(profile, session) {
|
|
103
|
+
const sessionId = session?.sessionId || session?.id || ''
|
|
104
|
+
const schedules = profile?.schedules?.get(sessionId) || EMPTY
|
|
105
|
+
const delegation = profile?.delegations?.get(sessionId) || null
|
|
106
|
+
const title = String(session?.title || '')
|
|
107
|
+
const alive = session?.agentAvailable === true && session?.state !== 'ended'
|
|
108
|
+
let kind = 'unknown'
|
|
109
|
+
let reason = 'no schedule record and no delegation record'
|
|
110
|
+
if (schedules.length > 0) {
|
|
111
|
+
kind = 'business-scheduled'
|
|
112
|
+
reason = `owns ${schedules.length} scheduled task(s) — injecting here would pollute the scheduled conversation`
|
|
113
|
+
} else if (delegation) {
|
|
114
|
+
kind = 'delegation'
|
|
115
|
+
reason = `created by delegation ${delegation.taskId}`
|
|
116
|
+
} else if (TITLE_SCHEDULE_HINT.test(title)) {
|
|
117
|
+
kind = 'business'
|
|
118
|
+
reason = 'title looks like a scheduled/business session (no schedule record found — still inject with care)'
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
kind,
|
|
122
|
+
protected: schedules.length > 0,
|
|
123
|
+
delegatable: alive && schedules.length === 0,
|
|
124
|
+
recommended: alive && String(session?.state) === 'idle' && schedules.length === 0,
|
|
125
|
+
reason,
|
|
126
|
+
schedules,
|
|
127
|
+
delegation
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Guard for write-capable tools (host_session_prompt / channel_wake /
|
|
133
|
+
* host_call / delegate continuation). Returns null when the target is safe.
|
|
134
|
+
*/
|
|
135
|
+
export function sessionBlock(profile, sessionId) {
|
|
136
|
+
const schedules = profile?.schedules?.get(String(sessionId || '')) || EMPTY
|
|
137
|
+
if (schedules.length === 0) return null
|
|
138
|
+
return {
|
|
139
|
+
kind: 'business-scheduled',
|
|
140
|
+
reason: `session ${sessionId} owns ${schedules.length} scheduled task(s): ${schedules
|
|
141
|
+
.map((row) => row.title || row.id)
|
|
142
|
+
.join(' / ')}`,
|
|
143
|
+
schedules,
|
|
144
|
+
hint: 'delegate to a FRESH conversation instead (omit sessionId), or pass force:true if you really mean to inject into this scheduled session'
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/core/tls.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS:自签证书生成与校验信息读取。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:樱花穿透的 TCP 隧道默认不加密(只有「访问认证」,没有传输加密),
|
|
5
|
+
* Bearer token 与所有 MCP 报文都会明文过公网。跨网阶段必须自带 TLS。
|
|
6
|
+
*
|
|
7
|
+
* 设计取舍:
|
|
8
|
+
* - 自签「CA-and-leaf」证书(basicConstraints CA:true + serverAuth EKU),这样对端
|
|
9
|
+
* 把 cert.pem 当作信任锚(NODE_EXTRA_CA_CERTS)就能通过校验,不需要额外 CA 基础设施。
|
|
10
|
+
* - SAN 里默认包含 localhost / 127.0.0.1 / 本机 hostname;樱花隧道建好后用
|
|
11
|
+
* `--san <节点域名>` 重新生成(或首次生成时一次给全)。
|
|
12
|
+
* - 私钥 0600 落 `~/.dsh-mesh/tls/key.pem`,不进 git、不进任何快照。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { X509Certificate } from 'node:crypto'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { hostname as osHostname } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import selfsigned from 'selfsigned'
|
|
20
|
+
|
|
21
|
+
/** TLS 文件目录。 */
|
|
22
|
+
export function tlsDir(home) {
|
|
23
|
+
return join(home, 'tls')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 证书路径(可在 config.tls.cert 覆盖)。 */
|
|
27
|
+
export function certPath(home) {
|
|
28
|
+
return join(tlsDir(home), 'cert.pem')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 私钥路径(可在 config.tls.key 覆盖)。 */
|
|
32
|
+
export function keyPath(home) {
|
|
33
|
+
return join(tlsDir(home), 'key.pem')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 是否已生成证书。 */
|
|
37
|
+
export function hasCert(home) {
|
|
38
|
+
return existsSync(certPath(home)) && existsSync(keyPath(home))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 判断字符串是不是 IPv4/IPv6 字面量。 */
|
|
42
|
+
function isIp(value) {
|
|
43
|
+
return /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || /^[0-9a-f:]+$/i.test(value) && value.includes(':')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 构造 SAN 列表:总是包含本机可用的默认项,再合并调用方给的。 */
|
|
47
|
+
export function buildAltNames(extra = []) {
|
|
48
|
+
const names = new Set(['localhost', '127.0.0.1', osHostname()])
|
|
49
|
+
for (const item of extra) {
|
|
50
|
+
const value = String(item).trim()
|
|
51
|
+
if (value.length > 0) names.add(value)
|
|
52
|
+
}
|
|
53
|
+
return [...names].map(value => (isIp(value) ? { type: 7, ip: value } : { type: 2, value }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 生成自签证书。
|
|
58
|
+
* @returns {{certPath: string, keyPath: string, info: object}}
|
|
59
|
+
*/
|
|
60
|
+
export async function generateCert({ home, sans = [], days = 825, keySize = 2048, commonName, force = false }) {
|
|
61
|
+
const dir = tlsDir(home)
|
|
62
|
+
mkdirSync(dir, { recursive: true })
|
|
63
|
+
if (!force && hasCert(home)) {
|
|
64
|
+
return { certPath: certPath(home), keyPath: keyPath(home), info: describeCert(home), reused: true }
|
|
65
|
+
}
|
|
66
|
+
const altNames = buildAltNames(sans)
|
|
67
|
+
const attrs = [{ name: 'commonName', value: commonName ?? `dsh-mesh-${osHostname()}` }]
|
|
68
|
+
const pems = await selfsigned.generate(attrs, {
|
|
69
|
+
days,
|
|
70
|
+
keySize,
|
|
71
|
+
algorithm: 'sha256',
|
|
72
|
+
extensions: [
|
|
73
|
+
{ name: 'basicConstraints', cA: true },
|
|
74
|
+
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true, keyCertSign: true },
|
|
75
|
+
{ name: 'extKeyUsage', serverAuth: true, clientAuth: true },
|
|
76
|
+
{ name: 'subjectAltName', altNames },
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
writeFileSync(certPath(home), pems.cert, { mode: 0o644 })
|
|
80
|
+
writeFileSync(keyPath(home), pems.private, { mode: 0o600 })
|
|
81
|
+
return { certPath: certPath(home), keyPath: keyPath(home), info: describeCert(home), reused: false, sans: altNames }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 读取证书的人类可读信息(也用于 `tls info`)。 */
|
|
85
|
+
export function describeCert(home) {
|
|
86
|
+
if (!hasCert(home)) return { present: false }
|
|
87
|
+
const cert = new X509Certificate(readFileSync(certPath(home)))
|
|
88
|
+
return {
|
|
89
|
+
present: true,
|
|
90
|
+
certPath: certPath(home),
|
|
91
|
+
keyPath: keyPath(home),
|
|
92
|
+
subject: cert.subject.replace(/\n/g, ', '),
|
|
93
|
+
issuer: cert.issuer.replace(/\n/g, ', '),
|
|
94
|
+
validFrom: cert.validFrom,
|
|
95
|
+
validTo: cert.validTo,
|
|
96
|
+
validToDate: new Date(cert.validTo).toISOString(),
|
|
97
|
+
fingerprint256: cert.fingerprint256,
|
|
98
|
+
serialNumber: cert.serialNumber,
|
|
99
|
+
subjectAltName: cert.subjectAltName,
|
|
100
|
+
selfSigned: cert.subject === cert.issuer,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 供 https.createServer 使用的证书对。路径缺失时抛结构化错误。 */
|
|
105
|
+
export function loadTlsMaterial({ home, cert, key }) {
|
|
106
|
+
const certFile = cert || certPath(home)
|
|
107
|
+
const keyFile = key || keyPath(home)
|
|
108
|
+
if (!existsSync(certFile) || !existsSync(keyFile)) {
|
|
109
|
+
const error = new Error(`TLS 已启用但缺少证书:${certFile} / ${keyFile},先跑 \`node src/cli.js tls init\``)
|
|
110
|
+
error.code = 'E_TLS_MISSING'
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
return { cert: readFileSync(certFile), key: readFileSync(keyFile), certFile, keyFile }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 证书指纹规范化:大小写无关、去掉冒号/空格,统一成 64 位大写 hex。
|
|
118
|
+
*
|
|
119
|
+
* 为什么需要它:`mesh_self` / `tls info` / `pair --code` 打印的是
|
|
120
|
+
* `AA:BB:...` 形式(X509Certificate.fingerprint256),而 socket 拿到的也是这种,
|
|
121
|
+
* 但人手工粘贴时常带上小写或漏冒号 —— 直接字符串比较会假失败。
|
|
122
|
+
*/
|
|
123
|
+
export function normalizeFingerprint(value) {
|
|
124
|
+
const compact = String(value || '')
|
|
125
|
+
.replace(/[^0-9a-fA-F]/g, '')
|
|
126
|
+
.toUpperCase()
|
|
127
|
+
if (compact.length !== 64) {
|
|
128
|
+
const error = new Error(`证书指纹应为 64 位 hex(SHA-256),收到 ${compact.length} 位`)
|
|
129
|
+
error.code = 'E_BAD_FINGERPRINT'
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
return compact
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 从 TLS socket 上取对端证书指纹,返回规范化的 64 位 hex。 */
|
|
136
|
+
export function fingerprintOfSocket(socket) {
|
|
137
|
+
const cert = socket?.getPeerCertificate?.()
|
|
138
|
+
if (!cert || !cert.fingerprint256) {
|
|
139
|
+
const error = new Error('对端没有提供证书,无法核对指纹')
|
|
140
|
+
error.code = 'E_NO_PEER_CERT'
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
return normalizeFingerprint(cert.fingerprint256)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 固定指纹校验:expected / actual 都规范化后比较,失败抛 E_PIN_MISMATCH。 */
|
|
147
|
+
export function assertFingerprint(expected, actual) {
|
|
148
|
+
const want = normalizeFingerprint(expected)
|
|
149
|
+
const got = normalizeFingerprint(actual)
|
|
150
|
+
if (want !== got) {
|
|
151
|
+
const error = new Error(
|
|
152
|
+
`证书指纹不匹配(可能被中间人替换或证书已轮换)\n 期望 ${want}\n 实际 ${got}`
|
|
153
|
+
)
|
|
154
|
+
error.code = 'E_PIN_MISMATCH'
|
|
155
|
+
error.expected = want
|
|
156
|
+
error.actual = got
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
return got
|
|
160
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// dsh-mesh 隧道采集(只读):自建 frps(.runtime/frpc/frpc.toml)+ Sakura Launcher 日志/进程 + 端口探测
|
|
2
|
+
// 用途:/health 上报给其它节点、mesh-health 聚合、面板显示、可独立 CLI 调用
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import net from 'node:net'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { collectSakuraAccount } from './sakura-api.js'
|
|
8
|
+
|
|
9
|
+
const SAKURA_LOG_DIR_WIN = 'C:\\ProgramData\\SakuraFrpService\\Logs'
|
|
10
|
+
const SAKURA_LOG_DIR_LINUX = '/var/log/sakurafrp'
|
|
11
|
+
|
|
12
|
+
function readText(p) {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(p, 'utf8')
|
|
15
|
+
} catch {
|
|
16
|
+
return ''
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function frpcToml(repo) {
|
|
21
|
+
const file = join(repo || '', '.runtime', 'frpc', 'frpc.toml')
|
|
22
|
+
const text = readText(file)
|
|
23
|
+
if (!text) return null
|
|
24
|
+
const grab = (key) => {
|
|
25
|
+
const m = new RegExp('^\\s*' + key + '\\s*=\\s*"?([^"\\r\\n]+)"?', 'm').exec(text)
|
|
26
|
+
return m ? m[1].trim() : null
|
|
27
|
+
}
|
|
28
|
+
const host = grab('serverAddr')
|
|
29
|
+
const port = grab('remotePort')
|
|
30
|
+
if (!host || !port) return null
|
|
31
|
+
return { host, port, serverPort: grab('serverPort') || null, localPort: grab('localPort') || null, name: grab('name') || null, configPath: file }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sakuraTunnels() {
|
|
35
|
+
const dir = process.platform === 'win32' ? SAKURA_LOG_DIR_WIN : SAKURA_LOG_DIR_LINUX
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(dir)) return []
|
|
38
|
+
const files = readdirSync(dir)
|
|
39
|
+
.filter((n) => n.endsWith('.log'))
|
|
40
|
+
.sort()
|
|
41
|
+
if (!files.length) return []
|
|
42
|
+
const text = readText(join(dir, files[files.length - 1]))
|
|
43
|
+
const tail = text.slice(-400000)
|
|
44
|
+
const found = new Map()
|
|
45
|
+
const re = /frpc\[([^\]|]+)[^\]]*\][^\n]*?使用 >>([^\s:]+):(\d+)<</g
|
|
46
|
+
let m
|
|
47
|
+
while ((m = re.exec(tail))) found.set(m[1].trim(), { name: m[1].trim(), host: m[2], port: m[3] })
|
|
48
|
+
return [...found.values()]
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function processes() {
|
|
55
|
+
const out = { frpc: [], sakura: [], error: null }
|
|
56
|
+
try {
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
const ps = execFileSync(
|
|
59
|
+
'powershell.exe',
|
|
60
|
+
[
|
|
61
|
+
'-NoProfile',
|
|
62
|
+
'-Command',
|
|
63
|
+
"Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(frpc|SakuraLauncher|SakuraFrpService)\\.exe$' } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress"
|
|
64
|
+
],
|
|
65
|
+
{ encoding: 'utf8', timeout: 8000, windowsHide: true }
|
|
66
|
+
).trim()
|
|
67
|
+
const parsed = ps ? JSON.parse(ps) : []
|
|
68
|
+
const list = Array.isArray(parsed) ? parsed : [parsed]
|
|
69
|
+
for (const p of list) {
|
|
70
|
+
const name = String(p.Name || '')
|
|
71
|
+
const cmd = String(p.CommandLine || '')
|
|
72
|
+
if (name.toLowerCase() === 'frpc.exe') {
|
|
73
|
+
out.frpc.push({ pid: p.ProcessId || null, kind: /SakuraFrpLauncher/i.test(cmd) ? 'sakura' : 'self-hosted', cmd: cmd.slice(0, 200) })
|
|
74
|
+
} else {
|
|
75
|
+
out.sakura.push({ pid: p.ProcessId || null, name })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const txt = execFileSync('ps', ['-eo', 'pid,comm,args'], { encoding: 'utf8', timeout: 5000 }).toString()
|
|
80
|
+
for (const line of txt.split('\n')) {
|
|
81
|
+
if (!/frpc/i.test(line)) continue
|
|
82
|
+
const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line)
|
|
83
|
+
if (!m) continue
|
|
84
|
+
out.frpc.push({ pid: Number(m[1]), kind: /sakura/i.test(m[3]) ? 'sakura' : 'self-hosted', cmd: m[3].slice(0, 200) })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
out.error = String(error?.message || error)
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 已知公网隧道端点(~/.dsh-mesh/tunnels-known.json):不依赖对端上报即可探测樱花/公网入口。 */
|
|
94
|
+
function knownEndpoints(home) {
|
|
95
|
+
if (!home) return []
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(readFileSync(join(home, 'tunnels-known.json'), 'utf8'))
|
|
98
|
+
const list = Array.isArray(raw?.endpoints) ? raw.endpoints : []
|
|
99
|
+
return list.filter((x) => x && x.host && x.port).map((x) => ({
|
|
100
|
+
node: String(x.node || ""),
|
|
101
|
+
kind: String(x.kind || 'sakura'),
|
|
102
|
+
host: String(x.host),
|
|
103
|
+
port: Number(x.port),
|
|
104
|
+
label: String(x.label || "")
|
|
105
|
+
}))
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function probe(host, port, timeoutMs) {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const startedAt = Date.now()
|
|
114
|
+
let settled = false
|
|
115
|
+
const sock = net.connect({ host, port: Number(port) })
|
|
116
|
+
sock.setTimeout(timeoutMs, () => finish(false, 'timeout'))
|
|
117
|
+
const finish = (up, error) => {
|
|
118
|
+
if (settled) return
|
|
119
|
+
settled = true
|
|
120
|
+
try {
|
|
121
|
+
sock.destroy()
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore */
|
|
124
|
+
}
|
|
125
|
+
resolve({ up, latencyMs: Date.now() - startedAt, error: error || null })
|
|
126
|
+
}
|
|
127
|
+
sock.once('connect', () => finish(true))
|
|
128
|
+
sock.once('error', (error) => finish(false, String(error?.code || error?.message || error)))
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 采集本机隧道状态(只读,会做少量端口探测,默认 ~2.5s 超时)。
|
|
134
|
+
* 返回:{ generatedAt, processes, selfHosted, sakura } —— 供 /health / mesh-health / 面板使用。
|
|
135
|
+
*/
|
|
136
|
+
export async function collectTunnels({ repo, home, timeoutMs = 2500 } = {}) {
|
|
137
|
+
const procs = processes()
|
|
138
|
+
const selfHosted = frpcToml(repo)
|
|
139
|
+
const sakura = sakuraTunnels()
|
|
140
|
+
const selfProbe = selfHosted ? await probe(selfHosted.host, selfHosted.port, timeoutMs) : null
|
|
141
|
+
const sakuraProbes = await Promise.all(
|
|
142
|
+
sakura.slice(0, 4).map(async (t) => ({ ...t, probe: await probe(t.host, t.port, timeoutMs) }))
|
|
143
|
+
)
|
|
144
|
+
const sakuraAlive = procs.sakura.length > 0 || procs.frpc.some((p) => p.kind === 'sakura')
|
|
145
|
+
const sakuraAccount = await collectSakuraAccount({ ttlMs: 60_000 })
|
|
146
|
+
const known = await Promise.all(
|
|
147
|
+
knownEndpoints(home).map(async (x) => ({ ...x, probe: await probe(x.host, x.port, timeoutMs) }))
|
|
148
|
+
)
|
|
149
|
+
return {
|
|
150
|
+
generatedAt: new Date().toISOString(),
|
|
151
|
+
processes: procs,
|
|
152
|
+
selfHosted: selfHosted ? { ...selfHosted, probe: selfProbe } : null,
|
|
153
|
+
sakura: { launcherRunning: sakuraAlive, tunnels: sakuraProbes },
|
|
154
|
+
known,
|
|
155
|
+
sakuraAccount,
|
|
156
|
+
summary: {
|
|
157
|
+
selfHostedUp: Boolean(selfProbe && selfProbe.up),
|
|
158
|
+
sakuraRunning: sakuraAlive,
|
|
159
|
+
sakuraTunnels: sakuraProbes.length,
|
|
160
|
+
sakuraUp: sakuraProbes.filter((t) => t.probe && t.probe.up).length,
|
|
161
|
+
knownUp: known.filter((x) => x.probe && x.probe.up).length,
|
|
162
|
+
knownTotal: known.length,
|
|
163
|
+
accountTotal: sakuraAccount.total || 0,
|
|
164
|
+
accountOnline: sakuraAccount.online || 0,
|
|
165
|
+
accountError: sakuraAccount.available ? null : (sakuraAccount.error || "unavailable")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 后台缓存:/health 每次请求都探测会拖慢 peer 探测,这里 60s 刷新一次。 */
|
|
171
|
+
export function createTunnelCache({ repo, home, refreshMs = 60_000, log } = {}) {
|
|
172
|
+
let value = null
|
|
173
|
+
let timer = null
|
|
174
|
+
let running = false
|
|
175
|
+
const file = home ? join(home, 'TUNNEL.json') : null
|
|
176
|
+
const refresh = async () => {
|
|
177
|
+
if (running) return value
|
|
178
|
+
running = true
|
|
179
|
+
try {
|
|
180
|
+
value = await collectTunnels({ repo, home })
|
|
181
|
+
if (file) {
|
|
182
|
+
try {
|
|
183
|
+
const { writeFileSync } = await import('node:fs')
|
|
184
|
+
writeFileSync(file, JSON.stringify(value, null, 2))
|
|
185
|
+
} catch {
|
|
186
|
+
/* ignore */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (log) log('tunnel cache: ' + String(error?.message || error))
|
|
191
|
+
} finally {
|
|
192
|
+
running = false
|
|
193
|
+
}
|
|
194
|
+
return value
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
start() {
|
|
198
|
+
refresh().catch(() => {})
|
|
199
|
+
timer = setInterval(() => refresh().catch(() => {}), refreshMs)
|
|
200
|
+
if (timer.unref) timer.unref()
|
|
201
|
+
return this
|
|
202
|
+
},
|
|
203
|
+
stop() {
|
|
204
|
+
if (timer) clearInterval(timer)
|
|
205
|
+
timer = null
|
|
206
|
+
},
|
|
207
|
+
get() {
|
|
208
|
+
return value
|
|
209
|
+
},
|
|
210
|
+
refresh
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/core/util.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
/** Run a command without a shell; resolves (never rejects) with stdout/stderr/error. */
|
|
6
|
+
export function run(cmd, args, { timeout = 20000, cwd, signal, maxBuffer = 8 * 1024 * 1024 } = {}) {
|
|
7
|
+
return new Promise((resolvePromise) => {
|
|
8
|
+
execFile(
|
|
9
|
+
cmd,
|
|
10
|
+
args,
|
|
11
|
+
{ cwd, timeout, signal, encoding: 'utf8', windowsHide: true, maxBuffer },
|
|
12
|
+
(error, stdout, stderr) => {
|
|
13
|
+
resolvePromise({
|
|
14
|
+
error,
|
|
15
|
+
stdout: stdout || '',
|
|
16
|
+
stderr: stderr || '',
|
|
17
|
+
code: typeof error?.code === 'number' ? error.code : 0,
|
|
18
|
+
killed: Boolean(error?.killed),
|
|
19
|
+
timedOut: Boolean(error?.killed && error?.signal === 'SIGTERM')
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Trim a string to a byte budget without splitting a UTF-8 code point badly. */
|
|
27
|
+
export function clip(text, maxBytes) {
|
|
28
|
+
const buf = Buffer.from(text, 'utf8')
|
|
29
|
+
if (buf.length <= maxBytes) return { text, truncated: false, bytes: buf.length }
|
|
30
|
+
return { text: buf.subarray(0, maxBytes).toString('utf8'), truncated: true, bytes: buf.length }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sha256(text) {
|
|
34
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function short(text, n = 500) {
|
|
38
|
+
const s = String(text ?? '')
|
|
39
|
+
return s.length <= n ? s : s.slice(0, n) + '…'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** ISO timestamp helper. */
|
|
43
|
+
export function nowIso() {
|
|
44
|
+
return new Date().toISOString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip a UTF-8 BOM. Windows editors (and PowerShell's Set-Content) love adding one,
|
|
49
|
+
* and `JSON.parse` chokes on it — a BOM'd config.json used to kill the node at boot.
|
|
50
|
+
*/
|
|
51
|
+
export function stripBom(text) {
|
|
52
|
+
return String(text).replace(/^\uFEFF/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read a JSON file, tolerating a BOM. */
|
|
56
|
+
export function readJsonFile(path) {
|
|
57
|
+
return JSON.parse(stripBom(readFileSync(path, 'utf8')))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Wrap a promise with a hard timeout so a stuck tool cannot hang a request forever. */
|
|
61
|
+
export function withTimeout(promise, ms, onTimeout) {
|
|
62
|
+
let timer
|
|
63
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
64
|
+
timer = setTimeout(() => reject(onTimeout || new Error(`timed out after ${ms}ms`)), ms)
|
|
65
|
+
timer.unref?.()
|
|
66
|
+
})
|
|
67
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// e2e: autostart helper (Windows only). Read-only: never registers/removes a task.
|
|
2
|
+
// Proves (a) status is safe on a clean machine, (b) both mutating paths refuse without --yes,
|
|
3
|
+
// (c) the generated logon command points at this checkout's cli.js.
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import {
|
|
6
|
+
TASK_NAME,
|
|
7
|
+
autostartCommand,
|
|
8
|
+
autostartInstall,
|
|
9
|
+
autostartRemove,
|
|
10
|
+
autostartStatus
|
|
11
|
+
} from './core/autostart.js'
|
|
12
|
+
|
|
13
|
+
const checks = []
|
|
14
|
+
function check(name, fn) {
|
|
15
|
+
checks.push({ name, fn })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
check('Windows guard / status shape', async () => {
|
|
19
|
+
if (process.platform !== 'win32') return 'skipped: not win32'
|
|
20
|
+
const st = await autostartStatus()
|
|
21
|
+
assert.equal(typeof st.installed, 'boolean')
|
|
22
|
+
assert.equal(st.name, TASK_NAME)
|
|
23
|
+
if (st.installed) {
|
|
24
|
+
assert.ok(st.taskToRun, 'installed task must report taskToRun')
|
|
25
|
+
} else {
|
|
26
|
+
assert.ok(typeof st.detail === 'string')
|
|
27
|
+
}
|
|
28
|
+
return `installed=${st.installed}`
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
check('command quotes node + cli.js + port', () => {
|
|
32
|
+
const cmd = autostartCommand(TASK_NAME, 8124)
|
|
33
|
+
assert.match(cmd, /^".*node(\.exe)?"/)
|
|
34
|
+
assert.ok(cmd.includes('cli.js'), 'command must point at the CLI')
|
|
35
|
+
assert.match(cmd, /serve --port 8124$/)
|
|
36
|
+
return cmd
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
check('install refuses without --yes', async () => {
|
|
40
|
+
await assert.rejects(() => autostartInstall({ yes: false }), /--yes/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
check('remove refuses without --yes', async () => {
|
|
44
|
+
await assert.rejects(() => autostartRemove({ yes: false }), /--yes/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
let failed = 0
|
|
48
|
+
for (const c of checks) {
|
|
49
|
+
try {
|
|
50
|
+
const detail = await c.fn()
|
|
51
|
+
console.log(`PASS ${c.name}${detail ? ` (${detail})` : ''}`)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
failed += 1
|
|
54
|
+
console.error(`FAIL ${c.name}\n ${err.message}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
console.log(`\n${checks.length - failed}/${checks.length} passed`)
|
|
58
|
+
process.exit(failed === 0 ? 0 : 1)
|