dsh-mobile-plugin 0.0.0-stage → 0.1.57
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/README.md +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 局域网直连代理:手机在同一个 Wi-Fi 下直接访问
|
|
5
|
+
* http://<电脑IP>:<lanPort>,经配对码校验后代理到本机 dsh web。
|
|
6
|
+
*
|
|
7
|
+
* 与中转隧道的关系:同网用本代理(零延迟、不依赖公网),
|
|
8
|
+
* 异地走 dsh-mobile-server 中转。两者互不影响,可同时开启。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
13
|
+
const core = require('./core.js');
|
|
14
|
+
const { safeEqual } = require('../shared/dshtoken');
|
|
15
|
+
const { isFreshRequest } = require('../shared/fresh-request');
|
|
16
|
+
const credentials = require('../shared/credentials-entry.js').credentialsEntry();
|
|
17
|
+
|
|
18
|
+
const HOP_BY_HOP = new Set([
|
|
19
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
20
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const PAIR_MAX_FAILS = 5;
|
|
24
|
+
const PAIR_LOCK_MS = 60_000;
|
|
25
|
+
// 上游 WS 握手的一发超时:连上之后这条连接就是长连接(HMR、SSE 都从它过),
|
|
26
|
+
// 所以只在「还没握上」这段时间里计时。
|
|
27
|
+
const WS_UPSTREAM_HANDSHAKE_MS = 10_000;
|
|
28
|
+
// 上游已经握上、手机的握手还没完成的这一瞬间,上游先推过来的字节最多攒这么多。
|
|
29
|
+
const WS_UPSTREAM_PENDING_MAX_BYTES = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
function createLanProxy(opts) {
|
|
32
|
+
const {
|
|
33
|
+
lanPort,
|
|
34
|
+
pairingCode,
|
|
35
|
+
instanceId,
|
|
36
|
+
lanSecret,
|
|
37
|
+
upstreamPort,
|
|
38
|
+
upstreamHost = '127.0.0.1',
|
|
39
|
+
onLog = () => {},
|
|
40
|
+
/**
|
|
41
|
+
* 桌面用户授权回调:async (device) => ({ approved, token? })。
|
|
42
|
+
* 默认**拒绝**。旧实现默认 `{ approved: true }`,任何忘了传 onApprove 的调用方
|
|
43
|
+
* 都会退化成「知道 6 位配对码即可直连」的 fail-open——授权判断必须显式给出。
|
|
44
|
+
*/
|
|
45
|
+
onApprove = async () => ({ approved: false }),
|
|
46
|
+
/** 读取隔离:本地镜像快照读取回调,命中时直接回(不再连主进程)。 */
|
|
47
|
+
readSnapshot = () => null,
|
|
48
|
+
} = opts;
|
|
49
|
+
|
|
50
|
+
let server = null;
|
|
51
|
+
let wss = null;
|
|
52
|
+
// 实际监听的端口。首选端口被占时(见 lib/relay/port-allocator.cjs)会退到内核
|
|
53
|
+
// 分配的空闲端口,这时对外必须报 `actualPort` 而不是 `lanPort`,否则状态接口和
|
|
54
|
+
// 配对二维码里带的都是那个根本没在监听的死端口。
|
|
55
|
+
let actualPort = lanPort;
|
|
56
|
+
// 配对失败限流:ip -> { fails, lockedUntil }(与 relay 侧策略一致:5 次失败锁 60s)
|
|
57
|
+
const pairLocks = new Map();
|
|
58
|
+
|
|
59
|
+
function log(...args) {
|
|
60
|
+
onLog('[lan-proxy]', ...args);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPairLocked(ip) {
|
|
64
|
+
const l = pairLocks.get(ip);
|
|
65
|
+
return !!l && l.lockedUntil > Date.now();
|
|
66
|
+
}
|
|
67
|
+
function recordPairFail(ip) {
|
|
68
|
+
const l = pairLocks.get(ip) || { fails: 0, lockedUntil: 0 };
|
|
69
|
+
l.fails += 1;
|
|
70
|
+
if (l.fails >= PAIR_MAX_FAILS) {
|
|
71
|
+
l.lockedUntil = Date.now() + PAIR_LOCK_MS;
|
|
72
|
+
l.fails = 0;
|
|
73
|
+
}
|
|
74
|
+
pairLocks.set(ip, l);
|
|
75
|
+
}
|
|
76
|
+
function clearPairFails(ip) {
|
|
77
|
+
pairLocks.delete(ip);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 请求出示的设备凭据(`dshc_…`),取不到返回 null。
|
|
82
|
+
*
|
|
83
|
+
* App 把设备凭据放在独立的 `X-Dsh-Device-Credential` 头里,`Authorization`
|
|
84
|
+
* 留给宿主 token(`app/src/services/authHeaders.ts`)。旧实现只从
|
|
85
|
+
* `Authorization` 里找 `dshc_`,于是本函数对真实 App 流量恒为 null,
|
|
86
|
+
* 「吊销能作用于局域网直连」从未落地。`Authorization` 分支仅用于兼容早期
|
|
87
|
+
* 客户端,保留但不作为主路径。
|
|
88
|
+
*/
|
|
89
|
+
function presentedCredential(req) {
|
|
90
|
+
const raw = req.headers['x-dsh-device-credential'];
|
|
91
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
92
|
+
if (typeof header === 'string' && header.startsWith('dshc_')) return header;
|
|
93
|
+
const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
|
|
94
|
+
return m && m[1].startsWith('dshc_') ? m[1] : null;
|
|
95
|
+
}
|
|
96
|
+
/** 当前仍然有效的本机设备凭据;用于把该设备对应的宿主 token 转发给上游。 */
|
|
97
|
+
function credential(req) {
|
|
98
|
+
const presented = presentedCredential(req);
|
|
99
|
+
return presented ? credentials.find(presented, instanceId) : null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 无状态凭据校验:dsh_lan cookie 或 `Authorization: Bearer <token>`。两者同源
|
|
103
|
+
* 同格式,均为 lanSecret 签名的 HMAC token;RN 在部分平台会丢弃手动 Cookie
|
|
104
|
+
* 头,故 Bearer 兜底。
|
|
105
|
+
*/
|
|
106
|
+
function cookieValid(req) {
|
|
107
|
+
const m = /dsh_lan=([^;]+)/.exec(req.headers.cookie || '');
|
|
108
|
+
return m ? core.verifyLanToken(lanSecret, m[1], instanceId) : false;
|
|
109
|
+
}
|
|
110
|
+
function bearerValid(req) {
|
|
111
|
+
const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
|
|
112
|
+
return !!m && core.verifyLanToken(lanSecret, m[1], instanceId);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 鉴权。
|
|
116
|
+
*
|
|
117
|
+
* 顺序是这里唯一的要点:出示了**本机签发过**的设备凭据时以它为准,一旦被吊销
|
|
118
|
+
* 或过期就直接拒绝,不再回退到无状态 token —— HMAC token 有效期一年,允许回退
|
|
119
|
+
* 就等于吊销永远不生效。
|
|
120
|
+
*
|
|
121
|
+
* 但「本机不认识这张凭据」必须回退:公网配对时凭据由中转签发并存在中转的凭据
|
|
122
|
+
* 库里(`dsh-mobile-server/lib/tunnel-handler.js`),宿主这里查不到。若一律拒绝,
|
|
123
|
+
* 「公网配对后回到局域网直连」这个主要用法会直接 401。
|
|
124
|
+
*/
|
|
125
|
+
function authorized(req) {
|
|
126
|
+
const presented = presentedCredential(req);
|
|
127
|
+
if (presented) {
|
|
128
|
+
const known = credentials.lookup(presented, instanceId);
|
|
129
|
+
if (known) return credentials.isLive(known);
|
|
130
|
+
}
|
|
131
|
+
return cookieValid(req) || bearerValid(req);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 配对失败的统一响应。
|
|
135
|
+
*
|
|
136
|
+
* 旧实现把「配对码错误」答成 401、把「配对码正确但桌面端拒绝」答成 403,
|
|
137
|
+
* 于是 `deny` 策略下任何人都能拿状态码逐位试探出真实配对码。两种情况现在
|
|
138
|
+
* 返回完全相同的状态码与响应体,配对码不再可被探测。
|
|
139
|
+
*/
|
|
140
|
+
function pairDenied(res) {
|
|
141
|
+
try {
|
|
142
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
143
|
+
res.end(JSON.stringify({ ok: false, error: '配对码错误或桌面端已拒绝' }));
|
|
144
|
+
} catch { /* 客户端已断开 */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setCookie(res) {
|
|
148
|
+
res.setHeader(
|
|
149
|
+
'set-cookie',
|
|
150
|
+
`dsh_lan=${core.signLanToken(lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC)}`
|
|
151
|
+
+ `; Path=/; HttpOnly; SameSite=Lax; Max-Age=${core.MOBILE_TOKEN_TTL_SEC}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
function clearCookie(res) {
|
|
155
|
+
// 与 setCookie 保持同一组属性:属性不一致时部分客户端会当成另一个 cookie,删不掉。
|
|
156
|
+
res.setHeader('set-cookie', 'dsh_lan=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const PAGE = `<!DOCTYPE html>
|
|
160
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
161
|
+
<title>连接 DeepSeek Harness</title>
|
|
162
|
+
<style>
|
|
163
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
164
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif;background:#0f1115;color:#e6e8eb;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
|
165
|
+
.card{width:min(380px,100%);background:#171a21;border:1px solid #262b36;border-radius:16px;padding:28px}
|
|
166
|
+
h1{font-size:18px;margin-bottom:6px}
|
|
167
|
+
.sub{font-size:13px;color:#8b93a1;margin-bottom:20px;line-height:1.6}
|
|
168
|
+
label{display:block;font-size:12px;color:#8b93a1;margin:14px 0 6px}
|
|
169
|
+
input{width:100%;padding:10px 12px;font-size:15px;background:#0f1115;color:#e6e8eb;border:1px solid #2c3340;border-radius:10px;outline:none}
|
|
170
|
+
input:focus{border-color:#4d6bfe}
|
|
171
|
+
button{width:100%;margin-top:20px;padding:11px;font-size:15px;background:#4d6bfe;color:#fff;border:none;border-radius:10px;cursor:pointer}
|
|
172
|
+
.err{margin-top:14px;font-size:13px;color:#f85149;min-height:18px}
|
|
173
|
+
</style></head><body>
|
|
174
|
+
<div class="card">
|
|
175
|
+
<h1>连接 DeepSeek Harness</h1>
|
|
176
|
+
<div class="sub">局域网直连 · 实例 <code>${instanceId}</code><br>请输入桌面端「远程连接」面板显示的 6 位配对码</div>
|
|
177
|
+
<form id="f"><label for="code">配对码</label><input id="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
|
178
|
+
<button type="submit">连接</button></form>
|
|
179
|
+
<div class="err" id="err"></div>
|
|
180
|
+
</div>
|
|
181
|
+
<script>
|
|
182
|
+
document.getElementById('f').addEventListener('submit', async (e) => {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
const code = document.getElementById('code').value.trim();
|
|
185
|
+
if (!/^\d{6}$/.test(code)) {
|
|
186
|
+
document.getElementById('err').textContent = '请输入 6 位数字配对码';
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const r = await fetch('/__lan_pair', { method:'POST', headers:{'content-type':'application/json'},
|
|
190
|
+
body: JSON.stringify({ code }) });
|
|
191
|
+
if (r.ok) { location.href = '/'; }
|
|
192
|
+
else { document.getElementById('err').textContent = '配对码错误或尝试过于频繁'; }
|
|
193
|
+
});
|
|
194
|
+
</script></body></html>`;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 是否是「程序化 API 请求」。
|
|
198
|
+
*
|
|
199
|
+
* 未认证时给浏览器导航回配对页(200 HTML)是刻意的:用户直接在手机浏览器里
|
|
200
|
+
* 打开局域网地址即可输入配对码。但**绝不能**这样答复 API 请求 ——
|
|
201
|
+
* `/api/mobile/v1/*` 被答成 200 + HTML 时,App 的 `app/src/api.ts` 会把它当成
|
|
202
|
+
* 成功(`response.ok` 为真 → `publishRoute` 把连接通道钉在局域网、并重置局域网
|
|
203
|
+
* 熔断),随后 `res.json()` 解析失败,`unwrapResult` 抛出「请求失败
|
|
204
|
+
* (HTTP 200)」。结果是桌面端换过 lanSecret、或设备凭据被吊销之后,App 会永久
|
|
205
|
+
* 卡在局域网通道上:既不会回退到中转,也不会提示重新配对。
|
|
206
|
+
*
|
|
207
|
+
* 判定规则:路径以 `/api/` 开头,或客户端明确不接受 `text/html`。
|
|
208
|
+
*/
|
|
209
|
+
function isApiRequest(req, url) {
|
|
210
|
+
if (url.pathname.startsWith('/api/')) return true;
|
|
211
|
+
return !/\btext\/html\b/i.test(String(req.headers.accept || ''));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 未认证的 API 请求:回 JSON 401,与移动 API 的鉴权失败保持同一种形状,
|
|
216
|
+
* App 据此显示可读的「缺少或无效的移动 token」,而不是「请求失败 (HTTP 200)」。
|
|
217
|
+
*/
|
|
218
|
+
function sendUnauthorized(res) {
|
|
219
|
+
try {
|
|
220
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
221
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'unauthorized', message: '缺少或无效的移动 token' } }));
|
|
222
|
+
} catch { /* 客户端已断开 */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sendPage(res, status) {
|
|
226
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
227
|
+
res.end(PAGE);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function proxyToUpstream(req, res) {
|
|
231
|
+
// 读取隔离:命中本地镜像快照时直接回,不再连主进程。
|
|
232
|
+
// 例外:带 ?fresh=1 的写后刷新必须回源——镜像最多旧 500ms,撞上写操作会把刚做完的
|
|
233
|
+
// 事显示成没生效(判定与实测见 lib/shared/fresh-request.js)。
|
|
234
|
+
const body = isFreshRequest(req.url) ? null : readSnapshot({ method: req.method, path: req.url });
|
|
235
|
+
if (body && body.ok) {
|
|
236
|
+
try {
|
|
237
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
238
|
+
res.end(JSON.stringify(body));
|
|
239
|
+
} catch { /* 已关闭 */ }
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const headers = {};
|
|
243
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
244
|
+
if (!HOP_BY_HOP.has(k)) headers[k] = v;
|
|
245
|
+
}
|
|
246
|
+
// 保留手机端原始 Host(信任围栏按 Host 判定,配合桌面壳 --trusted-host 本机 IP);缺省回退本地上游
|
|
247
|
+
if (!headers.host) headers.host = `${upstreamHost}:${upstreamPort}`;
|
|
248
|
+
const up = http.request(
|
|
249
|
+
{ hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url, headers },
|
|
250
|
+
(ures) => {
|
|
251
|
+
const out = {};
|
|
252
|
+
for (const [k, v] of Object.entries(ures.headers)) {
|
|
253
|
+
if (!HOP_BY_HOP.has(k)) out[k] = v;
|
|
254
|
+
}
|
|
255
|
+
res.writeHead(ures.statusCode, out);
|
|
256
|
+
ures.pipe(res);
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
up.on('error', (err) => {
|
|
260
|
+
log('proxy error:', err.message);
|
|
261
|
+
try {
|
|
262
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
263
|
+
res.end(`upstream error: ${err.message}`);
|
|
264
|
+
} catch { /* 已关闭 */ }
|
|
265
|
+
});
|
|
266
|
+
req.pipe(up);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 升级还没被 ws 接管时的普通 HTTP 应答(对方看到的是握手失败与状态码)。 */
|
|
270
|
+
function refuseSocket(socket, status, message) {
|
|
271
|
+
const safe = String(message).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
|
272
|
+
const body = Buffer.from(
|
|
273
|
+
`<!doctype html><meta charset="utf-8"><title>预览未建立</title><p>${safe}</p>`,
|
|
274
|
+
'utf8',
|
|
275
|
+
);
|
|
276
|
+
try {
|
|
277
|
+
socket.write(
|
|
278
|
+
`HTTP/1.1 ${status} ${http.STATUS_CODES[status] || 'Error'}\r\n`
|
|
279
|
+
+ 'Content-Type: text/html; charset=utf-8\r\n'
|
|
280
|
+
+ `Content-Length: ${body.length}\r\n`
|
|
281
|
+
+ 'Connection: close\r\n\r\n',
|
|
282
|
+
);
|
|
283
|
+
socket.end(body);
|
|
284
|
+
} catch { /* 对端可能已经走了 */ }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 局域网里的 WebSocket 升级。
|
|
289
|
+
*
|
|
290
|
+
* 两条与 HTTP 路径(proxyToUpstream)必须一致的规则,缺一条内置浏览器就打不开:
|
|
291
|
+
*
|
|
292
|
+
* 1. **转发头**:丢掉逐跳头、补 host,并按本机为该设备保存的凭据补上宿主 token。
|
|
293
|
+
* 什么都不带地连上游,宿主的预览管道会判定「没有移动端凭据」直接回 401。
|
|
294
|
+
* 2. **先连上游、连上再对手机回 101**。手机侧的原生管道只在「握手根本没成」时才换
|
|
295
|
+
* 下一条候选地址(见 app/android/app/src/main/java/com/dsh/mobile/PreviewModule.kt
|
|
296
|
+
* 的 Pipe.connect);一旦回了 101,它就把这条连接当成可用,之后再断只会关掉整个
|
|
297
|
+
* 内置浏览器,永远不回退到中转那条路。上游拒绝时状态码必须原样回给手机。
|
|
298
|
+
*/
|
|
299
|
+
function handleUpgrade(req, socket, head) {
|
|
300
|
+
if (!authorized(req)) {
|
|
301
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
302
|
+
socket.destroy();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const headers = {};
|
|
307
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
308
|
+
if (!HOP_BY_HOP.has(key) && value !== undefined) headers[key] = value;
|
|
309
|
+
}
|
|
310
|
+
if (!headers.host) headers.host = `${upstreamHost}:${upstreamPort}`;
|
|
311
|
+
const device = credential(req);
|
|
312
|
+
if (device?.hostToken) headers.authorization = `Bearer ${device.hostToken}`;
|
|
313
|
+
|
|
314
|
+
let up;
|
|
315
|
+
try {
|
|
316
|
+
up = new WebSocket(`ws://${upstreamHost}:${upstreamPort}${req.url}`, {
|
|
317
|
+
headers,
|
|
318
|
+
handshakeTimeout: WS_UPSTREAM_HANDSHAKE_MS,
|
|
319
|
+
});
|
|
320
|
+
} catch (err) {
|
|
321
|
+
refuseSocket(socket, 502, `无法连接上游:${err.message}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 是否已经给手机回过响应(成功或失败),保证只回一次。 */
|
|
326
|
+
let settled = false;
|
|
327
|
+
let phoneWs = null;
|
|
328
|
+
let upstreamReady = false;
|
|
329
|
+
let pendingBytes = 0;
|
|
330
|
+
/** 上游已连上、手机握手还没完成时先到达的字节。 */
|
|
331
|
+
const pending = [];
|
|
332
|
+
|
|
333
|
+
const dropUpstream = () => { try { up.terminate(); } catch { /* 可能还没连上 */ } };
|
|
334
|
+
|
|
335
|
+
const fail = (status, message) => {
|
|
336
|
+
if (settled) return;
|
|
337
|
+
settled = true;
|
|
338
|
+
log(`ws 升级未建立:${status} ${message}`);
|
|
339
|
+
dropUpstream();
|
|
340
|
+
refuseSocket(socket, status, message);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// 手机在等上游的时候先走了:别白连上游。这两个监听还兼着兜底 socket 错误——
|
|
344
|
+
// 这段窗口里 socket 是我们在拿着,没有人接 'error' 会冒泡成未捕获异常。
|
|
345
|
+
const abandon = () => { if (settled) return; settled = true; dropUpstream(); };
|
|
346
|
+
socket.on('error', abandon);
|
|
347
|
+
socket.on('close', abandon);
|
|
348
|
+
|
|
349
|
+
up.on('unexpected-response', (_req, res) => {
|
|
350
|
+
const status = res.statusCode || 502;
|
|
351
|
+
try { res.resume(); } catch { /* 读掉就走 */ }
|
|
352
|
+
fail(status, `上游拒绝了这次升级(${status})`);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
up.on('message', (data) => {
|
|
356
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
357
|
+
if (phoneWs) {
|
|
358
|
+
if (phoneWs.readyState === WebSocket.OPEN) phoneWs.send(chunk);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
pendingBytes += chunk.length;
|
|
362
|
+
if (pendingBytes > WS_UPSTREAM_PENDING_MAX_BYTES) {
|
|
363
|
+
settled = true;
|
|
364
|
+
dropUpstream();
|
|
365
|
+
socket.destroy();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
pending.push(chunk);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
up.on('open', () => {
|
|
372
|
+
upstreamReady = true;
|
|
373
|
+
if (settled) return;
|
|
374
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
375
|
+
settled = true;
|
|
376
|
+
phoneWs = ws;
|
|
377
|
+
ws.on('message', (data) => {
|
|
378
|
+
if (up.readyState === WebSocket.OPEN) up.send(data);
|
|
379
|
+
});
|
|
380
|
+
ws.on('close', () => { try { up.close(); } catch { /* ignore */ } });
|
|
381
|
+
ws.on('error', () => { try { up.terminate(); } catch { /* ignore */ } });
|
|
382
|
+
up.on('close', () => { try { ws.close(); } catch { /* ignore */ } });
|
|
383
|
+
for (const chunk of pending) {
|
|
384
|
+
if (ws.readyState !== WebSocket.OPEN) break;
|
|
385
|
+
ws.send(chunk);
|
|
386
|
+
}
|
|
387
|
+
pending.length = 0;
|
|
388
|
+
pendingBytes = 0;
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
up.on('error', (err) => {
|
|
393
|
+
const code = (err && err.code) || (err && err.message) || 'unknown';
|
|
394
|
+
if (!upstreamReady) {
|
|
395
|
+
const status = /timeout/i.test(String(code)) ? 504 : 502;
|
|
396
|
+
fail(status, `连不上上游 ${upstreamHost}:${upstreamPort}(${code})`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
log(`ws 上游中断:${code}`);
|
|
400
|
+
if (phoneWs && phoneWs.readyState === WebSocket.OPEN) {
|
|
401
|
+
try { phoneWs.close(1011, 'upstream error'); } catch { /* ignore */ }
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function start() {
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
server = http.createServer((req, res) => {
|
|
409
|
+
const url = new URL(req.url, 'http://lan.local');
|
|
410
|
+
try {
|
|
411
|
+
if (url.pathname === '/__lan_logout') {
|
|
412
|
+
clearCookie(res);
|
|
413
|
+
res.writeHead(302, { location: '/' });
|
|
414
|
+
res.end();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (url.pathname === '/__lan_pair' && req.method === 'POST') {
|
|
418
|
+
const ip = req.socket.remoteAddress || '';
|
|
419
|
+
let b = '';
|
|
420
|
+
req.on('data', (c) => (b += c));
|
|
421
|
+
req.on('end', () => {
|
|
422
|
+
try {
|
|
423
|
+
let code = '';
|
|
424
|
+
try { code = (JSON.parse(b || '{}').code || '').trim(); } catch { /* ignore */ }
|
|
425
|
+
// 长度不符直接判错(timingSafeEqual 长度不同会抛异常,曾导致整个 dsh web 崩溃)
|
|
426
|
+
if (!/^\d{6}$/.test(code)) {
|
|
427
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
428
|
+
res.end(JSON.stringify({ ok: false, error: '配对码需为 6 位数字' }));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (isPairLocked(ip)) {
|
|
432
|
+
res.writeHead(429, { 'content-type': 'application/json' });
|
|
433
|
+
res.end(JSON.stringify({ error: '尝试过于频繁,请稍后再试' }));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (!safeEqual(code, String(pairingCode))) {
|
|
437
|
+
recordPairFail(ip);
|
|
438
|
+
pairDenied(res);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// 配对码正确 → 桌面端授权(TeamViewer 模型)。保留旧版
|
|
442
|
+
// 仅传 code 的请求,同时透传手机端完整设备元数据。
|
|
443
|
+
// 注意:这里**不**清限流计数——被拒绝的尝试同样要计数,否则
|
|
444
|
+
// 猜对配对码但被拒的一方可以无限次重试。
|
|
445
|
+
const input = JSON.parse(b || '{}')
|
|
446
|
+
const suppliedDevice = input && typeof input.device === 'object' && input.device !== null
|
|
447
|
+
? input.device
|
|
448
|
+
: {
|
|
449
|
+
name: input?.name,
|
|
450
|
+
platform: input?.platform,
|
|
451
|
+
osVersion: input?.osVersion,
|
|
452
|
+
version: input?.version,
|
|
453
|
+
appVersion: input?.appVersion,
|
|
454
|
+
id: input?.id,
|
|
455
|
+
deviceId: input?.deviceId,
|
|
456
|
+
}
|
|
457
|
+
const device = {
|
|
458
|
+
...Object.fromEntries(Object.entries(suppliedDevice).filter(([, value]) => value !== undefined)),
|
|
459
|
+
ip: suppliedDevice.ip || ip,
|
|
460
|
+
}
|
|
461
|
+
onApprove(device)
|
|
462
|
+
.then(({ approved, token }) => {
|
|
463
|
+
if (!approved) {
|
|
464
|
+
// 与「配对码错误」返回同一响应,配对码不再可通过状态码探测。
|
|
465
|
+
pairDenied(res);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
clearPairFails(ip);
|
|
469
|
+
const issued = credentials.issue(instanceId, device, token);
|
|
470
|
+
const cookieValue = core.signLanToken(lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
|
|
471
|
+
setCookie(res);
|
|
472
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
473
|
+
// cookie 同时放进 body:RN fetch 读不到 Set-Cookie 响应头,App 需自行携带
|
|
474
|
+
res.end(JSON.stringify({
|
|
475
|
+
ok: true,
|
|
476
|
+
instanceId,
|
|
477
|
+
token: token || '',
|
|
478
|
+
cookie: `dsh_lan=${cookieValue}`,
|
|
479
|
+
deviceId: issued.deviceId,
|
|
480
|
+
deviceCredential: issued.credential,
|
|
481
|
+
credentialExpiresAt: issued.expiresAt,
|
|
482
|
+
}));
|
|
483
|
+
})
|
|
484
|
+
.catch((err) => {
|
|
485
|
+
log('approve error:', err.message);
|
|
486
|
+
try {
|
|
487
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
488
|
+
res.end(JSON.stringify({ error: 'internal error' }));
|
|
489
|
+
} catch { /* ignore */ }
|
|
490
|
+
});
|
|
491
|
+
} catch (err) {
|
|
492
|
+
log('pair error:', err.message);
|
|
493
|
+
try {
|
|
494
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
495
|
+
res.end(JSON.stringify({ error: 'internal error' }));
|
|
496
|
+
} catch { /* ignore */ }
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (url.pathname === '/healthz') {
|
|
502
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
503
|
+
res.end('ok');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!authorized(req)) {
|
|
507
|
+
// API 请求必须回 JSON 401(见 isApiRequest 注释);只有浏览器导航才给配对页。
|
|
508
|
+
if (isApiRequest(req, url)) sendUnauthorized(res);
|
|
509
|
+
else sendPage(res, 200);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const c = credential(req);
|
|
513
|
+
if (c?.hostToken) req.headers.authorization = `Bearer ${c.hostToken}`;
|
|
514
|
+
proxyToUpstream(req, res);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
log('request error:', err.message);
|
|
517
|
+
try {
|
|
518
|
+
res.writeHead(500);
|
|
519
|
+
res.end('internal error');
|
|
520
|
+
} catch { /* ignore */ }
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
wss = new WebSocketServer({ noServer: true });
|
|
525
|
+
server.on('upgrade', handleUpgrade);
|
|
526
|
+
|
|
527
|
+
// Node 的一个坑:一次 listen 失败之后再对同一个 server 调用 listen,**前一次**
|
|
528
|
+
// listen 注册的 listening 回调也会跟着跑一遍。所以每次 bind 都用 settled 守卫,
|
|
529
|
+
// 谁先谁后无所谓,每次尝试最多只认一次成功——否则一次端口回退会被当成两次成功
|
|
530
|
+
// 绑定,状态上报和日志都跟着说谎。
|
|
531
|
+
const bind = (port) => new Promise((onBound, onFailed) => {
|
|
532
|
+
let settled = false
|
|
533
|
+
const onError = (err) => {
|
|
534
|
+
if (settled) return
|
|
535
|
+
settled = true
|
|
536
|
+
server.off('error', onError)
|
|
537
|
+
onFailed(err)
|
|
538
|
+
}
|
|
539
|
+
const onListening = () => {
|
|
540
|
+
if (settled) return
|
|
541
|
+
settled = true
|
|
542
|
+
server.off('error', onError)
|
|
543
|
+
server.on('error', (err) => log('server error:', err.message))
|
|
544
|
+
const address = server.address()
|
|
545
|
+
actualPort = address && typeof address.port === 'number' ? address.port : port
|
|
546
|
+
log(`listening on 0.0.0.0:${actualPort}`)
|
|
547
|
+
onBound()
|
|
548
|
+
}
|
|
549
|
+
server.once('error', onError)
|
|
550
|
+
server.once('listening', onListening)
|
|
551
|
+
server.listen(port, '0.0.0.0')
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// 端口自适应:首选端口在探测之后、真正 listen 之前被别的进程抢走时(另一个
|
|
555
|
+
// 实例刚起、或者上一轮 stop() 还没释放干净),退到内核分配的空闲端口,而不是
|
|
556
|
+
// 让一次 EADDRINUSE 把整条 LAN 直连带走。
|
|
557
|
+
// 成功路径必须在这里就 resolve:只挂 .catch 的话,bind(lanPort) 成功时
|
|
558
|
+
// catch 分支根本不会执行,外层那个 Promise 就永远 pending——服务器确实在监听,
|
|
559
|
+
// 但调用方拿到的 start() 永不落定,lan.state 永远停在 connecting、error 还是
|
|
560
|
+
// null。端口回退是兜底,不是主路径。
|
|
561
|
+
bind(lanPort).then(() => resolve(), async (error) => {
|
|
562
|
+
if (!error || error.code !== 'EADDRINUSE') return reject(error);
|
|
563
|
+
log(`port ${lanPort} busy (${error.code}), falling back to an OS-assigned port`);
|
|
564
|
+
try { await bind(0); resolve() } catch (retryError) { reject(retryError) }
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function stop() {
|
|
570
|
+
if (wss) {
|
|
571
|
+
// 旧实现只 close():已经建立的手机 WebSocket 会继续占着 lanPort,
|
|
572
|
+
// 配置变更重启时 listen 直接 EADDRINUSE,于是「局域网开关」失效。
|
|
573
|
+
for (const client of wss.clients) {
|
|
574
|
+
try { client.terminate(); } catch { /* ignore */ }
|
|
575
|
+
}
|
|
576
|
+
try { wss.close(); } catch { /* ignore */ }
|
|
577
|
+
wss = null;
|
|
578
|
+
}
|
|
579
|
+
if (server) {
|
|
580
|
+
try { server.close(); } catch { /* ignore */ }
|
|
581
|
+
// 还在飞的 keep-alive 连接会让端口迟迟不释放,直接断掉。
|
|
582
|
+
try { server.closeAllConnections?.(); } catch { /* ignore */ }
|
|
583
|
+
server = null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 对外只暴露「实际在监听的端口」。配置端口仍是 `lanPort`(那只是首选值)。
|
|
588
|
+
const port = () => actualPort;
|
|
589
|
+
|
|
590
|
+
return { start, stop, port };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
module.exports = { createLanProxy };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 配对授权策略。
|
|
5
|
+
*
|
|
6
|
+
* 被删除的桌面端用弹窗逐次确认;无头宿主没有这个界面,授权改由本地策略决定。
|
|
7
|
+
* 两条链路都汇聚到父进程的 /api/relay/internal/approval:隧道收到 pairRequest 时,
|
|
8
|
+
* 以及局域网代理的 onApprove。sidecar 只接受显式 true,所以 'deny' 与原来恒定
|
|
9
|
+
* 返回 { approved: false } 的行为完全一致。
|
|
10
|
+
*
|
|
11
|
+
* 'code' — 凭配对码放行。中转(dsh-mobile-server/server.js:400)与局域网
|
|
12
|
+
* (lib/relay/lan-proxy.js:243)在询问宿主之前都已用恒定时间比较校验过
|
|
13
|
+
* 配对码,中转还有失败锁定;配对码只在用户主动索取时展示,可用
|
|
14
|
+
* /api/relay/regenerate 轮换、用 /api/relay/revoke-all 连同 lanSecret
|
|
15
|
+
* 一并作废。
|
|
16
|
+
* 'deny' — 拒绝一切新配对。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const POLICIES = ['code', 'deny']
|
|
20
|
+
const DEFAULT_POLICY = 'code'
|
|
21
|
+
|
|
22
|
+
/** 缺失或未知取值回落到默认值;settings schema 会在入口处挡掉其它取值。 */
|
|
23
|
+
function normalize(value) {
|
|
24
|
+
return POLICIES.includes(value) ? value : DEFAULT_POLICY
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} value 存储的 pairingPolicy
|
|
29
|
+
* @param {unknown} [device] 配对设备元信息,仅用于审计输出
|
|
30
|
+
* @returns {{ approved: boolean, policy: string, reason: string, device: string, ip: string }}
|
|
31
|
+
*/
|
|
32
|
+
function evaluate(value, device) {
|
|
33
|
+
const policy = normalize(value)
|
|
34
|
+
const meta = device && typeof device === 'object' ? device : {}
|
|
35
|
+
const approved = policy === 'code'
|
|
36
|
+
return {
|
|
37
|
+
approved,
|
|
38
|
+
policy,
|
|
39
|
+
reason: approved ? 'pairing code verified' : 'pairing policy is deny',
|
|
40
|
+
device: typeof meta.name === 'string' && meta.name ? meta.name : 'unknown',
|
|
41
|
+
ip: typeof meta.ip === 'string' ? meta.ip : '',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { POLICIES, DEFAULT_POLICY, normalize, evaluate }
|