dsh-pocket-relay 1.0.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 +344 -0
- package/README.en.md +219 -0
- package/README.md +258 -0
- package/bin/dsh-pocket-relay.mjs +216 -0
- package/client/api.js +88 -0
- package/client/build.mjs +47 -0
- package/client/client.js +3238 -0
- package/client/index.jsx +979 -0
- package/client/mobile/LICENSE.dsh-web-mobile +4 -0
- package/client/mobile/MobileDrawerFooter.tsx +56 -0
- package/client/mobile/MobileNavOverlay.tsx +290 -0
- package/client/mobile/MobileNavToggle.tsx +52 -0
- package/client/mobile/fileGuard.ts +200 -0
- package/client/mobile/layout-mode.mjs +46 -0
- package/client/mobile/locales.ts +23 -0
- package/client/mobile/mobile-apply.tsx +378 -0
- package/client/mobile/mobile.css.ts +1005 -0
- package/client/mobile/nav-targets.mjs +85 -0
- package/client/pocket-locales.js +331 -0
- package/cordis.patch.yml +11 -0
- package/lib/device-auth-http.mjs +338 -0
- package/lib/device-auth.mjs +485 -0
- package/lib/index.js +414 -0
- package/lib/ip.mjs +11 -0
- package/lib/proxy.mjs +1223 -0
- package/lib/relay-protocol.mjs +101 -0
- package/lib/relay.mjs +355 -0
- package/lib/restart.js +112 -0
- package/lib/service.mjs +578 -0
- package/lib/settings.mjs +392 -0
- package/lib/tunnel.mjs +621 -0
- package/lib/web-rpc.js +622 -0
- package/package.json +89 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// dsh-pocket-relay 插件入口(单包单插件:手机扫码访问 DSH + 自建 relay 远程,全在这一个包里)
|
|
2
|
+
//
|
|
3
|
+
// 设置一级入口「手机访问」:
|
|
4
|
+
// - 局域网二维码:自动显示(代理随插件启动)
|
|
5
|
+
// - 公网二维码:点「开启公网」→ cloudflared 隧道 → 扫码即用,人在外面也能访问
|
|
6
|
+
// - 更新提示:有新版本时显示一键更新按钮(dsh plugin update --latest)
|
|
7
|
+
// 手机看到的界面 = 电脑上的 dsh web,实时同步(WebSocket 透传)。
|
|
8
|
+
//
|
|
9
|
+
// 注:Web Push 已移除——浏览器推送依赖 Google FCM(Chrome)等境外服务,
|
|
10
|
+
// 国内直连被墙,普通用户用不了,且排障成本高。专注扫码同屏这一件事。
|
|
11
|
+
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { writeFile, readFile, mkdir, rm } from 'node:fs/promises';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
import { createPocketService } from './service.mjs';
|
|
21
|
+
import { installPocketRpc } from './web-rpc.js';
|
|
22
|
+
import { restartHost } from './restart.js';
|
|
23
|
+
import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
|
|
24
|
+
import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, cloudflaredPath, relayConfig, setRelayConfig } from './settings.mjs';
|
|
25
|
+
|
|
26
|
+
const name = 'dsh-pocket-relay';
|
|
27
|
+
const inject = ['connection', 'webServer'];
|
|
28
|
+
|
|
29
|
+
const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 本插件磁盘上的已安装版本。注意:**不能用 require 缓存**(进程内永远不变),
|
|
33
|
+
* 必须实时读文件——一键更新会改写 package.json,「已更新未重启」靠它识别。
|
|
34
|
+
*/
|
|
35
|
+
function currentVersion() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
38
|
+
} catch {
|
|
39
|
+
return '0.0.0';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 进程启动时加载的版本(模块加载瞬间固化;用于识别「磁盘已更新但进程还是旧代码」)。 */
|
|
44
|
+
const loadedVersion = currentVersion();
|
|
45
|
+
|
|
46
|
+
// ---------- 访问密码(issue #13 + #18 + #33) ----------
|
|
47
|
+
// 公网与局域网**分开**:各自 8 位,存本机 $DSH_HOME/dsh-pocket/。
|
|
48
|
+
// 公网密码(token):默认每次开启公网时轮换(旧链接作废);**用户自定义后不再轮换**;
|
|
49
|
+
// 局域网密码(token-lan):默认手动刷新(设置页按钮);自定义后刷新会换回随机值。
|
|
50
|
+
// 会话保持(issue #33):登录 cookie 绑定进程级 sessionKey(见 apply)——
|
|
51
|
+
// dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入。
|
|
52
|
+
// 密码规则:8 位英文字母(大小写)或数字(自动生成的为 8 位数字,自定义可为字母+数字)。
|
|
53
|
+
const PIN_RE = /^[a-zA-Z0-9]{8}$/;
|
|
54
|
+
function writePinToFile(p, fresh) {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
57
|
+
writeFileSync(p, fresh, { mode: 0o600 });
|
|
58
|
+
} catch { /* 忽略 */ }
|
|
59
|
+
return fresh;
|
|
60
|
+
}
|
|
61
|
+
function readPin(p) {
|
|
62
|
+
try {
|
|
63
|
+
const existing = readFileSync(p, 'utf8').trim();
|
|
64
|
+
if (PIN_RE.test(existing)) return existing;
|
|
65
|
+
} catch { /* 无文件 */ }
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 生成 8 位访问 PIN(issue #90)。
|
|
70
|
+
* 必须用 CSPRNG。语言内置的非加密随机数(V8 的 xorshift128+)是可预测的:拿到少量
|
|
71
|
+
* 输出即可还原内部状态并推算后续值——用它生成访问密码,等于把 9×10⁷ 的搜索空间
|
|
72
|
+
* 进一步压缩。`randomInt(min, max)` 上界开区间,取值 10000000..99999999。
|
|
73
|
+
* test/auth-routing.test.js 有源码守卫,禁止这里再出现非加密随机数调用。
|
|
74
|
+
*/
|
|
75
|
+
function newPin() {
|
|
76
|
+
return String(randomInt(10_000_000, 100_000_000));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- 公网密码 ---
|
|
80
|
+
const tokenRel = join('dsh-pocket', 'token');
|
|
81
|
+
function tokenPath() {
|
|
82
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), tokenRel);
|
|
83
|
+
}
|
|
84
|
+
/** 当前公网访问密码;文件不存在时生成一个。 */
|
|
85
|
+
export function getAccessToken() {
|
|
86
|
+
return readPin(tokenPath()) ?? writePinToFile(tokenPath(), newPin());
|
|
87
|
+
}
|
|
88
|
+
/** 轮换公网密码(开启公网时调用)。用户自定义后不再轮换(尊重自定义值)。 */
|
|
89
|
+
export function rotateAccessToken() {
|
|
90
|
+
if (pinCustom('public')) return getAccessToken();
|
|
91
|
+
return writePinToFile(tokenPath(), newPin());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- 局域网密码(issue #18:局域网与公网分开,手动刷新) ---
|
|
95
|
+
const lanTokenRel = join('dsh-pocket', 'token-lan');
|
|
96
|
+
function lanTokenPath() {
|
|
97
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), lanTokenRel);
|
|
98
|
+
}
|
|
99
|
+
/** 当前局域网访问密码;文件不存在时生成一个。 */
|
|
100
|
+
function getLanToken() {
|
|
101
|
+
return readPin(lanTokenPath()) ?? writePinToFile(lanTokenPath(), newPin());
|
|
102
|
+
}
|
|
103
|
+
/** 刷新局域网密码(设置页按钮触发,旧密码立即作废);换新后清除自定义标记。 */
|
|
104
|
+
function refreshLanToken() {
|
|
105
|
+
setPinCustom('lan', false);
|
|
106
|
+
return writePinToFile(lanTokenPath(), newPin());
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 按 Host 分发的访问密码(issue #66:fail closed)。
|
|
110
|
+
* 公网 = classifyHost 判为 public 的 Host(trycloudflare **或用户自建命名隧道的任意固定域名**),
|
|
111
|
+
* 一律校验公网密码;loopback/局域网(私网 IP、.local 等)才走局域网密码——
|
|
112
|
+
* 自建隧道 + 关闭局域网密码不再出现公网裸奔。
|
|
113
|
+
*/
|
|
114
|
+
export function tokenForHost(host) {
|
|
115
|
+
if (isLanOverrideHost(host)) return getLanToken();
|
|
116
|
+
return classifyHost(host) === 'public' ? getAccessToken() : getLanToken();
|
|
117
|
+
}
|
|
118
|
+
/** 去掉 Host 的端口(与 classifyHost 一致),用于和手动设置的局域网地址比较。 */
|
|
119
|
+
function hostNameOnly(host) {
|
|
120
|
+
let name = String(host ?? '').trim().toLowerCase();
|
|
121
|
+
if (name.startsWith('[')) {
|
|
122
|
+
const end = name.indexOf(']');
|
|
123
|
+
if (end >= 0) name = name.slice(1, end);
|
|
124
|
+
} else {
|
|
125
|
+
name = name.replace(/:\d+$/, '');
|
|
126
|
+
}
|
|
127
|
+
return name;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 用户手动指定的「局域网地址」覆盖(issue #79):无论什么网段(含 Radmin 26.x 等任意 overlay),
|
|
131
|
+
* 一律按局域网密码/开关裁决。优先级高于 classifyHost——避免某 overlay 地址落在公网判定里、
|
|
132
|
+
* 导致局域网入口被错判成公网、比对成公网密码而永远「密码错误」。
|
|
133
|
+
*/
|
|
134
|
+
export function isLanOverrideHost(host) {
|
|
135
|
+
const override = lanIpOverride().trim().toLowerCase();
|
|
136
|
+
return override.length > 0 && hostNameOnly(host) === override;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 用户自定义访问密码(issue #33):公网/局域网各自设一个固定的 8 位密码(英文字母大小写或数字)。
|
|
140
|
+
* 自定义后公网开启时不再自动轮换(rotateAccessToken 见上)。
|
|
141
|
+
* 非法输入(非 8 位、含字母数字以外字符)抛错,由 RPC 层转成错误响应。
|
|
142
|
+
*/
|
|
143
|
+
export function setCustomPin(which, value) {
|
|
144
|
+
const v = String(value ?? '').trim();
|
|
145
|
+
if (!PIN_RE.test(v)) throw new Error('密码必须是 8 位英文字母或数字 | PIN must be exactly 8 characters (letters and digits only)');
|
|
146
|
+
if (which === 'public') {
|
|
147
|
+
writePinToFile(tokenPath(), v);
|
|
148
|
+
setPinCustom('public', true);
|
|
149
|
+
return v;
|
|
150
|
+
}
|
|
151
|
+
if (which === 'lan') {
|
|
152
|
+
writePinToFile(lanTokenPath(), v);
|
|
153
|
+
setPinCustom('lan', true);
|
|
154
|
+
return v;
|
|
155
|
+
}
|
|
156
|
+
throw new Error('未知密码类型 | unknown PIN kind');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 恢复出厂设置(设置页底部按钮):清空本机设置 + 重设随机访问密码。
|
|
161
|
+
*
|
|
162
|
+
* 删除 settings.json → 所有开关回到默认(局域网访问开、访问密码开、局域网地址自动、
|
|
163
|
+
* 公网模式随机、固定域名/Tunnel Token 清空);公网与局域网密码都换新随机 8 位值
|
|
164
|
+
* (旧密码立即作废,手机需重新输入)。只动 $DSH_HOME/dsh-pocket/ 下的文件,
|
|
165
|
+
* DSH 自身的会话、模型、插件配置不受影响。
|
|
166
|
+
*
|
|
167
|
+
* 注意:调用方应先停掉正在运行的公网隧道(RPC 层已处理),避免重置后仍按旧模式连接。
|
|
168
|
+
* @returns {{accessToken:string, lanToken:string}} 重置后的新密码
|
|
169
|
+
*/
|
|
170
|
+
export function resetPocketState() {
|
|
171
|
+
resetSettings();
|
|
172
|
+
writePinToFile(tokenPath(), newPin());
|
|
173
|
+
writePinToFile(lanTokenPath(), newPin());
|
|
174
|
+
return { accessToken: getAccessToken(), lanToken: getLanToken() };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const restartNoticeRel = join('dsh-pocket', 'restarted.json');
|
|
178
|
+
function restartNoticePath() {
|
|
179
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), restartNoticeRel);
|
|
180
|
+
}
|
|
181
|
+
async function readRestartNotice() {
|
|
182
|
+
try {
|
|
183
|
+
const raw = JSON.parse(await readFile(restartNoticePath(), 'utf8'));
|
|
184
|
+
if (!raw?.at) return null;
|
|
185
|
+
if (Date.now() - raw.at > 30 * 60 * 1000) return null; // 30 分钟后过期
|
|
186
|
+
return raw;
|
|
187
|
+
} catch { return null; }
|
|
188
|
+
}
|
|
189
|
+
function writeRestartNotice() {
|
|
190
|
+
return mkdir(dirname(restartNoticePath()), { recursive: true })
|
|
191
|
+
.then(() => writeFile(restartNoticePath(), JSON.stringify({ at: Date.now(), pid: process.pid }), 'utf8'));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 读重启标记并**删除**(一次性消费):重启后首次打开设置页显示一次「已重启」横幅,
|
|
195
|
+
* 之后不再出现——否则残留文件会让「已重启」一直显示(用户没点重启也误报)。
|
|
196
|
+
*/
|
|
197
|
+
async function consumeRestartNotice() {
|
|
198
|
+
const notice = await readRestartNotice();
|
|
199
|
+
if (notice) {
|
|
200
|
+
await rm(restartNoticePath(), { force: true }).catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
return notice;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 自重启。
|
|
206
|
+
* 顺序很重要:先拉起 helper(失败就如实返回,不写标记、不停隧道)→ 停公网隧道
|
|
207
|
+
* (否则孤儿 cloudflared 让旧公网 URL 永活,与「重启即换 URL 作废」的宣传矛盾)→
|
|
208
|
+
* 写重启标记(新进程据此显示一次「已重启」横幅)。
|
|
209
|
+
*/
|
|
210
|
+
function pocketRestart(service) {
|
|
211
|
+
const result = restartHost();
|
|
212
|
+
if (!result || result.helperPid == null) return result; // helper 都没 spawn 出来 → 失败
|
|
213
|
+
// 停隧道是为了杀孤儿 cloudflared;保留自动恢复标记——重启后隧道要自动拉起(issue #11)
|
|
214
|
+
try { service?.stopTunnel({ keepAutoMarker: true }); } catch { /* 忽略 */ }
|
|
215
|
+
writeRestartNotice().catch(() => {});
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 执行更新:dsh plugin --profile <p> update dsh-pocket-relay --latest -w(超时保护)。 */
|
|
220
|
+
function performUpdate(profile, { timeoutMs = 180_000 } = {}) {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const child = spawn('dsh', ['plugin', '--profile', profile, 'update', 'dsh-pocket-relay', '--latest', '-w'], {
|
|
223
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
224
|
+
// Windows 上裸 spawn('dsh') 解析到无扩展名 POSIX shim → ENOENT;
|
|
225
|
+
// Node 22+ 直接 spawn .cmd 会 EINVAL(CVE-2024-27980),必须走 shell(PR #54)
|
|
226
|
+
shell: process.platform === 'win32',
|
|
227
|
+
});
|
|
228
|
+
let out = '';
|
|
229
|
+
const onData = (c) => { out += String(c); if (out.length > 4000) out = out.slice(-4000); };
|
|
230
|
+
child.stdout.on('data', onData);
|
|
231
|
+
child.stderr.on('data', onData);
|
|
232
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
233
|
+
child.once('exit', (code) => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
resolve({ ok: code === 0, code, output: out.slice(-800) });
|
|
236
|
+
});
|
|
237
|
+
child.once('error', (err) => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
resolve({ ok: false, error: err.message });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function apply(ctx, config = {}, internals = {}) {
|
|
245
|
+
const logger = ctx.logger?.(name) ?? console;
|
|
246
|
+
const dshPort = internals.dshPort ?? ctx.webServer?.port;
|
|
247
|
+
if (!dshPort) {
|
|
248
|
+
logger.error('dsh-pocket-relay: webServer port unavailable — cannot start proxy | 拿不到 dsh web 端口,无法启动代理');
|
|
249
|
+
return () => {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// cloudflared 自定义路径(issue #45):远程 Linux 服务器下载源全失败时,
|
|
253
|
+
// 让用户能在 settings.json 写死一个已经存在的二进制路径,绕过自动下载。
|
|
254
|
+
// 同步到 env 让 lib/tunnel.mjs 的 resolveCloudflared 第一时间读到。
|
|
255
|
+
const cfPath = cloudflaredPath();
|
|
256
|
+
if (cfPath) {
|
|
257
|
+
process.env.DSH_POCKET_CLOUDFLARED = cfPath;
|
|
258
|
+
logger.info(`dsh-pocket-relay: using custom cloudflared at ${cfPath} (skipping auto-download) | 使用自定义 cloudflared 路径(跳过自动下载)`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 桌面端环境识别(官方兼容模式,见 desktop 的 plugin-development.md):
|
|
262
|
+
// desktopProfiles / desktopPnpm 只在 DSH Desktop(Electron)里存在。
|
|
263
|
+
// 桌面端有自己的更新/进程管理,我们这两项功能在此环境**关闭**(不删除),
|
|
264
|
+
// 避免与 desktopPnpm / Electron 进程模型冲突;扫码同屏等正常功能照常。
|
|
265
|
+
const isDesktop = internals.isDesktop !== undefined
|
|
266
|
+
? internals.isDesktop === true
|
|
267
|
+
: ctx.get?.('desktopProfiles') !== undefined || ctx.get?.('desktopPnpm') !== undefined;
|
|
268
|
+
if (isDesktop) {
|
|
269
|
+
logger.info('dsh-pocket-relay: DSH Desktop detected — update/restart disabled here | 检测到桌面端环境,更新/重启已关闭');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 桌面端 advanced 模式检测(issue #19):dsh-plugin-desktop 的 mode 配置存在
|
|
273
|
+
// $DSH_HOME/settings.yaml 的 dsh-plugin-desktop 命名空间下。advanced 组合禁用网页版
|
|
274
|
+
// ui-layout、手机页面又拿不到桌面 layout → 手机访问白屏,这里注入覆盖层提示用户切回。
|
|
275
|
+
const desktopAdvanced = isDesktop && (() => {
|
|
276
|
+
try {
|
|
277
|
+
const raw = readFileSync(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'settings.yaml'), 'utf8');
|
|
278
|
+
return /dsh-plugin-desktop\s*:\s*[\s\S]{0,300}?mode\s*:\s*advanced/i.test(raw);
|
|
279
|
+
} catch { return false; }
|
|
280
|
+
})();
|
|
281
|
+
if (desktopAdvanced) {
|
|
282
|
+
logger.warn('dsh-pocket-relay: DSH Desktop advanced mode — phone access unsupported, injecting notice | 桌面端 advanced 模式:手机访问暂不支持,已注入提示');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const service = internals.service ?? createPocketService({
|
|
286
|
+
dshPort,
|
|
287
|
+
// 端口(issue #70):插件模式优先级 settings.proxyPort > cordis patch config.port > 3081。
|
|
288
|
+
// 3081 被占时 createPocketService 内部 EADDRINUSE 抛错,按 DSH 插件启动失败处理(用户改端口重启即可)。
|
|
289
|
+
port: internals.port ?? config.port ?? (proxyPort() || 3081),
|
|
290
|
+
home: internals.home,
|
|
291
|
+
internals,
|
|
292
|
+
getLanIpOverride: () => lanIpOverride(),
|
|
293
|
+
getLanEnabled: () => lanEnabled(),
|
|
294
|
+
// 桌面端**不再**注入 dsh-desktop-* 标记(issue #76):
|
|
295
|
+
// - 旧版 dsh-plugin-desktop 缺 mode/platform 会抛错,当初正是为此加了补丁(issue #3/#4);
|
|
296
|
+
// - 但 2.0.3 起,mode 与 platform **同时缺失**时 parseDesktopClientEnvironment 直接返回
|
|
297
|
+
// undefined(视作非桌面外壳,跳过全部桌面逻辑),正是手机页需要的效果;
|
|
298
|
+
// - 反之只要 URL 上出现任一 dsh-desktop-* 标记,客户端就会强制校验整组
|
|
299
|
+
// (material + semver version + mica),只补两个必然抛 "invalid or missing
|
|
300
|
+
// dsh-desktop-material" → 插件树加载失败 → 页面变成「打开恢复模式」;
|
|
301
|
+
// - 更糟的是 decideDesktopBrowserAccess 见到 dsh-desktop-* 前缀就把没有渲染器 token 的
|
|
302
|
+
// 普通浏览器判为 denied(403),刷新后直接打不开。
|
|
303
|
+
// 结论:手机/浏览器访问一律不带这些标记,advanced 模式另加警告覆盖层(issue #19)。
|
|
304
|
+
injectHtml: isDesktop
|
|
305
|
+
? DEFAULT_INJECT + (desktopAdvanced ? advancedNoticeScript() : '')
|
|
306
|
+
: undefined,
|
|
307
|
+
// 访问密码(issue #13 + #18 + #24 + #33 + #66):公网永远要密码(默认每次开启变新,
|
|
308
|
+
// 用户自定义后不再轮换);局域网按开关(默认开启;关闭后局域网扫码直连)。
|
|
309
|
+
// 公网的判定是 fail closed:除了 loopback/私网 Host,一切陌生域名(含自建
|
|
310
|
+
// 命名隧道的固定域名)都按公网处理、强制公网密码。
|
|
311
|
+
auth: {
|
|
312
|
+
// sessionKey:进程级随机密钥——登录 cookie 绑定它(会话保持,issue #33):
|
|
313
|
+
// dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入
|
|
314
|
+
sessionKey: randomBytes(16).toString('hex'),
|
|
315
|
+
getToken: (host) => tokenForHost(host),
|
|
316
|
+
isProtected: (host) => {
|
|
317
|
+
if (isLanOverrideHost(host)) return lanAuthEnabled();
|
|
318
|
+
return classifyHost(host) === 'public' ? true : lanAuthEnabled();
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
// dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
|
|
322
|
+
// 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
|
|
323
|
+
// 所以每次请求实时从 connection 服务取;老版本没有这个方法 → 返回空,行为不变。
|
|
324
|
+
launchToken: () => {
|
|
325
|
+
try {
|
|
326
|
+
const fn = ctx.connection?.authenticatedUrl;
|
|
327
|
+
if (typeof fn !== 'function') return '';
|
|
328
|
+
const url = new URL(fn.call(ctx.connection, `http://127.0.0.1:${dshPort}`));
|
|
329
|
+
return url.searchParams.get('token') ?? '';
|
|
330
|
+
} catch {
|
|
331
|
+
return '';
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
// 隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
|
|
335
|
+
getTunnelConfig: () => ({ mode: tunnelMode(), token: tunnelToken(), hostname: tunnelHostname() }),
|
|
336
|
+
// 中继配置:service 启动时常驻连接(「无人监听」靠它)
|
|
337
|
+
getRelayConfig: () => relayConfig(),
|
|
338
|
+
// 隧道就绪回调:快速模式每次开启轮换 8 位密码(旧链接作废);命名模式地址固定,
|
|
339
|
+
// 不自动轮换(重启后密码不变,用户可用「自定义密码」主动更换)。
|
|
340
|
+
onTunnelReady: (mode) => {
|
|
341
|
+
if (mode === 'named') {
|
|
342
|
+
logger.info('dsh-pocket-relay: named tunnel ready — PIN not rotated | 命名隧道就绪(公网密码不自动轮换)');
|
|
343
|
+
return getAccessToken();
|
|
344
|
+
}
|
|
345
|
+
const fresh = rotateAccessToken();
|
|
346
|
+
logger.info('dsh-pocket-relay: public access PIN refreshed | 公网访问密码已更新(自定义密码不受影响)');
|
|
347
|
+
return fresh;
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const disposers = [];
|
|
352
|
+
const disposeRpc = installPocketRpc(ctx, {
|
|
353
|
+
service,
|
|
354
|
+
desktop: isDesktop,
|
|
355
|
+
getToken: () => getAccessToken(),
|
|
356
|
+
getLanToken: () => getLanToken(),
|
|
357
|
+
refreshLanToken: () => refreshLanToken(),
|
|
358
|
+
getLanAuthEnabled: () => lanAuthEnabled(),
|
|
359
|
+
setLanAuthEnabled: (on) => setLanAuthEnabled(on),
|
|
360
|
+
getLanEnabled: () => lanEnabled(),
|
|
361
|
+
setLanEnabled: (on) => setLanEnabled(on),
|
|
362
|
+
getLanIpOverride: () => lanIpOverride(),
|
|
363
|
+
setLanIpOverride: (ip) => setLanIpOverride(ip),
|
|
364
|
+
getPinCustom: (which) => pinCustom(which),
|
|
365
|
+
setCustomPin: (which, value) => setCustomPin(which, value),
|
|
366
|
+
// 命名隧道配置(issue #66):RPC 只回显脱敏视图(token 只写不读)
|
|
367
|
+
getTunnelConfig: () => ({ mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 }),
|
|
368
|
+
// 中继(自建服务器):这里给的是**完整**配置(含 token),
|
|
369
|
+
// 因为 relayView() 要据它算 tokenSet、applyRelayState() 要据它判配置是否完整;
|
|
370
|
+
// 对外回显一律走 web-rpc 的 relayView() 脱敏,token 永不进响应体。
|
|
371
|
+
getRelayConfig: () => relayConfig(),
|
|
372
|
+
setRelayConfig: (patch) => setRelayConfig(patch),
|
|
373
|
+
// 恢复出厂设置:清空设置文件 + 重设随机密码(RPC 层已先停掉公网隧道)
|
|
374
|
+
resetPocket: () => resetPocketState(),
|
|
375
|
+
setTunnelConfig: ({ mode, hostname, token } = {}) => {
|
|
376
|
+
const m = mode === undefined ? tunnelMode() : mode;
|
|
377
|
+
if (m !== 'quick' && m !== 'named') throw new Error('未知隧道模式 | unknown tunnel mode');
|
|
378
|
+
setTunnelMode(m);
|
|
379
|
+
if (hostname !== undefined) setTunnelHostname(hostname);
|
|
380
|
+
if (token !== undefined) setTunnelToken(token);
|
|
381
|
+
return { mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 };
|
|
382
|
+
},
|
|
383
|
+
runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
|
|
384
|
+
restart: internals.restart ?? (() => pocketRestart(service)),
|
|
385
|
+
restartNotice: internals.restartNotice ?? consumeRestartNotice,
|
|
386
|
+
log: logger,
|
|
387
|
+
});
|
|
388
|
+
disposers.push(disposeRpc);
|
|
389
|
+
|
|
390
|
+
// 代理随插件自动启动(局域网二维码开箱即用,零配置)
|
|
391
|
+
void service.startProxy().then((proxy) => {
|
|
392
|
+
logger.info('dsh-pocket-relay: proxy ready on :%d | 局域网代理已就绪', proxy.port);
|
|
393
|
+
// 自动恢复上次开启的公网隧道(DSH 重启后 cloudflared 子进程被杀,issue #11)
|
|
394
|
+
void service.restoreTunnelIfNeeded?.().catch(() => {});
|
|
395
|
+
// 中继:配置好就直接连,**不等用户点任何按钮**——这就是「无人监听」。
|
|
396
|
+
// 放在 startProxy 之后:relay 的流量要打到代理上,代理必须先就绪。
|
|
397
|
+
// 失败只记日志、不抛出:中继连不上不该拖垮整个插件(局域网照常可用)。
|
|
398
|
+
const rc = relayConfig();
|
|
399
|
+
if (rc.enabled && rc.host && rc.port && rc.token) {
|
|
400
|
+
void service.startRelay().catch((err) => {
|
|
401
|
+
logger.warn('dsh-pocket-relay: relay start failed | 中继启动失败: %s', err?.message ?? err);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}).catch((err) => {
|
|
405
|
+
logger.error('dsh-pocket-relay: proxy start failed | 代理启动失败: %s', err?.message ?? err);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
ctx.effect(() => async () => {
|
|
409
|
+
for (const d of disposers.reverse()) { try { d(); } catch { /* 忽略 */ } }
|
|
410
|
+
await service.dispose();
|
|
411
|
+
}, 'dsh-pocket-relay: stop proxy and tunnel');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export { name, inject, readRestartNotice, consumeRestartNotice };
|
package/lib/ip.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Small shared helpers for the optional LAN IP override.
|
|
2
|
+
|
|
3
|
+
/** @param {unknown} value
|
|
4
|
+
* @returns {boolean} */
|
|
5
|
+
export function isValidIpv4(value) {
|
|
6
|
+
const m = String(value ?? '').match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
7
|
+
return m !== null && m.slice(1).every((part) => {
|
|
8
|
+
const n = Number(part);
|
|
9
|
+
return n >= 0 && n <= 255;
|
|
10
|
+
});
|
|
11
|
+
}
|