cofluxd 0.4.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +22 -3
  2. package/cofluxd.mjs +189 -48
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,9 +14,10 @@ npm i -g cofluxd
14
14
  cofluxd # 首次=up(起服务后打印浏览器授权链接),之后=看状态
15
15
  cofluxd up # 幂等:零参数即可装/起;已装则按当前配置重装服务并重启
16
16
  cofluxd status # 服务器/登记(含"等待授权")/服务/连接状态
17
- cofluxd doctor # 分层连通性自检(DNS→TCP→TLS→WS 升级)+ 本地状态汇总
17
+ cofluxd doctor # 中心网络 + gateway/grant/loopback + daemon 状态分层自检
18
18
  cofluxd logs -f # 看 daemon 日志
19
- cofluxd update # 更新本地 supervisor 二进制并重启(worker server 自动热升级)
19
+ cofluxd update # 下载新二进制(不重启;supervisor 有变化时提示用 restart 应用)
20
+ cofluxd restart # 重启 daemon 应用新 supervisor(⚠ 结束本机所有活会话)
20
21
  cofluxd down # 停止
21
22
  cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一并删)
22
23
  ```
@@ -25,7 +26,25 @@ cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一
25
26
 
26
27
  `cofluxd up` 起服务后会打印一个一次性授权链接,在浏览器用已登录的账号打开确认即可(链接可在任意设备打开,包括无头设备),无需先去 web 控制台生成密钥。已登记设备重跑 `up` 不会重新触发授权。
27
28
 
28
- 连不上时用 `cofluxd doctor` 逐层排查(DNS 解析 → TCP 连接 → TLS 握手 → WS 升级握手),失败层会给出具体指向;各层都通但仍未连接说明问题在认证层,查 `cofluxd logs`。
29
+ ## 本地优先与 doctor
30
+
31
+ desktop web 与 daemon 同机时,terminal/普通 Device RPC 优先连接本机固定 gateway(默认
32
+ `127.0.0.1:8788`);失败会自动走中心 opaque relay。远端访问始终可走 relay。`cofluxd doctor` 把两条
33
+ 路径分开诊断:
34
+
35
+ ```text
36
+ 中心:DNS → TCP → TLS → WebSocket
37
+ 本地:gateway bind → 持久 grant/Origin → loopback WebSocket
38
+ 状态:daemon → 中心的实际连接状态
39
+ ```
40
+
41
+ - 本地项失败:结论是“直连降级”,只影响同机低延迟路径;中心 relay 正常时 daemon 仍在线可用。
42
+ - 中心项失败:已经加载、已经配对且 cached direct 可用的页面仍能控制存活 session;刷新/冷启动不保证。
43
+ - 网络层都通但 daemon 未连接:查看 `cofluxd logs`,通常是认证、版本或服务进程问题。
44
+
45
+ doctor 只读取 gateway store 的结构、grant/Origin 数量和 bind 状态,不打印 browser 私钥、grant id、
46
+ device token 或其它凭证。它的 loopback 检查只做主机侧 WebSocket upgrade;浏览器自身的 LNA/permission
47
+ 仍以 Chrome/Safari/Firefox 页面实测为准。
29
48
 
30
49
  > `onboard`、`reload` 命令已移除:onboard 并入零参数 `up`,reload 并入幂等化后的 `up`(重跑 `up` 即按 settings.json 重装服务并重启)。
31
50
 
package/cofluxd.mjs CHANGED
@@ -24,6 +24,7 @@ const LOG_FILE = join(HOME, "daemon.log");
24
24
  const CRED = join(HOME, "credentials.json");
25
25
  const PENDING_AUTH = join(HOME, "pending-auth.json"); // worker 落盘的待授权链接(daemon.authorizePending)
26
26
  const CONN_STATE = join(HOME, "conn-state.json"); // worker 落盘的连接态快照(plan 033,见 crates/worker/src/conn_state.rs)
27
+ const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // gateway key/origin/grant;doctor 只读结构与数量,绝不打印秘密
27
28
  const FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
28
29
  const SUP_BIN = join(BIN_DIR, "coflux-supervisor");
29
30
  const WRK_BIN = join(BIN_DIR, "coflux-worker");
@@ -31,6 +32,7 @@ const IS_MAC = platform() === "darwin";
31
32
  const IS_LINUX = platform() === "linux";
32
33
  const PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.daemon.plist");
33
34
  const UNIT = join(homedir(), ".config", "systemd", "user", "coflux-daemon.service");
35
+ const DEFAULT_LOCAL_GATEWAY_PORT = 8788; // 与 packages/crates protocol 的 LOCAL_GATEWAY_PORT 保持一致
34
36
 
35
37
  const die = (m) => { console.error("✗ " + m); process.exit(1); };
36
38
  const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opts });
@@ -53,6 +55,46 @@ function readPendingAuth() {
53
55
  function readConnState() {
54
56
  try { return JSON.parse(fs.readFileSync(CONN_STATE, "utf8")); } catch { return null; }
55
57
  }
58
+
59
+ function readLocalGatewaySummary() {
60
+ let store;
61
+ try { store = JSON.parse(fs.readFileSync(LOCAL_GATEWAY_STORE, "utf8")); }
62
+ catch {
63
+ return {
64
+ ok: false,
65
+ ready: false,
66
+ // JSON.parse 的错误在部分 Node 版本会带原文片段;store 含私钥,诊断输出绝不能回显。
67
+ error: fs.existsSync(LOCAL_GATEWAY_STORE) ? "grant store 无法解析" : "grant store 尚未创建",
68
+ };
69
+ }
70
+ if (store?.version !== 1 || !Array.isArray(store.origins) || !Array.isArray(store.grants)) {
71
+ return { ok: false, ready: false, error: "grant store 结构/版本无效" };
72
+ }
73
+ const origins = store.origins.filter((origin) => typeof origin === "string" && origin.length > 0);
74
+ const grants = store.grants.filter((grant) => grant && typeof grant.origin === "string" && grant.origin.length > 0);
75
+ const origin = grants.find((grant) => origins.includes(grant.origin))?.origin || origins[0];
76
+ const ready = grants.length > 0 && !!origin;
77
+ const summary = ready
78
+ ? `${grants.length} 个持久 grant / ${origins.length} 个 Origin`
79
+ : `${grants.length} 个 grant / ${origins.length} 个 Origin(尚无可用浏览器配对)`;
80
+ return {
81
+ ok: ready,
82
+ ready,
83
+ origin,
84
+ detail: ready ? summary : undefined,
85
+ error: ready ? undefined : summary,
86
+ };
87
+ }
88
+
89
+ function localGatewayPort() {
90
+ const raw = process.env.COFLUX_LOCAL_GATEWAY_PORT;
91
+ if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
92
+ const port = Number(raw);
93
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
94
+ return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} 无法定位固定监听端口` };
95
+ }
96
+ return { ok: true, port };
97
+ }
56
98
  const CONN_STATE_LABEL = { connecting: "连接中", connected: "已连接", reconnecting: "重连中" };
57
99
  function formatDuration(ms) {
58
100
  const s = Math.max(0, Math.round(ms / 1000));
@@ -73,6 +115,22 @@ function fdaLabel(status) {
73
115
 
74
116
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
75
117
 
118
+ function fileSha256(path) {
119
+ try { return crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); } catch { return null; }
120
+ }
121
+
122
+ // macOS:新落盘的二进制带 com.apple.provenance,launchd 顶层 spawn 会被 AMFI 以
123
+ // OS_REASON_CODESIGNING 静默杀死——即使产物是 Developer ID 签名 + 已公证(2026-07-25
124
+ // v0.13.0 实测仍被连杀)。本地 ad-hoc 重签使其成为"本机产物"绕开该检查;签名威胁模型
125
+ // 不受影响(防的是中心被攻破推恶意产物,靠下载后的 sha256+ed25519 验签,与此无关)。
126
+ function resignMacBinaries(paths) {
127
+ if (!IS_MAC) return;
128
+ for (const p of paths) {
129
+ const r = run("codesign", ["--force", "-s", "-", p]);
130
+ if (r.status !== 0) console.warn(`⚠ 本地重签失败: ${p}(daemon 若被系统杀,手动 codesign --force -s - 该文件后 cofluxd restart)`);
131
+ }
132
+ }
133
+
76
134
  async function download(url, dest) {
77
135
  const res = await fetch(url, { redirect: "follow" });
78
136
  if (!res.ok) die(`下载失败 HTTP ${res.status}: ${url}\n(该版本/平台的 release 资产是否已发布?)`);
@@ -103,6 +161,7 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
103
161
  fs.copyFileSync(src, join(BIN_DIR, b));
104
162
  fs.chmodSync(join(BIN_DIR, b), 0o755);
105
163
  }
164
+ resignMacBinaries([SUP_BIN, WRK_BIN]);
106
165
  console.log(`✓ 用本地二进制(${binDir})`);
107
166
  return;
108
167
  }
@@ -124,6 +183,7 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
124
183
  await download(`${base}/${b}-${t}`, join(BIN_DIR, b));
125
184
  console.log("✓");
126
185
  }
186
+ resignMacBinaries([SUP_BIN, WRK_BIN]);
127
187
  }
128
188
 
129
189
  // 写 settings.json(daemon 直接读)。
@@ -256,20 +316,41 @@ function cmdDown() { stopService(); console.log("✓ 已停止"); }
256
316
  // 只有 supervisor 真正靠这条命令更新——它不自动升级(持 PTY,重启会杀会话,见 plan 017)。
257
317
  // worker 运行中会被 server 自动热升级到最新 stable(无需停服);这里顺带刷新的是 worker 的
258
318
  // "内置兜底二进制"(supervisor 冷启动/热升级缓存皆无时的 fallback),日常可不管。
319
+ //
320
+ // update 与 restart 拆开(2026-07-25 用户拍板):update 只落盘二进制、绝不重启——替换磁盘
321
+ // 文件对运行中的 supervisor/PTY 零影响;supervisor 内容没变就明说不用重启(大多数发版
322
+ // 都不动 supervisor,旧行为等于白丢一次全部会话)。真正的中断收敛到显式的 `cofluxd restart`。
259
323
  async function cmdUpdate(v) {
260
324
  if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up");
261
325
  const version = v.version || "latest";
326
+ const before = fileSha256(SUP_BIN);
262
327
  await ensureBinaries({ version, binDir: v["bin-dir"] });
328
+ if (before === fileSha256(SUP_BIN)) {
329
+ console.log("✓ 已更新:supervisor 无变化——不需要重启,活会话不受影响");
330
+ console.log(" worker 由 server 自动热升级;本次刷新的只是兜底 worker 二进制。");
331
+ } else {
332
+ console.log(`✓ 新 supervisor 已就位(${v["bin-dir"] ? "本地产物" : version}),但尚未生效`);
333
+ console.log(" ⚠ 运行 `cofluxd restart` 应用——会重启 daemon 并结束本机所有活会话(PTY 随 supervisor 退出)。");
334
+ }
335
+ }
336
+
337
+ // 唯一会主动中断会话的命令:应用已就位的新 supervisor(或单纯重启 daemon)。
338
+ function cmdRestart() {
339
+ if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up");
340
+ console.log("⚠ 正在重启 daemon:本机所有活会话将结束(PTY 随 supervisor 退出)…");
263
341
  restartService();
264
- console.log(`✓ supervisor 已更新到 ${v["bin-dir"] ? "本地产物" : version} 并重启`);
265
- console.log(" worker 由 server 自动热升级,通常无需手动更新;此命令只更新 supervisor(及其兜底 worker 二进制)。");
342
+ console.log("✓ 已重启(cofluxd status 查看状态)");
266
343
  }
267
344
 
268
345
  // launchd(macOS) / systemd --user(Linux) 服务活跃态查询,status 与 doctor 共用。
346
+ // macOS 必须以 launchctl list 输出里的 PID 为准:label 登记 ≠ 进程存活(supervisor 被
347
+ // OS_REASON_CODESIGNING 杀掉时 label 仍在,旧实现会报假"运行中",2026-07-25 实测踩过)。
269
348
  function serviceRunningInfo() {
270
349
  if (IS_MAC) {
271
- const running = run("launchctl", ["list", "com.coflux.daemon"]).status === 0;
272
- return { running, active: running ? "运行中" : "未运行" };
350
+ const out = run("launchctl", ["list", "com.coflux.daemon"]);
351
+ const registered = out.status === 0;
352
+ const running = registered && /"PID"\s*=\s*\d+/.test(out.stdout || "");
353
+ return { running, active: running ? "运行中" : registered ? "已登记但进程不在(看 cofluxd logs;常见于系统拒签杀进程)" : "未运行" };
273
354
  }
274
355
  if (IS_LINUX) {
275
356
  const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim() || "未运行";
@@ -315,10 +396,9 @@ function cmdStatus() {
315
396
  }
316
397
  }
317
398
 
318
- /* ------------------------------ doctor:分层连通性自检 ------------------------------ */
319
- // 只做传输层探测(DNS/TCP/TLS/WS 升级),不解析 coflux 协议消息——CLI 保持零协议
320
- // (见 plan 035 Decisions)。每层探测成功即断开,不占用 server 的已认证连接名额;
321
- // WS 升级对 server 是一条未认证连接,server 侧 authDeadline 自然回收。
399
+ /* ------------------------------ doctor:中心 + 本地直连分层自检 ------------------------------ */
400
+ // 中心与 loopback 都只做传输层探测,不解析 coflux 协议消息——CLI 保持零协议。中心 WS
401
+ // 成功升级后立即断开;loopback 只验证已持久 Origin 能拿到 101,不发送 browser 私钥或 grant。
322
402
  const DOCTOR_TIMEOUT_MS = 5000;
323
403
 
324
404
  function parseServerUrl(serverUrl) {
@@ -368,12 +448,19 @@ function probeTls(host, port) {
368
448
  }
369
449
 
370
450
  // 手写一条最小 HTTP/1.1 Upgrade 请求,只看是否拿到 101——不建立真实 WebSocket 帧连接。
371
- function probeWsUpgrade({ host, port, path, useTls }) {
451
+ function probeWsUpgrade({ host, port, path, useTls, origin }) {
372
452
  return new Promise((resolve) => {
373
453
  const t0 = Date.now();
374
454
  const key = crypto.randomBytes(16).toString("base64");
375
- const req = `GET ${path} HTTP/1.1\r\nHost: ${host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
376
- const finish = (r) => resolve({ ms: Date.now() - t0, ...r });
455
+ const hostHeader = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
456
+ const originHeader = origin ? `Origin: ${origin}\r\n` : "";
457
+ const req = `GET ${path} HTTP/1.1\r\nHost: ${hostHeader}\r\n${originHeader}Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
458
+ let settled = false;
459
+ const finish = (r) => {
460
+ if (settled) return;
461
+ settled = true;
462
+ resolve({ ms: Date.now() - t0, ...r });
463
+ };
377
464
  const onOpen = (socket) => {
378
465
  let buf = "";
379
466
  const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error: `升级响应超时(>${DOCTOR_TIMEOUT_MS}ms)` }); }, DOCTOR_TIMEOUT_MS);
@@ -404,85 +491,138 @@ function printLayer(name, r) {
404
491
  console.log(` ${mark} ${name} (${r.ms}ms)${msg ? ` ${msg}` : ""}`);
405
492
  }
406
493
 
494
+ function printLocalLayer(name, r) {
495
+ const mark = r.ok ? "✓" : "⚠";
496
+ const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
497
+ const elapsed = Number.isFinite(r.ms) ? ` (${r.ms}ms)` : "";
498
+ console.log(` ${mark} ${name}${elapsed}${msg ? ` ${msg}` : ""}`);
499
+ }
500
+
407
501
  // 本地事实汇总:服务进程存活、conn-state.json 连接态、凭证有无、FDA。返回连接态三态:
408
502
  // "connected" | "not-connected"(有快照但非 connected,如 connecting/reconnecting)
409
503
  // | "unknown"(服务未运行,或无快照——daemon 版本较旧还没写、或刚启动)。
410
504
  // 三态区分是因为"无快照"不等于"未连接":旧版 worker 不写 conn-state.json,把它当"未连接"
411
505
  // 会把"连接态未知"误报成"认证/授权层有问题"(2026-07-23 实操验收发现)。
412
506
  function printLocalFacts() {
413
- console.log("\n 本地状态\n ────────");
414
- console.log(` 凭证: ${fs.existsSync(CRED) ? "已登记" : "未登记"}`);
507
+ console.log("\n Daemon 状态\n ───────────");
508
+ const registered = fs.existsSync(CRED);
509
+ console.log(` 凭证: ${registered ? "已登记" : "未登记"}`);
415
510
  const { running, active } = serviceRunningInfo();
416
511
  console.log(` 服务: ${active}`);
417
512
  let connState = "unknown";
418
513
  if (running) {
419
514
  const conn = readConnState();
420
515
  if (conn?.state && CONN_STATE_LABEL[conn.state]) {
421
- console.log(` 连接: ${CONN_STATE_LABEL[conn.state]}`);
516
+ console.log(` 中心: ${CONN_STATE_LABEL[conn.state]}`);
422
517
  connState = conn.state === "connected" ? "connected" : "not-connected";
423
518
  } else {
424
- console.log(" 连接: (无快照)");
519
+ console.log(" 中心: (无连接态快照)");
425
520
  }
521
+ } else {
522
+ console.log(" 中心: 未探测(服务未运行)");
426
523
  }
427
524
  if (IS_MAC) console.log(` FDA: ${fdaLabel(readFdaStatus())}`);
428
525
  console.log("");
429
- return connState;
526
+ return { registered, running, connState };
430
527
  }
431
528
 
432
- function printConclusion(ok, msg) {
433
- console.log(` ${ok ? "✓" : "✗"} ${msg}\n`);
529
+ function printConclusion(level, msg) {
530
+ const mark = level === "ok" ? "✓" : level === "warning" ? "⚠" : "✗";
531
+ console.log(` ${mark} ${msg}\n`);
434
532
  }
435
533
 
436
- async function cmdDoctor() {
437
- const s = readSettings();
438
- const serverUrl = s.serverUrl || DEFAULT_SERVER;
439
- console.log(`\n 连通性自检 —— ${serverUrl}\n ────────────────────────────\n`);
440
- let target;
441
- try { target = parseServerUrl(serverUrl); }
442
- catch (e) { die(`server_url 解析失败: ${serverUrl}(${e.message})`); }
534
+ async function probeCenter(target) {
443
535
  const { host, port, useTls } = target;
444
-
445
536
  const dnsR = await probeDns(host);
446
537
  printLayer("DNS 解析", dnsR);
447
538
  if (!dnsR.ok) {
448
- printConclusion(false, "DNS 解析失败——检查网络连接/DNS 配置,或该域名是否可达。");
449
- printLocalFacts();
450
- return;
539
+ return { ok: false, reason: "DNS 解析失败——检查网络连接/DNS 配置,或该域名是否可达。" };
451
540
  }
452
541
 
453
542
  const tcpR = await probeTcp(host, port);
454
543
  printLayer(`TCP 连接 (${host}:${port})`, tcpR);
455
544
  if (!tcpR.ok) {
456
- printConclusion(false, "DNS 可解析但 TCP 连不上——防火墙/代理拦截,或目标端口未开放。");
457
- printLocalFacts();
458
- return;
545
+ return { ok: false, reason: "DNS 可解析但 TCP 连不上——防火墙/代理拦截,或目标端口未开放。" };
459
546
  }
460
547
 
461
548
  if (useTls) {
462
549
  const tlsR = await probeTls(host, port);
463
550
  printLayer("TLS 握手", tlsR);
464
551
  if (!tlsR.ok) {
465
- printConclusion(false, "TCP 可连但 TLS 握手失败——可能是企业代理 MITM 证书、系统时间错误,或服务端证书问题。");
466
- printLocalFacts();
467
- return;
552
+ return { ok: false, reason: "TCP 可连但 TLS 握手失败——可能是企业代理 MITM 证书、系统时间错误,或服务端证书问题。" };
468
553
  }
469
554
  }
470
555
 
471
556
  const wsR = await probeWsUpgrade(target);
472
557
  printLayer("WS 升级握手", wsR);
473
558
  if (!wsR.ok) {
474
- printConclusion(false, "网络层通但 WebSocket 升级被拒——可能是反代/负载均衡未正确转发 Upgrade 头,或路径不对。");
475
- printLocalFacts();
476
- return;
559
+ return { ok: false, reason: "网络层通但 WebSocket 升级被拒——可能是反代/负载均衡未正确转发 Upgrade 头,或路径不对。" };
560
+ }
561
+ return { ok: true };
562
+ }
563
+
564
+ async function probeLocalDirect() {
565
+ const portResult = localGatewayPort();
566
+ if (!portResult.ok) {
567
+ printLocalLayer("Gateway bind", { ok: false, error: portResult.error });
568
+ printLocalLayer("Loopback WS", { ok: false, error: "固定 gateway 端口未知,无法探测" });
569
+ const grant = readLocalGatewaySummary();
570
+ printLocalLayer("本地 grant", grant);
571
+ return { ready: false };
477
572
  }
478
573
 
479
- const connState = printLocalFacts();
480
- const conclusion = {
481
- connected: "各层连通性正常。",
482
- "not-connected": "各层连通性正常,但本地未显示已连接——问题大概率在认证/授权层而非网络层,查 `cofluxd logs`。",
483
- unknown: "各层连通性正常;本地连接态未知(daemon 版本较旧或刚启动,尚无 conn-state 快照),如有异常查 `cofluxd logs`。",
484
- }[connState];
485
- printConclusion(true, conclusion);
574
+ const bind = await probeTcp("127.0.0.1", portResult.port);
575
+ printLocalLayer(`Gateway bind (127.0.0.1:${portResult.port})`, bind);
576
+ const grant = readLocalGatewaySummary();
577
+ printLocalLayer("本地 grant", grant);
578
+
579
+ let loopback;
580
+ if (!bind.ok) {
581
+ loopback = { ok: false, error: "gateway 未监听,跳过 WS 握手" };
582
+ } else {
583
+ loopback = await probeWsUpgrade({
584
+ host: "127.0.0.1",
585
+ port: portResult.port,
586
+ path: "/device",
587
+ useTls: false,
588
+ // 没有持久 Origin 时仍发一个合法 Origin;403 能区分“coflux gateway 可达但未配对”。
589
+ origin: grant.origin || "http://127.0.0.1",
590
+ });
591
+ }
592
+ printLocalLayer("Loopback WS(主机侧)", loopback);
593
+ return { ready: bind.ok && grant.ready && loopback.ok };
594
+ }
595
+
596
+ async function cmdDoctor() {
597
+ const s = readSettings();
598
+ const serverUrl = s.serverUrl || DEFAULT_SERVER;
599
+ console.log(`\n 中心网络 —— ${serverUrl}\n ────────────────────────────`);
600
+ let center;
601
+ try {
602
+ center = await probeCenter(parseServerUrl(serverUrl));
603
+ } catch (error) {
604
+ printLayer("server URL", { ok: false, ms: 0, error: `${serverUrl}(${error.message})` });
605
+ center = { ok: false, reason: "中心地址配置无效。" };
606
+ }
607
+
608
+ console.log("\n 本地直连\n ────────");
609
+ const direct = await probeLocalDirect();
610
+ const facts = printLocalFacts();
611
+ const relayReady = center.ok && facts.running && facts.connState === "connected";
612
+
613
+ if (direct.ready && relayReady) {
614
+ printConclusion("ok", "本地直连与中心 relay 均可用。");
615
+ } else if (!direct.ready && relayReady) {
616
+ printConclusion("warning", "直连降级:中心 relay 仍可用,daemon 不是离线;检查上面的 gateway/grant/loopback 项。");
617
+ } else if (direct.ready && !center.ok) {
618
+ printConclusion("warning", "中心不可达,但本地直连可用;已加载且已配对页面可继续会话,刷新/冷启动不保证。");
619
+ } else if (direct.ready) {
620
+ printConclusion("warning", "本地直连可用;中心网络可达,但 daemon→中心连接未确认,relay 状态未知。");
621
+ } else if (center.ok) {
622
+ printConclusion("warning", "直连降级;中心网络可达,但 daemon→中心连接未确认。不要据此把 daemon 判为离线,查 `cofluxd logs`。");
623
+ } else {
624
+ printConclusion("error", `${center.reason || "中心不可达"} 同时本地直连降级;当前两条路径都未确认可用。`);
625
+ }
486
626
  }
487
627
 
488
628
  function cmdLogs(v) {
@@ -529,8 +669,9 @@ const HELP = `cofluxd —— coflux daemon 管理
529
669
  cofluxd 首次=up(打印浏览器授权链接),已配置=status
530
670
  cofluxd up [flags] 幂等:首次装+起,已装则按当前 settings.json 重装服务并重启
531
671
  cofluxd status 服务器/登记(含"等待授权")/服务/连接状态
532
- cofluxd doctor 分层连通性自检(DNS→TCP→TLS→WS 升级)+ 本地状态汇总
533
- cofluxd update 更新本地 supervisor 二进制并重启(worker server 自动热升级)
672
+ cofluxd doctor 中心网络 + gateway bind/grant/loopback + daemon 状态分层自检
673
+ cofluxd update 下载新二进制(不重启;supervisor 有变化时提示用 restart 应用)
674
+ cofluxd restart 重启 daemon 应用新 supervisor(⚠ 结束本机所有活会话)
534
675
  cofluxd fda [仅 macOS] 引导授予完全磁盘访问权限(避免 PTY 因 TCC 弹窗卡住)
535
676
  cofluxd logs [-f] 看 daemon 日志
536
677
  cofluxd down 停止
@@ -566,7 +707,7 @@ let cmd = positionals[0];
566
707
  if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
567
708
  if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "up"; // 首次裸跑 → 引导
568
709
 
569
- const handlers = { up: cmdUp, update: cmdUpdate, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall };
710
+ const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall };
570
711
  const h = handlers[cmd];
571
712
  if (!h) die(`未知命令: ${cmd}${MIGRATED[cmd] ? `\n${MIGRATED[cmd]}` : ""}\n\n${HELP}`);
572
713
  await h(values);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "coflux daemon 管理 CLI:装/起/停/升级 Rust daemon(supervisor + worker)",
5
5
  "type": "module",
6
6
  "bin": {