@mrrisega/dsh-remote 0.3.0
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/LICENSE +81 -0
- package/README.md +103 -0
- package/clients/dsh-remote/dsh-bridge.mjs +658 -0
- package/clients/dsh-remote/package.json +11 -0
- package/clients/dsh-remote/src/lifecycle.mjs +3 -0
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +215 -0
- package/clients/dsh-remote/test/lifecycle.test.mjs +7 -0
- package/clients/dsh-remote/test/tunnel-auth.test.mjs +111 -0
- package/docs/self-hosting.md +109 -0
- package/dsh-setup.mjs +616 -0
- package/package.json +37 -0
- package/packages/dsh-remote-ui/README.md +81 -0
- package/packages/dsh-remote-ui/lib/client.js +1173 -0
- package/packages/dsh-remote-ui/lib/index.js +733 -0
- package/packages/dsh-remote-ui/package.json +26 -0
- package/packages/dsh-remote-ui/test/account-info.test.mjs +92 -0
- package/packages/dsh-remote-ui/test/account-switch.test.mjs +80 -0
- package/packages/dsh-remote-ui/test/feedback-proxy.test.mjs +154 -0
- package/packages/dsh-remote-ui/test/register-proxy.test.mjs +52 -0
- package/packages/dsh-remote-ui/test/settings-entry.test.mjs +282 -0
- package/packages/dsh-remote-ui/test/sms-captcha-ui.test.mjs +123 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// dsh-remote-ui — node half (host plugin)
|
|
2
|
+
//
|
|
3
|
+
// 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的配置面板调用:
|
|
4
|
+
// - 读写 dsh-remote-open/.dsh-config.json(0600)
|
|
5
|
+
// - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
|
|
6
|
+
// - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
|
|
7
|
+
//
|
|
8
|
+
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, constants as fsConstants } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { homedir, hostname, platform } from "node:os";
|
|
13
|
+
|
|
14
|
+
/** 本插件在 host 侧的服务依赖。 */
|
|
15
|
+
export const inject = ["webServer"];
|
|
16
|
+
|
|
17
|
+
/** 默认配置目录(可被 entry config 的 relayDir / DSH_RELAY_DIR 环境变量覆盖)。 */
|
|
18
|
+
const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-remote");
|
|
19
|
+
// 默认云端服务地址(SaaS 入口;自建用户在设置页/面板切换)
|
|
20
|
+
const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
21
|
+
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
22
|
+
|
|
23
|
+
// ---------- 小工具 ----------
|
|
24
|
+
|
|
25
|
+
/** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
|
|
26
|
+
function sh(cmd) {
|
|
27
|
+
try {
|
|
28
|
+
const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 });
|
|
29
|
+
return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 解析真实可执行路径(launchd 需要真实文件 + 可执行位)。 */
|
|
36
|
+
function resolveExecutable(p) {
|
|
37
|
+
try {
|
|
38
|
+
const real = realpathSync(p);
|
|
39
|
+
accessSync(real, fsConstants.X_OK);
|
|
40
|
+
return real;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 优先 node@20(node-datachannel 兼容性),回退当前 node。 */
|
|
47
|
+
function preferredNode() {
|
|
48
|
+
const candidates = [
|
|
49
|
+
"/opt/homebrew/opt/node@20/bin/node",
|
|
50
|
+
"/usr/local/opt/node@20/bin/node",
|
|
51
|
+
process.env.DSH_SETUP_NODE20 || "",
|
|
52
|
+
].filter(Boolean);
|
|
53
|
+
for (const p of candidates) {
|
|
54
|
+
const real = resolveExecutable(p);
|
|
55
|
+
if (real) return real;
|
|
56
|
+
}
|
|
57
|
+
return resolveExecutable(process.execPath) || process.execPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NODE_BIN = preferredNode();
|
|
61
|
+
|
|
62
|
+
/** 读取 JSON body。 */
|
|
63
|
+
async function readJsonBody(req) {
|
|
64
|
+
let raw = "";
|
|
65
|
+
for await (const chunk of req) raw += chunk;
|
|
66
|
+
if (!raw) return {};
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
return { __parseError: true };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 统一 JSON 响应。 */
|
|
75
|
+
function sendJson(res, code, body) {
|
|
76
|
+
const payload = JSON.stringify(body);
|
|
77
|
+
res.writeHead(code, {
|
|
78
|
+
"content-type": "application/json; charset=utf-8",
|
|
79
|
+
"cache-control": "no-store",
|
|
80
|
+
});
|
|
81
|
+
res.end(payload);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- 配置读写(与 dsh-setup.mjs 同一份 .dsh-config.json) ----------
|
|
85
|
+
|
|
86
|
+
function configPathOf(relayDir) {
|
|
87
|
+
return join(relayDir, ".dsh-config.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadConfig(relayDir) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(readFileSync(configPathOf(relayDir), "utf8"));
|
|
93
|
+
} catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function saveConfig(relayDir, cfg) {
|
|
99
|
+
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
100
|
+
// mode 0o600:与 dsh-setup.mjs 一致(文件已存在时 writeFileSync 不改权限,显式 chmod 兜底)
|
|
101
|
+
writeFileSync(configPathOf(relayDir), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
102
|
+
try {
|
|
103
|
+
chmodSync(configPathOf(relayDir), 0o600);
|
|
104
|
+
} catch {
|
|
105
|
+
/* 非关键 */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------- bridge 服务状态 / 启停(launchctl,macOS) ----------
|
|
110
|
+
|
|
111
|
+
function launchAgentPath() {
|
|
112
|
+
if (platform() === "darwin") return join(homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function launchTarget() {
|
|
117
|
+
return `gui/${process.getuid()}/com.dshremote.bridge`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 检查 launchd 服务状态(state=running + pid;兜底 launchctl list)。 */
|
|
121
|
+
function launchdStatus() {
|
|
122
|
+
const target = launchTarget();
|
|
123
|
+
const pr = sh(`launchctl print ${target}`);
|
|
124
|
+
if (pr.ok && /state\s*=\s*running/.test(pr.stdout)) {
|
|
125
|
+
const m = pr.stdout.match(/pid\s*=\s*(\d+)/);
|
|
126
|
+
return { running: true, pid: m ? Number(m[1]) : null };
|
|
127
|
+
}
|
|
128
|
+
const ls = sh(`launchctl list | grep com.dshremote.bridge`);
|
|
129
|
+
if (ls.ok) {
|
|
130
|
+
const pidStr = ls.stdout.trim().split(/\s+/)[0];
|
|
131
|
+
if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr)) return { running: true, pid: Number(pidStr) };
|
|
132
|
+
}
|
|
133
|
+
return { running: false, pid: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 检查手动运行的 watcher(dsh-setup.mjs run)与 bridge 子进程(排除 launchd 托管链)。 */
|
|
137
|
+
function manualStatus() {
|
|
138
|
+
const launchdPid = launchdStatus().pid;
|
|
139
|
+
const out = (() => {
|
|
140
|
+
const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'");
|
|
141
|
+
return r.ok ? r.stdout : "";
|
|
142
|
+
})();
|
|
143
|
+
const watcher = [];
|
|
144
|
+
const bridge = [];
|
|
145
|
+
// 取候选进程的父 pid,判断是否属于 launchd 托管链
|
|
146
|
+
const parentOf = (pid) => {
|
|
147
|
+
const r = sh(`ps -o ppid= -p ${pid}`);
|
|
148
|
+
const m = r.ok && r.stdout.trim().match(/^(\d+)/);
|
|
149
|
+
return m ? Number(m[1]) : null;
|
|
150
|
+
};
|
|
151
|
+
for (const line of out.split("\n")) {
|
|
152
|
+
if (/pgrep/.test(line)) continue; // 排除 execSync 的 sh -c 包装进程
|
|
153
|
+
const m = line.match(/^(\d+)\s+(.+)$/);
|
|
154
|
+
if (!m) continue;
|
|
155
|
+
const pid = Number(m[1]);
|
|
156
|
+
if (pid === process.pid || pid === launchdPid) continue;
|
|
157
|
+
if (launchdPid !== null && parentOf(pid) === launchdPid) continue; // launchd 托管的 bridge 子进程
|
|
158
|
+
if (/dsh-setup\.mjs/.test(m[2])) watcher.push(pid);
|
|
159
|
+
else if (/dsh-bridge\.mjs/.test(m[2])) bridge.push(pid);
|
|
160
|
+
}
|
|
161
|
+
return { watcher, bridge };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 生成 plist(与 dsh-setup.mjs writeAutostartFile 同构),返回路径。 */
|
|
165
|
+
function writeAutostartFile(relayDir) {
|
|
166
|
+
const plistPath = launchAgentPath();
|
|
167
|
+
if (!plistPath) return null;
|
|
168
|
+
const setupUrl = join(relayDir, "dsh-setup.mjs");
|
|
169
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
170
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
171
|
+
<plist version="1.0"><dict>
|
|
172
|
+
<key>Label</key><string>com.dshremote.bridge</string>
|
|
173
|
+
<key>ProgramArguments</key>
|
|
174
|
+
<array><string>${NODE_BIN}</string><string>${setupUrl}</string><string>run</string></array>
|
|
175
|
+
<key>RunAtLoad</key><true/>
|
|
176
|
+
<key>KeepAlive</key><true/>
|
|
177
|
+
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
178
|
+
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
179
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
|
|
180
|
+
</dict></plist>`;
|
|
181
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
182
|
+
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
183
|
+
return plistPath;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 启动 bridge:确保 plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
187
|
+
function startBridge(relayDir) {
|
|
188
|
+
const plistPath = launchAgentPath();
|
|
189
|
+
if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
|
|
190
|
+
if (!existsSync(plistPath)) writeAutostartFile(relayDir);
|
|
191
|
+
if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
|
|
192
|
+
const target = launchTarget();
|
|
193
|
+
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
194
|
+
sh(`launchctl bootout ${target}`);
|
|
195
|
+
let boot = sh(`launchctl bootstrap gui/${process.getuid()} ${q(plistPath)}`);
|
|
196
|
+
if (!boot.ok) {
|
|
197
|
+
sh(`launchctl unload ${q(plistPath)}`);
|
|
198
|
+
boot = sh(`launchctl load -w ${q(plistPath)}`);
|
|
199
|
+
}
|
|
200
|
+
if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
|
|
201
|
+
const st = launchdStatus();
|
|
202
|
+
return { ok: st.running, status: st.running ? "running" : "failed", pid: st.pid, detail: st.running ? void 0 : "服务未进入运行态" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 停止 bridge:launchctl bootout。 */
|
|
206
|
+
function stopBridge() {
|
|
207
|
+
const target = launchTarget();
|
|
208
|
+
const r = sh(`launchctl bootout ${target}`);
|
|
209
|
+
const st = launchdStatus();
|
|
210
|
+
return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- relay API 代理(直连,不走系统代理;undici 默认忽略代理环境变量) ----------
|
|
214
|
+
|
|
215
|
+
async function relayFetch(relayDir, pathname, init) {
|
|
216
|
+
const cfg = loadConfig(relayDir);
|
|
217
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
218
|
+
const url = `${api}${pathname}`;
|
|
219
|
+
try {
|
|
220
|
+
// 6s 超时:relay 不可达时快速降级,不拖慢面板
|
|
221
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(6000) });
|
|
222
|
+
const text = await res.text();
|
|
223
|
+
let body = null;
|
|
224
|
+
try {
|
|
225
|
+
body = JSON.parse(text);
|
|
226
|
+
} catch {
|
|
227
|
+
body = text;
|
|
228
|
+
}
|
|
229
|
+
return { status: res.status, ok: res.ok, body };
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return { status: 0, ok: false, body: { error: { message: `relay 不可达: ${e.message}` } } };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
|
|
236
|
+
|
|
237
|
+
/** 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。 */
|
|
238
|
+
async function relayToken(relayDir) {
|
|
239
|
+
const cfg = loadConfig(relayDir);
|
|
240
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
241
|
+
if (cfg.local_key) {
|
|
242
|
+
// 本地认证:POST {tunnel 同源}/_login
|
|
243
|
+
const u = new URL(cfg.tunnel_url || api.replace(/^https?/, "wss"));
|
|
244
|
+
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
245
|
+
u.pathname = "/_login";
|
|
246
|
+
const r = await fetch(u.toString(), {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "content-type": "application/json" },
|
|
249
|
+
body: JSON.stringify({ key: cfg.local_key }),
|
|
250
|
+
signal: AbortSignal.timeout(6000)
|
|
251
|
+
});
|
|
252
|
+
if (!r.ok) return "";
|
|
253
|
+
const d = await r.json();
|
|
254
|
+
return d.token || "";
|
|
255
|
+
}
|
|
256
|
+
if (!cfg.phone || !cfg.password) return "";
|
|
257
|
+
const r = await fetch(`${api}/api/device-login`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "content-type": "application/json", ...(cfg.bridge_secret ? { "x-dsh-bridge-secret": cfg.bridge_secret } : {}) },
|
|
260
|
+
body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
|
|
261
|
+
signal: AbortSignal.timeout(6000)
|
|
262
|
+
});
|
|
263
|
+
if (!r.ok) return "";
|
|
264
|
+
const d = await r.json();
|
|
265
|
+
return d.token || "";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
|
|
269
|
+
async function relayAccount(relayDir) {
|
|
270
|
+
const token = await relayToken(relayDir);
|
|
271
|
+
if (!token) return null;
|
|
272
|
+
const r = await relayFetch(relayDir, "/api/me", { headers: { authorization: `Bearer ${token}` } });
|
|
273
|
+
if (!r.ok || !r.body || !r.body.user) return null;
|
|
274
|
+
const u = r.body.user;
|
|
275
|
+
return {
|
|
276
|
+
phone: u.phone || "",
|
|
277
|
+
plan: u.plan || "free",
|
|
278
|
+
plan_source: u.plan_source || "plan",
|
|
279
|
+
plan_ends_at: u.plan_ends_at ?? null,
|
|
280
|
+
trial_expires_at: u.trial_expires_at ?? null,
|
|
281
|
+
invite_code: u.invite_code || "",
|
|
282
|
+
invited_by: u.invited_by ?? null
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 流量用量:router /_quota(免费用户百分比提示)。 */
|
|
287
|
+
async function relayQuota(relayDir) {
|
|
288
|
+
const token = await relayToken(relayDir);
|
|
289
|
+
if (!token) return null;
|
|
290
|
+
const cfg = loadConfig(relayDir);
|
|
291
|
+
// router 同源:apiUrl(https://host/relay-api) → https://host;本地模式从 tunnel_url 推导
|
|
292
|
+
let origin = "";
|
|
293
|
+
if (cfg.tunnel_url) {
|
|
294
|
+
const u = new URL(cfg.tunnel_url);
|
|
295
|
+
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
296
|
+
origin = u.origin;
|
|
297
|
+
} else {
|
|
298
|
+
const u = new URL((cfg.api_url || DEFAULT_API));
|
|
299
|
+
origin = u.origin;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const r = await fetch(`${origin}/_quota`, {
|
|
303
|
+
headers: { cookie: `dsh_token=${encodeURIComponent(token)}` },
|
|
304
|
+
signal: AbortSignal.timeout(6000)
|
|
305
|
+
});
|
|
306
|
+
if (!r.ok) return null;
|
|
307
|
+
const d = await r.json();
|
|
308
|
+
return d.quota || null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 我的邀请记录(登录态):有效邀请 + 奖励。 */
|
|
315
|
+
async function relayInviteRecords(relayDir) {
|
|
316
|
+
const token = await relayToken(relayDir);
|
|
317
|
+
if (!token) return null;
|
|
318
|
+
const r = await relayFetch(relayDir, "/api/invite-records", { headers: { authorization: `Bearer ${token}` } });
|
|
319
|
+
if (!r.ok || !r.body) return null;
|
|
320
|
+
return { records: r.body.records || [], rewards: r.body.rewards || [] };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------- 综合状态 ----------
|
|
324
|
+
|
|
325
|
+
async function composeStatus(relayDir) {
|
|
326
|
+
const cfg = loadConfig(relayDir);
|
|
327
|
+
const launchd = launchdStatus();
|
|
328
|
+
const manual = manualStatus();
|
|
329
|
+
// 远程地址(public-config 的 app_url,取不到用默认)
|
|
330
|
+
const pub = await relayFetch(relayDir, "/api/public-config");
|
|
331
|
+
const pubBody = pub.ok && pub.body && typeof pub.body === "object" ? pub.body : {};
|
|
332
|
+
const remoteUrl = pubBody.app_url || DEFAULT_APP_URL;
|
|
333
|
+
const apiUrl = pubBody.api_url || cfg.api_url || DEFAULT_API;
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
config: {
|
|
337
|
+
phone: cfg.phone || "",
|
|
338
|
+
hasPassword: Boolean(cfg.password),
|
|
339
|
+
deviceId: cfg.device_id || "",
|
|
340
|
+
apiUrl,
|
|
341
|
+
mode: cfg.local_key ? "local" : "saas", // 连接模式:saas(公网) | local(自建)
|
|
342
|
+
selfHostUrl: cfg.tunnel_url ? cfg.tunnel_url.replace(/^wss?:\/\//, "").replace(/\/+$/, "") : "",
|
|
343
|
+
hasLocalKey: Boolean(cfg.local_key),
|
|
344
|
+
},
|
|
345
|
+
remoteUrl,
|
|
346
|
+
relayReachable: pub.ok,
|
|
347
|
+
service: {
|
|
348
|
+
plistExists: Boolean(launchAgentPath() && existsSync(launchAgentPath())),
|
|
349
|
+
launchd,
|
|
350
|
+
manual,
|
|
351
|
+
running: launchd.running || manual.bridge.length > 0,
|
|
352
|
+
},
|
|
353
|
+
host: hostname(),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- 用户反馈代理(反馈 API 由 relay-enterprise 提供,同源 /relay-api/) ----------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 反馈 API 基址:feedback_url(自建/兼容实现)> 账号 API 基址(默认生产 relay-api)。
|
|
361
|
+
* 反馈端点路径与账号 API 同构:{base}/api/feedback*。
|
|
362
|
+
*/
|
|
363
|
+
function feedbackApiOf(cfg) {
|
|
364
|
+
return (cfg.feedback_url || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 读取请求体(上限 64KB,与反馈服务一致)。 */
|
|
368
|
+
async function readBodyBuffer(req, limit = 64 * 1024) {
|
|
369
|
+
const chunks = [];
|
|
370
|
+
let total = 0;
|
|
371
|
+
for await (const c of req) {
|
|
372
|
+
total += c.length;
|
|
373
|
+
if (total > limit) {
|
|
374
|
+
const e = new Error("body too large");
|
|
375
|
+
e.status = 413;
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
chunks.push(c);
|
|
379
|
+
}
|
|
380
|
+
return Buffer.concat(chunks);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 把 /dsh-remote/feedback/* 代理到反馈 API(relay-enterprise 同源 /relay-api/):
|
|
385
|
+
* - 自动附加本机稳定身份 X-Dsh-Device(device_id)与 X-Dsh-Phone(已登录手机号)
|
|
386
|
+
* - 透传浏览器带的 Authorization(thread_token,存于浏览器 localStorage)
|
|
387
|
+
* - 不转发 cookie/浏览器标记;反馈服务不可达时降级 502 JSON
|
|
388
|
+
*/
|
|
389
|
+
// 账号 JWT 缓存(反馈请求高频,避免每次 device-login 刷审计日志);过期前复用
|
|
390
|
+
let fbTokenCache = { token: "", exp: 0 };
|
|
391
|
+
async function feedbackAuthToken(relayDir) {
|
|
392
|
+
if (fbTokenCache.token && Date.now() < fbTokenCache.exp) return fbTokenCache.token;
|
|
393
|
+
const t = await relayToken(relayDir).catch(() => "");
|
|
394
|
+
if (t) fbTokenCache = { token: t, exp: Date.now() + 100 * 60 * 1000 };
|
|
395
|
+
else fbTokenCache = { token: "", exp: 0 };
|
|
396
|
+
return t;
|
|
397
|
+
}
|
|
398
|
+
async function proxyFeedback(relayDir, req, res, pathname) {
|
|
399
|
+
const cfg = loadConfig(relayDir);
|
|
400
|
+
const api = feedbackApiOf(cfg);
|
|
401
|
+
const suffix = pathname.replace(/^\/dsh-remote\/feedback/, "") || "/";
|
|
402
|
+
// 相对路径解析:保留基址的路径前缀(如 /relay-api),避免 new URL 绝对路径吞掉 base path
|
|
403
|
+
const base = api.endsWith("/") ? api : `${api}/`;
|
|
404
|
+
const url = new URL(suffix.replace(/^\//, ""), base);
|
|
405
|
+
const headers = {
|
|
406
|
+
"x-dsh-device": cfg.device_id || "",
|
|
407
|
+
"x-dsh-client": "dsh-remote-ui/0.1.0",
|
|
408
|
+
};
|
|
409
|
+
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
410
|
+
const auth = req.headers.authorization;
|
|
411
|
+
if (auth && /^Bearer\s+/i.test(auth)) {
|
|
412
|
+
headers.authorization = auth;
|
|
413
|
+
} else if (cfg.local_key || (cfg.phone && cfg.password)) {
|
|
414
|
+
// 登录态统一免验证码:节点半自动附加账号 JWT(服务端对有效 JWT 免验证码)
|
|
415
|
+
const t = await feedbackAuthToken(relayDir);
|
|
416
|
+
if (t) headers.authorization = `Bearer ${t}`;
|
|
417
|
+
}
|
|
418
|
+
const method = req.method || "GET";
|
|
419
|
+
const init = { method, headers };
|
|
420
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
421
|
+
let buf;
|
|
422
|
+
try {
|
|
423
|
+
buf = await readBodyBuffer(req);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
return sendJson(res, e.status || 413, { ok: false, error: "请求体过大" });
|
|
426
|
+
}
|
|
427
|
+
if (buf.length) {
|
|
428
|
+
const ct = req.headers["content-type"] || "application/json";
|
|
429
|
+
init.body = buf;
|
|
430
|
+
headers["content-type"] = ct;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
const r = await fetch(url, { ...init, signal: AbortSignal.timeout(8000) });
|
|
435
|
+
// 401(token 失效)→ 清缓存,下次请求自动刷新
|
|
436
|
+
if (r.status === 401) fbTokenCache = { token: "", exp: 0 };
|
|
437
|
+
const text = await r.text();
|
|
438
|
+
let body = null;
|
|
439
|
+
try {
|
|
440
|
+
body = JSON.parse(text);
|
|
441
|
+
} catch {
|
|
442
|
+
body = text;
|
|
443
|
+
}
|
|
444
|
+
res.writeHead(r.status, { "content-type": r.headers.get("content-type") || "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
445
|
+
res.end(text);
|
|
446
|
+
return body;
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return sendJson(res, 502, { ok: false, error: `反馈服务不可达: ${e.message}`, hint: "请确认反馈服务已启动,或检查 .dsh-config.json 的 feedback_url" });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------- 路由 ----------
|
|
453
|
+
|
|
454
|
+
/** 路由表:{method, path, handler}。 */
|
|
455
|
+
function registerRoutes(ctx, relayDir) {
|
|
456
|
+
const routes = [
|
|
457
|
+
{
|
|
458
|
+
method: "GET",
|
|
459
|
+
path: "/dsh-remote/status",
|
|
460
|
+
handler: async (_req, res) => {
|
|
461
|
+
sendJson(res, 200, await composeStatus(relayDir));
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
method: "GET",
|
|
466
|
+
path: "/dsh-remote/account",
|
|
467
|
+
handler: async (_req, res) => {
|
|
468
|
+
sendJson(res, 200, { ok: true, account: await relayAccount(relayDir) });
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
method: "GET",
|
|
473
|
+
path: "/dsh-remote/quota",
|
|
474
|
+
handler: async (_req, res) => {
|
|
475
|
+
sendJson(res, 200, { ok: true, quota: await relayQuota(relayDir) });
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
method: "GET",
|
|
480
|
+
path: "/dsh-remote/invite-records",
|
|
481
|
+
handler: async (_req, res) => {
|
|
482
|
+
sendJson(res, 200, { ok: true, ...(await relayInviteRecords(relayDir)) });
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
method: "GET",
|
|
487
|
+
path: "/dsh-remote/remote-url",
|
|
488
|
+
handler: async (_req, res) => {
|
|
489
|
+
const pub = await relayFetch(relayDir, "/api/public-config");
|
|
490
|
+
const body = pub.ok && pub.body && typeof pub.body === "object" ? pub.body : {};
|
|
491
|
+
sendJson(res, 200, {
|
|
492
|
+
ok: true,
|
|
493
|
+
remoteUrl: body.app_url || DEFAULT_APP_URL,
|
|
494
|
+
relayReachable: pub.ok,
|
|
495
|
+
publicConfig: body
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
method: "POST",
|
|
501
|
+
path: "/dsh-remote/config",
|
|
502
|
+
handler: async (req, res) => {
|
|
503
|
+
const body = await readJsonBody(req);
|
|
504
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
505
|
+
const cfg = loadConfig(relayDir);
|
|
506
|
+
const mode = body.mode === "local" ? "local" : "saas";
|
|
507
|
+
if (mode === "local") {
|
|
508
|
+
// 自建模式:服务器地址 + 访问密钥(免账号体系;随时可切回 SaaS)
|
|
509
|
+
const selfHostUrl = String(body.selfHostUrl ?? "").trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
510
|
+
const localKey = String(body.localKey ?? "").trim();
|
|
511
|
+
if (!selfHostUrl || !localKey) return sendJson(res, 400, { ok: false, error: "自建模式需要服务器地址与访问密钥" });
|
|
512
|
+
cfg.tunnel_url = `wss://${selfHostUrl}`;
|
|
513
|
+
cfg.local_key = localKey;
|
|
514
|
+
} else {
|
|
515
|
+
const phone = String(body.phone ?? "").trim();
|
|
516
|
+
const password = String(body.password ?? "");
|
|
517
|
+
if (!phone || !password) return sendJson(res, 400, { ok: false, error: "手机号与密码必填" });
|
|
518
|
+
const accountChanged = (cfg.phone || cfg.email || "") !== phone || Boolean(cfg.email && cfg.email !== phone);
|
|
519
|
+
cfg.phone = phone;
|
|
520
|
+
cfg.password = password;
|
|
521
|
+
delete cfg.email;
|
|
522
|
+
if (accountChanged) {
|
|
523
|
+
delete cfg.device_id;
|
|
524
|
+
delete cfg.device_private_key;
|
|
525
|
+
delete cfg.device_public_key;
|
|
526
|
+
}
|
|
527
|
+
// 切回 SaaS:仅当此前是自建模式(local_key)才清理本地隧道/API 地址;
|
|
528
|
+
// 正常 SaaS 用户的 tunnel_url 是核心配置(wss://公网域名),绝不能删
|
|
529
|
+
const wasLocal = Boolean(cfg.local_key);
|
|
530
|
+
delete cfg.local_key;
|
|
531
|
+
if (wasLocal) {
|
|
532
|
+
if (cfg.tunnel_url) delete cfg.tunnel_url;
|
|
533
|
+
if (cfg.api_url) delete cfg.api_url;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
saveConfig(relayDir, cfg);
|
|
537
|
+
const bridgeRestart = startBridge(relayDir);
|
|
538
|
+
sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
method: "POST",
|
|
543
|
+
path: "/dsh-remote/logout",
|
|
544
|
+
handler: async (_req, res) => {
|
|
545
|
+
// 退出登录:清除本机保存的账号(邮箱/密码),bridge 下次重启将不再自动登录
|
|
546
|
+
const cfg = loadConfig(relayDir);
|
|
547
|
+
delete cfg.phone;
|
|
548
|
+
delete cfg.password;
|
|
549
|
+
saveConfig(relayDir, cfg);
|
|
550
|
+
sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
method: "POST",
|
|
555
|
+
path: "/dsh-remote/start",
|
|
556
|
+
handler: async (_req, res) => {
|
|
557
|
+
const r = startBridge(relayDir);
|
|
558
|
+
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, status: r.status, pid: r.pid, detail: r.detail, ...(await composeStatus(relayDir)) });
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
method: "POST",
|
|
563
|
+
path: "/dsh-remote/stop",
|
|
564
|
+
handler: async (_req, res) => {
|
|
565
|
+
const r = stopBridge();
|
|
566
|
+
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, status: r.status, detail: r.detail, ...(await composeStatus(relayDir)) });
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
method: "POST",
|
|
571
|
+
path: "/dsh-remote/sms-code",
|
|
572
|
+
handler: async (req, res) => {
|
|
573
|
+
const body = await readJsonBody(req);
|
|
574
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
575
|
+
const phone = String(body.phone ?? "").trim();
|
|
576
|
+
if (!phone) return sendJson(res, 400, { ok: false, error: "手机号必填" });
|
|
577
|
+
const r = await relayFetch(relayDir, "/api/sms-code", {
|
|
578
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
579
|
+
body: JSON.stringify({ phone, ...(body.captcha_id !== undefined ? { captcha_id: String(body.captcha_id), captcha_answer: String(body.captcha_answer ?? "") } : {}) }),
|
|
580
|
+
});
|
|
581
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
method: "GET",
|
|
586
|
+
path: "/dsh-remote/captcha",
|
|
587
|
+
handler: async (_req, res) => {
|
|
588
|
+
// 代理 relay /api/captcha。live 契约:200 JSON {captcha_id, svg};
|
|
589
|
+
// 兼容旧服务端可能返回的图片(content-type 以 image/ 开头时原样透传)。
|
|
590
|
+
const cfg = loadConfig(relayDir);
|
|
591
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
592
|
+
try {
|
|
593
|
+
const r = await fetch(`${api}/api/captcha`, { signal: AbortSignal.timeout(6000) });
|
|
594
|
+
const type = r.headers.get("content-type") || "";
|
|
595
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
596
|
+
if (!r.ok) {
|
|
597
|
+
sendJson(res, r.status, { ok: false, error: "验证码获取失败", relayStatus: r.status });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (type.startsWith("image/")) {
|
|
601
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
602
|
+
res.end(buf);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
// JSON({captcha_id, svg})原样透传
|
|
606
|
+
res.writeHead(200, { "content-type": type || "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
607
|
+
res.end(buf);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${e.message}` });
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
method: "POST",
|
|
615
|
+
path: "/dsh-remote/register",
|
|
616
|
+
handler: async (req, res) => {
|
|
617
|
+
const body = await readJsonBody(req);
|
|
618
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
619
|
+
const phone = String(body.phone ?? "").trim();
|
|
620
|
+
const smsCode = String(body.sms_code ?? "").trim();
|
|
621
|
+
const password = String(body.password ?? "");
|
|
622
|
+
if (!phone || !smsCode || !password) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与密码必填" });
|
|
623
|
+
const payload = { phone, sms_code: smsCode, password };
|
|
624
|
+
const captchaId = body.captcha_id ?? body.captchaId;
|
|
625
|
+
const captchaAnswer = body.captcha_answer ?? body.captcha;
|
|
626
|
+
if (captchaId !== void 0) payload.captcha_id = String(captchaId);
|
|
627
|
+
if (captchaAnswer !== void 0) payload.captcha_answer = String(captchaAnswer);
|
|
628
|
+
const r = await relayFetch(relayDir, "/api/register", {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { "content-type": "application/json" },
|
|
631
|
+
body: JSON.stringify(payload),
|
|
632
|
+
});
|
|
633
|
+
// 透传 relay 响应体(成功 {token,user} / 失败 {error:{message}})
|
|
634
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
method: "POST",
|
|
639
|
+
path: "/dsh-remote/login",
|
|
640
|
+
handler: async (req, res) => {
|
|
641
|
+
const body = await readJsonBody(req);
|
|
642
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
643
|
+
const phone = String(body.phone ?? "").trim();
|
|
644
|
+
const password = String(body.password ?? "");
|
|
645
|
+
if (!phone || !password) return sendJson(res, 400, { ok: false, error: "手机号与密码必填" });
|
|
646
|
+
// 登录接口已加图形验证码,透传 captcha 字段
|
|
647
|
+
const payload = { phone, password };
|
|
648
|
+
if (body.captcha_id !== undefined) payload.captcha_id = String(body.captcha_id);
|
|
649
|
+
if (body.captcha_answer !== undefined) payload.captcha_answer = String(body.captcha_answer);
|
|
650
|
+
const r = await relayFetch(relayDir, "/api/login", {
|
|
651
|
+
method: "POST",
|
|
652
|
+
headers: { "content-type": "application/json" },
|
|
653
|
+
body: JSON.stringify(payload),
|
|
654
|
+
});
|
|
655
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
method: "GET",
|
|
660
|
+
path: "/dsh-remote/feedback-config",
|
|
661
|
+
handler: async (_req, res) => {
|
|
662
|
+
const cfg = loadConfig(relayDir);
|
|
663
|
+
const api = feedbackApiOf(cfg);
|
|
664
|
+
let reachable = false;
|
|
665
|
+
try {
|
|
666
|
+
const r = await fetch(`${api}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
667
|
+
reachable = r.ok;
|
|
668
|
+
} catch {
|
|
669
|
+
reachable = false;
|
|
670
|
+
}
|
|
671
|
+
sendJson(res, 200, {
|
|
672
|
+
ok: true,
|
|
673
|
+
feedbackUrl: api,
|
|
674
|
+
reachable,
|
|
675
|
+
deviceId: cfg.device_id || "",
|
|
676
|
+
phone: cfg.phone || "",
|
|
677
|
+
// 登录态(已配置账号或自建密钥)→ 节点半自动附加 JWT,免图形验证码
|
|
678
|
+
auth: cfg.local_key || (cfg.phone && cfg.password) ? "account" : "anonymous"
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
method: "ALL",
|
|
684
|
+
path: "/dsh-remote/feedback",
|
|
685
|
+
prefix: true,
|
|
686
|
+
handler: async (req, res) => {
|
|
687
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
688
|
+
await proxyFeedback(relayDir, req, res, url.pathname + url.search);
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
const disposers = [];
|
|
694
|
+
for (const route of routes) {
|
|
695
|
+
const dispose = ctx.webServer.register({
|
|
696
|
+
kind: route.prefix ? "prefix" : "exact",
|
|
697
|
+
path: route.path,
|
|
698
|
+
handler: (req, res) => {
|
|
699
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
700
|
+
const match = route.prefix
|
|
701
|
+
? url.pathname === route.path || url.pathname.startsWith(route.path + "/")
|
|
702
|
+
: url.pathname === route.path;
|
|
703
|
+
const methodOk = route.method === "ALL" || req.method === route.method;
|
|
704
|
+
if (!match || !methodOk) {
|
|
705
|
+
res.writeHead(404);
|
|
706
|
+
res.end();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
Promise.resolve(route.handler(req, res)).catch((e) => {
|
|
710
|
+
ctx.logger?.warn?.(`dsh-remote-ui: ${route.method} ${route.path} failed: ${e?.stack || e}`);
|
|
711
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: String(e?.message || e) });
|
|
712
|
+
else res.end();
|
|
713
|
+
});
|
|
714
|
+
return; // webserver 不需要返回值;返回 void 保持 node:http 语义
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
disposers.push(dispose);
|
|
718
|
+
}
|
|
719
|
+
return () => {
|
|
720
|
+
for (const dispose of disposers) dispose();
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* 插件主体:注册 /dsh-remote/* 路由。
|
|
726
|
+
* @param ctx - host cordis context(注入 webServer)。
|
|
727
|
+
* @param config - entry config(可选 relayDir)。
|
|
728
|
+
*/
|
|
729
|
+
export function apply(ctx, config = {}) {
|
|
730
|
+
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
731
|
+
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-ui: /dsh-remote routes");
|
|
732
|
+
ctx.logger?.info?.(`dsh-remote-ui: /dsh-remote routes ready (relayDir=${relayDir})`);
|
|
733
|
+
}
|