dsh-mesh-node 0.0.1 → 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/package.json +1 -1
- package/src/core/cluster.js +341 -340
- package/src/core/sakura-api.js +103 -0
- package/src/core/tunnels.js +212 -0
- package/src/server.js +317 -313
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|