dsh-remote-web 0.5.0 → 0.6.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/lib/client.js +1134 -120
- package/lib/index.js +559 -39
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-web
|
|
1
|
+
// dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-ui 更名 dsh-remote-web;卸载/清理兼容旧名)
|
|
2
2
|
//
|
|
3
|
-
// 提供 /dsh-remote/* 同源 HTTP
|
|
4
|
-
// -
|
|
3
|
+
// 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的「远程访问」设置面板调用:
|
|
4
|
+
// - 读写配置目录下 .dsh-config.json(0600)
|
|
5
5
|
// - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
|
|
6
|
-
// - 代理 relay API(captcha / register / login / public-config
|
|
6
|
+
// - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理;
|
|
7
|
+
// 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke、
|
|
8
|
+
// DELETE …/:id、POST …/purge,Bearer)供面板「📱 远程访问」卡使用
|
|
7
9
|
// - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
|
|
8
|
-
// 面板内即官方管理入口;更新=后台 npx
|
|
10
|
+
// 面板内即官方管理入口;更新=后台 npx 按 dist-tag(默认 latest,DSH_UPDATE_TAG 可切 beta/alpha)(幂等补齐运行环境并重启 bridge);
|
|
9
11
|
// 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
|
|
10
12
|
// 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
|
|
11
13
|
// - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
|
|
16
18
|
import { join, dirname, sep } from "node:path";
|
|
17
19
|
import { execSync, spawn } from "node:child_process";
|
|
18
|
-
import { homedir,
|
|
20
|
+
import { homedir, platform } from "node:os";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
20
22
|
|
|
21
23
|
/** 本插件在 host 侧的服务依赖。 */
|
|
@@ -378,7 +380,7 @@ function ensureRuntime(relayDir) {
|
|
|
378
380
|
try {
|
|
379
381
|
mkdirSync(relayDir, { recursive: true });
|
|
380
382
|
const log = join(relayDir, AUTO_INSTALL_LOG);
|
|
381
|
-
const child = spawn(npxCommand(), ["--yes",
|
|
383
|
+
const child = spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
|
|
382
384
|
detached: true,
|
|
383
385
|
env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org" }),
|
|
384
386
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
@@ -610,15 +612,135 @@ async function relayFetch(relayDir, pathname, init) {
|
|
|
610
612
|
}
|
|
611
613
|
}
|
|
612
614
|
|
|
613
|
-
// ----------
|
|
615
|
+
// ---------- bridge_secret 自愈(device-login 共享密钥) ----------
|
|
614
616
|
|
|
615
|
-
/**
|
|
616
|
-
|
|
617
|
-
|
|
617
|
+
/**
|
|
618
|
+
* 企业端 POST /api/device-login 强制校验 `x-dsh-bridge-secret`(缺失/失效 → 401 "需要有效设备密钥"),
|
|
619
|
+
* 密钥由服务端公开配置 /api/public-config 下发、与 dsh-setup.mjs 的 `bridge_secret` 同源。
|
|
620
|
+
*
|
|
621
|
+
* 一键安装器(npx @mrrisega/dsh-remote → dsh-setup.mjs)会在安装时取一次并写入 .dsh-config.json;
|
|
622
|
+
* 但**只装插件**的路径(dsh plugin add / 插件市场安装)没有这一步,于是:
|
|
623
|
+
* 首次安装 → 打开设置面板立即登录 → 面板请求 /dsh-remote/access-key & mobile-sessions
|
|
624
|
+
* → relayToken 拿不到 token(401 需要设备密钥)→ 面板显示「尚未登录」(其实是密钥缺失)
|
|
625
|
+
* 直到后台自愈(scheduleRuntime → ensureRuntime 跑一次 npx 安装器)把 bridge_secret 写回配置,
|
|
626
|
+
* 或用户手动刷新页面才恢复——这正是「首次安装后立即登录,二维码/设备列表报红字」的根因。
|
|
627
|
+
*
|
|
628
|
+
* 这里在插件侧补上同一份自愈:缺密钥就取一次、落盘并缓存;失败短退避后可再试,不阻塞面板。
|
|
629
|
+
*/
|
|
630
|
+
let bridgeSecretCache = { dir: "", secret: "", failedAt: 0 };
|
|
631
|
+
const BRIDGE_SECRET_RETRY_MS = 15_000;
|
|
632
|
+
|
|
633
|
+
/** 读取配置里已保存的 bridge_secret(无则空串)。 */
|
|
634
|
+
function readBridgeSecret(relayDir) {
|
|
635
|
+
try {
|
|
636
|
+
return String(loadConfig(relayDir).bridge_secret || "").trim();
|
|
637
|
+
} catch {
|
|
638
|
+
return "";
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* 取 device-login 共享密钥:配置已有 → 直接用;否则向企业端公开配置取一次并写回配置(内存缓存兜底)。
|
|
644
|
+
* @param {string} relayDir 配置目录
|
|
645
|
+
* @param {object} [cfgOverride] 尚未落盘的配置(登录请求刚写入 phone/password 时用),仅用于判断模式
|
|
646
|
+
* @param {{force?: boolean, timeoutMs?: number}} [opts] force=true 跳过本地文件/缓存强制重取;
|
|
647
|
+
* timeoutMs 限制单次公开配置请求耗时(登录路径用更短的上限,避免拖慢登录响应)
|
|
648
|
+
* @returns {Promise<string>} 密钥(取不到为空串,调用方按“中继未就绪”降级,不抛错)
|
|
649
|
+
*/
|
|
650
|
+
async function bridgeSecretOf(relayDir, cfgOverride, opts) {
|
|
651
|
+
const force = !!(opts && opts.force);
|
|
652
|
+
if (!force) {
|
|
653
|
+
const local = readBridgeSecret(relayDir);
|
|
654
|
+
if (local) {
|
|
655
|
+
bridgeSecretCache = { dir: relayDir, secret: local, failedAt: 0 };
|
|
656
|
+
return local;
|
|
657
|
+
}
|
|
658
|
+
if (bridgeSecretCache.dir === relayDir && bridgeSecretCache.secret) return bridgeSecretCache.secret;
|
|
659
|
+
if (bridgeSecretCache.dir === relayDir && Date.now() - bridgeSecretCache.failedAt < BRIDGE_SECRET_RETRY_MS) return ""; // 负缓存:别把公开配置打爆
|
|
660
|
+
}
|
|
661
|
+
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
662
|
+
if (cfg.local_key) return ""; // 自建模式走 /_login,不用设备密钥
|
|
618
663
|
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
664
|
+
const timeoutMs = Number((opts && opts.timeoutMs) || 0) > 0 ? Number(opts.timeoutMs) : 6000;
|
|
665
|
+
let secret = "";
|
|
666
|
+
try {
|
|
667
|
+
const r = await fetch(`${api}/api/public-config`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
668
|
+
if (r.ok) {
|
|
669
|
+
const d = await r.json();
|
|
670
|
+
secret = String((d && d.bridge_secret) || "").trim();
|
|
671
|
+
}
|
|
672
|
+
} catch { /* 网络抖动:负缓存短退避后再试 */ }
|
|
673
|
+
if (!secret) {
|
|
674
|
+
bridgeSecretCache = { dir: relayDir, secret: "", failedAt: Date.now() };
|
|
675
|
+
return "";
|
|
676
|
+
}
|
|
677
|
+
bridgeSecretCache = { dir: relayDir, secret, failedAt: 0 };
|
|
678
|
+
// 落盘(与 dsh-setup.mjs 同一份配置):即使本进程退出,下次启动也不再缺密钥/不再是旧密钥
|
|
679
|
+
let healed = false;
|
|
680
|
+
try {
|
|
681
|
+
const cur = loadConfig(relayDir);
|
|
682
|
+
if (!cur.bridge_secret || (force && cur.bridge_secret !== secret)) {
|
|
683
|
+
cur.bridge_secret = secret;
|
|
684
|
+
saveConfig(relayDir, cur);
|
|
685
|
+
healed = true;
|
|
686
|
+
}
|
|
687
|
+
} catch { /* 写盘失败不致命:内存缓存本次进程内仍生效 */ }
|
|
688
|
+
// 密钥是「从无到有」补上的:已经在跑的 bridge 是用空密钥起的,隧道认证同样会失败——
|
|
689
|
+
// 后台重启一次让它带上密钥(一次性事件,失败只记日志不打断面板请求)。
|
|
690
|
+
if (healed && !UNINSTALLED_DIRS.has(relayDir)) {
|
|
691
|
+
setTimeout(() => {
|
|
692
|
+
try {
|
|
693
|
+
const st = launchdStatus();
|
|
694
|
+
if (!st.running) return;
|
|
695
|
+
const r = startBridge(relayDir);
|
|
696
|
+
console.log(`[dsh-remote-web] 已补记 bridge_secret,后台重启 bridge 使其生效: ${r.status}`);
|
|
697
|
+
} catch (e) {
|
|
698
|
+
console.warn(`[dsh-remote-web] 补记 bridge_secret 后重启 bridge 失败: ${e.message}`);
|
|
699
|
+
}
|
|
700
|
+
}, 50).unref?.();
|
|
701
|
+
}
|
|
702
|
+
return secret;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
|
|
706
|
+
|
|
707
|
+
/** token 获取失败的原因(供 UI 区分“未登录”与“中继未就绪”,不再一律谎报“尚未登录”)。 */
|
|
708
|
+
const AUTH_NO_CREDENTIALS = "no_credentials"; // 本机没存账号/自建密钥 → 该去登录
|
|
709
|
+
const AUTH_BAD_CREDENTIALS = "bad_credentials"; // 账号密码被企业端拒绝 → 该重新登录
|
|
710
|
+
const AUTH_RELAY_UNREACHABLE = "relay_unreachable"; // 网络/5xx/超时 → 稍后重试
|
|
711
|
+
const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端未接受(缺失/轮换中的设备密钥等)→ 稍后重试
|
|
712
|
+
|
|
713
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
714
|
+
|
|
715
|
+
/** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
|
|
716
|
+
async function deviceLoginOnce(api, cfg, secret) {
|
|
717
|
+
try {
|
|
718
|
+
const r = await fetch(`${api}/api/device-login`, {
|
|
719
|
+
method: "POST",
|
|
720
|
+
headers: { "content-type": "application/json", ...(secret ? { "x-dsh-bridge-secret": secret } : {}) },
|
|
721
|
+
body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
|
|
722
|
+
signal: AbortSignal.timeout(6000)
|
|
723
|
+
});
|
|
724
|
+
const text = await r.text();
|
|
725
|
+
let d = null;
|
|
726
|
+
try { d = JSON.parse(text); } catch { d = null; }
|
|
727
|
+
return {
|
|
728
|
+
ok: r.ok,
|
|
729
|
+
status: r.status,
|
|
730
|
+
token: (d && d.token) || "",
|
|
731
|
+
code: (d && d.error && d.error.code) || "",
|
|
732
|
+
detail: (d && d.error && (d.error.message || d.error.code)) || "",
|
|
733
|
+
};
|
|
734
|
+
} catch (e) {
|
|
735
|
+
return { ok: false, status: 0, token: "", code: "", detail: e.message };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** 单次本地认证(自建模式):POST {隧道同源}/_login。 */
|
|
740
|
+
async function localLoginOnce(cfg, api) {
|
|
741
|
+
const fallbackApi = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
742
|
+
try {
|
|
743
|
+
const u = new URL(cfg.tunnel_url || fallbackApi.replace(/^https?/, "wss"));
|
|
622
744
|
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
623
745
|
u.pathname = "/_login";
|
|
624
746
|
const r = await fetch(u.toString(), {
|
|
@@ -627,20 +749,59 @@ async function relayToken(relayDir) {
|
|
|
627
749
|
body: JSON.stringify({ key: cfg.local_key }),
|
|
628
750
|
signal: AbortSignal.timeout(6000)
|
|
629
751
|
});
|
|
630
|
-
if (!r.ok) return "";
|
|
752
|
+
if (!r.ok) return { ok: false, status: r.status, token: "", code: "", detail: "" };
|
|
631
753
|
const d = await r.json();
|
|
632
|
-
return d.token || "";
|
|
754
|
+
return { ok: true, status: 200, token: (d && d.token) || "", code: "", detail: "" };
|
|
755
|
+
} catch (e) {
|
|
756
|
+
return { ok: false, status: 0, token: "", code: "", detail: e.message };
|
|
633
757
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。
|
|
762
|
+
* 与旧版差异:① 缺 device-login 共享密钥时自愈补齐;② 服务端轮换密钥(401/403)自动重取重试;
|
|
763
|
+
* ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
|
|
764
|
+
* @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
|
|
765
|
+
*/
|
|
766
|
+
async function relayTokenWithReason(relayDir, cfgOverride) {
|
|
767
|
+
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
768
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
769
|
+
if (cfg.local_key) {
|
|
770
|
+
let r = await localLoginOnce(cfg, api);
|
|
771
|
+
if (!r.token && (r.status === 0 || r.status >= 500)) { await sleep(350); r = await localLoginOnce(cfg, api); }
|
|
772
|
+
if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
|
|
773
|
+
return {
|
|
774
|
+
token: "",
|
|
775
|
+
reason: r.status === 401 || r.status === 403 ? AUTH_BAD_CREDENTIALS : AUTH_RELAY_UNREACHABLE,
|
|
776
|
+
status: r.status,
|
|
777
|
+
detail: r.detail,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (!cfg.phone || !cfg.password) return { token: "", reason: AUTH_NO_CREDENTIALS, status: 0, detail: "" };
|
|
781
|
+
|
|
782
|
+
let secret = await bridgeSecretOf(relayDir, cfgOverride);
|
|
783
|
+
let r = await deviceLoginOnce(api, cfg, secret);
|
|
784
|
+
// 带了密钥仍被拒 → 可能是服务端轮换了共享密钥:丢弃缓存强制重取一次再试
|
|
785
|
+
if (!r.token && (r.status === 401 || r.status === 403) && secret) {
|
|
786
|
+
const fresh = await bridgeSecretOf(relayDir, cfgOverride, { force: true });
|
|
787
|
+
if (fresh && fresh !== secret) r = await deviceLoginOnce(api, cfg, fresh);
|
|
788
|
+
}
|
|
789
|
+
// 网络抖动/超时/5xx:短退避重试一次(首次安装后中继握手偶发失败很常见)
|
|
790
|
+
if (!r.token && (r.status === 0 || r.status >= 500)) {
|
|
791
|
+
await sleep(350);
|
|
792
|
+
r = await deviceLoginOnce(api, cfg, await bridgeSecretOf(relayDir, cfgOverride));
|
|
793
|
+
}
|
|
794
|
+
if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
|
|
795
|
+
if (r.code === "bad_credentials") return { token: "", reason: AUTH_BAD_CREDENTIALS, status: r.status, detail: r.detail };
|
|
796
|
+
// 凭证齐全但企业端没给 token(设备密钥缺失/轮换中、限流、其它 4xx)→ 可重试,不谎报“未登录”
|
|
797
|
+
if (r.status === 0 || r.status >= 500) return { token: "", reason: AUTH_RELAY_UNREACHABLE, status: r.status, detail: r.detail };
|
|
798
|
+
return { token: "", reason: AUTH_RELAY_NOT_READY, status: r.status, detail: r.detail };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 兼容旧调用方(反馈/账号/配额等只需 token 字符串)。 */
|
|
802
|
+
async function relayToken(relayDir) {
|
|
803
|
+
const r = await relayTokenWithReason(relayDir).catch(() => ({ token: "" }));
|
|
804
|
+
return r.token || "";
|
|
644
805
|
}
|
|
645
806
|
|
|
646
807
|
/** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
|
|
@@ -651,7 +812,7 @@ async function relayAccount(relayDir) {
|
|
|
651
812
|
if (!r.ok || !r.body || !r.body.user) return null;
|
|
652
813
|
const u = r.body.user;
|
|
653
814
|
return {
|
|
654
|
-
phone: u.phone || "",
|
|
815
|
+
phone: maskPhone(u.phone || ""),
|
|
655
816
|
plan: u.plan || "free",
|
|
656
817
|
plan_source: u.plan_source || "plan",
|
|
657
818
|
plan_ends_at: u.plan_ends_at ?? null,
|
|
@@ -698,8 +859,270 @@ async function relayInviteRecords(relayDir) {
|
|
|
698
859
|
return { records: r.body.records || [], rewards: r.body.rewards || [] };
|
|
699
860
|
}
|
|
700
861
|
|
|
862
|
+
// ---------- 交流群二维码(运营配置,公开读取) ----------
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 交流群二维码/客服微信:企业端配置经公开配置 /api/public-config 的 community 字段下发
|
|
866
|
+
* (community.qrcode 形如 "/qr/group-qr.png")。面板与反馈页展示「加入交流群」用。
|
|
867
|
+
*
|
|
868
|
+
* 面板跑在电脑本机 dsh web(http://127.0.0.1:3080),相对路径无法直接当 <img src>,
|
|
869
|
+
* 故这里按企业端公开约定拼成绝对地址:<api_url><qrcode>(与手机端 PWA 的 API_BASE + qr 一致)。
|
|
870
|
+
* 30s 内存缓存:面板与反馈卡都会取,避免重复打公开配置。
|
|
871
|
+
*/
|
|
872
|
+
const COMMUNITY_TTL_MS = 30_000;
|
|
873
|
+
let communityCache = { dir: "", at: 0, data: null };
|
|
874
|
+
|
|
875
|
+
async function communityInfo(relayDir) {
|
|
876
|
+
if (communityCache.data && communityCache.dir === relayDir && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
|
|
877
|
+
return communityCache.data;
|
|
878
|
+
}
|
|
879
|
+
const cfg = loadConfig(relayDir);
|
|
880
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
881
|
+
const r = await relayFetch(relayDir, "/api/public-config");
|
|
882
|
+
const b = r.ok && r.body && typeof r.body === "object" ? r.body : {};
|
|
883
|
+
const c = b.community && typeof b.community === "object" ? b.community : {};
|
|
884
|
+
const raw = String(c.qrcode || "").trim();
|
|
885
|
+
const qrcode = raw && !/^https?:/i.test(raw) ? api + (raw.startsWith("/") ? raw : `/${raw}`) : raw;
|
|
886
|
+
const data = { qrcode, wechat: String(c.wechat || "") };
|
|
887
|
+
communityCache = { dir: relayDir, at: Date.now(), data };
|
|
888
|
+
return data;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
|
|
892
|
+
|
|
893
|
+
/** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
|
|
894
|
+
function relayErrorMessage(r) {
|
|
895
|
+
const b = r && typeof r === "object" ? r.body : null;
|
|
896
|
+
if (b && typeof b === "object") {
|
|
897
|
+
if (typeof b.error === "string" && b.error) return b.error;
|
|
898
|
+
if (b.error && typeof b.error === "object") {
|
|
899
|
+
if (typeof b.error.message === "string" && b.error.message) return b.error.message;
|
|
900
|
+
}
|
|
901
|
+
if (typeof b.message === "string" && b.message) return b.message;
|
|
902
|
+
}
|
|
903
|
+
if (typeof b === "string" && b.trim()) return b.trim();
|
|
904
|
+
const st = r && r.status;
|
|
905
|
+
return st ? `企业端请求失败(HTTP ${st})` : "企业端不可达,请稍后重试";
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** 未登录(无账号/自建密钥)时的统一返回文案。 */
|
|
909
|
+
function notLoggedInJson() {
|
|
910
|
+
return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* 凭证齐全但拿不到 relay token 时的统一返回:区分「密码失效需重新登录」与「中继暂未就绪可重试」。
|
|
915
|
+
* 关键:不再把中继未就绪/设备密钥缺失谎报成“尚未登录”(旧版 UI 因此显示误导性红字)。
|
|
916
|
+
*/
|
|
917
|
+
function relayAuthFailureJson(reason, detail) {
|
|
918
|
+
if (reason === AUTH_NO_CREDENTIALS) return { status: 401, body: notLoggedInJson() };
|
|
919
|
+
if (reason === AUTH_BAD_CREDENTIALS) {
|
|
920
|
+
return {
|
|
921
|
+
status: 401,
|
|
922
|
+
body: {
|
|
923
|
+
ok: false,
|
|
924
|
+
error: "本机保存的账号密码已被中继拒绝(可能已在别处修改过密码):请用新密码重新登录「账号」卡片后重试",
|
|
925
|
+
hint: "relogin_required",
|
|
926
|
+
retryable: false,
|
|
927
|
+
detail: String(detail || ""),
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
const unreachable = reason === AUTH_RELAY_UNREACHABLE;
|
|
932
|
+
return {
|
|
933
|
+
status: 503,
|
|
934
|
+
body: {
|
|
935
|
+
ok: false,
|
|
936
|
+
error: unreachable
|
|
937
|
+
? "中继服务暂时不可达(网络波动或中继正在重启),请稍后重试"
|
|
938
|
+
: "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试",
|
|
939
|
+
hint: unreachable ? "relay_unreachable" : "relay_not_ready",
|
|
940
|
+
retryable: true,
|
|
941
|
+
detail: String(detail || ""),
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 取 relay token 或直接回错误响应(面板代理统一入口)。
|
|
948
|
+
* @returns {Promise<string>} token;为空串表示已写出错误响应(调用方直接 return)
|
|
949
|
+
*/
|
|
950
|
+
async function relayTokenOrReject(relayDir, res, cfgOverride) {
|
|
951
|
+
const a = await relayTokenWithReason(relayDir, cfgOverride).catch((e) => ({
|
|
952
|
+
token: "",
|
|
953
|
+
reason: AUTH_RELAY_UNREACHABLE,
|
|
954
|
+
status: 0,
|
|
955
|
+
detail: e && e.message,
|
|
956
|
+
}));
|
|
957
|
+
if (a.token) return a.token;
|
|
958
|
+
const { status, body } = relayAuthFailureJson(a.reason, a.detail);
|
|
959
|
+
console.warn(`[dsh-remote-web] 取 relay token 失败(${a.reason}/${a.status}): ${a.detail || "无详情"}`);
|
|
960
|
+
sendJson(res, status, body);
|
|
961
|
+
return "";
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
|
|
966
|
+
* 返回扁平对象;数组字段只取首层数组。
|
|
967
|
+
*/
|
|
968
|
+
function flattenRelayBody(r) {
|
|
969
|
+
const b = r && typeof r === "object" && r.body && typeof r.body === "object" ? r.body : {};
|
|
970
|
+
const d = b.data && typeof b.data === "object" ? { ...b.data, ...b } : b;
|
|
971
|
+
return d;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* GET /dsh-remote/access-key → 创建一次性访问密钥(企业端 POST /api/auth-key,Bearer device-login token)。
|
|
976
|
+
* 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
|
|
977
|
+
*/
|
|
978
|
+
async function proxyCreateAccessKey(relayDir, res) {
|
|
979
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
980
|
+
if (!token) return;
|
|
981
|
+
const r = await relayFetch(relayDir, "/api/auth-key", {
|
|
982
|
+
method: "POST",
|
|
983
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
984
|
+
body: JSON.stringify({}),
|
|
985
|
+
});
|
|
986
|
+
const d = flattenRelayBody(r);
|
|
987
|
+
const url = typeof d.url === "string" ? d.url.trim() : "";
|
|
988
|
+
if (!r.ok || !url) {
|
|
989
|
+
// url 不可用是硬失败(企业端契约缺陷);错误码/状态透传给 UI 展示
|
|
990
|
+
const err = r.ok && !url ? "企业端未返回可用的访问地址(缺 url)" : relayErrorMessage(r);
|
|
991
|
+
return sendJson(res, (r && r.status) || 502, { ok: false, error: err, relayStatus: (r && r.status) || 0 });
|
|
992
|
+
}
|
|
993
|
+
return sendJson(res, 200, {
|
|
994
|
+
ok: true,
|
|
995
|
+
url,
|
|
996
|
+
key: d.key ?? null,
|
|
997
|
+
expires_at: d.expires_at ?? null,
|
|
998
|
+
ttl_ms: d.ttl_ms ?? null,
|
|
999
|
+
qr_data_url: d.qr_data_url ?? null,
|
|
1000
|
+
relayStatus: (r && r.status) || 200,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 GET /api/mobile-sessions,Bearer)。
|
|
1006
|
+
*/
|
|
1007
|
+
async function proxyMobileSessions(relayDir, res) {
|
|
1008
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1009
|
+
if (!token) return;
|
|
1010
|
+
const r = await relayFetch(relayDir, "/api/mobile-sessions", {
|
|
1011
|
+
method: "GET",
|
|
1012
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1013
|
+
});
|
|
1014
|
+
const d = flattenRelayBody(r);
|
|
1015
|
+
const sessions = Array.isArray(d.sessions) ? d.sessions : [];
|
|
1016
|
+
if (!r.ok) {
|
|
1017
|
+
return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0, sessions });
|
|
1018
|
+
}
|
|
1019
|
+
return sendJson(res, 200, { ok: true, sessions, relayStatus: (r && r.status) || 200 });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* POST /dsh-remote/mobile-sessions/revoke(body {id})→ 取消配对(企业端 POST /api/mobile-sessions/:id/revoke,Bearer)。
|
|
1024
|
+
*/
|
|
1025
|
+
async function proxyRevokeMobileSession(relayDir, req, res) {
|
|
1026
|
+
const body = await readJsonBody(req);
|
|
1027
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1028
|
+
const id = String(body.id ?? "").trim();
|
|
1029
|
+
if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
|
|
1030
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1031
|
+
if (!token) return;
|
|
1032
|
+
const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
|
|
1033
|
+
method: "POST",
|
|
1034
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1035
|
+
});
|
|
1036
|
+
const d = flattenRelayBody(r);
|
|
1037
|
+
// 兼容两种成功形态:HTTP ok,或 body.ok === true(允许企业端 200 + ok:false 表示业务失败)
|
|
1038
|
+
const ok = !!(r.ok && d.ok !== false);
|
|
1039
|
+
if (!ok) {
|
|
1040
|
+
return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
|
|
1041
|
+
}
|
|
1042
|
+
return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* DELETE /dsh-remote/mobile-sessions/delete(body {id})→ 删除本机该设备的授权记录
|
|
1047
|
+
* (企业端 DELETE /api/mobile-sessions/:id,Bearer:本人整行删除并拉黑 jti)。
|
|
1048
|
+
*/
|
|
1049
|
+
async function proxyDeleteMobileSession(relayDir, req, res) {
|
|
1050
|
+
const body = await readJsonBody(req);
|
|
1051
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1052
|
+
const id = String(body.id ?? "").trim();
|
|
1053
|
+
if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
|
|
1054
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1055
|
+
if (!token) return;
|
|
1056
|
+
const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
|
|
1057
|
+
method: "DELETE",
|
|
1058
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1059
|
+
});
|
|
1060
|
+
const d = flattenRelayBody(r);
|
|
1061
|
+
const ok = !!(r.ok && d.ok !== false);
|
|
1062
|
+
if (!ok) {
|
|
1063
|
+
return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
|
|
1064
|
+
}
|
|
1065
|
+
return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* POST /dsh-remote/mobile-sessions/purge → 清理本人所有已解绑(revoked)记录
|
|
1070
|
+
* (企业端 POST /api/mobile-sessions/purge,Bearer)。
|
|
1071
|
+
*/
|
|
1072
|
+
async function proxyPurgeMobileSessions(relayDir, res) {
|
|
1073
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1074
|
+
if (!token) return;
|
|
1075
|
+
const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
|
|
1076
|
+
method: "POST",
|
|
1077
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1078
|
+
});
|
|
1079
|
+
const d = flattenRelayBody(r);
|
|
1080
|
+
const ok = !!(r.ok && d.ok !== false);
|
|
1081
|
+
if (!ok) {
|
|
1082
|
+
return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
|
|
1083
|
+
}
|
|
1084
|
+
const removed = Number.isInteger(d.removed) ? d.removed : null;
|
|
1085
|
+
const payload = { ok: true, relayStatus: (r && r.status) || 200 };
|
|
1086
|
+
if (removed !== null) payload.removed = removed;
|
|
1087
|
+
return sendJson(res, 200, payload);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
701
1090
|
// ---------- 综合状态 ----------
|
|
702
1091
|
|
|
1092
|
+
/**
|
|
1093
|
+
* 读取 bridge 写入的 E2EE 开关状态(<relayDir>/.e2ee-state.json,写入方见
|
|
1094
|
+
* clients/dsh-remote/e2ee-client.mjs 的 writeE2eeStateFile:{enabled, reason, profile, epoch, caps, at})。
|
|
1095
|
+
* 归一化后只透出固定展示字段(enabled/reason/profile/epoch/caps),at 等调试字段不外泄;
|
|
1096
|
+
* 文件缺失/损坏一律视为「未启用(明文回退)」——面板据此显示普通安全连接。
|
|
1097
|
+
*/
|
|
1098
|
+
export function readE2eeStateFile(relayDir) {
|
|
1099
|
+
const none = () => ({ enabled: false, reason: "no_state_file", profile: "", epoch: 0, caps: [] });
|
|
1100
|
+
try {
|
|
1101
|
+
const f = join(relayDir, ".e2ee-state.json");
|
|
1102
|
+
if (!existsSync(f)) return none();
|
|
1103
|
+
const s = JSON.parse(readFileSync(f, "utf8"));
|
|
1104
|
+
if (!s || typeof s !== "object") return none();
|
|
1105
|
+
return {
|
|
1106
|
+
enabled: s.enabled === true,
|
|
1107
|
+
reason: typeof s.reason === "string" && s.reason ? s.reason : "",
|
|
1108
|
+
profile: typeof s.profile === "string" ? s.profile : "",
|
|
1109
|
+
epoch: Number.isInteger(s.epoch) && s.epoch >= 0 ? s.epoch : 0,
|
|
1110
|
+
caps: Array.isArray(s.caps) ? s.caps.filter((c) => typeof c === "string") : [],
|
|
1111
|
+
};
|
|
1112
|
+
} catch {
|
|
1113
|
+
return none();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* 手机号脱敏(隐私审计 2026-09):面板/镜像 UI 一律不下发明文手机号。
|
|
1119
|
+
* 明文只在 bridge 本机 config / 服务端账号体系内流转,浏览器侧仅见掩码。
|
|
1120
|
+
*/
|
|
1121
|
+
function maskPhone(p) {
|
|
1122
|
+
const s = String(p || "");
|
|
1123
|
+
return s.length >= 7 ? s.slice(0, 3) + "****" + s.slice(-4) : (s ? s.slice(0, 1) + "****" : "");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
703
1126
|
async function composeStatus(relayDir) {
|
|
704
1127
|
const cfg = loadConfig(relayDir);
|
|
705
1128
|
const launchd = launchdStatus();
|
|
@@ -718,7 +1141,8 @@ async function composeStatus(relayDir) {
|
|
|
718
1141
|
return {
|
|
719
1142
|
ok: true,
|
|
720
1143
|
config: {
|
|
721
|
-
phone: cfg.phone || "",
|
|
1144
|
+
phone: maskPhone(cfg.phone || ""),
|
|
1145
|
+
hasPhone: Boolean(cfg.phone),
|
|
722
1146
|
hasPassword: Boolean(cfg.password),
|
|
723
1147
|
deviceId: cfg.device_id || "",
|
|
724
1148
|
apiUrl,
|
|
@@ -734,8 +1158,11 @@ async function composeStatus(relayDir) {
|
|
|
734
1158
|
manual,
|
|
735
1159
|
running: launchd.running || manual.bridge.length > 0,
|
|
736
1160
|
bindError,
|
|
1161
|
+
// E2EE 开关状态(bridge 写 .e2ee-state.json;文件缺失 = 未启用明文):
|
|
1162
|
+
// {enabled, reason, profile, epoch, caps},供面板「📱 远程访问」卡展示加密状态。
|
|
1163
|
+
e2ee: readE2eeStateFile(relayDir),
|
|
737
1164
|
},
|
|
738
|
-
|
|
1165
|
+
// 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
|
|
739
1166
|
};
|
|
740
1167
|
}
|
|
741
1168
|
|
|
@@ -842,27 +1269,35 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
842
1269
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
843
1270
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
844
1271
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
845
|
-
const PLUGIN_VERSION = "0.
|
|
1272
|
+
const PLUGIN_VERSION = "0.6.1";
|
|
846
1273
|
const UPDATE_LOG = ".dsh-update.log";
|
|
847
1274
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
848
1275
|
|
|
849
|
-
/**
|
|
1276
|
+
/**
|
|
1277
|
+
* 更新通道(发布策略):普通用户只拉稳定 dist-tag `latest`;预发(alpha/beta)由作者/内测
|
|
1278
|
+
* 通过 `DSH_UPDATE_TAG=beta`(或显式版本号)拉取。迭代一律先发 beta/alpha,稳定后才升 latest。
|
|
1279
|
+
*/
|
|
1280
|
+
const UPDATE_TAG = (process.env.DSH_UPDATE_TAG || "latest").replace(/^@/, "");
|
|
1281
|
+
const UPDATE_SPEC = `@mrrisega/dsh-remote@${UPDATE_TAG}`;
|
|
1282
|
+
|
|
1283
|
+
/** 查询所选通道(npm dist-tag)最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
|
|
850
1284
|
async function npmLatestVersion() {
|
|
851
1285
|
for (const reg of ["https://registry.npmjs.org/@mrrisega/dsh-remote", "https://registry.npmmirror.com/@mrrisega/dsh-remote"]) {
|
|
852
1286
|
try {
|
|
853
1287
|
const res = await fetch(reg, { signal: AbortSignal.timeout(8000) });
|
|
854
1288
|
if (!res.ok) continue;
|
|
855
1289
|
const j = await res.json();
|
|
856
|
-
|
|
1290
|
+
const tags = j && j["dist-tags"] ? j["dist-tags"] : {};
|
|
1291
|
+
if (typeof tags[UPDATE_TAG] === "string") return tags[UPDATE_TAG];
|
|
857
1292
|
} catch { /* 试下一个源 */ }
|
|
858
1293
|
}
|
|
859
1294
|
return "";
|
|
860
1295
|
}
|
|
861
1296
|
|
|
862
|
-
/** 以 detached 子进程执行 `npx --yes
|
|
1297
|
+
/** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
|
|
863
1298
|
function spawnUpdater(relayDir, extraEnv) {
|
|
864
1299
|
const log = join(relayDir, UPDATE_LOG);
|
|
865
|
-
return spawn(npxCommand(), ["--yes",
|
|
1300
|
+
return spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
|
|
866
1301
|
detached: true,
|
|
867
1302
|
cwd: homedir(),
|
|
868
1303
|
env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
|
|
@@ -871,7 +1306,7 @@ function spawnUpdater(relayDir, extraEnv) {
|
|
|
871
1306
|
}
|
|
872
1307
|
|
|
873
1308
|
/**
|
|
874
|
-
* 后台执行在线一键更新:npx
|
|
1309
|
+
* 后台执行在线一键更新:npx 按 dist-tag(默认 latest)(幂等自愈:补运行环境/更新 bridge/收敛 include)。
|
|
875
1310
|
* 稳健性:
|
|
876
1311
|
* - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
|
|
877
1312
|
* - 【官方源优先】镜像(npmmirror)滞后时会把旧版(如 0.4.4)当成最新安装,旧 pluginCmd 会把
|
|
@@ -884,7 +1319,7 @@ function runOnlineUpdate(relayDir) {
|
|
|
884
1319
|
mkdirSync(relayDir, { recursive: true });
|
|
885
1320
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
886
1321
|
if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
|
|
887
|
-
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新
|
|
1322
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
|
|
888
1323
|
|
|
889
1324
|
let retried = false;
|
|
890
1325
|
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
@@ -1030,7 +1465,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1030
1465
|
if (rt.removedPlist) bits.push("自启动项已删除");
|
|
1031
1466
|
if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
|
|
1032
1467
|
if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
|
|
1033
|
-
bits.push("请重启 dsh web
|
|
1468
|
+
bits.push("请重启 dsh web 后完全卸载生效(本插件与「远程访问」面板将消失);如需再次使用,在插件市场重新安装即可。");
|
|
1034
1469
|
sendJson(res, 200, {
|
|
1035
1470
|
ok: true,
|
|
1036
1471
|
...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
|
|
@@ -1086,6 +1521,54 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1086
1521
|
});
|
|
1087
1522
|
},
|
|
1088
1523
|
},
|
|
1524
|
+
// 交流群二维码(设置面板「加入交流群」按钮 + 用户反馈页展示;未配置返回空串,UI 不展示入口)
|
|
1525
|
+
{
|
|
1526
|
+
method: "GET",
|
|
1527
|
+
path: "/dsh-remote/community",
|
|
1528
|
+
handler: async (_req, res) => {
|
|
1529
|
+
sendJson(res, 200, { ok: true, ...(await communityInfo(relayDir)) });
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
// 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
|
|
1533
|
+
{
|
|
1534
|
+
method: "GET",
|
|
1535
|
+
path: "/dsh-remote/access-key",
|
|
1536
|
+
handler: async (_req, res) => {
|
|
1537
|
+
await proxyCreateAccessKey(relayDir, res);
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
// 已授权设备列表(企业端 POST /api/mobile-sessions,Bearer)
|
|
1541
|
+
{
|
|
1542
|
+
method: "GET",
|
|
1543
|
+
path: "/dsh-remote/mobile-sessions",
|
|
1544
|
+
handler: async (_req, res) => {
|
|
1545
|
+
await proxyMobileSessions(relayDir, res);
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
// 取消已授权设备配对(企业端 POST /api/mobile-sessions/:id/revoke,Bearer)
|
|
1549
|
+
{
|
|
1550
|
+
method: "POST",
|
|
1551
|
+
path: "/dsh-remote/mobile-sessions/revoke",
|
|
1552
|
+
handler: async (req, res) => {
|
|
1553
|
+
await proxyRevokeMobileSession(relayDir, req, res);
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
// 删除已授权设备记录(整行删除并拉黑 jti;企业端 DELETE /api/mobile-sessions/:id,Bearer)
|
|
1557
|
+
{
|
|
1558
|
+
method: "DELETE",
|
|
1559
|
+
path: "/dsh-remote/mobile-sessions/delete",
|
|
1560
|
+
handler: async (req, res) => {
|
|
1561
|
+
await proxyDeleteMobileSession(relayDir, req, res);
|
|
1562
|
+
},
|
|
1563
|
+
},
|
|
1564
|
+
// 清理本人全部已解绑(revoked)记录(企业端 POST /api/mobile-sessions/purge,Bearer)
|
|
1565
|
+
{
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
path: "/dsh-remote/mobile-sessions/purge",
|
|
1568
|
+
handler: async (_req, res) => {
|
|
1569
|
+
await proxyPurgeMobileSessions(relayDir, res);
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1089
1572
|
{
|
|
1090
1573
|
method: "POST",
|
|
1091
1574
|
path: "/dsh-remote/config",
|
|
@@ -1116,6 +1599,14 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1116
1599
|
}
|
|
1117
1600
|
// SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
|
|
1118
1601
|
applySaaSMode(cfg);
|
|
1602
|
+
// device-login 共享密钥自愈:只装插件的路径没有安装器那一步,缺密钥会导致
|
|
1603
|
+
// 「首次安装后立即登录」时面板拿不到二维码/设备列表(旧版要等后台自愈或手动刷新)。
|
|
1604
|
+
// 这里在启动 bridge 之前补齐,bridge 首次启动即可带上密钥。
|
|
1605
|
+
if (!cfg.bridge_secret) {
|
|
1606
|
+
// 3s 上限:这次取密钥在登录响应路径上,宁可先放行(后续请求还会自愈)也不拖慢登录
|
|
1607
|
+
const secret = await bridgeSecretOf(relayDir, cfg, { timeoutMs: 3000 });
|
|
1608
|
+
if (secret) cfg.bridge_secret = secret;
|
|
1609
|
+
}
|
|
1119
1610
|
}
|
|
1120
1611
|
saveConfig(relayDir, cfg);
|
|
1121
1612
|
const bridgeRestart = startBridge(relayDir);
|
|
@@ -1156,7 +1647,11 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1156
1647
|
handler: async (req, res) => {
|
|
1157
1648
|
const body = await readJsonBody(req);
|
|
1158
1649
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1159
|
-
|
|
1650
|
+
let phone = String(body.phone ?? "").trim();
|
|
1651
|
+
if (!phone) {
|
|
1652
|
+
// 隐私审计(2026-09):账号卡修改密码不再下发明文手机号——服务端以本机账号为准
|
|
1653
|
+
phone = String((loadConfig(relayDir).phone) || "").trim();
|
|
1654
|
+
}
|
|
1160
1655
|
if (!phone) return sendJson(res, 400, { ok: false, error: "手机号必填" });
|
|
1161
1656
|
const r = await relayFetch(relayDir, "/api/sms-code", {
|
|
1162
1657
|
method: "POST", headers: { "content-type": "application/json" },
|
|
@@ -1165,6 +1660,31 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1165
1660
|
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
1166
1661
|
},
|
|
1167
1662
|
},
|
|
1663
|
+
{
|
|
1664
|
+
method: "POST",
|
|
1665
|
+
path: "/dsh-remote/password/reset",
|
|
1666
|
+
handler: async (req, res) => {
|
|
1667
|
+
// 公开 POST /api/password/reset(无需 Bearer):短信验证码重置密码。
|
|
1668
|
+
// 成功 → 企业端使该账号全部授权设备/会话失效(含 E2EE 派生口令)。
|
|
1669
|
+
const body = await readJsonBody(req);
|
|
1670
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1671
|
+
let phone = String(body.phone ?? "").trim();
|
|
1672
|
+
if (!phone) {
|
|
1673
|
+
// 隐私审计(2026-09):同上,账号卡改密不传明文手机号,服务端回填本机账号
|
|
1674
|
+
phone = String((loadConfig(relayDir).phone) || "").trim();
|
|
1675
|
+
}
|
|
1676
|
+
const smsCode = String(body.sms_code ?? "").trim();
|
|
1677
|
+
const newPassword = String(body.new_password ?? body.password ?? "");
|
|
1678
|
+
if (!phone || !smsCode || !newPassword) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与新密码必填" });
|
|
1679
|
+
const r = await relayFetch(relayDir, "/api/password/reset", {
|
|
1680
|
+
method: "POST",
|
|
1681
|
+
headers: { "content-type": "application/json" },
|
|
1682
|
+
body: JSON.stringify({ phone, sms_code: smsCode, new_password: newPassword }),
|
|
1683
|
+
});
|
|
1684
|
+
// 透传企业端 ok/error(成功 200 {ok:true};失败保留 status 与错误体)
|
|
1685
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
1686
|
+
},
|
|
1687
|
+
},
|
|
1168
1688
|
{
|
|
1169
1689
|
method: "GET",
|
|
1170
1690
|
path: "/dsh-remote/captcha",
|
|
@@ -1257,7 +1777,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1257
1777
|
feedbackUrl: api,
|
|
1258
1778
|
reachable,
|
|
1259
1779
|
deviceId: cfg.device_id || "",
|
|
1260
|
-
phone: cfg.phone || "",
|
|
1780
|
+
phone: maskPhone(cfg.phone || ""), // 隐私:浏览器端只见掩码(代理提交时服务端另附真号)
|
|
1261
1781
|
// 登录态(已配置账号或自建密钥)→ 节点半自动附加 JWT,免图形验证码
|
|
1262
1782
|
auth: cfg.local_key || (cfg.phone && cfg.password) ? "account" : "anonymous"
|
|
1263
1783
|
});
|