-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathproxy-core.js
More file actions
226 lines (206 loc) · 9.33 KB
/
Copy pathproxy-core.js
File metadata and controls
226 lines (206 loc) · 9.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
'use strict';
// 代理核心:HTTP + WebSocket 反向代理,带 Basic Auth、Origin 对齐、
// crypto.randomUUID polyfill 注入(HTML)与 dsh 0.1.1+ 客户端 loopback
// 信任补丁(JS)。由 index.js(环境变量方式)和 app.js(打包版交互方式)共用。
const http = require('http');
const os = require('os');
const httpProxy = require('http-proxy');
const crypto = require('crypto');
const AUTH_REALM = 'dsh-proxy';
// 核心修复:crypto.randomUUID polyfill。
// DSH 前端用 crypto.randomUUID() 生成 rpcId,但该 API 只在 https/localhost
// 等安全上下文可用;通过局域网 IP 访问时页面是非安全上下文,randomUUID
// 不存在 → RPC 请求发不出去 → 实时通道(WS)建立失败。
// 代理在转发 HTML 时注入基于 getRandomValues 的兼容实现(该 API 非安全源可用)。
const POLYFILL = '<script>(function(){try{if(typeof crypto!=="undefined"&&crypto&&typeof crypto.randomUUID!=="function"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h="";for(var i=0;i<16;i++){h+=b[i].toString(16).padStart(2,"0")}return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}}catch(e){}})();</script>';
// dsh 0.1.1+ 客户端 loopback 信任补丁(与插件版 src/clientpatch.ts 等价)。
// 新版前端按页面 location.hostname 判定"远程浏览器":非 loopback 时设置镜像
// 保持仅内存模式,设置页模型列表报 "settings are unavailable in this browser"。
// 主机名无法从注入的 HTML 伪造,因此对所服务的 JS 做精确字节串重写,使局域网
// 访问获得与本机一致的完整设置能力。与其它兼容修复一样无条件生效;Basic Auth
// 是唯一闸门。客户端包未压缩发布,needle 为字节级稳定串;上游若变更形态,
// 受影响页面退回上游的远程降级行为,不会出现新的错误。
const LOOPBACK_PATCHES = [
{
// dsh-client-connection:connection.isLoopback 的唯一诞生地,改这一处
// 所有消费方(设置镜像持久化、通用设置的文档存储、交付物打开文件等)
// 都把经代理的来源视作本机信任。
needle: 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),',
replacement: 'isLoopback: true,',
},
{
// dsh-client-ui-settings:纵深防御——连接层形态万一变化,两处镜像构造
// 仍保持 host 模式。
needle: 'connection.isLoopback ? "host" : "memory"',
replacement: '"host"',
},
];
function isJavaScriptContentType(ct) {
return String(ct || '').toLowerCase().includes('javascript');
}
function patchClientScript(code) {
let out = code;
for (const { needle, replacement } of LOOPBACK_PATCHES) {
if (!out.includes(needle)) continue;
out = out.split(needle).join(replacement);
}
return out;
}
/**
* 启动反向代理。
* @param {object} opts
* @param {number} opts.listenPort 代理对外监听端口
* @param {number} opts.dshPort 上游 DSH 服务端口(127.0.0.1)
* @param {string} [opts.username] Basic Auth 用户名(空则不启用认证)
* @param {string} [opts.password] Basic Auth 密码(空则不启用认证)
* @returns {http.Server}
*/
function startProxy({ listenPort, dshPort, username = '', password = '', host = '0.0.0.0' }) {
const TARGET_ORIGIN = `http://127.0.0.1:${dshPort}`;
const AUTH_USER = String(username);
const AUTH_PASS = String(password);
// 公开静态资源白名单:只含应用名/图标等非敏感数据(PWA manifest、站点图标)。
// 浏览器抓取 <link rel="manifest"> 时(标签未带 crossorigin="use-credentials")
// 不会携带 Basic Auth 凭据,若这些路径也强制认证,控制台会一直报
// /manifest.webmanifest 401。因此对白名单路径跳过认证;页面、API、WS 仍全部要求认证。
const PUBLIC_PATHS = new Set(['/manifest.webmanifest', '/favicon.svg', '/favicon.ico']);
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
function checkAuth(req) {
if (!AUTH_USER || !AUTH_PASS) return true; // 未配置 → 不需要认证
const m = /^Basic\s+(.+)$/i.exec(req.headers.authorization || '');
if (!m) return false;
let decoded;
try {
decoded = Buffer.from(m[1], 'base64').toString('utf8');
} catch {
return false;
}
const i = decoded.indexOf(':');
if (i === -1) return false;
return safeEqual(decoded.slice(0, i), AUTH_USER) && safeEqual(decoded.slice(i + 1), AUTH_PASS);
}
function rejectUnauthorized(res) {
res.writeHead(401, {
'WWW-Authenticate': `Basic realm="${AUTH_REALM}"`,
'Content-Type': 'text/plain; charset=utf-8',
});
res.end('401 Unauthorized');
}
function rejectUpgrade(socket) {
socket.end(`HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="${AUTH_REALM}"\r\nConnection: close\r\n\r\n`);
}
const proxy = httpProxy.createProxyServer({
target: TARGET_ORIGIN,
ws: true,
changeOrigin: true,
});
// HTML 注入 randomUUID polyfill(首块改写即可);JS 响应整包缓冲后应用
// loopback 信任补丁——needle 可能跨 chunk,必须拼齐再替换。两种情况都丢弃
// content-length(改写后长度变化,由 chunked 流承载)。
proxy.on('proxyRes', (proxyRes, req, res) => {
const ct = String(proxyRes.headers['content-type'] || '');
if (proxyRes.headers['content-encoding']) return;
if (ct.includes('text/html')) {
delete proxyRes.headers['content-length'];
res.removeHeader('content-length');
let injected = false;
const origWrite = res.write.bind(res);
res.write = function (chunk, ...rest) {
if (!injected) {
injected = true;
let str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
const i = str.toLowerCase().indexOf('<head');
if (i !== -1) {
const e = str.indexOf('>', i);
str = e !== -1 ? str.slice(0, e + 1) + POLYFILL + str.slice(e + 1) : POLYFILL + str;
} else {
str = POLYFILL + str;
}
chunk = Buffer.from(str);
}
return origWrite(chunk, ...rest);
};
return;
}
if (!isJavaScriptContentType(ct)) return;
delete proxyRes.headers['content-length'];
res.removeHeader('content-length');
const chunks = [];
const capture = (chunk) => {
const part =
typeof chunk === 'string'
? Buffer.from(chunk, 'utf8')
: ArrayBuffer.isView(chunk) || chunk instanceof ArrayBuffer
? Buffer.from(chunk)
: null;
if (part !== null) chunks.push(part);
};
const origWrite = res.write.bind(res);
const origEnd = res.end.bind(res);
let ended = false;
res.write = function (chunk) {
capture(chunk);
return true;
};
res.end = function (chunk, ...rest) {
if (ended) return;
ended = true;
if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') capture(chunk);
const callback = [chunk, ...rest].find((arg) => typeof arg === 'function');
const out = Buffer.from(patchClientScript(Buffer.concat(chunks).toString('utf8')));
return callback === undefined ? origEnd(out) : origEnd(out, callback);
};
});
// changeOrigin 把 Host 改写为目标地址,浏览器带的 Origin 需同步对齐,
// 否则 DSH 的 /api 同源校验(Origin 必须等于它看到的 Host)会拒绝(403),
// WS 握手同样走该校验。
function alignOrigin(req) {
if (req.headers.origin) req.headers.origin = TARGET_ORIGIN;
}
const server = http.createServer((req, res) => {
const pathname = new URL(req.url ?? '/', 'http://proxy').pathname;
if (!PUBLIC_PATHS.has(pathname) && !checkAuth(req)) {
rejectUnauthorized(res);
return;
}
alignOrigin(req);
proxy.web(req, res);
});
server.on('upgrade', (req, socket, head) => {
if (!checkAuth(req)) {
rejectUpgrade(socket);
return;
}
alignOrigin(req);
proxy.ws(req, socket, head);
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n错误:端口 ${listenPort} 已被其他程序占用。请换一个目标端口后重试。`);
} else if (err.code === 'EACCES') {
console.error(`\n错误:没有权限监听端口 ${listenPort}(可能需要管理员权限)。`);
} else {
console.error(`\n代理启动失败:${err.message}`);
}
process.exitCode = 1;
});
server.listen(listenPort, host, () => {
const authText = AUTH_USER && AUTH_PASS ? `Basic Auth 已启用(用户名:${AUTH_USER})` : '未启用认证';
console.log(`代理已启动,监听 0.0.0.0:${listenPort},转发到 ${TARGET_ORIGIN}(${authText})`);
console.log(`本机访问: http://127.0.0.1:${listenPort}`);
const nets = os.networkInterfaces();
const ips = [];
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
if (net.family === 'IPv4' && !net.internal) ips.push(net.address);
}
}
for (const ip of ips) console.log(`局域网访问:http://${ip}:${listenPort}`);
});
return server;
}
module.exports = { startProxy };