@whyshu/argent 0.2.4 → 0.2.6
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/config.mjs +42 -0
- package/src/index.js +30 -2
- package/src/tunnel.mjs +848 -0
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 单独成文件是为了让 **office 运行时** 与 **health 聚合** 共用同一份解析,
|
|
5
5
|
* 避免"两处各写一遍 enabled/token 判据"(历史事故的根因形态)。
|
|
6
|
+
* 2026-11-07 增 {@link readTunnelConfig}:App 隧道(契约 §2.1)的开关/地址/machineId 也走本文件,
|
|
7
|
+
* ⛔ 不在这里读凭据(账号 JWT 由 tunnel.mjs 复用 account-token 的解析点)。
|
|
6
8
|
* 纯 node 内建,无宿主 import。
|
|
7
9
|
*/
|
|
8
10
|
import { readFileSync } from 'node:fs'
|
|
@@ -12,6 +14,8 @@ import { join } from 'node:path'
|
|
|
12
14
|
export const REPORT_FILE = 'agent-report.json'
|
|
13
15
|
export const MIN_INTERVAL_SEC = 5
|
|
14
16
|
export const MAX_INTERVAL_SEC = 300
|
|
17
|
+
/** App 隧道默认地址(契约 §2.1;`ARGENT_TUNNEL_URL` 可覆盖) */
|
|
18
|
+
export const TUNNEL_DEFAULT_URL = 'wss://whyshu.com/api/argent/tunnel'
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* @param {string} dataDir
|
|
@@ -45,3 +49,41 @@ export function readConfig(dataDir) {
|
|
|
45
49
|
return { ok: false, reason: e?.code === 'ENOENT' ? 'missing' : 'invalid', error: String(e?.message || e) }
|
|
46
50
|
}
|
|
47
51
|
}
|
|
52
|
+
|
|
53
|
+
/** 隧道总开关:`ARGENT_TUNNEL=1` 才开(契约 §2.1;默认关)。 */
|
|
54
|
+
export function isTunnelEnabled(env = process.env) {
|
|
55
|
+
return String(env.ARGENT_TUNNEL || '').trim() === '1'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* App 隧道配置(契约 §2.1)——本文件是 agent-report.json 的唯一解析点,隧道也走这里。
|
|
60
|
+
* · 开关:`ARGENT_TUNNEL=1`(默认关)
|
|
61
|
+
* · 地址:`ARGENT_TUNNEL_URL` → {@link TUNNEL_DEFAULT_URL}
|
|
62
|
+
* · machineId:`ARGENT_MACHINE_ID` → `agent-report.json#machineId`(缺失 ⇒ ok:false,不启动)
|
|
63
|
+
* ⛔ 不含任何凭据读取(账号 JWT 由 tunnel.mjs 复用 account-token 的解析点;此处只解析非密字段)。
|
|
64
|
+
* @returns {{enabled:false,ok:false,reason:'disabled'}|{enabled:true,ok:false,reason:string,url:string}|{enabled:true,ok:true,url:string,machineId:string,deviceName:string,serverUrl:string}}
|
|
65
|
+
*/
|
|
66
|
+
export function readTunnelConfig({ env = process.env, dataDir } = {}) {
|
|
67
|
+
if (!isTunnelEnabled(env)) return { enabled: false, ok: false, reason: 'disabled' }
|
|
68
|
+
const url = String(env.ARGENT_TUNNEL_URL || '').trim() || TUNNEL_DEFAULT_URL
|
|
69
|
+
try {
|
|
70
|
+
const proto = new URL(url).protocol
|
|
71
|
+
if (proto !== 'ws:' && proto !== 'wss:') return { enabled: true, ok: false, reason: 'bad-url', url }
|
|
72
|
+
} catch {
|
|
73
|
+
return { enabled: true, ok: false, reason: 'bad-url', url }
|
|
74
|
+
}
|
|
75
|
+
let report = {}
|
|
76
|
+
if (dataDir) {
|
|
77
|
+
try { report = JSON.parse(readFileSync(join(dataDir, REPORT_FILE), 'utf8')) || {} } catch { report = {} }
|
|
78
|
+
}
|
|
79
|
+
const machineId = String(env.ARGENT_MACHINE_ID || report.machineId || '').trim()
|
|
80
|
+
if (!machineId) return { enabled: true, ok: false, reason: 'no-machine-id', url }
|
|
81
|
+
return {
|
|
82
|
+
enabled: true,
|
|
83
|
+
ok: true,
|
|
84
|
+
url,
|
|
85
|
+
machineId,
|
|
86
|
+
deviceName: String(report.machineName || '').trim() || os.hostname(),
|
|
87
|
+
serverUrl: String(report.serverUrl || '').trim(),
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { acquireLease, heartbeatLease, releaseLease, leaseHolder } from './lease
|
|
|
25
25
|
import { resolveAccountToken } from './account-token.mjs'
|
|
26
26
|
import { installMachineSessionReporter } from './machine-session.mjs'
|
|
27
27
|
import { syncRuntimeIfNeeded } from './runtime-sync.mjs'
|
|
28
|
+
import { isTunnelEnabled } from './config.mjs'
|
|
28
29
|
|
|
29
30
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
30
31
|
const PKG_VERSION = (() => {
|
|
@@ -211,10 +212,25 @@ export function apply(ctx) {
|
|
|
211
212
|
role = 'ui'
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
// ⑥ App 真流式隧道(默认关:ARGENT_TUNNEL=1 才启动;Mac 出站到 relay,契约 §2)
|
|
216
|
+
// 放在内核解析之后(用最终 dataDir 读 agent-report.json#machineId);未登录/缺 machineId 只记日志不抛。
|
|
217
|
+
if (isTunnelEnabled()) {
|
|
218
|
+
try {
|
|
219
|
+
const { startTunnel } = await import('./tunnel.mjs')
|
|
220
|
+
const tunnel = startTunnel({ dataDir, version: PKG_VERSION })
|
|
221
|
+
if (tunnel) {
|
|
222
|
+
ctx.on?.('dispose', () => { try { tunnel.stop() } catch { /* 退出清理失败不影响宿主 */ } })
|
|
223
|
+
console.log(`[argent] App 隧道已启动(${tunnel.endpoint},machineId=${tunnel.machineId})`)
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error('[argent] 隧道启动失败(不影响其余功能):', e?.message || e)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
214
230
|
// MCP 网关(engine.* / erp.* / ml.*)
|
|
215
231
|
try {
|
|
216
232
|
const mcpClient = await hostImport('@deepseek-ai/dsh-mcp-client')
|
|
217
|
-
|
|
233
|
+
const mcpReady = ctx.plugin(mcpClient, {
|
|
218
234
|
transport: 'stdio',
|
|
219
235
|
serverName: 'argent',
|
|
220
236
|
command: process.execPath,
|
|
@@ -229,7 +245,18 @@ export function apply(ctx) {
|
|
|
229
245
|
toolCallTimeoutMs: 180000,
|
|
230
246
|
failOnStartupError: false,
|
|
231
247
|
})
|
|
232
|
-
|
|
248
|
+
// 启动等待上限:网关子进程已起但 mcp-client 握手可能很慢(2026-11-07 本机实测挂起)
|
|
249
|
+
// ⇒ 超时只告警、不阻塞后续(隧道/ERP 连接器已在前面启动)。
|
|
250
|
+
const MCP_START_TIMEOUT_MS = 20_000
|
|
251
|
+
const raced = await Promise.race([
|
|
252
|
+
mcpReady.then(() => 'ok'),
|
|
253
|
+
new Promise((resolve) => {
|
|
254
|
+
const t = setTimeout(() => resolve('timeout'), MCP_START_TIMEOUT_MS)
|
|
255
|
+
t.unref?.()
|
|
256
|
+
}),
|
|
257
|
+
])
|
|
258
|
+
if (raced === 'ok') console.log(`[argent] MCP 网关已注册(engineDir=${engineDir || '(无引擎)'})`)
|
|
259
|
+
else console.warn(`[argent] MCP 网关握手超时(${MCP_START_TIMEOUT_MS}ms,后台继续;不影响隧道/ERP 连接器)`)
|
|
233
260
|
} catch (e) {
|
|
234
261
|
console.error('[argent] MCP 网关注册失败(其余功能不受影响):', e?.message || e)
|
|
235
262
|
}
|
|
@@ -243,5 +270,6 @@ export function apply(ctx) {
|
|
|
243
270
|
console.error('[argent] ERP 连接器启动失败(不影响 dsh):', e?.message || e)
|
|
244
271
|
}
|
|
245
272
|
}
|
|
273
|
+
|
|
246
274
|
})()
|
|
247
275
|
}
|
package/src/tunnel.mjs
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App ↔ Mac 真流式隧道(Mac 侧;契约 §2/§6/§7,唯一真源:
|
|
3
|
+
* `~/Whyshu/argent-android/docs/真流式-隧道与租户门控-实施契约-2026-11-07.md`)
|
|
4
|
+
*
|
|
5
|
+
* 链路:`App ──WSS──▶ whyshu relay ──WS──▶ 本隧穿 ──WS/HTTP──▶ dsh 127.0.0.1:3080`
|
|
6
|
+
*
|
|
7
|
+
* 本文件只做三件事,**不解析业务帧**:
|
|
8
|
+
* ① 出站 WSS 注册到 relay(Authorization: Bearer 账号 JWT + x-argent-machine-id)
|
|
9
|
+
* ② 代持 dsh 准入 cookie(`/api/argent-state/access` → `/?token=` 换 `dsh-auth-*`,仅内存缓存)
|
|
10
|
+
* ③ 按契约 §2.3 的帧协议透传 http / WS 两种流量(path 白名单 = `/api/` 前缀)
|
|
11
|
+
*
|
|
12
|
+
* 红线(契约 §6):
|
|
13
|
+
* · 隧道默认关闭(`ARGENT_TUNNEL=1` 才启动);只允许 `/api/` 开头的路径,⛔ 不做通用反向代理;
|
|
14
|
+
* · 账号 JWT / dsh 启动 token / dsh cookie **只在内存**,⛔ 不落盘、不打印、不写日志正文;
|
|
15
|
+
* · 审计只记连接/断开、帧数、字节数、错误码;⛔ 不记正文。
|
|
16
|
+
*
|
|
17
|
+
* 依赖:纯 node 内建(node:http/https + 自带的 RFC 6455 客户端)。
|
|
18
|
+
* 为什么不用 `ws` 包:本插件是**已发布 npm 包**(@whyshu/argent),隧道要带自定义
|
|
19
|
+
* Authorization 头,而 Node 内置 WebSocket 不支持自定义头;为此给发布包加一个运行时依赖
|
|
20
|
+
* 不划算 ⇒ 只实现客户端必需的 RFC 6455 子集(文本帧/分片/ping-pong/close,客户端掩码)。
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
23
|
+
import http from 'node:http'
|
|
24
|
+
import https from 'node:https'
|
|
25
|
+
import { readFileSync } from 'node:fs'
|
|
26
|
+
import { hostname } from 'node:os'
|
|
27
|
+
import { dirname, join } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
import { readTunnelConfig, TUNNEL_DEFAULT_URL } from './config.mjs'
|
|
31
|
+
import { resolveAccountToken } from './account-token.mjs'
|
|
32
|
+
|
|
33
|
+
// ── 常量(契约 §2.1/§2.2/§3.3)─────────────────────────────────────────────
|
|
34
|
+
export const DEFAULT_TUNNEL_URL = TUNNEL_DEFAULT_URL
|
|
35
|
+
export const PING_MS = 25_000
|
|
36
|
+
export const PONG_TIMEOUT_MS = 45_000
|
|
37
|
+
export const RECONNECT_MIN_MS = 1_000
|
|
38
|
+
export const RECONNECT_MAX_MS = 30_000
|
|
39
|
+
export const RECONNECT_JITTER = 0.2
|
|
40
|
+
/** relay ⇄ tunnel 单帧上限(§3.3:单帧 ≤1MB) */
|
|
41
|
+
export const MAX_RELAY_FRAME_BYTES = 1024 * 1024
|
|
42
|
+
/** dsh ⇄ tunnel 单帧上限(会话快照可能偏大;超过 relay 上限的转出帧会被主动断开) */
|
|
43
|
+
export const MAX_DSH_FRAME_BYTES = 4 * 1024 * 1024
|
|
44
|
+
/** http 帧 body 上限(§3.3 同理) */
|
|
45
|
+
export const MAX_HTTP_BODY_BYTES = 1024 * 1024
|
|
46
|
+
/** dsh 一元响应体上限(防御性;正常 API 远小于此) */
|
|
47
|
+
export const MAX_DSH_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
50
|
+
const DSH_ACCESS_PATH = '/api/argent-state/access'
|
|
51
|
+
|
|
52
|
+
function readPkgVersion() {
|
|
53
|
+
try { return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version || '' } catch { return '' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── 纯函数:帧协议(§2.3;字段名逐字对齐,⛔ 不自创)───────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* 契约 §2.3 的帧构造器。所有帧都是 JSON 文本帧。
|
|
59
|
+
* | 方向 | 帧 |
|
|
60
|
+
* | relay→tunnel | {t:"http", id, method, path, headers?, bodyB64?} |
|
|
61
|
+
* | tunnel→relay | {t:"http_res", id, status, headers?, bodyB64?} |
|
|
62
|
+
* | relay→tunnel | {t:"ws_open", id, path, query?} |
|
|
63
|
+
* | tunnel→relay | {t:"ws_ready", id} / {t:"ws_msg", id, data} / {t:"ws_close", id, code?, reason?} |
|
|
64
|
+
* | 双向 | {t:"ping", ts} / {t:"pong", ts} |
|
|
65
|
+
*/
|
|
66
|
+
export const FRAME = {
|
|
67
|
+
hello: ({ machineId, deviceName, version }) => ({ t: 'hello', machineId, deviceName, version }),
|
|
68
|
+
ping: (ts) => ({ t: 'ping', ts }),
|
|
69
|
+
pong: (ts) => ({ t: 'pong', ts }),
|
|
70
|
+
httpRes: ({ id, status, headers, bodyB64 }) => ({
|
|
71
|
+
t: 'http_res', id, status,
|
|
72
|
+
...(headers && Object.keys(headers).length ? { headers } : {}),
|
|
73
|
+
...(bodyB64 ? { bodyB64 } : {}),
|
|
74
|
+
}),
|
|
75
|
+
wsReady: (id) => ({ t: 'ws_ready', id }),
|
|
76
|
+
wsMsg: (id, data) => ({ t: 'ws_msg', id, data }),
|
|
77
|
+
wsClose: (id, code, reason) => ({
|
|
78
|
+
t: 'ws_close', id,
|
|
79
|
+
...(Number.isFinite(code) ? { code } : {}),
|
|
80
|
+
...(reason ? { reason } : {}),
|
|
81
|
+
}),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const encodeFrame = (obj) => JSON.stringify(obj)
|
|
85
|
+
|
|
86
|
+
/** 解析 relay 帧;形状不对一律抛(调用方只记错误码,不记正文)。 */
|
|
87
|
+
export function decodeFrame(text) {
|
|
88
|
+
const v = JSON.parse(String(text))
|
|
89
|
+
if (!v || typeof v !== 'object' || Array.isArray(v) || typeof v.t !== 'string' || !v.t) throw new Error('bad-frame')
|
|
90
|
+
return v
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* path 白名单(契约 §2.4/§6.1):只允许 `/api/` 前缀,且**归一化后**仍在 /api/ 下。
|
|
95
|
+
* ⛔ 拒绝绝对 URL、`//host`、越界 `..`、CRLF 注入。
|
|
96
|
+
*/
|
|
97
|
+
export function isAllowedPath(path) {
|
|
98
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > 4096) return false
|
|
99
|
+
if (!path.startsWith('/')) return false // 相对路径会被 URL 解析成 /api/…,必须显式拒绝
|
|
100
|
+
if (/[\r\n]/.test(path)) return false
|
|
101
|
+
let u
|
|
102
|
+
try { u = new URL(path, 'http://127.0.0.1') } catch { return false }
|
|
103
|
+
if (u.origin !== 'http://127.0.0.1') return false
|
|
104
|
+
if (!u.pathname.startsWith('/api/')) return false
|
|
105
|
+
let decoded
|
|
106
|
+
try { decoded = decodeURIComponent(u.pathname) } catch { return false }
|
|
107
|
+
if (decoded.split('/').includes('..')) return false
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 指数退避 1s→30s,±20% 抖动(`random` 可注入 ⇒ 测试可确定)。 */
|
|
112
|
+
export function backoffDelay(attempt, {
|
|
113
|
+
minMs = RECONNECT_MIN_MS, maxMs = RECONNECT_MAX_MS, jitter = RECONNECT_JITTER, random = Math.random,
|
|
114
|
+
} = {}) {
|
|
115
|
+
const n = Math.min(30, Math.max(0, Math.floor(Number(attempt) || 0)))
|
|
116
|
+
const base = Math.min(maxMs, minMs * 2 ** n)
|
|
117
|
+
const delta = base * jitter
|
|
118
|
+
const jittered = base + (random() * 2 - 1) * delta
|
|
119
|
+
return Math.max(0, Math.round(jittered))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 纯函数:HTTP 头过滤(代持 cookie;⛔ 不透传 relay 侧 cookie/authorization)──
|
|
123
|
+
const HOP_BY_HOP = new Set([
|
|
124
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
|
|
125
|
+
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
|
|
126
|
+
])
|
|
127
|
+
const UNSAFE_RESPONSE_HEADERS = new Set([
|
|
128
|
+
'set-cookie', 'content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive', 'upgrade',
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
/** relay 帧里的 headers 只做"去危险头"后转发;cookie 由隧道自己代持。 */
|
|
132
|
+
export function filterRequestHeaders(headers) {
|
|
133
|
+
const out = {}
|
|
134
|
+
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return out
|
|
135
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
136
|
+
const key = String(k).toLowerCase()
|
|
137
|
+
if (!key || HOP_BY_HOP.has(key) || key.startsWith('sec-websocket-')) continue
|
|
138
|
+
if (v === undefined || v === null) continue
|
|
139
|
+
out[key] = Array.isArray(v) ? v.map(String) : String(v)
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function filterResponseHeaders(headers) {
|
|
145
|
+
const out = {}
|
|
146
|
+
try {
|
|
147
|
+
for (const [k, v] of headers) {
|
|
148
|
+
const key = String(k).toLowerCase()
|
|
149
|
+
if (UNSAFE_RESPONSE_HEADERS.has(key)) continue
|
|
150
|
+
out[key] = v
|
|
151
|
+
}
|
|
152
|
+
} catch { /* 无 headers 的桩响应:返回空表 */ }
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readSetCookies(res) {
|
|
157
|
+
try {
|
|
158
|
+
if (typeof res.headers?.getSetCookie === 'function') return res.headers.getSetCookie()
|
|
159
|
+
const single = res.headers?.get?.('set-cookie')
|
|
160
|
+
return single ? [single] : []
|
|
161
|
+
} catch { return [] }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const b64Json = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64')
|
|
165
|
+
|
|
166
|
+
/** 只保留 origin+pathname,日志里永不带 query(可能含 token)。 */
|
|
167
|
+
function safeEndpoint(raw) {
|
|
168
|
+
try {
|
|
169
|
+
const u = new URL(raw)
|
|
170
|
+
return `${u.origin}${u.pathname}`
|
|
171
|
+
} catch { return 'invalid-url' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const err = (code, detail) => Object.assign(new Error(code), { code, detail })
|
|
175
|
+
|
|
176
|
+
// ── RFC 6455 客户端(只做隧道需要的最小子集)────────────────────────────────
|
|
177
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
178
|
+
export const wsAcceptKey = (key) => createHash('sha1').update(String(key) + WS_GUID).digest('base64')
|
|
179
|
+
|
|
180
|
+
/** 客户端→服务端文本帧:FIN+text,必须掩码。 */
|
|
181
|
+
export function encodeClientTextFrame(text) {
|
|
182
|
+
const payload = Buffer.isBuffer(text) ? text : Buffer.from(String(text), 'utf8')
|
|
183
|
+
const len = payload.length
|
|
184
|
+
let headLen
|
|
185
|
+
if (len < 126) headLen = 2
|
|
186
|
+
else if (len < 65536) headLen = 4
|
|
187
|
+
else headLen = 10
|
|
188
|
+
const header = Buffer.alloc(headLen + 4)
|
|
189
|
+
header[0] = 0x81 // FIN + opcode=text
|
|
190
|
+
if (len < 126) header[1] = 0x80 | len
|
|
191
|
+
else if (len < 65536) { header[1] = 0x80 | 126; header.writeUInt16BE(len, 2) } else { header[1] = 0x80 | 127; header.writeUInt32BE(0, 2); header.writeUInt32BE(len, 6) }
|
|
192
|
+
const mask = randomBytes(4)
|
|
193
|
+
mask.copy(header, headLen)
|
|
194
|
+
const out = Buffer.alloc(header.length + len)
|
|
195
|
+
header.copy(out, 0)
|
|
196
|
+
for (let i = 0; i < len; i += 1) out[header.length + i] = payload[i] ^ mask[i & 3]
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clientControlFrame(opcode, payload = Buffer.alloc(0)) {
|
|
201
|
+
const p = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), 'utf8')
|
|
202
|
+
const len = Math.min(p.length, 125)
|
|
203
|
+
const mask = randomBytes(4)
|
|
204
|
+
const out = Buffer.alloc(6 + len)
|
|
205
|
+
out[0] = 0x80 | (opcode & 0x0f)
|
|
206
|
+
out[1] = 0x80 | len
|
|
207
|
+
mask.copy(out, 2)
|
|
208
|
+
for (let i = 0; i < len; i += 1) out[6 + i] = p[i] ^ mask[i & 3]
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const closePayload = (code, reason) => {
|
|
213
|
+
const r = Buffer.from(String(reason || ''), 'utf8').subarray(0, 123)
|
|
214
|
+
const b = Buffer.alloc(2 + r.length)
|
|
215
|
+
b.writeUInt16BE(Number.isFinite(code) ? code : 1000, 0)
|
|
216
|
+
r.copy(b, 2)
|
|
217
|
+
return b
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 增量帧解析器。服务端帧不掩码(掩码也接受);文本/二进制都按 utf8 交给 onText
|
|
222
|
+
* (dsh 与 relay 的业务帧都是 JSON 文本)。超限 ⇒ onError({closeCode:1009})。
|
|
223
|
+
*/
|
|
224
|
+
export function createFrameParser({
|
|
225
|
+
onText, onPing, onPong, onClose, onError, maxPayload = MAX_RELAY_FRAME_BYTES,
|
|
226
|
+
} = {}) {
|
|
227
|
+
let buf = Buffer.alloc(0)
|
|
228
|
+
let fragments = null
|
|
229
|
+
let failed = false
|
|
230
|
+
|
|
231
|
+
const fail = (reason, closeCode = 1002) => {
|
|
232
|
+
if (failed) return
|
|
233
|
+
failed = true
|
|
234
|
+
onError?.({ code: 'protocol-error', reason, closeCode })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const parseOne = () => {
|
|
238
|
+
if (buf.length < 2) return false
|
|
239
|
+
const b0 = buf[0]
|
|
240
|
+
const b1 = buf[1]
|
|
241
|
+
const fin = (b0 & 0x80) !== 0
|
|
242
|
+
const opcode = b0 & 0x0f
|
|
243
|
+
const masked = (b1 & 0x80) !== 0
|
|
244
|
+
let len = b1 & 0x7f
|
|
245
|
+
let offset = 2
|
|
246
|
+
if ((b0 & 0x70) !== 0) { fail('rsv'); return false }
|
|
247
|
+
if (len === 126) {
|
|
248
|
+
if (buf.length < 4) return false
|
|
249
|
+
len = buf.readUInt16BE(2)
|
|
250
|
+
offset = 4
|
|
251
|
+
} else if (len === 127) {
|
|
252
|
+
if (buf.length < 10) return false
|
|
253
|
+
const big = buf.readBigUInt64BE(2)
|
|
254
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) { fail('length'); return false }
|
|
255
|
+
len = Number(big)
|
|
256
|
+
offset = 10
|
|
257
|
+
}
|
|
258
|
+
if (len > maxPayload) { fail('too-large', 1009); return false }
|
|
259
|
+
const maskOffset = masked ? offset : -1
|
|
260
|
+
const payloadOffset = offset + (masked ? 4 : 0)
|
|
261
|
+
const total = payloadOffset + len
|
|
262
|
+
if (buf.length < total) return false
|
|
263
|
+
let payload = buf.subarray(payloadOffset, total)
|
|
264
|
+
if (masked) {
|
|
265
|
+
const mask = buf.subarray(maskOffset, maskOffset + 4)
|
|
266
|
+
const out = Buffer.allocUnsafe(len)
|
|
267
|
+
for (let i = 0; i < len; i += 1) out[i] = payload[i] ^ mask[i & 3]
|
|
268
|
+
payload = out
|
|
269
|
+
}
|
|
270
|
+
buf = buf.subarray(total)
|
|
271
|
+
|
|
272
|
+
if (opcode >= 0x8) {
|
|
273
|
+
if (!fin || len > 125) { fail('control'); return false }
|
|
274
|
+
if (opcode === 0x9) onPing?.(payload)
|
|
275
|
+
else if (opcode === 0xa) onPong?.(payload)
|
|
276
|
+
else if (opcode === 0x8) {
|
|
277
|
+
let code = 1005
|
|
278
|
+
let reason = ''
|
|
279
|
+
if (payload.length >= 2) { code = payload.readUInt16BE(0); reason = payload.subarray(2).toString('utf8') }
|
|
280
|
+
onClose?.({ code, reason, remote: true })
|
|
281
|
+
} else fail('opcode')
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
284
|
+
if (opcode === 0x1 || opcode === 0x2) {
|
|
285
|
+
if (fragments) { fail('nested-fragment'); return false }
|
|
286
|
+
if (fin) { onText?.(payload.toString('utf8'), opcode); return true }
|
|
287
|
+
fragments = { opcode, chunks: [payload], size: len }
|
|
288
|
+
return true
|
|
289
|
+
}
|
|
290
|
+
if (opcode === 0x0) {
|
|
291
|
+
if (!fragments) { fail('stray-continuation'); return false }
|
|
292
|
+
fragments.chunks.push(payload)
|
|
293
|
+
fragments.size += len
|
|
294
|
+
if (fragments.size > maxPayload) { fail('too-large', 1009); return false }
|
|
295
|
+
if (fin) {
|
|
296
|
+
const whole = Buffer.concat(fragments.chunks)
|
|
297
|
+
fragments = null
|
|
298
|
+
onText?.(whole.toString('utf8'), opcode)
|
|
299
|
+
}
|
|
300
|
+
return true
|
|
301
|
+
}
|
|
302
|
+
fail('opcode')
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
push(chunk) {
|
|
308
|
+
if (failed || !chunk || !chunk.length) return
|
|
309
|
+
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk
|
|
310
|
+
while (!failed && parseOne()) { /* 继续吃缓冲区 */ }
|
|
311
|
+
},
|
|
312
|
+
get buffered() { return buf.length },
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 建一条 WS 客户端连接(带自定义请求头)。回调**异步**触发(与真实 socket 一致)。
|
|
318
|
+
* @returns {{send:(text:string)=>boolean, close:(code?:number,reason?:string)=>void, readyState:string}}
|
|
319
|
+
*/
|
|
320
|
+
export function connectWebSocket({
|
|
321
|
+
url, headers = {}, maxPayload = MAX_RELAY_FRAME_BYTES, handshakeTimeoutMs = 10_000,
|
|
322
|
+
onOpen, onText, onClose, onError,
|
|
323
|
+
} = {}) {
|
|
324
|
+
const target = new URL(String(url))
|
|
325
|
+
if (target.protocol !== 'ws:' && target.protocol !== 'wss:') throw err('bad-ws-url')
|
|
326
|
+
const secure = target.protocol === 'wss:'
|
|
327
|
+
const key = randomBytes(16).toString('base64')
|
|
328
|
+
let socket = null
|
|
329
|
+
let closed = false
|
|
330
|
+
let closeNotified = false
|
|
331
|
+
let upgraded = false
|
|
332
|
+
|
|
333
|
+
const notifyClose = (info) => {
|
|
334
|
+
if (closeNotified) return
|
|
335
|
+
closeNotified = true
|
|
336
|
+
try { onClose?.(info) } catch { /* 回调失败不影响 socket 清理 */ }
|
|
337
|
+
}
|
|
338
|
+
const notifyError = (info) => { try { onError?.(info) } catch { /* noop */ } }
|
|
339
|
+
const sendRaw = (frameBuf) => {
|
|
340
|
+
if (closed || !socket || socket.destroyed) return false
|
|
341
|
+
try { socket.write(frameBuf); return true } catch { return false }
|
|
342
|
+
}
|
|
343
|
+
const send = (text) => sendRaw(encodeClientTextFrame(text))
|
|
344
|
+
const destroy = () => { try { socket?.destroy() } catch { /* noop */ } }
|
|
345
|
+
const close = (code = 1000, reason = '') => {
|
|
346
|
+
if (closed) return
|
|
347
|
+
sendRaw(clientControlFrame(0x8, closePayload(code, reason)))
|
|
348
|
+
closed = true
|
|
349
|
+
destroy()
|
|
350
|
+
notifyClose({ code, reason, local: true })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const parser = createFrameParser({
|
|
354
|
+
maxPayload,
|
|
355
|
+
onText: (text) => onText?.(text),
|
|
356
|
+
onPing: (payload) => sendRaw(clientControlFrame(0xa, payload)),
|
|
357
|
+
onPong: () => {},
|
|
358
|
+
onClose: (info) => {
|
|
359
|
+
sendRaw(clientControlFrame(0x8, closePayload(info.code, info.reason)))
|
|
360
|
+
closed = true
|
|
361
|
+
destroy()
|
|
362
|
+
notifyClose(info)
|
|
363
|
+
},
|
|
364
|
+
onError: (e) => {
|
|
365
|
+
notifyError(e)
|
|
366
|
+
sendRaw(clientControlFrame(0x8, closePayload(e.closeCode || 1002, '')))
|
|
367
|
+
closed = true
|
|
368
|
+
destroy()
|
|
369
|
+
notifyClose({ code: e.closeCode || 1002, reason: e.reason || 'protocol-error' })
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const reqHeaders = {
|
|
374
|
+
Connection: 'Upgrade',
|
|
375
|
+
Upgrade: 'websocket',
|
|
376
|
+
'Sec-WebSocket-Version': '13',
|
|
377
|
+
'Sec-WebSocket-Key': key,
|
|
378
|
+
...headers,
|
|
379
|
+
}
|
|
380
|
+
const path = `${target.pathname}${target.search}`
|
|
381
|
+
const req = (secure ? https : http).request({
|
|
382
|
+
host: target.hostname,
|
|
383
|
+
port: target.port || (secure ? 443 : 80),
|
|
384
|
+
path,
|
|
385
|
+
method: 'GET',
|
|
386
|
+
headers: reqHeaders,
|
|
387
|
+
})
|
|
388
|
+
const timer = setTimeout(() => req.destroy(new Error('handshake-timeout')), handshakeTimeoutMs)
|
|
389
|
+
timer.unref?.()
|
|
390
|
+
|
|
391
|
+
req.on('upgrade', (res, sock, head) => {
|
|
392
|
+
upgraded = true
|
|
393
|
+
clearTimeout(timer)
|
|
394
|
+
socket = sock
|
|
395
|
+
sock.setNoDelay?.(true)
|
|
396
|
+
sock.setTimeout?.(0)
|
|
397
|
+
if (String(res.headers['sec-websocket-accept'] || '') !== wsAcceptKey(key)) {
|
|
398
|
+
notifyError({ code: 'handshake-accept' })
|
|
399
|
+
closed = true
|
|
400
|
+
destroy()
|
|
401
|
+
notifyClose({ code: 1006, reason: 'handshake-accept' })
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
sock.on('data', (chunk) => parser.push(chunk))
|
|
405
|
+
sock.on('error', () => {
|
|
406
|
+
closed = true
|
|
407
|
+
notifyClose({ code: 1006, reason: 'socket-error' })
|
|
408
|
+
})
|
|
409
|
+
sock.on('close', () => {
|
|
410
|
+
closed = true
|
|
411
|
+
notifyClose({ code: 1006, reason: 'socket-closed' })
|
|
412
|
+
})
|
|
413
|
+
if (head && head.length) parser.push(head)
|
|
414
|
+
try { onOpen?.() } catch { /* 回调失败不影响连接 */ }
|
|
415
|
+
})
|
|
416
|
+
req.on('response', (res) => {
|
|
417
|
+
clearTimeout(timer)
|
|
418
|
+
try { res.resume?.() } catch { /* noop */ }
|
|
419
|
+
notifyError({ code: 'handshake-status', status: res.statusCode })
|
|
420
|
+
closed = true
|
|
421
|
+
notifyClose({ code: 1006, reason: 'handshake-status', status: res.statusCode })
|
|
422
|
+
})
|
|
423
|
+
req.on('error', (e) => {
|
|
424
|
+
clearTimeout(timer)
|
|
425
|
+
if (upgraded) return
|
|
426
|
+
notifyError({ code: e?.code || 'handshake-error' })
|
|
427
|
+
closed = true
|
|
428
|
+
notifyClose({ code: 1006, reason: 'handshake-error' })
|
|
429
|
+
})
|
|
430
|
+
req.end()
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
send,
|
|
434
|
+
close,
|
|
435
|
+
get readyState() { return closed ? 'closed' : (socket ? 'open' : 'connecting') },
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── 隧道主体 ────────────────────────────────────────────────────────────────
|
|
440
|
+
const DEFAULT_DSH_BASE = 'http://127.0.0.1:3080'
|
|
441
|
+
|
|
442
|
+
/** 把 `ws_open` 的 query(字符串/对象)拼到 path 上;⛔ 不放行换行/绝对 URL。 */
|
|
443
|
+
export function buildWsPath(path, query) {
|
|
444
|
+
if (!isAllowedPath(path)) return null
|
|
445
|
+
if (query === undefined || query === null || query === '') return path
|
|
446
|
+
let qs
|
|
447
|
+
if (typeof query === 'string') qs = query.replace(/^[?&]+/, '')
|
|
448
|
+
else if (typeof query === 'object' && !Array.isArray(query)) qs = new URLSearchParams(query).toString()
|
|
449
|
+
else return null
|
|
450
|
+
if (/[\r\n]/.test(qs)) return null
|
|
451
|
+
if (!qs) return path
|
|
452
|
+
const joined = `${path}${path.includes('?') ? '&' : '?'}${qs}`
|
|
453
|
+
return isAllowedPath(joined) ? joined : null
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 启动隧道(`ARGENT_TUNNEL=1` 且 machineId 可解析才真正启动)。
|
|
458
|
+
* @param {object} options 测试/宿主注入点:config、env、dataDir、tokenProvider、fetchImpl、
|
|
459
|
+
* connectWs、timers、random、now、log、dshBase、version、deviceName、pingMs、pongTimeoutMs、
|
|
460
|
+
* reconnect、autoStart
|
|
461
|
+
* @returns {null|{stop:()=>void, getStats:()=>object, machineId:string, endpoint:string}}
|
|
462
|
+
*/
|
|
463
|
+
export function startTunnel(options = {}) {
|
|
464
|
+
const env = options.env || process.env
|
|
465
|
+
const logFn = options.log || ((m) => console.log(`[argent] ${m}`))
|
|
466
|
+
const cfg = options.config || readTunnelConfig({ env, dataDir: options.dataDir })
|
|
467
|
+
if (!cfg || cfg.enabled === false) return null
|
|
468
|
+
if (!cfg.ok) {
|
|
469
|
+
const why = cfg.reason === 'no-machine-id'
|
|
470
|
+
? '缺 machineId(ARGENT_MACHINE_ID 与 agent-report.json#machineId 都没有)'
|
|
471
|
+
: cfg.reason === 'bad-url' ? 'ARGENT_TUNNEL_URL 不是 ws/wss 地址' : String(cfg.reason || 'unknown')
|
|
472
|
+
logFn(`隧道未启动:${why}`)
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const timers = options.timers || { setTimeout, clearTimeout }
|
|
477
|
+
const random = options.random || Math.random
|
|
478
|
+
const now = options.now || Date.now
|
|
479
|
+
const fetchImpl = options.fetchImpl || globalThis.fetch
|
|
480
|
+
const connect = options.connectWs || connectWebSocket
|
|
481
|
+
const endpoint = options.url || cfg.url
|
|
482
|
+
const machineId = options.machineId || cfg.machineId
|
|
483
|
+
const deviceName = options.deviceName || cfg.deviceName || hostname()
|
|
484
|
+
const version = options.version || readPkgVersion()
|
|
485
|
+
const dshBase = String(options.dshBase || env.ARGENT_DSH_BASE || DEFAULT_DSH_BASE).replace(/\/+$/, '')
|
|
486
|
+
const dshWsBase = dshBase.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
|
487
|
+
const pingMs = options.pingMs ?? PING_MS
|
|
488
|
+
const pongTimeoutMs = options.pongTimeoutMs ?? PONG_TIMEOUT_MS
|
|
489
|
+
const reconnect = {
|
|
490
|
+
minMs: RECONNECT_MIN_MS, maxMs: RECONNECT_MAX_MS, jitter: RECONNECT_JITTER, ...(options.reconnect || {}),
|
|
491
|
+
}
|
|
492
|
+
const tokenProvider = options.tokenProvider || (() => resolveAccountToken({ env }).token || '')
|
|
493
|
+
|
|
494
|
+
const stats = {
|
|
495
|
+
connects: 0, disconnects: 0, framesIn: 0, framesOut: 0, bytesIn: 0, bytesOut: 0,
|
|
496
|
+
errors: 0, http: 0, wsOpen: 0,
|
|
497
|
+
}
|
|
498
|
+
const dsh = { cookie: '' }
|
|
499
|
+
const streams = new Map()
|
|
500
|
+
let stopped = false
|
|
501
|
+
let attempt = 0
|
|
502
|
+
let ws = null
|
|
503
|
+
let reconnectTimer = null
|
|
504
|
+
let pingTimer = null
|
|
505
|
+
let tokenMissingLogged = false
|
|
506
|
+
|
|
507
|
+
const scheduleReconnect = () => {
|
|
508
|
+
if (stopped) return
|
|
509
|
+
const delay = backoffDelay(attempt, { ...reconnect, random })
|
|
510
|
+
attempt += 1
|
|
511
|
+
logFn(`隧道将重连(${delay}ms 后,第 ${attempt} 次)`)
|
|
512
|
+
reconnectTimer = timers.setTimeout(() => { reconnectTimer = null; void connectOnce() }, delay)
|
|
513
|
+
reconnectTimer?.unref?.()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const stopHeartbeat = () => {
|
|
517
|
+
if (pingTimer) { timers.clearTimeout(pingTimer); pingTimer = null }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const send = (obj) => {
|
|
521
|
+
if (!ws) return false
|
|
522
|
+
let text
|
|
523
|
+
try { text = encodeFrame(obj) } catch { return false }
|
|
524
|
+
const ok = ws.send(text)
|
|
525
|
+
if (ok) {
|
|
526
|
+
stats.framesOut += 1
|
|
527
|
+
stats.bytesOut += Buffer.byteLength(text, 'utf8')
|
|
528
|
+
}
|
|
529
|
+
return ok
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── dsh 准入 cookie(仅内存;401/403 重换一次)──────────────────────────
|
|
533
|
+
const acquireCookie = async ({ refresh = false } = {}) => {
|
|
534
|
+
if (!refresh && dsh.cookie) return dsh.cookie
|
|
535
|
+
let access
|
|
536
|
+
try {
|
|
537
|
+
access = await fetchImpl(`${dshBase}${DSH_ACCESS_PATH}`, {
|
|
538
|
+
headers: { accept: 'application/json' },
|
|
539
|
+
signal: AbortSignal.timeout(5000),
|
|
540
|
+
})
|
|
541
|
+
} catch { throw err('access-unreachable') }
|
|
542
|
+
let body = null
|
|
543
|
+
try { body = await access.json() } catch { body = null }
|
|
544
|
+
if (!access.ok || !body || body.ok !== true || !body.token) throw err('access-unavailable', `HTTP ${access.status}`)
|
|
545
|
+
let res
|
|
546
|
+
try {
|
|
547
|
+
res = await fetchImpl(`${dshBase}/?token=${encodeURIComponent(String(body.token))}`, {
|
|
548
|
+
redirect: 'manual',
|
|
549
|
+
signal: AbortSignal.timeout(5000),
|
|
550
|
+
})
|
|
551
|
+
} catch { throw err('cookie-unreachable') }
|
|
552
|
+
const cookies = readSetCookies(res)
|
|
553
|
+
.map((c) => String(c).split(';')[0].trim())
|
|
554
|
+
.filter((c) => c.startsWith('dsh-auth-'))
|
|
555
|
+
if (!cookies.length) throw err('cookie-missing', `HTTP ${res.status}`)
|
|
556
|
+
dsh.cookie = cookies.join('; ')
|
|
557
|
+
return dsh.cookie
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const dshRequest = async ({ method, path, headers, body, retry = true }) => {
|
|
561
|
+
const build = async (cookie) => {
|
|
562
|
+
const h = filterRequestHeaders(headers)
|
|
563
|
+
h.cookie = cookie
|
|
564
|
+
const init = { method, headers: h, redirect: 'manual', signal: AbortSignal.timeout(30_000) }
|
|
565
|
+
if (body && body.length && method !== 'GET' && method !== 'HEAD') init.body = body
|
|
566
|
+
return { init, headers: h }
|
|
567
|
+
}
|
|
568
|
+
const first = await build(await acquireCookie())
|
|
569
|
+
let res = await fetchImpl(`${dshBase}${path}`, first.init)
|
|
570
|
+
if (retry && (res.status === 401 || res.status === 403)) {
|
|
571
|
+
const retried = await build(await acquireCookie({ refresh: true }))
|
|
572
|
+
res = await fetchImpl(`${dshBase}${path}`, retried.init)
|
|
573
|
+
}
|
|
574
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
575
|
+
if (buf.length > MAX_DSH_RESPONSE_BYTES) throw err('response-too-large')
|
|
576
|
+
return { status: res.status, headers: filterResponseHeaders(res.headers), body: buf }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── 帧处理 ────────────────────────────────────────────────────────────────
|
|
580
|
+
const handleHttp = async (frame) => {
|
|
581
|
+
stats.http += 1
|
|
582
|
+
const id = frame.id
|
|
583
|
+
if (!isAllowedPath(frame.path)) {
|
|
584
|
+
send(FRAME.httpRes({ id, status: 403, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'path-not-allowed' }) }))
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
let body = Buffer.alloc(0)
|
|
588
|
+
if (frame.bodyB64) {
|
|
589
|
+
try { body = Buffer.from(String(frame.bodyB64), 'base64') } catch { body = null }
|
|
590
|
+
if (!body) {
|
|
591
|
+
send(FRAME.httpRes({ id, status: 400, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'bad-body' }) }))
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (body.length > MAX_HTTP_BODY_BYTES) {
|
|
595
|
+
send(FRAME.httpRes({ id, status: 413, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'body-too-large' }) }))
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const method = String(frame.method || 'GET').toUpperCase()
|
|
600
|
+
if (!/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/.test(method)) {
|
|
601
|
+
send(FRAME.httpRes({ id, status: 405, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'method-not-allowed' }) }))
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const res = await dshRequest({ method, path: frame.path, headers: frame.headers, body })
|
|
606
|
+
send(FRAME.httpRes({
|
|
607
|
+
id, status: res.status, headers: res.headers,
|
|
608
|
+
bodyB64: res.body.length ? res.body.toString('base64') : undefined,
|
|
609
|
+
}))
|
|
610
|
+
} catch (e) {
|
|
611
|
+
stats.errors += 1
|
|
612
|
+
const reason = String(e?.code || 'dsh-error')
|
|
613
|
+
logFn(`隧道代理 dsh 失败:${reason}`)
|
|
614
|
+
send(FRAME.httpRes({
|
|
615
|
+
id, status: 502, headers: { 'content-type': 'application/json' },
|
|
616
|
+
bodyB64: b64Json({ ok: false, error: 'dsh-unavailable', reason }),
|
|
617
|
+
}))
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const closeStream = (id, code = 1000, reason = '') => {
|
|
622
|
+
const stream = streams.get(id)
|
|
623
|
+
if (!stream) return
|
|
624
|
+
stream.closed = true
|
|
625
|
+
streams.delete(id)
|
|
626
|
+
try { stream.handle?.close(code, reason) } catch { /* noop */ }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const closeAllStreams = (code = 1001, reason = 'relay-closed') => {
|
|
630
|
+
for (const id of [...streams.keys()]) closeStream(id, code, reason)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const connectDshWs = ({ path, cookie, stream }) => new Promise((resolve, reject) => {
|
|
634
|
+
let open = false
|
|
635
|
+
let settled = false
|
|
636
|
+
let handle = null
|
|
637
|
+
const fail = (e) => { if (settled) return; settled = true; reject(e) }
|
|
638
|
+
const done = () => { if (settled) return; settled = true; open = true; resolve(handle) }
|
|
639
|
+
try {
|
|
640
|
+
handle = connect({
|
|
641
|
+
url: `${dshWsBase}${path}`,
|
|
642
|
+
headers: { cookie, host: new URL(dshBase).host },
|
|
643
|
+
maxPayload: MAX_DSH_FRAME_BYTES,
|
|
644
|
+
onOpen: () => done(),
|
|
645
|
+
onText: (text) => {
|
|
646
|
+
if (!open || stream.closed) return
|
|
647
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RELAY_FRAME_BYTES) {
|
|
648
|
+
stats.errors += 1
|
|
649
|
+
send(FRAME.wsClose(stream.id, 1009, 'message-too-large'))
|
|
650
|
+
closeStream(stream.id, 1009, 'message-too-large')
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
send(FRAME.wsMsg(stream.id, text))
|
|
654
|
+
},
|
|
655
|
+
onClose: (info) => {
|
|
656
|
+
if (!open) { fail({ code: 'closed-before-open', status: info?.status }); return }
|
|
657
|
+
if (stream.closed) return
|
|
658
|
+
stream.closed = true
|
|
659
|
+
streams.delete(stream.id)
|
|
660
|
+
send(FRAME.wsClose(stream.id, info?.code, info?.reason))
|
|
661
|
+
},
|
|
662
|
+
onError: (e) => {
|
|
663
|
+
if (!open) { fail(e); return }
|
|
664
|
+
stats.errors += 1
|
|
665
|
+
logFn(`隧道 dsh 流错误:${e?.code || 'dsh-ws-error'}`)
|
|
666
|
+
},
|
|
667
|
+
})
|
|
668
|
+
} catch (e) { fail(e) }
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
const openDshWs = async (path, stream) => {
|
|
672
|
+
let cookie = await acquireCookie()
|
|
673
|
+
for (let i = 0; i < 2; i += 1) {
|
|
674
|
+
try {
|
|
675
|
+
return await connectDshWs({ path, cookie, stream })
|
|
676
|
+
} catch (e) {
|
|
677
|
+
if (i === 0 && e?.code === 'handshake-status' && (e.status === 401 || e.status === 403)) {
|
|
678
|
+
cookie = await acquireCookie({ refresh: true })
|
|
679
|
+
continue
|
|
680
|
+
}
|
|
681
|
+
throw e
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
throw err('dsh-ws-unavailable')
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const handleWsOpen = async (frame) => {
|
|
688
|
+
stats.wsOpen += 1
|
|
689
|
+
const id = frame.id
|
|
690
|
+
if (!isAllowedPath(frame.path)) {
|
|
691
|
+
send(FRAME.wsClose(id, 1008, 'path-not-allowed'))
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
const path = buildWsPath(frame.path, frame.query)
|
|
695
|
+
if (!path) {
|
|
696
|
+
send(FRAME.wsClose(id, 1008, 'bad-query'))
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
if (streams.has(id)) closeStream(id, 1000, 'replaced')
|
|
700
|
+
const stream = { id, handle: null, closed: false }
|
|
701
|
+
streams.set(id, stream)
|
|
702
|
+
try {
|
|
703
|
+
const handle = await openDshWs(path, stream)
|
|
704
|
+
stream.handle = handle
|
|
705
|
+
if (stream.closed) { try { handle.close(1000, 'cancelled') } catch { /* noop */ }; return }
|
|
706
|
+
send(FRAME.wsReady(id))
|
|
707
|
+
} catch (e) {
|
|
708
|
+
stats.errors += 1
|
|
709
|
+
logFn(`隧道打开 dsh 流失败:${e?.code || 'dsh-ws-error'}`)
|
|
710
|
+
const code = String(e?.code || 'dsh-ws-error').slice(0, 60)
|
|
711
|
+
if (streams.get(id) === stream) { stream.closed = true; streams.delete(id) }
|
|
712
|
+
send(FRAME.wsClose(id, 1011, code))
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const handleWsMsg = (frame) => {
|
|
717
|
+
const stream = streams.get(frame.id)
|
|
718
|
+
if (!stream || stream.closed || !stream.handle) { stats.errors += 1; return }
|
|
719
|
+
const data = typeof frame.data === 'string' ? frame.data : JSON.stringify(frame.data ?? '')
|
|
720
|
+
if (!stream.handle.send(data)) stats.errors += 1
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const handleWsClose = (frame) => {
|
|
724
|
+
closeStream(frame.id, Number.isFinite(frame.code) ? frame.code : 1000, String(frame.reason || ''))
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const handleRelayText = (text, conn) => {
|
|
728
|
+
const size = Buffer.byteLength(String(text ?? ''), 'utf8')
|
|
729
|
+
stats.framesIn += 1
|
|
730
|
+
stats.bytesIn += size
|
|
731
|
+
if (size > MAX_RELAY_FRAME_BYTES) {
|
|
732
|
+
stats.errors += 1
|
|
733
|
+
logFn('隧道收到超大帧,断开连接')
|
|
734
|
+
try { conn.handle?.close(1009, 'frame-too-large') } catch { /* noop */ }
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
let frame
|
|
738
|
+
try { frame = decodeFrame(text) } catch { stats.errors += 1; logFn('隧道收到非法帧(已忽略)'); return }
|
|
739
|
+
switch (frame.t) {
|
|
740
|
+
case 'ping': send(FRAME.pong(frame.ts)); break
|
|
741
|
+
case 'pong': conn.lastPong = now(); break
|
|
742
|
+
case 'http': void handleHttp(frame); break
|
|
743
|
+
case 'ws_open': void handleWsOpen(frame); break
|
|
744
|
+
case 'ws_msg': handleWsMsg(frame); break
|
|
745
|
+
case 'ws_close': handleWsClose(frame); break
|
|
746
|
+
default: stats.errors += 1; logFn(`隧道收到未知帧类型(已忽略):${String(frame.t).slice(0, 24)}`)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const startHeartbeat = (conn) => {
|
|
751
|
+
stopHeartbeat()
|
|
752
|
+
const tick = () => {
|
|
753
|
+
if (stopped || conn.closed || !conn.open) return
|
|
754
|
+
if (now() - conn.lastPong > pongTimeoutMs) {
|
|
755
|
+
stats.errors += 1
|
|
756
|
+
logFn(`隧道心跳超时(${pongTimeoutMs}ms 无 pong),重连`)
|
|
757
|
+
try { conn.handle?.close(1001, 'pong-timeout') } catch { /* noop */ }
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
send(FRAME.ping(now()))
|
|
761
|
+
pingTimer = timers.setTimeout(tick, pingMs)
|
|
762
|
+
pingTimer?.unref?.()
|
|
763
|
+
}
|
|
764
|
+
pingTimer = timers.setTimeout(tick, pingMs)
|
|
765
|
+
pingTimer?.unref?.()
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const handleRelayClose = (conn, info) => {
|
|
769
|
+
conn.closed = true
|
|
770
|
+
stopHeartbeat()
|
|
771
|
+
closeAllStreams(1001, 'relay-closed')
|
|
772
|
+
if (ws === conn.handle) ws = null
|
|
773
|
+
if (conn.open) {
|
|
774
|
+
stats.disconnects += 1
|
|
775
|
+
logFn(`隧道连接已断开(code=${info?.code ?? 1006})`)
|
|
776
|
+
}
|
|
777
|
+
if (stopped) return
|
|
778
|
+
scheduleReconnect()
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const connectOnce = async () => {
|
|
782
|
+
if (stopped) return
|
|
783
|
+
let token = ''
|
|
784
|
+
try { token = await tokenProvider() } catch { token = '' }
|
|
785
|
+
if (!token) {
|
|
786
|
+
if (!tokenMissingLogged) {
|
|
787
|
+
logFn('隧道未连接:未登录问述账号(WHYSHU_API_KEY 缺失),将持续重试')
|
|
788
|
+
tokenMissingLogged = true
|
|
789
|
+
}
|
|
790
|
+
scheduleReconnect()
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
tokenMissingLogged = false
|
|
794
|
+
const conn = { handle: null, open: false, closed: false, lastPong: 0 }
|
|
795
|
+
let handle
|
|
796
|
+
try {
|
|
797
|
+
handle = connect({
|
|
798
|
+
url: endpoint,
|
|
799
|
+
headers: { Authorization: `Bearer ${token}`, 'x-argent-machine-id': machineId },
|
|
800
|
+
maxPayload: MAX_RELAY_FRAME_BYTES,
|
|
801
|
+
onOpen: () => {
|
|
802
|
+
if (stopped || conn.closed) return
|
|
803
|
+
conn.open = true
|
|
804
|
+
attempt = 0
|
|
805
|
+
stats.connects += 1
|
|
806
|
+
conn.lastPong = now()
|
|
807
|
+
logFn(`隧道已注册(machineId=${machineId},${safeEndpoint(endpoint)})`)
|
|
808
|
+
send(FRAME.hello({ machineId, deviceName, version }))
|
|
809
|
+
startHeartbeat(conn)
|
|
810
|
+
},
|
|
811
|
+
onText: (text) => { if (ws === conn.handle) handleRelayText(text, conn) },
|
|
812
|
+
onClose: (info) => handleRelayClose(conn, info),
|
|
813
|
+
onError: (e) => {
|
|
814
|
+
stats.errors += 1
|
|
815
|
+
logFn(`隧道连接错误:${e?.code || 'unknown'}${e?.status ? `(HTTP ${e.status})` : ''}`)
|
|
816
|
+
},
|
|
817
|
+
})
|
|
818
|
+
} catch (e) {
|
|
819
|
+
stats.errors += 1
|
|
820
|
+
logFn(`隧道连接失败:${e?.code || 'connect-error'}`)
|
|
821
|
+
scheduleReconnect()
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
conn.handle = handle
|
|
825
|
+
ws = handle
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const stop = () => {
|
|
829
|
+
if (stopped) return
|
|
830
|
+
stopped = true
|
|
831
|
+
if (reconnectTimer) { timers.clearTimeout(reconnectTimer); reconnectTimer = null }
|
|
832
|
+
stopHeartbeat()
|
|
833
|
+
closeAllStreams(1000, 'shutdown')
|
|
834
|
+
const closing = ws
|
|
835
|
+
ws = null
|
|
836
|
+
try { closing?.close(1000, 'shutdown') } catch { /* noop */ }
|
|
837
|
+
logFn('隧道已停止')
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (options.autoStart !== false) void connectOnce()
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
stop,
|
|
844
|
+
getStats: () => ({ ...stats }),
|
|
845
|
+
machineId,
|
|
846
|
+
endpoint: safeEndpoint(endpoint),
|
|
847
|
+
}
|
|
848
|
+
}
|