@mrrisega/dsh-remote 0.7.6 → 0.7.7
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/clients/dsh-remote/dsh-bridge.mjs +300 -16
- package/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/clients/dsh-remote/test/channel-ref.test.mjs +250 -0
- package/clients/dsh-remote/test/device-locked.test.mjs +271 -0
- package/dsh-setup.mjs +97 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +82 -14
- package/packages/dsh-remote-web/lib/index.js +208 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +300 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +97 -3
- package/packages/dsh-remote-web/test/device-locked-panel.test.mjs +301 -0
|
@@ -92,6 +92,8 @@ import { gzip as gzipCb } from "node:zlib";
|
|
|
92
92
|
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
93
93
|
import { maybeInjectMirrorDiag } from "./mirror-diag.mjs";
|
|
94
94
|
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
|
|
95
|
+
// 渠道归因码(安装器 --ref → .dsh-config.json 的 ref_code)的归一化规则,与安装器/插件同一份契约。
|
|
96
|
+
import { normalizeRefCode } from "./src/channel-ref.mjs";
|
|
95
97
|
// 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
|
|
96
98
|
import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
|
|
97
99
|
// E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
|
|
@@ -309,6 +311,15 @@ function loadLocalConfig() {
|
|
|
309
311
|
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
310
312
|
}
|
|
311
313
|
|
|
314
|
+
/**
|
|
315
|
+
* 渠道归因码:安装器 `--ref` 写进配置的 `ref_code`(3~6 位数字)。
|
|
316
|
+
* 每次读配置(不缓存):配置由安装器/插件随时重写,缓存会让「装完没带、后补 --ref」
|
|
317
|
+
* 这种升级路径永远不上报。缺失/非法 → 空串,调用方据此**整个省略**该字段。
|
|
318
|
+
*/
|
|
319
|
+
function refCodeFromConfig() {
|
|
320
|
+
try { return normalizeRefCode(loadLocalConfig().ref_code); } catch { return ""; }
|
|
321
|
+
}
|
|
322
|
+
|
|
312
323
|
/**
|
|
313
324
|
* 把文件权限收紧到「只有本人可读写」。
|
|
314
325
|
*
|
|
@@ -1981,6 +1992,15 @@ let consecutive4000 = 0;
|
|
|
1981
1992
|
export const DUPLICATE_CLOSE_CODE = 4000;
|
|
1982
1993
|
/** bridge 判定"本机已有另一个同 deviceId 实例"后的退出码(必须与 dsh-setup.mjs 的常量一致)。 */
|
|
1983
1994
|
export const DUPLICATE_INSTANCE_EXIT_CODE = 4;
|
|
1995
|
+
/**
|
|
1996
|
+
* 设备登记遇到**防滥用硬上限**(`device_bind_cap`)后的专用退出码(必须与 dsh-setup.mjs 的常量一致)。
|
|
1997
|
+
*
|
|
1998
|
+
* 为什么要跟普通失败(exit 1)分开:普通失败是瞬时的(网络/服务端抖动),watcher 10s 重拉是对的;
|
|
1999
|
+
* 而"已绑定条数 ≥ 硬上限"是**永久性**失败——用户不去手机端解绑就不会好。旧实现两者都走 exit(1),
|
|
2000
|
+
* watcher 于是每 10s 拉起一次、每 10s 撞一次 409,日志刷屏还把真正的第一现场淹没(契约 §6.4 P0)。
|
|
2001
|
+
* 用专用退出码让 watcher 做指数退避(10s→1min→5min→15min,封顶 30min),用户解绑后自动恢复。
|
|
2002
|
+
*/
|
|
2003
|
+
export const DEVICE_BIND_CAP_EXIT_CODE = 5;
|
|
1984
2004
|
export const DUPLICATE_RECONNECT_BASE_MS = 30_000;
|
|
1985
2005
|
export const MAX_RECONNECT_DELAY_MS = 10_000;
|
|
1986
2006
|
export const RECONNECT_JITTER_MS = 3_000;
|
|
@@ -2136,10 +2156,18 @@ function tunnelEndpoint(raw) {
|
|
|
2136
2156
|
}
|
|
2137
2157
|
}
|
|
2138
2158
|
|
|
2139
|
-
/** 账号设备表登记:手机端 /api/devices 才能看到本设备(带稳定 device_id)。自建模式无账号体系,跳过。
|
|
2159
|
+
/** 账号设备表登记:手机端 /api/devices 才能看到本设备(带稳定 device_id)。自建模式无账号体系,跳过。
|
|
2160
|
+
*
|
|
2161
|
+
* @returns {Promise<{status:"ok"|"locked", locked?:object}>}
|
|
2162
|
+
* `locked` = 服务端按套餐额度判定本机不可用(旧版 409 `device_limit_exceeded` 或新契约 `device_locked`)。
|
|
2163
|
+
* 此时**不要**再去连隧道,由调用方进入解锁轮询(见 enterLockedWait)。
|
|
2164
|
+
*/
|
|
2140
2165
|
async function registerDeviceInAccount(token) {
|
|
2141
|
-
if (process.env.DSH_BRIDGE_LOCAL_KEY) return; // 自建模式:设备列表来自 router 实时 WS 注册表
|
|
2166
|
+
if (process.env.DSH_BRIDGE_LOCAL_KEY) return { status: "ok", local: true }; // 自建模式:设备列表来自 router 实时 WS 注册表
|
|
2142
2167
|
const pubKey = resolveDevicePubKey();
|
|
2168
|
+
// 渠道归因码(可选):安装器 `--ref 368` 写进配置,这里随设备登记原样带给服务端。
|
|
2169
|
+
// 契约:字段可选、3~6 位数字;没有/非法 → **整个省略**,绝不发空串或猜的值。
|
|
2170
|
+
const refCode = refCodeFromConfig();
|
|
2143
2171
|
try {
|
|
2144
2172
|
const r = await fetch(API_BASE + "/api/devices", {
|
|
2145
2173
|
method: "POST",
|
|
@@ -2156,6 +2184,7 @@ async function registerDeviceInAccount(token) {
|
|
|
2156
2184
|
// v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
|
|
2157
2185
|
// 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
|
|
2158
2186
|
harness_version: HARNESS_VERSION,
|
|
2187
|
+
...(refCode ? { ref_code: refCode } : {}),
|
|
2159
2188
|
host_os: process.platform,
|
|
2160
2189
|
host_arch: process.arch
|
|
2161
2190
|
})
|
|
@@ -2165,29 +2194,265 @@ async function registerDeviceInAccount(token) {
|
|
|
2165
2194
|
clearBindError();
|
|
2166
2195
|
persistBridgeState({ device_id: DEVICE_ID, account_bound_at: Date.now(), phase: "bound" });
|
|
2167
2196
|
console.log(`[bridge] ✅ 设备已登记到账号: ${DEVICE_ID}`);
|
|
2168
|
-
|
|
2197
|
+
// v18 契约 §4.2:201 响应带 locked —— 但**不据此跳过隧道连接**(旧中继不做准入检查,
|
|
2198
|
+
// 直接跳过会让老中继下的用户被误锁)。锁定以 router 的 tunnel-register-err 为准。
|
|
2199
|
+
if (d.locked === true) {
|
|
2200
|
+
console.log("[bridge] 服务端标记本机已超额(等 router 准入结论)…");
|
|
2201
|
+
}
|
|
2202
|
+
return { status: "ok" };
|
|
2169
2203
|
}
|
|
2170
2204
|
if (r.status === 409) {
|
|
2171
2205
|
const code = d.error?.code || "";
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2206
|
+
const message = d.error?.message || "绑定冲突";
|
|
2207
|
+
const policy = d.policy && typeof d.policy === "object" ? d.policy : {};
|
|
2208
|
+
// 详情一律带 device_id;plan/limit/upgrade_to 有才写(没有就不硬编造,面板按缺省文案兜底)
|
|
2209
|
+
const details = {
|
|
2210
|
+
device_id: DEVICE_ID,
|
|
2211
|
+
...(d.error?.plan || policy.plan ? { plan: d.error?.plan || policy.plan } : {}),
|
|
2212
|
+
...(Number.isFinite(Number(d.error?.limit ?? policy.limit)) ? { limit: Number(d.error?.limit ?? policy.limit) } : {}),
|
|
2213
|
+
...(d.error?.upgrade_to || policy.upgrade_to ? { upgrade_to: d.error?.upgrade_to || policy.upgrade_to } : {})
|
|
2214
|
+
};
|
|
2215
|
+
if (code === "device_bind_cap") {
|
|
2216
|
+
// 防滥用硬上限:永久性失败 → 专用退出码,watcher 按指数退避重拉(契约 §6.3/§6.4)
|
|
2217
|
+
persistBindError({ code, status: 409, message, ...details });
|
|
2218
|
+
console.error(`[bridge] 设备绑定已达上限:${message}`);
|
|
2219
|
+
console.error("[bridge] 这是按套餐设定的防滥用硬上限(不是网络问题),本机不会反复空撞接口。");
|
|
2220
|
+
console.error("[bridge] 请在手机端「管理电脑」解绑不再使用的设备;守护会按指数退避自动重试,解绑后自动恢复。");
|
|
2221
|
+
process.exit(DEVICE_BIND_CAP_EXIT_CODE);
|
|
2222
|
+
}
|
|
2223
|
+
if (code === "device_limit_exceeded" || code === "device_locked") {
|
|
2224
|
+
// 旧版服务端 409 device_limit_exceeded / 新契约 device_locked:**不再 exit(1) 空转**,
|
|
2225
|
+
// 交给解锁轮询等待(向后兼容,契约 §6.2)。旧服务端不返回 limit,从文案里尽力解析。
|
|
2226
|
+
const limitFromMsg = (() => {
|
|
2227
|
+
const m = /(?:最多|上限|超过)[^\d]{0,6}(\d+)\s*台/.exec(String(message));
|
|
2228
|
+
return m ? Number(m[1]) : null;
|
|
2229
|
+
})();
|
|
2230
|
+
const locked = normalizeLockedInfo({
|
|
2231
|
+
message,
|
|
2232
|
+
plan: d.error?.plan || policy.plan,
|
|
2233
|
+
limit: d.error?.limit ?? policy.limit ?? limitFromMsg,
|
|
2234
|
+
upgrade_to: d.error?.upgrade_to || policy.upgrade_to || "pro_max"
|
|
2235
|
+
});
|
|
2236
|
+
console.error(`[bridge] 本机设备额度已满(${code}):${message}`);
|
|
2237
|
+
return { status: "locked", locked };
|
|
2180
2238
|
}
|
|
2239
|
+
persistBindError({ code: code || "device_conflict", status: 409, message, ...details });
|
|
2240
|
+
console.error(`[bridge] 设备 ${DEVICE_ID} 绑定失败(${code || 409}): ${message}`);
|
|
2181
2241
|
process.exit(1);
|
|
2182
2242
|
}
|
|
2183
|
-
persistBindError({ code: d.error?.code || `http_${r.status}`, status: r.status, message: d.error?.message || "设备登记失败" });
|
|
2243
|
+
persistBindError({ code: d.error?.code || `http_${r.status}`, status: r.status, message: d.error?.message || "设备登记失败", device_id: DEVICE_ID });
|
|
2184
2244
|
console.warn(`[bridge] 设备登记失败(${r.status}): ${d.error?.message || "未知错误"}(手机端设备列表可能看不到本设备)`);
|
|
2185
2245
|
} catch (e) {
|
|
2186
|
-
persistBindError({ code: "api_unreachable", message: `无法连接账号 API: ${e.message}
|
|
2246
|
+
persistBindError({ code: "api_unreachable", message: `无法连接账号 API: ${e.message}`, device_id: DEVICE_ID });
|
|
2187
2247
|
console.warn(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}(手机端设备列表可能看不到本设备)`);
|
|
2188
2248
|
}
|
|
2249
|
+
return { status: "failed" };
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/* ══════════════════════════════════════════════════════════════════════════════
|
|
2253
|
+
* 「设备被超额锁定」等待态(契约 docs/device-overquota-lock-contract.md §6)
|
|
2254
|
+
*
|
|
2255
|
+
* 触发:router 回 `tunnel-register-err code=device_locked`(服务端按套餐额度判定本机不可用),
|
|
2256
|
+
* 或旧版服务端 409 `device_limit_exceeded`。
|
|
2257
|
+
* 行为(**不退出进程**——旧实现 exit(1),watcher 每 10s 重拉、每 10s 撞一次,用户侧表现为
|
|
2258
|
+
* "设备列表里能看到这台电脑但永远连不上",日志还被刷屏):
|
|
2259
|
+
* ① 关掉当前隧道,写状态 phase=locked + locked 详情(面板据此显示升级引导);
|
|
2260
|
+
* ② 每 30s `GET /api/devices`(Bearer token)找本机 device_id:
|
|
2261
|
+
* · locked === false → 清 locked 状态、立即 connectTunnel 重连;
|
|
2262
|
+
* · 行不存在(用户在手机端解绑了这台)→ registerDeviceInAccount 后重连;
|
|
2263
|
+
* · 仍 locked → 继续等。
|
|
2264
|
+
* ③ **轮询期间不重连隧道、不发心跳**(本模块只在 ws 已关闭后启动轮询;close 处理器
|
|
2265
|
+
* 看到 lockedWait.active 会直接 return,不排重连)。
|
|
2266
|
+
* ══════════════════════════════════════════════════════════════════════════════ */
|
|
2267
|
+
|
|
2268
|
+
/** 解锁轮询间隔(契约 §6.1:30s)。测试/诊断可用 DSH_BRIDGE_LOCK_POLL_MS 压缩。 */
|
|
2269
|
+
export const DEVICE_LOCKED_POLL_MS = 30_000;
|
|
2270
|
+
function lockedPollIntervalMs() {
|
|
2271
|
+
const n = Number(process.env.DSH_BRIDGE_LOCK_POLL_MS);
|
|
2272
|
+
return Number.isFinite(n) && n >= 20 ? Math.floor(n) : DEVICE_LOCKED_POLL_MS;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** 进程内等待态(同一时刻只允许一份轮询;测试钩子可读)。 */
|
|
2276
|
+
const lockedWait = { active: false, timer: null, token: "", detail: null, polls: 0, warnedUnknown: false };
|
|
2277
|
+
|
|
2278
|
+
/** 整理 locked 详情:字段可能来自 frame(message/plan/limit/upgrade_to)、409 error 或 policy。 */
|
|
2279
|
+
export function normalizeLockedInfo(src = {}, fallback = {}) {
|
|
2280
|
+
const pick = (k) => (src[k] !== undefined && src[k] !== null && src[k] !== "" ? src[k] : fallback[k]);
|
|
2281
|
+
const limitRaw = pick("limit");
|
|
2282
|
+
const limit = Number.isFinite(Number(limitRaw)) && Number(limitRaw) >= 0 ? Number(limitRaw) : null;
|
|
2283
|
+
return {
|
|
2284
|
+
message: String(pick("message") || ""),
|
|
2285
|
+
plan: String(pick("plan") || ""),
|
|
2286
|
+
limit,
|
|
2287
|
+
upgrade_to: String(pick("upgrade_to") || pick("upgradeTo") || "pro_max")
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/** 面向用户的可操作中文日志(这就是他们唯一能看到的解释:为什么手机连不上)。 */
|
|
2292
|
+
function logLockedHelp(info) {
|
|
2293
|
+
const detail = normalizeLockedInfo(info);
|
|
2294
|
+
const limitTxt = detail.limit !== null && detail.limit >= 0 ? `${detail.limit} 台设备` : "套餐额度内的设备";
|
|
2295
|
+
console.error("[bridge] 🔒 本机已被锁定(超出当前套餐的设备额度),暂时无法连上中继。");
|
|
2296
|
+
if (detail.message) console.error(`[bridge] 服务端说明:${detail.message}`);
|
|
2297
|
+
console.error(`[bridge] 当前套餐(${detail.plan || "free"})可同时使用 ${limitTxt};本机排在额度之外。`);
|
|
2298
|
+
console.error("[bridge] 处理办法(二选一,任一步完成后本机会自动恢复,不需要重启):");
|
|
2299
|
+
console.error("[bridge] ① 打开手机端「管理电脑」,解绑不再使用的设备(先绑定的优先可用)→ 本机自动解锁;");
|
|
2300
|
+
console.error(`[bridge] ② 升级 ${detail.upgrade_to === "pro_max" ? "Pro Max 会员" : String(detail.upgrade_to).toUpperCase()} → 可多台设备同时使用。`);
|
|
2301
|
+
console.error(`[bridge] 已进入自动等待:每 ${Math.round(lockedPollIntervalMs() / 1000)} 秒检查一次解锁状态,期间不重连、不占用中继。`);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** 只有 `{status:"ok"}` 的登记才算登记成功(其余状态留给调用方决定下一步)。 */
|
|
2305
|
+
function registrationOk(reg) { return Boolean(reg && reg.status === "ok"); }
|
|
2306
|
+
|
|
2307
|
+
/**
|
|
2308
|
+
* 进入(或刷新)「设备被锁定」等待态。幂等:已在等待时只更新详情,不重复打日志、不重复排轮询。
|
|
2309
|
+
* @param {string} token 当前可用 JWT(轮询解锁用)
|
|
2310
|
+
* @param {object} info {message, plan, limit, upgrade_to}
|
|
2311
|
+
*/
|
|
2312
|
+
function enterLockedWait(token, info) {
|
|
2313
|
+
const detail = normalizeLockedInfo(info || {});
|
|
2314
|
+
if (token) lockedWait.token = token;
|
|
2315
|
+
lockedWait.detail = detail;
|
|
2316
|
+
persistBridgeState({ device_id: DEVICE_ID, phase: "locked", locked: detail, last_error: null });
|
|
2317
|
+
// .bind-error.json 同步一份:面板即使读不到状态文件(旧版 bridge 状态 / 文件被删)也能识别
|
|
2318
|
+
persistBindError({
|
|
2319
|
+
code: "device_locked",
|
|
2320
|
+
message: detail.message || "本机已被锁定(超出当前套餐设备额度)",
|
|
2321
|
+
device_id: DEVICE_ID,
|
|
2322
|
+
...(detail.plan ? { plan: detail.plan } : {}),
|
|
2323
|
+
...(detail.limit !== null ? { limit: detail.limit } : {}),
|
|
2324
|
+
...(detail.upgrade_to ? { upgrade_to: detail.upgrade_to } : {})
|
|
2325
|
+
});
|
|
2326
|
+
if (lockedWait.active) return; // 已在等待:只刷新详情
|
|
2327
|
+
lockedWait.active = true;
|
|
2328
|
+
lockedWait.polls = 0;
|
|
2329
|
+
lockedWait.warnedUnknown = false;
|
|
2330
|
+
logLockedHelp(detail);
|
|
2331
|
+
scheduleLockedPoll();
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/** 排下一拍解锁检查(不 unref:bridge 必须靠这个定时器保持进程存活)。 */
|
|
2335
|
+
function scheduleLockedPoll() {
|
|
2336
|
+
if (!lockedWait.active) return;
|
|
2337
|
+
if (lockedWait.timer) { try { clearTimeout(lockedWait.timer); } catch { /* 已触发 */ } }
|
|
2338
|
+
lockedWait.timer = setTimeout(() => {
|
|
2339
|
+
lockedWait.timer = null;
|
|
2340
|
+
lockedPollTick().catch((e) => {
|
|
2341
|
+
console.log(`[bridge] 解锁检查异常:${e.message};稍后再试`);
|
|
2342
|
+
scheduleLockedPoll();
|
|
2343
|
+
});
|
|
2344
|
+
}, lockedPollIntervalMs());
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* 等待期内换一张 JWT。**刻意不复用 resolveToken()**:它在失败时会 process.exit(1)/(3),
|
|
2349
|
+
* 而等待态的要求是"进程活着把轮询跑完",不能让一次刷新失败把 bridge 打死。
|
|
2350
|
+
*/
|
|
2351
|
+
async function refreshTokenForLockedPoll() {
|
|
2352
|
+
if (!PHONE || !PASSWORD) return "";
|
|
2353
|
+
try {
|
|
2354
|
+
const r = await fetch(API_BASE + "/api/device-login", {
|
|
2355
|
+
method: "POST",
|
|
2356
|
+
headers: { "content-type": "application/json", ...(process.env.DSH_BRIDGE_SECRET ? { "x-dsh-bridge-secret": process.env.DSH_BRIDGE_SECRET } : {}) },
|
|
2357
|
+
body: JSON.stringify({ phone: PHONE, email: PHONE, password: PASSWORD })
|
|
2358
|
+
});
|
|
2359
|
+
const d = await r.json().catch(() => ({}));
|
|
2360
|
+
return r.status === 200 && d.token ? d.token : "";
|
|
2361
|
+
} catch { return ""; }
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/** 解锁(或设备行已不存在且重新登记成功)→ 退出等待态并立即重连隧道。 */
|
|
2365
|
+
function resumeTunnelFromLock(token) {
|
|
2366
|
+
if (!lockedWait.active) return;
|
|
2367
|
+
lockedWait.active = false;
|
|
2368
|
+
if (lockedWait.timer) { try { clearTimeout(lockedWait.timer); } catch { /* 已触发 */ } }
|
|
2369
|
+
lockedWait.timer = null;
|
|
2370
|
+
lockedWait.detail = null;
|
|
2371
|
+
lockedWait.polls = 0;
|
|
2372
|
+
lockedWait.warnedUnknown = false;
|
|
2373
|
+
clearBindError();
|
|
2374
|
+
persistBridgeState({ device_id: DEVICE_ID, phase: "connecting", locked: null, last_error: null });
|
|
2375
|
+
tunnelRetry = 0;
|
|
2376
|
+
consecutive4000 = 0;
|
|
2377
|
+
console.log("[bridge] 🔓 锁定已解除,重新连接中继…");
|
|
2378
|
+
connectTunnel(token || lockedWait.token);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* 一拍解锁检查:`GET /api/devices` → 判断本机是否恢复可用。
|
|
2383
|
+
* 任何失败都不退出进程(网络/服务端抖动只是"这一拍没结论"),排下一拍继续。
|
|
2384
|
+
*/
|
|
2385
|
+
async function lockedPollTick() {
|
|
2386
|
+
if (!lockedWait.active) return;
|
|
2387
|
+
lockedWait.polls += 1;
|
|
2388
|
+
const intervalMs = lockedPollIntervalMs();
|
|
2389
|
+
const retryLater = (why) => {
|
|
2390
|
+
if (why) console.log(`[bridge] 解锁检查:${why};${Math.round(intervalMs / 1000)} 秒后再试`);
|
|
2391
|
+
scheduleLockedPoll();
|
|
2392
|
+
};
|
|
2393
|
+
let status = 0;
|
|
2394
|
+
let body = null;
|
|
2395
|
+
try {
|
|
2396
|
+
const r = await fetch(API_BASE + "/api/devices", { headers: { authorization: `Bearer ${lockedWait.token}` } });
|
|
2397
|
+
status = r.status;
|
|
2398
|
+
body = await r.json().catch(() => null);
|
|
2399
|
+
} catch (e) {
|
|
2400
|
+
return retryLater(`无法连接账号 API(${e.message})`);
|
|
2401
|
+
}
|
|
2402
|
+
if (!lockedWait.active) return;
|
|
2403
|
+
if (status === 401) {
|
|
2404
|
+
const fresh = await refreshTokenForLockedPoll();
|
|
2405
|
+
if (fresh) {
|
|
2406
|
+
lockedWait.token = fresh;
|
|
2407
|
+
console.log("[bridge] 等待期间登录凭证已刷新,继续检查解锁状态。");
|
|
2408
|
+
return retryLater("");
|
|
2409
|
+
}
|
|
2410
|
+
return retryLater("登录凭证已失效(401),请在面板重新登录后重启后台服务");
|
|
2411
|
+
}
|
|
2412
|
+
if (status !== 200 || !body || typeof body !== "object") {
|
|
2413
|
+
return retryLater(`服务端返回 ${status || "无响应"}`);
|
|
2414
|
+
}
|
|
2415
|
+
const rows = Array.isArray(body.devices) ? body.devices : [];
|
|
2416
|
+
const policy = body.policy && typeof body.policy === "object" ? body.policy : {};
|
|
2417
|
+
if (policy.plan || Number.isFinite(Number(policy.limit))) {
|
|
2418
|
+
// 解绑/升级后 policy 会实时变化:同步刷新等待详情(面板的「可同时使用 N 台」跟着变)
|
|
2419
|
+
const merged = normalizeLockedInfo({}, {
|
|
2420
|
+
...lockedWait.detail,
|
|
2421
|
+
...(policy.plan ? { plan: policy.plan } : {}),
|
|
2422
|
+
...(Number.isFinite(Number(policy.limit)) ? { limit: Number(policy.limit) } : {}),
|
|
2423
|
+
...(policy.upgrade_to ? { upgrade_to: policy.upgrade_to } : {})
|
|
2424
|
+
});
|
|
2425
|
+
if (JSON.stringify(merged) !== JSON.stringify(lockedWait.detail)) {
|
|
2426
|
+
lockedWait.detail = merged;
|
|
2427
|
+
persistBridgeState({ device_id: DEVICE_ID, phase: "locked", locked: merged, last_error: null });
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
const row = rows.find((d) => d && String(d.id || d.device_id || "") === DEVICE_ID);
|
|
2431
|
+
if (!row) {
|
|
2432
|
+
console.log("[bridge] 解锁检查:本机设备行已不存在(可能已在手机端解绑)→ 重新登记后重连…");
|
|
2433
|
+
const reg = await registerDeviceInAccount(lockedWait.token);
|
|
2434
|
+
if (registrationOk(reg)) { resumeTunnelFromLock(lockedWait.token); return; }
|
|
2435
|
+
if (reg && reg.status === "locked") { enterLockedWait(lockedWait.token, reg.locked); }
|
|
2436
|
+
return scheduleLockedPoll();
|
|
2437
|
+
}
|
|
2438
|
+
if (row.locked === false) {
|
|
2439
|
+
console.log("[bridge] ✅ 解锁检查:本机已恢复可用 → 立即重连中继…");
|
|
2440
|
+
resumeTunnelFromLock(lockedWait.token);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
if (row.locked !== true && !lockedWait.warnedUnknown) {
|
|
2444
|
+
lockedWait.warnedUnknown = true;
|
|
2445
|
+
console.log("[bridge] 解锁检查:服务端未返回本机锁定状态(可能是旧版服务端),继续等待;长期不恢复请升级后台运行环境。");
|
|
2446
|
+
}
|
|
2447
|
+
scheduleLockedPoll();
|
|
2189
2448
|
}
|
|
2190
2449
|
|
|
2450
|
+
/** 测试钩子:读取等待态(不改变行为);用例据此断言「不退出 + 在轮询 + 解锁后重连」。 */
|
|
2451
|
+
export function __lockedWaitStateForTest() {
|
|
2452
|
+
return { active: lockedWait.active, polls: lockedWait.polls, detail: lockedWait.detail ? { ...lockedWait.detail } : null, hasTimer: Boolean(lockedWait.timer) };
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
|
|
2191
2456
|
function connectTunnel(token) {
|
|
2192
2457
|
const endpoint = tunnelEndpoint(TUNNEL_URL);
|
|
2193
2458
|
// maxPayload 必须显式给:ws 客户端默认 100MiB,而 router→bridge 的单帧不分块,
|
|
@@ -2269,6 +2534,13 @@ function connectTunnel(token) {
|
|
|
2269
2534
|
return;
|
|
2270
2535
|
}
|
|
2271
2536
|
if (frame?.type === "tunnel-register-err") {
|
|
2537
|
+
if (frame.code === "device_locked") {
|
|
2538
|
+
// 契约 §6.1:服务端按套餐额度判定本机不可用。**不退出进程**,关掉这条隧道后进入
|
|
2539
|
+
// 解锁轮询(30s 一次 GET /api/devices);期间不重连、不发心跳(close 处理器见等待态直接 return)。
|
|
2540
|
+
enterLockedWait(token, { message: frame.message, plan: frame.plan, limit: frame.limit, upgrade_to: frame.upgrade_to });
|
|
2541
|
+
try { ws.close(4009, "device locked"); } catch { /* 对端可能已经关了 */ }
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2272
2544
|
persistBridgeState({ phase: "error", last_error: { code: frame.code || "register_err", message: frame.message || "router 拒绝注册" } });
|
|
2273
2545
|
console.error(`[bridge] router 拒绝注册: ${frame.code} ${frame.message || ""}`);
|
|
2274
2546
|
return;
|
|
@@ -2284,6 +2556,12 @@ function connectTunnel(token) {
|
|
|
2284
2556
|
sender.close(); // 丢掉未发分片,别让重连后的新连接接到旧响应的剩余部分
|
|
2285
2557
|
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
2286
2558
|
closeAllWsSessions();
|
|
2559
|
+
// 锁定等待中:**不排重连**(契约 §6.1 要求轮询期间不重连隧道)。解锁后由
|
|
2560
|
+
// resumeTunnelFromLock 统一重连一次,避免这里每 2s 再撞一次必然被拒的注册。
|
|
2561
|
+
if (lockedWait.active) {
|
|
2562
|
+
console.log("[bridge] 当前处于「设备被锁定」等待态,暂不重连中继(解锁后自动恢复)。");
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2287
2565
|
// C2 + C5:4000(被同 deviceId 的新连接顶掉)走长退避/退出;其余走 2/4/8/10s + 抖动。
|
|
2288
2566
|
const plan = reconnectPlan({ code, retry: tunnelRetry, consecutive4000 });
|
|
2289
2567
|
consecutive4000 = plan.consecutive4000;
|
|
@@ -2681,9 +2959,15 @@ async function runTunnel() {
|
|
|
2681
2959
|
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
|
|
2682
2960
|
persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
|
|
2683
2961
|
await initE2ee(token);
|
|
2684
|
-
await registerDeviceInAccount(token);
|
|
2685
|
-
|
|
2686
|
-
|
|
2962
|
+
const reg = await registerDeviceInAccount(token);
|
|
2963
|
+
if (reg && reg.status === "locked") {
|
|
2964
|
+
// 旧版服务端 409 device_limit_exceeded(新契约走 router 的 device_locked,见 connectTunnel):
|
|
2965
|
+
// 不再 process.exit(1) 空转,改为 30s 轮询等待解锁/解绑(契约 §6.2,向后兼容)
|
|
2966
|
+
enterLockedWait(token, reg.locked);
|
|
2967
|
+
} else {
|
|
2968
|
+
connectTunnel(token);
|
|
2969
|
+
}
|
|
2970
|
+
// 隧道已发起(或已进入锁定等待)后再起微信:主链路优先,微信永不阻塞远程访问
|
|
2687
2971
|
startWeChat();
|
|
2688
2972
|
setTimeout(() => {
|
|
2689
2973
|
console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 渠道归因码(ref)的**唯一**解析规则。
|
|
3
|
+
*
|
|
4
|
+
* 契约见 dsh-relay-enterprise/docs/channel-referral-contract.md §1/§5:
|
|
5
|
+
* 安装器 `npx @mrrisega/dsh-remote --ref bili`
|
|
6
|
+
* → 写 ~/.dsh-remote/.dsh-config.json 的 `ref_code`(字符串)
|
|
7
|
+
* → bridge `POST /api/devices`、插件 `POST /api/install-report` 都带 `ref_code`
|
|
8
|
+
* → 面板注册 `POST /api/register` 带 `channel_code`。
|
|
9
|
+
* 服务端字段是**可选**的:非法值一律忽略,不报错、不阻断。
|
|
10
|
+
*
|
|
11
|
+
* 这里只做一件事:把任意输入归一化成「3~6 位数字的字符串」或空串。
|
|
12
|
+
* ⚠️ 绝不抛错、绝不因为归因码非法而让安装/上报失败 —— 归因是锦上添花,
|
|
13
|
+
* 主链路(装得上、连得上)永远优先。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 合法形态:3~12 位字母或数字(归一化后小写),与 channel_codes.code 契约一致。
|
|
18
|
+
*
|
|
19
|
+
* 业主 2026-10 口径:纯数字「368」太像渠道码,改用小写短词(bili/dsh/xhs8)更隐蔽。
|
|
20
|
+
* 老数字码仍是合法子集;`BILI` / `BILI` / ` Bili ` 统一归一化为 `bili`(与后端同口径)。
|
|
21
|
+
*/
|
|
22
|
+
export const REF_CODE_RE = /^[a-z0-9]{3,12}$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 归一化 ref 码:合法返回字符串(保留前导零),否则返回 ""(调用方据此省略字段)。
|
|
26
|
+
* 兼容配置文件里被手改成数字的情况(`"ref_code": 368`)。
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeRefCode(raw) {
|
|
29
|
+
let v = "";
|
|
30
|
+
if (typeof raw === "number") {
|
|
31
|
+
if (!Number.isInteger(raw) || raw < 0) return "";
|
|
32
|
+
v = String(raw);
|
|
33
|
+
} else if (typeof raw === "string") {
|
|
34
|
+
v = raw;
|
|
35
|
+
} else {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
// NFKC:全角字母/数字 → 半角;再 trim + 小写(与后端 channel.js normalizeCode 一致)
|
|
39
|
+
const normalized = v.normalize("NFKC").trim().toLowerCase();
|
|
40
|
+
return REF_CODE_RE.test(normalized) ? normalized : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 本次安装要写入的 ref_code:命令行 `--ref <code>` 优先,其次环境变量 `DSH_REF`。
|
|
45
|
+
*
|
|
46
|
+
* 「仅当命令行没给时」才用环境变量:命令行给了(即使是非法值)就按命令行来,
|
|
47
|
+
* 非法 → 空串(忽略),**不会**悄悄回退到环境变量 —— 否则用户显式写错时会得到一个
|
|
48
|
+
* 来路不明的归因码,比丢掉更难查。
|
|
49
|
+
*/
|
|
50
|
+
export function resolveRefCode(argv, env = process.env) {
|
|
51
|
+
const list = Array.isArray(argv) ? argv.map(String) : [];
|
|
52
|
+
const i = list.indexOf("--ref");
|
|
53
|
+
if (i > -1) return normalizeRefCode(list[i + 1]);
|
|
54
|
+
return normalizeRefCode(env && env.DSH_REF);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 把本次安装解析到的 ref_code 合并进**已从磁盘读出的**配置对象。
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ saveConfig() 是整对象覆盖写:必须先 loadConfig() 拿到旧对象,在这里合并,再整份写回。
|
|
61
|
+
* 否则「升级时没带 --ref」会把上一次写入的 ref_code 清空(归因只在第一次安装时存在,
|
|
62
|
+
* 之后就永久丢了)。本次没带/非法 → 原样保留配置里的旧值。
|
|
63
|
+
*/
|
|
64
|
+
export function applyRefCodeToConfig(cfg, argv, env = process.env) {
|
|
65
|
+
const next = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? { ...cfg } : {};
|
|
66
|
+
const code = resolveRefCode(argv, env);
|
|
67
|
+
if (code) next.ref_code = code;
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execSync } from "node:child_process";
|
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
32
32
|
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
|
|
33
|
+
import { normalizeRefCode, applyRefCodeToConfig } from "./clients/dsh-remote/src/channel-ref.mjs";
|
|
33
34
|
|
|
34
35
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
35
36
|
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
@@ -74,6 +75,39 @@ const BRIDGE_PID_FILE = ".dsh-bridge.pid";
|
|
|
74
75
|
*/
|
|
75
76
|
const DUPLICATE_INSTANCE_EXIT_CODE = 4;
|
|
76
77
|
const DUPLICATE_BRIDGE_BACKOFF_MS = 5 * 60 * 1000;
|
|
78
|
+
/**
|
|
79
|
+
* 设备绑定「永久性失败」的退避(契约 docs/device-overquota-lock-contract.md §6.3/§6.4)。
|
|
80
|
+
*
|
|
81
|
+
* bridge 遇到服务端 `device_bind_cap`(防滥用硬上限)会写 `.bind-error.json` 并以**专用退出码 5**
|
|
82
|
+
* 退出:这是永久性失败 —— 用户不去手机端解绑就不会好。旧逻辑与普通失败共用 exit(1),
|
|
83
|
+
* watcher 于是每 10s 拉起一次、每 10s 撞一次 409(日志刷屏、白耗一次 device-login 额度)。
|
|
84
|
+
* 现在按 10s→1min→5min→15min 指数退避(封顶 30min);`.bind-error.json` 的**内容签名**变化
|
|
85
|
+
* (换账号 / 解绑后重试得到了不同结论 / 提示被清掉)时复位,用户处理完不必等满退避窗口。
|
|
86
|
+
*/
|
|
87
|
+
const DEVICE_BIND_CAP_EXIT_CODE = 5;
|
|
88
|
+
const BIND_CAP_BACKOFF_MS = [10_000, 60_000, 300_000, 900_000];
|
|
89
|
+
const BIND_CAP_BACKOFF_MAX_MS = 30 * 60 * 1000;
|
|
90
|
+
/** bridge 写的「设备登记失败提示」(面板读它展示原因;watcher 用它的签名判断是否需要复位退避)。 */
|
|
91
|
+
const BIND_ERROR_FILE = path.join(CONFIG_DIR, ".bind-error.json");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `.bind-error.json` 的稳定签名:只取 code/device_id/message,**不含 at** ——
|
|
95
|
+
* bridge 每次重试都会刷新 at,拿它当变化依据等于"永远变化",退避会形同虚设。
|
|
96
|
+
*/
|
|
97
|
+
function bindErrorSignature() {
|
|
98
|
+
try {
|
|
99
|
+
const j = JSON.parse(fs.readFileSync(BIND_ERROR_FILE, "utf8")) || {};
|
|
100
|
+
return [j.code || "", j.device_id || "", j.message || ""].join("|");
|
|
101
|
+
} catch { return ""; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 第 step 次(从 1 开始)退出码 5 后的退避时长:10s→1min→5min→15min,之后翻倍到封顶 30min。 */
|
|
105
|
+
function bindCapBackoffMs(step) {
|
|
106
|
+
const list = BIND_CAP_BACKOFF_MS;
|
|
107
|
+
const n = Math.max(1, Number(step) || 1);
|
|
108
|
+
if (n <= list.length) return Math.min(BIND_CAP_BACKOFF_MAX_MS, list[n - 1]);
|
|
109
|
+
return BIND_CAP_BACKOFF_MAX_MS;
|
|
110
|
+
}
|
|
77
111
|
|
|
78
112
|
// ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
|
|
79
113
|
// npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
|
|
@@ -104,9 +138,9 @@ function findDepWs(startDir) {
|
|
|
104
138
|
* 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。
|
|
105
139
|
* ⚠️ `src/lifecycle.mjs` 也在表里:本安装器**自己**import 它(进程优雅停机的共用实现),
|
|
106
140
|
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
107
|
-
* 却永远起不来 bridge —— 2026-09-23
|
|
141
|
+
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
|
|
108
142
|
*/
|
|
109
|
-
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs"];
|
|
143
|
+
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
110
144
|
|
|
111
145
|
/**
|
|
112
146
|
* 把"客户端脚本"补齐到配置目录。
|
|
@@ -196,6 +230,9 @@ function printHelp() {
|
|
|
196
230
|
自建模式(可选):
|
|
197
231
|
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
198
232
|
|
|
233
|
+
可选参数:
|
|
234
|
+
--ref <标识> 指定接入标识(可选,用于路由/灰度分组)
|
|
235
|
+
|
|
199
236
|
登录/连接配置: 打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可
|
|
200
237
|
(自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
|
|
201
238
|
文档: ${REPO_URL}
|
|
@@ -204,7 +241,7 @@ function printHelp() {
|
|
|
204
241
|
|
|
205
242
|
// 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
|
|
206
243
|
const KNOWN_FLAGS = new Set([
|
|
207
|
-
"--api", "--server", "--key", "--profile",
|
|
244
|
+
"--api", "--server", "--key", "--profile", "--ref",
|
|
208
245
|
"--no-autostart", "--no-plugin", "--no-restart", "--uninstall", "--force"
|
|
209
246
|
]);
|
|
210
247
|
|
|
@@ -1803,6 +1840,12 @@ async function runBridge() {
|
|
|
1803
1840
|
let warnedDuplicateBridge = false;
|
|
1804
1841
|
/** 限流提示只打一次,避免刷屏。 */
|
|
1805
1842
|
let warnedRateLimit = false;
|
|
1843
|
+
/** 设备绑定硬上限(bridge 退出码 5)的指数退避状态(契约 §6.4)。 */
|
|
1844
|
+
let bindCapBackoffStep = 0;
|
|
1845
|
+
let bindCapBackoffUntil = 0;
|
|
1846
|
+
let bindCapErrorSig = "";
|
|
1847
|
+
/** 退避提示只打一次,避免刷屏。 */
|
|
1848
|
+
let warnedBindCap = false;
|
|
1806
1849
|
|
|
1807
1850
|
// watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
|
|
1808
1851
|
// 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
|
|
@@ -1863,6 +1906,7 @@ async function runBridge() {
|
|
|
1863
1906
|
await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
|
|
1864
1907
|
bridgeAccountFp = "";
|
|
1865
1908
|
duplicateBridgeBackoffUntil = 0; // 用户明确换了账号:清掉"让位"暂停,立即用新凭据拉起
|
|
1909
|
+
bindCapBackoffStep = 0; bindCapBackoffUntil = 0; bindCapErrorSig = ""; // 换账号 = 绑定前提已变:设备绑定退避一并复位
|
|
1866
1910
|
return; // 下一轮(10s 内)用新配置重新拉起
|
|
1867
1911
|
}
|
|
1868
1912
|
// 指纹又回到一致(或本来就没变)→ 之前的"疑似变化"作废,绝不让它跨轮累积。
|
|
@@ -1891,6 +1935,28 @@ async function runBridge() {
|
|
|
1891
1935
|
}
|
|
1892
1936
|
warnedDuplicateBridge = false;
|
|
1893
1937
|
|
|
1938
|
+
// 设备绑定硬上限(bridge 退出码 5):永久性失败 → 指数退避,别每 10s 空撞一次。
|
|
1939
|
+
// 复位:`.bind-error.json` 的内容签名变了(换账号 / 用户解绑后重试得到不同结论 / 提示被清掉)。
|
|
1940
|
+
// 提示只打一次;退避窗口内直接返回、不拉起 bridge(也不占用一次 device-login 额度)。
|
|
1941
|
+
const bindSig = bindErrorSignature();
|
|
1942
|
+
if (bindSig !== bindCapErrorSig) {
|
|
1943
|
+
if (bindCapBackoffUntil > Date.now()) {
|
|
1944
|
+
console.log("[dsh-remote] 设备登记失败提示已变化(解绑/换账号/服务端结论变了)→ 重置设备绑定退避,立即重试。");
|
|
1945
|
+
}
|
|
1946
|
+
bindCapErrorSig = bindSig;
|
|
1947
|
+
bindCapBackoffStep = 0;
|
|
1948
|
+
bindCapBackoffUntil = 0;
|
|
1949
|
+
}
|
|
1950
|
+
if (Date.now() < bindCapBackoffUntil) {
|
|
1951
|
+
if (!warnedBindCap) {
|
|
1952
|
+
warnedBindCap = true;
|
|
1953
|
+
console.log(`[dsh-remote] 设备绑定已达上限(bridge code=${DEVICE_BIND_CAP_EXIT_CODE})→ 本守护暂停拉起约 ${Math.ceil((bindCapBackoffUntil - Date.now()) / 1000)} 秒(指数退避,最长 30 分钟)。`);
|
|
1954
|
+
console.log(" 请在手机端「管理电脑」解绑不再使用的设备;解绑后会自动恢复,无需重启。");
|
|
1955
|
+
}
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
warnedBindCap = false;
|
|
1959
|
+
|
|
1894
1960
|
reconcileUpstreamEnvFromFile(); // dsh 可能刚重启并换了端口:拉起前再以最新端口文件为准
|
|
1895
1961
|
const alive = await checkUpstream();
|
|
1896
1962
|
if (alive && childStopped(bridgeProc)) {
|
|
@@ -1954,6 +2020,24 @@ async function runBridge() {
|
|
|
1954
2020
|
console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
|
|
1955
2021
|
return;
|
|
1956
2022
|
}
|
|
2023
|
+
// 设备绑定硬上限(退出码 5,契约 §6.3/§6.4):永久性失败 → 指数退避重试,
|
|
2024
|
+
// 绝不沿用"10s 后照常重拉"(那会每 10s 撞一次 409,日志刷屏且白耗登录额度)。
|
|
2025
|
+
if (code === DEVICE_BIND_CAP_EXIT_CODE) {
|
|
2026
|
+
const sig = bindErrorSignature();
|
|
2027
|
+
if (sig && sig !== bindCapErrorSig) {
|
|
2028
|
+
bindCapErrorSig = sig;
|
|
2029
|
+
bindCapBackoffStep = 1;
|
|
2030
|
+
console.log("[dsh-remote] 设备绑定失败提示已变化 → 重置设备绑定退避。");
|
|
2031
|
+
} else {
|
|
2032
|
+
bindCapBackoffStep = Math.min(bindCapBackoffStep + 1, 99);
|
|
2033
|
+
}
|
|
2034
|
+
const waitMs = bindCapBackoffMs(bindCapBackoffStep);
|
|
2035
|
+
bindCapBackoffUntil = Date.now() + waitMs;
|
|
2036
|
+
warnedBindCap = false;
|
|
2037
|
+
console.log(`[dsh-remote] bridge 因设备绑定达到硬上限退出(code=${code},第 ${bindCapBackoffStep} 次)——本守护 ${Math.round(waitMs / 1000)} 秒后再试(指数退避,最长 30 分钟)。`);
|
|
2038
|
+
console.log(" 请在手机端「管理电脑」解绑不再使用的设备;解绑后自动恢复,无需重启。");
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
1957
2041
|
console.log("[dsh-remote] bridge 退出,等待重启...");
|
|
1958
2042
|
});
|
|
1959
2043
|
// Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
|
|
@@ -2022,6 +2106,11 @@ async function setup(argv) {
|
|
|
2022
2106
|
const selfHosted = Boolean(server || key);
|
|
2023
2107
|
|
|
2024
2108
|
let cfg = loadConfig();
|
|
2109
|
+
// 接入标识(可选):本次带合法 `--ref <数字>`(或环境变量 DSH_REF)才写入;没带/非法 →
|
|
2110
|
+
// **保留**配置里已有的值。⚠️ saveConfig 是整对象覆盖写,所以必须在刚 loadConfig() 的这份
|
|
2111
|
+
// 对象上合并再写回 —— 绝不能新建对象,否则升级装一次就把上一次的归因清空了。
|
|
2112
|
+
// 非法值静默忽略:归因是附加信息,绝不能因此让安装失败。
|
|
2113
|
+
cfg = applyRefCodeToConfig(cfg, argv);
|
|
2025
2114
|
|
|
2026
2115
|
if (selfHosted) {
|
|
2027
2116
|
if (!server || !key) {
|
|
@@ -3446,6 +3535,11 @@ else if (cmd === "status") {
|
|
|
3446
3535
|
console.log("配置文件:", CONFIG_PATH);
|
|
3447
3536
|
console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
|
|
3448
3537
|
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
3538
|
+
// 接入标识(安装时 --ref 写入):运维自查用;没设置就不打这一行,保持默认输出稳定。
|
|
3539
|
+
{
|
|
3540
|
+
const refCode = normalizeRefCode(cfg.ref_code);
|
|
3541
|
+
if (refCode) console.log("接入标识:", refCode);
|
|
3542
|
+
}
|
|
3449
3543
|
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程访问」查看与操作");
|
|
3450
3544
|
// 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
|
|
3451
3545
|
const profileDir = resolveProfileDir([]);
|