dsh-dock 0.6.0 → 0.7.1
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 +6 -4
- package/client.js +620 -71
- package/features/animation/view.jsx +491 -18
- package/features/balance/view.js +25 -4
- package/features/games/games/breakout.jsx +225 -0
- package/features/games/games/gomoku.jsx +175 -0
- package/features/games/games/racer.jsx +171 -0
- package/features/games/games/snake.jsx +103 -0
- package/features/games/games/sokoban.jsx +153 -0
- package/features/games/games/tank.jsx +116 -0
- package/features/games/games/tetris.jsx +235 -0
- package/features/games/games/xiangqi.jsx +128 -0
- package/features/games/tank-core.js +152 -0
- package/features/games/view.jsx +632 -0
- package/features/games/xiangqi-core.js +270 -0
- package/features/mobile-relay/gateway.js +279 -0
- package/features/mobile-relay/host.js +328 -0
- package/features/mobile-relay/view.jsx +207 -0
- package/features/tokenlog/host.js +2 -2
- package/features/tokenlog/view.jsx +12 -21
- package/package.json +3 -2
- package/screenshots/dock-animation.png +0 -0
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +45 -9
- package/src/host-core.js +4 -1
- package/screenshots/dock-robot.png +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/* 趣味游戏 · 象棋核心规则(纯函数,无 React)——供组件复用,也便于单测。 */
|
|
2
|
+
export const ROWS = 10, COLS = 9;
|
|
3
|
+
// 类型:g 将/帅 · a 仕/士 · e 相/象 · h 马 · k 车 · c 炮 · p 兵/卒
|
|
4
|
+
// 颜色:r 红(下)· b 黑(上)
|
|
5
|
+
|
|
6
|
+
export function pieceChar(p) {
|
|
7
|
+
const map = {
|
|
8
|
+
g: { r: "帥", b: "將" }, a: { r: "仕", b: "士" }, e: { r: "相", b: "象" },
|
|
9
|
+
h: { r: "馬", b: "馬" }, k: { r: "車", b: "車" }, c: { r: "炮", b: "炮" },
|
|
10
|
+
p: { r: "兵", b: "卒" },
|
|
11
|
+
};
|
|
12
|
+
return map[p.t] && map[p.t][p.c];
|
|
13
|
+
}
|
|
14
|
+
export const VALUE = { g: 100000, k: 900, c: 450, h: 400, e: 200, a: 200, p: 100 };
|
|
15
|
+
|
|
16
|
+
export function initialBoard() {
|
|
17
|
+
const b = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
|
18
|
+
const back = ["k", "h", "e", "a", "g", "a", "e", "h", "k"];
|
|
19
|
+
back.forEach((t, c) => { b[0][c] = { t, c: "b" }; });
|
|
20
|
+
b[2][1] = { t: "c", c: "b" }; b[2][7] = { t: "c", c: "b" };
|
|
21
|
+
[0, 2, 4, 6, 8].forEach((c) => { b[4][c] = { t: "p", c: "b" }; });
|
|
22
|
+
back.forEach((t, c) => { b[9][c] = { t, c: "r" }; });
|
|
23
|
+
b[8][1] = { t: "c", c: "r" }; b[8][7] = { t: "c", c: "r" };
|
|
24
|
+
[0, 2, 4, 6, 8].forEach((c) => { b[6][c] = { t: "p", c: "r" }; });
|
|
25
|
+
return b;
|
|
26
|
+
}
|
|
27
|
+
export function cloneBoard(b) {
|
|
28
|
+
return b.map((row) => row.slice());
|
|
29
|
+
}
|
|
30
|
+
function inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }
|
|
31
|
+
function inPalace(r, c, color) {
|
|
32
|
+
if (c < 3 || c > 5) return false;
|
|
33
|
+
return color === "r" ? r >= 7 && r <= 9 : r >= 0 && r <= 2;
|
|
34
|
+
}
|
|
35
|
+
function ownSide(r, color) { return color === "r" ? r >= 5 : r <= 4; }
|
|
36
|
+
function crossedRiver(r, color) { return color === "r" ? r <= 4 : r >= 5; }
|
|
37
|
+
function countBetween(board, r, c, tr, tc, dr, dc) {
|
|
38
|
+
let n = 0, rr = r + dr, cc = c + dc;
|
|
39
|
+
while (!(rr === tr && cc === tc)) {
|
|
40
|
+
if (rr < 0 || rr >= ROWS || cc < 0 || cc >= COLS) return -1;
|
|
41
|
+
if (board[rr][cc]) n += 1;
|
|
42
|
+
rr += dr; cc += dc;
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function attacks(board, r, c, tr, tc) {
|
|
48
|
+
const p = board[r][c];
|
|
49
|
+
if (!p) return false;
|
|
50
|
+
const dr = Math.sign(tr - r), dc = Math.sign(tc - c);
|
|
51
|
+
const t = board[tr][tc];
|
|
52
|
+
if (p.t === "k") {
|
|
53
|
+
if (r !== tr && c !== tc) return false;
|
|
54
|
+
return countBetween(board, r, c, tr, tc, dr, dc) === 0;
|
|
55
|
+
}
|
|
56
|
+
if (p.t === "c") {
|
|
57
|
+
if (r !== tr && c !== tc) return false;
|
|
58
|
+
if (dr === 0 && dc === 0) return false;
|
|
59
|
+
return countBetween(board, r, c, tr, tc, dr, dc) === 1 && !!t;
|
|
60
|
+
}
|
|
61
|
+
if (p.t === "h") {
|
|
62
|
+
const adr = Math.abs(tr - r), adc = Math.abs(tc - c);
|
|
63
|
+
if (!((adr === 2 && adc === 1) || (adr === 1 && adc === 2))) return false;
|
|
64
|
+
const legR = adr === 2 ? r + dr : r;
|
|
65
|
+
const legC = adc === 2 ? c + dc : c;
|
|
66
|
+
return !board[legR][legC];
|
|
67
|
+
}
|
|
68
|
+
if (p.t === "g") {
|
|
69
|
+
return Math.abs(tr - r) + Math.abs(tc - c) === 1 && inPalace(tr, tc, p.c);
|
|
70
|
+
}
|
|
71
|
+
if (p.t === "a") {
|
|
72
|
+
return Math.abs(tr - r) === 1 && Math.abs(tc - c) === 1 && inPalace(tr, tc, p.c);
|
|
73
|
+
}
|
|
74
|
+
if (p.t === "e") {
|
|
75
|
+
const adr = Math.abs(tr - r), adc = Math.abs(tc - c);
|
|
76
|
+
if (!(adr === 2 && adc === 2)) return false;
|
|
77
|
+
if (!ownSide(tr, p.c)) return false;
|
|
78
|
+
const eyeR = r + dr, eyeC = c + dc;
|
|
79
|
+
return !board[eyeR][eyeC];
|
|
80
|
+
}
|
|
81
|
+
if (p.t === "p") {
|
|
82
|
+
const forward = p.c === "r" ? r - 1 : r + 1;
|
|
83
|
+
if (tr === forward && tc === c) return true;
|
|
84
|
+
if (crossedRiver(r, p.c) && tr === r && Math.abs(tc - c) === 1) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findGeneral(board, color) {
|
|
91
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
92
|
+
for (let c = 0; c < COLS; c += 1) {
|
|
93
|
+
const p = board[r][c];
|
|
94
|
+
if (p && p.t === "g" && p.c === color) return { r, c };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
export function pseudoMoves(board, r, c) {
|
|
100
|
+
const p = board[r][c];
|
|
101
|
+
if (!p) return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
const push = (nr, nc) => { if (inBounds(nr, nc)) out.push([nr, nc]); };
|
|
104
|
+
const clear = (nr, nc) => !board[nr][nc];
|
|
105
|
+
const enemy = (nr, nc) => { const q = board[nr][nc]; return q && q.c !== p.c; };
|
|
106
|
+
if (p.t === "k") {
|
|
107
|
+
[[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
|
|
108
|
+
const nr = r + dr, nc = c + dc;
|
|
109
|
+
if (inPalace(nr, nc, p.c) && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (p.t === "a") {
|
|
113
|
+
[[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(([dr, dc]) => {
|
|
114
|
+
const nr = r + dr, nc = c + dc;
|
|
115
|
+
if (inPalace(nr, nc, p.c) && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (p.t === "e") {
|
|
119
|
+
[[-2, -2], [-2, 2], [2, -2], [2, 2]].forEach(([dr, dc]) => {
|
|
120
|
+
const nr = r + dr, nc = c + dc, er = r + dr / 2, ec = c + dc / 2;
|
|
121
|
+
if (inBounds(nr, nc) && ownSide(nr, p.c) && !board[er][ec] && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (p.t === "h") {
|
|
125
|
+
[[-2, -1], [-2, 1], [2, -1], [2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2]].forEach(([dr, dc]) => {
|
|
126
|
+
const nr = r + dr, nc = c + dc;
|
|
127
|
+
const legR = Math.abs(dr) === 2 ? r + dr / 2 : r;
|
|
128
|
+
const legC = Math.abs(dc) === 2 ? c + dc / 2 : c;
|
|
129
|
+
if (inBounds(nr, nc) && !board[legR][legC] && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (p.t === "k" || p.t === "c") {
|
|
133
|
+
[[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
|
|
134
|
+
let rr = r + dr, cc = c + dc, jumped = false;
|
|
135
|
+
while (inBounds(rr, cc)) {
|
|
136
|
+
const q = board[rr][cc];
|
|
137
|
+
if (p.t === "k") {
|
|
138
|
+
if (!q) push(rr, cc);
|
|
139
|
+
else { if (q.c !== p.c) push(rr, cc); break; }
|
|
140
|
+
} else {
|
|
141
|
+
if (!q && !jumped) push(rr, cc);
|
|
142
|
+
else if (q && !jumped) jumped = true;
|
|
143
|
+
else if (q && jumped) { if (q.c !== p.c) push(rr, cc); break; }
|
|
144
|
+
}
|
|
145
|
+
rr += dr; cc += dc;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (p.t === "p") {
|
|
150
|
+
const forward = p.c === "r" ? -1 : 1;
|
|
151
|
+
[[forward, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
|
|
152
|
+
const nr = r + dr, nc = c + dc;
|
|
153
|
+
if (!inBounds(nr, nc) || (dc !== 0 && !crossedRiver(r, p.c))) return;
|
|
154
|
+
if (clear(nr, nc) || enemy(nr, nc)) push(nr, nc);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
export function applyMove(board, fr, fc, tr, tc) {
|
|
160
|
+
const nb = cloneBoard(board);
|
|
161
|
+
nb[tr][tc] = nb[fr][fc];
|
|
162
|
+
nb[fr][fc] = null;
|
|
163
|
+
return nb;
|
|
164
|
+
}
|
|
165
|
+
export function inCheck(board, color) {
|
|
166
|
+
const g = findGeneral(board, color);
|
|
167
|
+
if (!g) return true;
|
|
168
|
+
const enemy = color === "r" ? "b" : "r";
|
|
169
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
170
|
+
const p = board[r][g.c];
|
|
171
|
+
if (p && p.t === "g" && p.c === enemy) {
|
|
172
|
+
let clear = true;
|
|
173
|
+
const lo = Math.min(r, g.r), hi = Math.max(r, g.r);
|
|
174
|
+
for (let i = lo + 1; i < hi; i += 1) if (board[i][g.c]) { clear = false; break; }
|
|
175
|
+
if (clear) return true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
179
|
+
for (let c = 0; c < COLS; c += 1) {
|
|
180
|
+
const p = board[r][c];
|
|
181
|
+
if (p && p.c === enemy && attacks(board, r, c, g.r, g.c)) return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
export function legalMoves(board, color) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
189
|
+
for (let c = 0; c < COLS; c += 1) {
|
|
190
|
+
const p = board[r][c];
|
|
191
|
+
if (!p || p.c !== color) continue;
|
|
192
|
+
for (const [nr, nc] of pseudoMoves(board, r, c)) {
|
|
193
|
+
const nb = applyMove(board, r, c, nr, nc);
|
|
194
|
+
if (!inCheck(nb, color)) out.push([r, c, nr, nc]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
export function hasLegalMoves(board, color) {
|
|
201
|
+
return legalMoves(board, color).length > 0;
|
|
202
|
+
}
|
|
203
|
+
function evaluatePosition(board) {
|
|
204
|
+
let score = 0;
|
|
205
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
206
|
+
for (let c = 0; c < COLS; c += 1) {
|
|
207
|
+
const p = board[r][c];
|
|
208
|
+
if (!p) continue;
|
|
209
|
+
let v = VALUE[p.t];
|
|
210
|
+
if (p.t === "p") v += crossedRiver(r, p.c) ? 50 : 0;
|
|
211
|
+
score += p.c === "b" ? v : -v;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return score;
|
|
215
|
+
}
|
|
216
|
+
function evalFrom(board, color) {
|
|
217
|
+
const e = evaluatePosition(board);
|
|
218
|
+
return color === "b" ? e : -e;
|
|
219
|
+
}
|
|
220
|
+
function negamax(board, color, depth, alpha, beta) {
|
|
221
|
+
const moves = legalMoves(board, color);
|
|
222
|
+
if (moves.length === 0) {
|
|
223
|
+
return color === "b" ? -1000000 + (10 - depth) : 1000000 - (10 - depth);
|
|
224
|
+
}
|
|
225
|
+
if (depth === 0) return evalFrom(board, color);
|
|
226
|
+
const enemy = color === "r" ? "b" : "r";
|
|
227
|
+
const ordered = orderMoves(board, moves);
|
|
228
|
+
let best = -Infinity;
|
|
229
|
+
for (const m of ordered) {
|
|
230
|
+
const nb = applyMove(board, m[0], m[1], m[2], m[3]);
|
|
231
|
+
const score = -negamax(nb, enemy, depth - 1, -beta, -alpha);
|
|
232
|
+
if (score > best) best = score;
|
|
233
|
+
if (best > alpha) alpha = best;
|
|
234
|
+
if (alpha >= beta) break;
|
|
235
|
+
}
|
|
236
|
+
return best;
|
|
237
|
+
}
|
|
238
|
+
function orderMoves(board, moves) {
|
|
239
|
+
const scored = moves.map((m) => {
|
|
240
|
+
let s = 0;
|
|
241
|
+
const target = board[m[2]][m[3]];
|
|
242
|
+
if (target) s += VALUE[target.t] * 10;
|
|
243
|
+
return { m, s };
|
|
244
|
+
});
|
|
245
|
+
scored.sort((a, b) => b.s - a.s);
|
|
246
|
+
return scored.map((o) => o.m);
|
|
247
|
+
}
|
|
248
|
+
export function bestMove(board, color, depth) {
|
|
249
|
+
const moves = legalMoves(board, color);
|
|
250
|
+
if (!moves.length) return null;
|
|
251
|
+
if (depth <= 1) {
|
|
252
|
+
let best = moves[0], bestScore = -Infinity;
|
|
253
|
+
for (const m of moves) {
|
|
254
|
+
const nb = applyMove(board, m[0], m[1], m[2], m[3]);
|
|
255
|
+
const s = evalFrom(nb, color);
|
|
256
|
+
if (s > bestScore) { bestScore = s; best = m; }
|
|
257
|
+
}
|
|
258
|
+
return best;
|
|
259
|
+
}
|
|
260
|
+
const enemy = color === "r" ? "b" : "r";
|
|
261
|
+
const ordered = orderMoves(board, moves);
|
|
262
|
+
let best = ordered[0], bestScore = -Infinity, alpha = -Infinity;
|
|
263
|
+
for (const m of ordered) {
|
|
264
|
+
const nb = applyMove(board, m[0], m[1], m[2], m[3]);
|
|
265
|
+
const score = -negamax(nb, enemy, depth - 1, -Infinity, -alpha);
|
|
266
|
+
if (score > bestScore) { bestScore = score; best = m; }
|
|
267
|
+
if (bestScore > alpha) alpha = bestScore;
|
|
268
|
+
}
|
|
269
|
+
return best;
|
|
270
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// dsh-dock · 手机接力局域网安全网关
|
|
2
|
+
//
|
|
3
|
+
// DSH 0.1.1-rc.2 有意拒绝直接绑定 0.0.0.0:浏览器表层尚无登录认证,直接开放
|
|
4
|
+
// 会把代码执行能力交给同一网络里的任何人。本文件提供一个最小认证反向代理:主 DSH
|
|
5
|
+
// 继续只监听 127.0.0.1,手机凭 256-bit 一次性链接换取 HttpOnly 会话后才能访问。
|
|
6
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
7
|
+
import { networkInterfaces } from 'node:os'
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
const COOKIE_NAME = 'dsh_mobile_session'
|
|
11
|
+
const CONNECT_PATH = '/__dsh_mobile/connect'
|
|
12
|
+
const HEALTH_PATH = '/__dsh_mobile/health'
|
|
13
|
+
const COMPAT_PATH = '/__dsh_mobile/compat.js'
|
|
14
|
+
const SESSION_TTL_MS = 12 * 60 * 60 * 1000
|
|
15
|
+
const MAX_CONNECT_BODY = 4096
|
|
16
|
+
const MAX_HTML_BYTES = 2 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
// LAN HTTP is not a browser secure context. Some mobile browsers therefore expose
|
|
19
|
+
// crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
|
|
20
|
+
// connection/workspace clients call randomUUID() directly. This tiny standards-compatible
|
|
21
|
+
// UUID v4 fallback is injected before every DSH bootstrap script through the proxy only.
|
|
22
|
+
const MOBILE_COMPAT_JS = `(()=>{const c=globalThis.crypto;if(!c||typeof c.randomUUID==='function'||typeof c.getRandomValues!=='function')return;const make=()=>{const b=new Uint8Array(16);c.getRandomValues(b);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=Array.from(b,x=>x.toString(16).padStart(2,'0')).join('');return h.slice(0,8)+'-'+h.slice(8,12)+'-'+h.slice(12,16)+'-'+h.slice(16,20)+'-'+h.slice(20)};try{Object.defineProperty(c,'randomUUID',{value:make,configurable:true})}catch{try{c.randomUUID=make}catch{}}})()`
|
|
23
|
+
|
|
24
|
+
function secret() { return randomBytes(32).toString('base64url') }
|
|
25
|
+
function now() { return Date.now() }
|
|
26
|
+
function isPrivateIpv4(ip) {
|
|
27
|
+
const p = String(ip).split('.').map(Number)
|
|
28
|
+
return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
|
|
29
|
+
}
|
|
30
|
+
function interfacePriority(name) {
|
|
31
|
+
if (/^en0$/.test(name)) return 100
|
|
32
|
+
if (/^en\d+$/.test(name)) return 90
|
|
33
|
+
if (/^(eth|enp|wlan|wl)/.test(name)) return 80
|
|
34
|
+
if (/^bridge/.test(name)) return 20
|
|
35
|
+
if (/^(utun|tun|tap)/.test(name)) return 10
|
|
36
|
+
return 50
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 当前可用于手机连接的非回环 IPv4,私网地址优先。 */
|
|
40
|
+
export function lanAddresses() {
|
|
41
|
+
const rows = []
|
|
42
|
+
const table = networkInterfaces()
|
|
43
|
+
for (const [name, list] of Object.entries(table)) {
|
|
44
|
+
for (const item of list || []) {
|
|
45
|
+
if (!item || item.internal || item.family !== 'IPv4') continue
|
|
46
|
+
rows.push({ address: item.address, interface: name, private: isPrivateIpv4(item.address) })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return rows.sort((a, b) => Number(b.private) - Number(a.private)
|
|
50
|
+
|| interfacePriority(b.interface) - interfacePriority(a.interface)
|
|
51
|
+
|| a.interface.localeCompare(b.interface))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseCookies(header) {
|
|
55
|
+
const result = {}
|
|
56
|
+
for (const part of String(header || '').split(';')) {
|
|
57
|
+
const at = part.indexOf('=')
|
|
58
|
+
if (at <= 0) continue
|
|
59
|
+
result[part.slice(0, at).trim()] = part.slice(at + 1).trim()
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
function sendJson(res, status, value, headers = {}) {
|
|
64
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
|
|
65
|
+
res.end(JSON.stringify(value))
|
|
66
|
+
}
|
|
67
|
+
function readSmallJson(req) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
let size = 0
|
|
70
|
+
const chunks = []
|
|
71
|
+
req.on('data', (chunk) => {
|
|
72
|
+
size += chunk.length
|
|
73
|
+
if (size > MAX_CONNECT_BODY) { reject(new Error('请求过大')); req.destroy(); return }
|
|
74
|
+
chunks.push(chunk)
|
|
75
|
+
})
|
|
76
|
+
req.on('end', () => {
|
|
77
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
|
|
78
|
+
catch { reject(new Error('请求格式错误')) }
|
|
79
|
+
})
|
|
80
|
+
req.on('error', reject)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
function connectHtml() {
|
|
84
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>连接 DSH</title><style>html{font-family:system-ui,-apple-system,sans-serif;color-scheme:dark light}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom);background:#111318;color:#f4f6fa}.card{box-sizing:border-box;width:min(420px,calc(100vw - 32px));padding:28px;border:1px solid #30343d;border-radius:18px;background:#1c1f26;box-shadow:0 18px 60px #0007;text-align:center}.icon{width:48px;height:48px;margin:0 auto 16px;display:grid;place-items:center;border-radius:15px;background:#38bdf822;color:#38bdf8}.icon svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}h1{margin:0 0 8px;font-size:20px}p{margin:0;color:#abb1bd;line-height:1.6;font-size:14px}.error{margin-top:16px;padding:10px 12px;border-radius:10px;background:#ef44441a;color:#ff8b8b;text-align:left}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f1f4f8;color:#16181d}.card{background:#fff;border-color:#d8dde6}.card p{color:#586173}}</style></head><body><main class="card"><div class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.14-1.14"/></svg></div><h1>正在安全连接</h1><p id="status">验证一次性链接并进入 DSH…</p><div id="error" class="error" role="alert" hidden></div></main><script>(()=>{const status=document.getElementById('status'),error=document.getElementById('error'),token=decodeURIComponent(location.hash.slice(1));history.replaceState(null,'',location.pathname);if(!token){status.textContent='连接链接不完整';error.hidden=false;error.textContent='请回到电脑端重新复制手机接力链接。';return}fetch(${JSON.stringify(CONNECT_PATH)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok)throw new Error(d.error||'连接验证失败');status.textContent='验证成功,正在打开任务…';location.replace('/#dsh-mobile-relay='+encodeURIComponent(d.launch))}).catch(e=>{status.textContent='无法连接';error.hidden=false;error.textContent=e&&e.message?e.message:String(e)})})()</script></body></html>`
|
|
85
|
+
}
|
|
86
|
+
function rewriteProxyHeaders(headers, upstreamAuthority) {
|
|
87
|
+
const next = { ...headers, host: upstreamAuthority }
|
|
88
|
+
delete next.cookie
|
|
89
|
+
// HTML must stay uncompressed so the proxy can inject the early compatibility script.
|
|
90
|
+
delete next['accept-encoding']
|
|
91
|
+
if (next.origin) next.origin = 'http://' + upstreamAuthority
|
|
92
|
+
return next
|
|
93
|
+
}
|
|
94
|
+
function injectMobileCompat(html) {
|
|
95
|
+
if (html.includes('data-dsh-mobile-compat')) return html
|
|
96
|
+
const match = /<head(?:\s[^>]*)?>/i.exec(html)
|
|
97
|
+
const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
|
|
98
|
+
if (!match) return script + html
|
|
99
|
+
const at = match.index + match[0].length
|
|
100
|
+
return html.slice(0, at) + script + html.slice(at)
|
|
101
|
+
}
|
|
102
|
+
function writeUpgradeHead(socket, response) {
|
|
103
|
+
const lines = [`HTTP/1.1 ${response.statusCode || 101} ${response.statusMessage || 'Switching Protocols'}`]
|
|
104
|
+
for (let i = 0; i < response.rawHeaders.length; i += 2) lines.push(response.rawHeaders[i] + ': ' + response.rawHeaders[i + 1])
|
|
105
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 开启一个需要一次性链接认证的局域网代理。返回的 issue() 生成只在 fragment 中传输的令牌。
|
|
110
|
+
*/
|
|
111
|
+
export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort }) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const pending = new Map() // gateway token -> { pairId, launch, expiresAt }
|
|
114
|
+
const sessions = new Map() // cookie secret -> { pairId, expiresAt, seenAt }
|
|
115
|
+
const upgradedSockets = new Map() // client socket -> { sessionId, pairId, upstreamSocket }
|
|
116
|
+
const authority = upstreamHost + ':' + upstreamPort
|
|
117
|
+
|
|
118
|
+
function purge() {
|
|
119
|
+
const stamp = now()
|
|
120
|
+
for (const [key, value] of pending) if (value.expiresAt <= stamp) pending.delete(key)
|
|
121
|
+
const expiredSessions = new Set()
|
|
122
|
+
for (const [key, value] of sessions) {
|
|
123
|
+
if (value.expiresAt <= stamp) { sessions.delete(key); expiredSessions.add(key) }
|
|
124
|
+
}
|
|
125
|
+
if (expiredSessions.size) {
|
|
126
|
+
for (const [socket, value] of upgradedSockets) {
|
|
127
|
+
if (expiredSessions.has(value.sessionId)) {
|
|
128
|
+
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
129
|
+
socket.destroy()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function authorize(req) {
|
|
135
|
+
purge()
|
|
136
|
+
const value = parseCookies(req.headers.cookie)[COOKIE_NAME]
|
|
137
|
+
const session = value ? sessions.get(value) : null
|
|
138
|
+
if (!session) return null
|
|
139
|
+
session.seenAt = now()
|
|
140
|
+
return { ...session, sessionId: value }
|
|
141
|
+
}
|
|
142
|
+
function proxyHttp(req, res) {
|
|
143
|
+
const upstream = httpRequest({
|
|
144
|
+
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
145
|
+
headers: rewriteProxyHeaders(req.headers, authority),
|
|
146
|
+
}, (upstreamRes) => {
|
|
147
|
+
const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
|
|
148
|
+
if (!contentType.includes('text/html')) {
|
|
149
|
+
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
|
|
150
|
+
upstreamRes.pipe(res)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const chunks = []
|
|
154
|
+
let size = 0
|
|
155
|
+
upstreamRes.on('data', (chunk) => {
|
|
156
|
+
size += chunk.length
|
|
157
|
+
if (size <= MAX_HTML_BYTES) chunks.push(chunk)
|
|
158
|
+
})
|
|
159
|
+
upstreamRes.on('end', () => {
|
|
160
|
+
if (size > MAX_HTML_BYTES) {
|
|
161
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const headers = { ...upstreamRes.headers }
|
|
165
|
+
delete headers['content-length']
|
|
166
|
+
delete headers['content-encoding']
|
|
167
|
+
headers['cache-control'] = 'no-store'
|
|
168
|
+
res.writeHead(upstreamRes.statusCode || 502, headers)
|
|
169
|
+
res.end(injectMobileCompat(Buffer.concat(chunks).toString('utf8')))
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
upstream.on('error', (error) => {
|
|
173
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
|
|
174
|
+
else res.destroy(error)
|
|
175
|
+
})
|
|
176
|
+
req.pipe(upstream)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const server = createServer(async (req, res) => {
|
|
180
|
+
const url = new URL(req.url || '/', 'http://dsh-mobile.internal')
|
|
181
|
+
if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
|
|
182
|
+
if (url.pathname === CONNECT_PATH && req.method === 'GET') {
|
|
183
|
+
res.writeHead(200, {
|
|
184
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
|
|
185
|
+
'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
186
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
|
|
187
|
+
})
|
|
188
|
+
return res.end(connectHtml())
|
|
189
|
+
}
|
|
190
|
+
if (url.pathname === CONNECT_PATH && req.method === 'POST') {
|
|
191
|
+
try {
|
|
192
|
+
const body = await readSmallJson(req)
|
|
193
|
+
const token = String(body && body.token || '')
|
|
194
|
+
const grant = pending.get(token)
|
|
195
|
+
if (!grant || grant.expiresAt <= now()) return sendJson(res, 403, { ok: false, error: '一次性链接无效或已过期,请在电脑端重新开启。' })
|
|
196
|
+
pending.delete(token)
|
|
197
|
+
const sessionId = secret()
|
|
198
|
+
sessions.set(sessionId, { pairId: grant.pairId, expiresAt: now() + SESSION_TTL_MS, seenAt: now() })
|
|
199
|
+
return sendJson(res, 200, { ok: true, launch: grant.launch }, {
|
|
200
|
+
'set-cookie': `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/`,
|
|
201
|
+
})
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return sendJson(res, 400, { ok: false, error: error.message || String(error) })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!authorize(req)) return sendJson(res, 401, { ok: false, error: '手机连接未认证或已过期,请使用电脑端生成的接力链接重新进入。' })
|
|
207
|
+
if (url.pathname === COMPAT_PATH && req.method === 'GET') {
|
|
208
|
+
res.writeHead(200, {
|
|
209
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
210
|
+
'cache-control': 'no-store',
|
|
211
|
+
'x-content-type-options': 'nosniff',
|
|
212
|
+
})
|
|
213
|
+
return res.end(MOBILE_COMPAT_JS)
|
|
214
|
+
}
|
|
215
|
+
return proxyHttp(req, res)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
server.on('upgrade', (req, socket, head) => {
|
|
219
|
+
const authorized = authorize(req)
|
|
220
|
+
if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
|
|
221
|
+
const connection = { sessionId: authorized.sessionId, pairId: authorized.pairId, upstreamSocket: null }
|
|
222
|
+
upgradedSockets.set(socket, connection)
|
|
223
|
+
const forgetSocket = () => upgradedSockets.delete(socket)
|
|
224
|
+
socket.once('close', forgetSocket)
|
|
225
|
+
const upstream = httpRequest({
|
|
226
|
+
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
227
|
+
headers: rewriteProxyHeaders(req.headers, authority),
|
|
228
|
+
})
|
|
229
|
+
upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
|
|
230
|
+
connection.upstreamSocket = upstreamSocket
|
|
231
|
+
writeUpgradeHead(socket, response)
|
|
232
|
+
if (head && head.length) upstreamSocket.write(head)
|
|
233
|
+
if (upstreamHead && upstreamHead.length) socket.write(upstreamHead)
|
|
234
|
+
socket.once('close', () => upstreamSocket.destroy())
|
|
235
|
+
upstreamSocket.once('close', () => socket.destroy())
|
|
236
|
+
socket.pipe(upstreamSocket).pipe(socket)
|
|
237
|
+
})
|
|
238
|
+
upstream.on('response', (response) => {
|
|
239
|
+
socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
|
|
240
|
+
})
|
|
241
|
+
upstream.on('error', () => socket.destroy())
|
|
242
|
+
upstream.end()
|
|
243
|
+
})
|
|
244
|
+
server.once('error', reject)
|
|
245
|
+
server.listen(port, '0.0.0.0', () => {
|
|
246
|
+
server.removeListener('error', reject)
|
|
247
|
+
const listened = server.address()
|
|
248
|
+
const listenedPort = listened && typeof listened === 'object' ? listened.port : port
|
|
249
|
+
resolve({
|
|
250
|
+
port: listenedPort,
|
|
251
|
+
addresses: lanAddresses(),
|
|
252
|
+
issue(pairId, launch, expiresAt) {
|
|
253
|
+
const token = secret()
|
|
254
|
+
pending.set(token, { pairId, launch, expiresAt })
|
|
255
|
+
return token
|
|
256
|
+
},
|
|
257
|
+
revokePair(pairId) {
|
|
258
|
+
for (const [key, value] of pending) if (value.pairId === pairId) pending.delete(key)
|
|
259
|
+
for (const [key, value] of sessions) if (value.pairId === pairId) sessions.delete(key)
|
|
260
|
+
for (const [socket, value] of upgradedSockets) {
|
|
261
|
+
if (value.pairId !== pairId) continue
|
|
262
|
+
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
263
|
+
socket.destroy()
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
purge,
|
|
267
|
+
close() {
|
|
268
|
+
pending.clear(); sessions.clear()
|
|
269
|
+
for (const [socket, value] of upgradedSockets) {
|
|
270
|
+
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
271
|
+
socket.destroy()
|
|
272
|
+
}
|
|
273
|
+
upgradedSockets.clear()
|
|
274
|
+
return new Promise((done) => server.close(() => done()))
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
}
|