cofluxd 2.4.0 → 2.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.
- package/README.md +2 -2
- package/account-client.mjs +126 -5
- package/cofluxd.mjs +85 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,10 +45,10 @@ Requires Node.js 20 or later.
|
|
|
45
45
|
|
|
46
46
|
```sh
|
|
47
47
|
npm install -g cofluxd
|
|
48
|
-
cofluxd up
|
|
48
|
+
cofluxd up --key <join key>
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Get the command, join key included, from **Add device → Headless** in the Coflux desktop app. The key is single use and valid for one hour; the host joins your account as soon as the command runs. Without `--key`, `cofluxd up` prints an authorization link instead: open it in a browser signed in to your account. Use `cofluxd status`, `cofluxd doctor`, or `cofluxd logs -f` to inspect it.
|
|
52
52
|
|
|
53
53
|
## Operate your workspaces
|
|
54
54
|
|
package/account-client.mjs
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// 账号客户端与桌面共用中心操作层;本文件不依赖 MCP 或 Node 常驻进程。
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
4
|
import fs from "node:fs";
|
|
5
|
+
import http from "node:http";
|
|
3
6
|
import net from "node:net";
|
|
7
|
+
import { hostname } from "node:os";
|
|
4
8
|
import { join } from "node:path";
|
|
9
|
+
import readline from "node:readline";
|
|
5
10
|
|
|
6
11
|
/* --------------------------------- 实体标识 -------------------------------- */
|
|
7
12
|
// `coflux:<kind>:<hex>`:设备 / 项目 / 工作区 / 终端 ID 的可粘贴短形式,hex 是 ID 的前几位
|
|
@@ -73,6 +78,118 @@ function broker(home, body, timeout) {
|
|
|
73
78
|
socket.on("end", () => { try { resolve(unwrap(JSON.parse(text))); } catch (error) { reject(error); } });
|
|
74
79
|
});
|
|
75
80
|
}
|
|
81
|
+
/* ------------------------------ browser login ------------------------------ */
|
|
82
|
+
// `coflux login` with no credential flags (plan 20260923-oauth-login-redesign): RFC 8252 loopback
|
|
83
|
+
// redirect with PKCE (S256), or a paste code when the browser cannot reach back (SSH, or forced with
|
|
84
|
+
// COFLUX_LOGIN_PASTE=1). Same requests, same copy as the Rust CLI (crates/cli/src/browser_login.rs).
|
|
85
|
+
|
|
86
|
+
const LOGIN_FAILURES = { not_allowed: "该邮箱未开通 Coflux", not_verified: "该账号的邮箱未经验证,无法登录", cancelled: "已取消登录" };
|
|
87
|
+
|
|
88
|
+
function prefersPaste() {
|
|
89
|
+
return ["COFLUX_LOGIN_PASTE", "SSH_CONNECTION", "SSH_TTY", "SSH_CLIENT"].some((name) => !!process.env[name]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function openBrowser(url) {
|
|
93
|
+
try {
|
|
94
|
+
const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore", detached: true });
|
|
95
|
+
child.on("error", () => undefined);
|
|
96
|
+
child.unref();
|
|
97
|
+
} catch {
|
|
98
|
+
/* printing the URL is enough */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function escapeHtml(value) {
|
|
103
|
+
return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function callbackPage(title, body) {
|
|
107
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(title)} · Coflux</title><style>:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font:15px/1.5 -apple-system,BlinkMacSystemFont,sans-serif}main{text-align:center;padding:24px}h1{font-size:18px}</style></head><body><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(body)}</p></main></body></html>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function listenLoopback() {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const server = http.createServer();
|
|
113
|
+
server.once("error", () => resolve(null));
|
|
114
|
+
server.listen(0, "127.0.0.1", () => resolve(server));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function askLine(prompt) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: !!process.stdin.isTTY });
|
|
121
|
+
rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function browserLogin(server) {
|
|
126
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
127
|
+
const challenge = createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
128
|
+
const state = randomBytes(24).toString("base64url");
|
|
129
|
+
const listener = prefersPaste() ? null : await listenLoopback();
|
|
130
|
+
const port = listener ? listener.address().port : undefined;
|
|
131
|
+
let registered;
|
|
132
|
+
try {
|
|
133
|
+
registered = await request(server, "/api/client/login/request", null, {
|
|
134
|
+
protocolVersion: 1, clientKind: "cli", host: (hostname() || "unknown-host").slice(0, 253),
|
|
135
|
+
redirect: listener ? "loopback" : "paste", ...(listener ? { port } : {}),
|
|
136
|
+
codeChallenge: challenge, codeChallengeMethod: "S256", state,
|
|
137
|
+
}, 30000);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
listener?.close();
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const page = new URL(registered.url);
|
|
143
|
+
// Only ever open a page on the server we are logging into.
|
|
144
|
+
if (page.origin !== server) { listener?.close(); throw new Error("服务器返回的登录地址不在该服务器上"); }
|
|
145
|
+
const exchange = (code) => request(server, "/api/client/login/exchange", null, { protocolVersion: 1, code, codeVerifier: verifier }, 30000);
|
|
146
|
+
process.stderr.write(`在浏览器中打开以下地址完成登录(Ctrl-C 取消):\n ${page.href}\n`);
|
|
147
|
+
if (!listener) {
|
|
148
|
+
process.stderr.write("登录后页面会显示一次性登录码。\n");
|
|
149
|
+
const code = await askLine("粘贴登录码:");
|
|
150
|
+
if (!code) throw new Error("没有输入登录码");
|
|
151
|
+
return exchange(code);
|
|
152
|
+
}
|
|
153
|
+
openBrowser(page.href);
|
|
154
|
+
const waitMs = typeof registered.expiresAt === "number" && registered.expiresAt > Date.now() ? registered.expiresAt - Date.now() : 600000;
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const timer = setTimeout(() => finish(new Error("登录超时,请重新运行 coflux login")), waitMs);
|
|
157
|
+
let done = false;
|
|
158
|
+
function finish(error, value) {
|
|
159
|
+
if (done) return;
|
|
160
|
+
done = true;
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
listener.close();
|
|
163
|
+
setTimeout(() => listener.closeAllConnections?.(), 1000).unref();
|
|
164
|
+
if (error) reject(error); else resolve(value);
|
|
165
|
+
}
|
|
166
|
+
listener.on("request", (req, res) => {
|
|
167
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
168
|
+
const answer = (status, title, body) => { res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(callbackPage(title, body)); };
|
|
169
|
+
if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state) { answer(404, "页面不存在", "这个地址只用于 Coflux 登录回调。"); return; }
|
|
170
|
+
const failed = url.searchParams.get("error");
|
|
171
|
+
if (failed) {
|
|
172
|
+
const message = LOGIN_FAILURES[failed] ?? "登录未完成,请重试";
|
|
173
|
+
answer(200, "登录未完成", `${message}。可以关闭此页面,回到终端。`);
|
|
174
|
+
finish(new Error(message));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
exchange(url.searchParams.get("code") ?? "").then(
|
|
178
|
+
(value) => { answer(200, "已登录", "已登录,可以回到终端。"); finish(null, value); },
|
|
179
|
+
(error) => { answer(200, "登录未完成", "登录未完成,请回到终端查看原因。"); finish(error); },
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function saveSession(home, sessionPath, record) {
|
|
186
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
187
|
+
fs.chmodSync(home, 0o700);
|
|
188
|
+
const temp = `${sessionPath}.${process.pid}.tmp`;
|
|
189
|
+
try { fs.writeFileSync(temp, JSON.stringify(record), { mode: 0o600, flag: "wx" }); fs.renameSync(temp, sessionPath); }
|
|
190
|
+
finally { fs.rmSync(temp, { force: true }); }
|
|
191
|
+
}
|
|
192
|
+
|
|
76
193
|
/** 写到底再继续:`device exec` 之后要用远端退出码退出,而 process.exit 会截断写向管道的输出。 */
|
|
77
194
|
function writeAll(stream, text) {
|
|
78
195
|
if (!text) return Promise.resolve();
|
|
@@ -108,16 +225,20 @@ export async function runAccountCommand(positionals, flags, home) {
|
|
|
108
225
|
const print = (value) => console.log(JSON.stringify(value));
|
|
109
226
|
if (command === "login") {
|
|
110
227
|
const server = origin(flags.server || "https://api.coflux.dev");
|
|
228
|
+
// No credential flags: sign in through the browser (loopback + PKCE, or a paste code over SSH).
|
|
229
|
+
if (!flags.username && !flags["password-stdin"]) {
|
|
230
|
+
const value = await browserLogin(server);
|
|
231
|
+
if (!value?.token) throw new Error("服务器没有返回会话");
|
|
232
|
+
saveSession(home, sessionPath, { server, token: value.token, accountId: value.accountId });
|
|
233
|
+
console.log(`已登录为 ${value.login || "当前账号"}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
111
236
|
const username = required("username");
|
|
112
237
|
if (!flags["password-stdin"]) throw new Error("用 --password-stdin 从标准输入读取密码;密码不进入命令参数或配置文件");
|
|
113
238
|
let password = "";
|
|
114
239
|
for await (const chunk of process.stdin) { password += chunk; if (Buffer.byteLength(password) > 4096) throw new Error("密码过长"); if (password.includes("\n")) break; }
|
|
115
240
|
const value = await request(server, "/api/client/login", null, { protocolVersion: 1, username, password: password.split("\n", 1)[0].replace(/\r$/, "") }, 30000);
|
|
116
|
-
|
|
117
|
-
fs.chmodSync(home, 0o700);
|
|
118
|
-
const temp = `${sessionPath}.${process.pid}.tmp`;
|
|
119
|
-
try { fs.writeFileSync(temp, JSON.stringify({ server, token: value.token, accountId: value.accountId }), { mode: 0o600, flag: "wx" }); fs.renameSync(temp, sessionPath); }
|
|
120
|
-
finally { fs.rmSync(temp, { force: true }); }
|
|
241
|
+
saveSession(home, sessionPath, { server, token: value.token, accountId: value.accountId });
|
|
121
242
|
print({ accountId: value.accountId, server });
|
|
122
243
|
return;
|
|
123
244
|
}
|
package/cofluxd.mjs
CHANGED
|
@@ -38,6 +38,10 @@ const SETTINGS = join(HOME, "settings.json"); // 用户配置(serverUrl/device
|
|
|
38
38
|
const LOG_FILE = join(HOME, "daemon.log");
|
|
39
39
|
const CRED = join(HOME, "credentials.json");
|
|
40
40
|
const PENDING_AUTH = join(HOME, "pending-auth.json"); // worker 落盘的待授权链接(daemon.authorizePending)
|
|
41
|
+
// One-time join key (plan 20260924-device-join-keys): `up --key` writes it, the worker presents it once
|
|
42
|
+
// and deletes it on the server's answer, recording what happened in the outcome file (0600 each).
|
|
43
|
+
const JOIN_KEY = join(HOME, "join-key.json");
|
|
44
|
+
const JOIN_OUTCOME = join(HOME, "join-outcome.json");
|
|
41
45
|
const CONN_STATE = join(HOME, "conn-state.json"); // worker 落盘的连接态快照(plan 033,见 crates/worker/src/conn_state.rs)
|
|
42
46
|
const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // gateway key/origin/grant;doctor 只读结构与数量,绝不打印秘密
|
|
43
47
|
const FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
|
|
@@ -69,6 +73,17 @@ function readPendingAuth() {
|
|
|
69
73
|
try { return JSON.parse(fs.readFileSync(PENDING_AUTH, "utf8")); } catch { return null; }
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
function readJoinOutcome() {
|
|
77
|
+
try { return JSON.parse(fs.readFileSync(JOIN_OUTCOME, "utf8")); } catch { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readCredentialsSummary() {
|
|
81
|
+
try {
|
|
82
|
+
const c = JSON.parse(fs.readFileSync(CRED, "utf8"));
|
|
83
|
+
return { serverUrl: typeof c.serverUrl === "string" ? c.serverUrl : "", daemonId: typeof c.daemonId === "string" ? c.daemonId : "" };
|
|
84
|
+
} catch { return { serverUrl: "", daemonId: "" }; }
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
function readConnState() {
|
|
73
88
|
try { return JSON.parse(fs.readFileSync(CONN_STATE, "utf8")); } catch { return null; }
|
|
74
89
|
}
|
|
@@ -449,6 +464,55 @@ async function applyAndStart({ serverUrl, deviceName, shell, version, binDir, no
|
|
|
449
464
|
}
|
|
450
465
|
}
|
|
451
466
|
|
|
467
|
+
/**
|
|
468
|
+
* `cofluxd up --key` (plan 20260924-device-join-keys): join the account with a one-time key minted in
|
|
469
|
+
* Coflux's 添加设备 dialog. Never prints a link and never enters the 11-minute link wait.
|
|
470
|
+
*
|
|
471
|
+
* Order matters: stop the service before the key file exists, so a worker still running from the link
|
|
472
|
+
* era can never pick it up in a renewal and then be killed mid-answer; starting the stopped unit again
|
|
473
|
+
* (`enable --now` on Linux, unload/load on macOS) is the restart that makes the new worker read it.
|
|
474
|
+
* The CLI still never speaks the protocol: it learns the result only from `credentials.json` and the
|
|
475
|
+
* outcome file the worker writes.
|
|
476
|
+
*/
|
|
477
|
+
async function joinWithKey({ serverUrl, deviceName, shell, version, binDir, key }) {
|
|
478
|
+
await ensureBinaries({ version, binDir, skipIfPresent: true });
|
|
479
|
+
applyConfig({ serverUrl, deviceName, shell });
|
|
480
|
+
if (serverUrl !== DEFAULT_SERVER) console.log(`⚠ 使用非默认服务器: ${serverUrl}`);
|
|
481
|
+
stopService();
|
|
482
|
+
// The worker clears pending-auth.json only at connection end, not on SIGTERM: a stale one would
|
|
483
|
+
// read as "daemon too old for keys" below.
|
|
484
|
+
for (const file of [JOIN_OUTCOME, PENDING_AUTH]) { try { fs.rmSync(file, { force: true }); } catch { /* */ } }
|
|
485
|
+
fs.writeFileSync(JOIN_KEY, JSON.stringify({ key }) + "\n", { mode: 0o600 });
|
|
486
|
+
fs.chmodSync(JOIN_KEY, 0o600);
|
|
487
|
+
installService(true);
|
|
488
|
+
console.log(`✓ daemon 已启动 → ${serverUrl}`);
|
|
489
|
+
console.log("\n 正在用接入密钥接入账号 …\n");
|
|
490
|
+
const maxWaitMs = 2 * 60 * 1000;
|
|
491
|
+
const start = Date.now();
|
|
492
|
+
while (Date.now() - start < maxWaitMs) {
|
|
493
|
+
const outcome = readJoinOutcome();
|
|
494
|
+
if (fs.existsSync(CRED) || outcome?.status === "joined") {
|
|
495
|
+
console.log("✓ 已接入\n");
|
|
496
|
+
cmdStatus();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (outcome?.status === "rejected") {
|
|
500
|
+
die(`接入失败:${outcome.reason || "密钥无效、已过期或已被使用"}\n 在 Coflux 的添加设备里换一个密钥,再重新运行这条命令。`);
|
|
501
|
+
}
|
|
502
|
+
if (outcome?.status === "unsupported") {
|
|
503
|
+
die("服务器还不支持接入密钥(需要更新服务器)。");
|
|
504
|
+
}
|
|
505
|
+
if (fs.existsSync(JOIN_KEY) && fs.existsSync(PENDING_AUTH)) {
|
|
506
|
+
// The daemon enrolled without reading the key: its binary predates join keys (up keeps existing
|
|
507
|
+
// binaries). Drop the key so a later worker never presents it behind the user's back.
|
|
508
|
+
try { fs.rmSync(JOIN_KEY, { force: true }); } catch { /* */ }
|
|
509
|
+
die("已安装的 daemon 版本过旧,不认识接入密钥:先运行 `cofluxd update`,再在 Coflux 的添加设备里换一个密钥重新运行这条命令。");
|
|
510
|
+
}
|
|
511
|
+
await sleep(500);
|
|
512
|
+
}
|
|
513
|
+
die("等待接入超时:daemon 仍在后台尝试连接服务器。用 `cofluxd status` 查看状态,`cofluxd logs` 查看原因。");
|
|
514
|
+
}
|
|
515
|
+
|
|
452
516
|
// 轮询 ~/.coflux/pending-auth.json(授权链接)与 credentials.json(登记成功)给出全程反馈。
|
|
453
517
|
// CLI 保持零协议:只读文件,不连 WS;daemon 断线重连会自动重发 enrollRequest、换发新链接。
|
|
454
518
|
async function waitForAuthorization() {
|
|
@@ -479,6 +543,24 @@ async function waitForAuthorization() {
|
|
|
479
543
|
|
|
480
544
|
async function cmdUp(v) {
|
|
481
545
|
const s = readSettings();
|
|
546
|
+
if (v.key !== undefined) {
|
|
547
|
+
const key = v.key.trim();
|
|
548
|
+
if (!/^cf_join_[A-Za-z0-9_-]+$/.test(key)) die("--key 不是接入密钥:请完整复制 Coflux 添加设备里的命令(密钥以 cf_join_ 开头)");
|
|
549
|
+
if (v["no-start"]) die("--key 不能与 --no-start 同用:密钥只在启动时使用一次");
|
|
550
|
+
if (fs.existsSync(CRED)) {
|
|
551
|
+
// Already registered: a key would never be presented, so change nothing at all.
|
|
552
|
+
const c = readCredentialsSummary();
|
|
553
|
+
console.log(`✓ 此设备已接入 ${c.serverUrl || s.serverUrl || "(未知服务器)"}${c.daemonId ? `(设备 ${c.daemonId})` : ""},忽略 --key,未做任何改动。`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await joinWithKey({
|
|
557
|
+
serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
|
|
558
|
+
deviceName: v.name || s.deviceName || hostname(),
|
|
559
|
+
shell: v.shell || s.shell,
|
|
560
|
+
version: v.version, binDir: v["bin-dir"], key,
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
482
564
|
await applyAndStart({
|
|
483
565
|
serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
|
|
484
566
|
deviceName: v.name || s.deviceName || hostname(),
|
|
@@ -844,6 +926,7 @@ const HELP = `cofluxd —— coflux daemon 管理
|
|
|
844
926
|
|
|
845
927
|
cofluxd 首次=up(打印浏览器授权链接),已配置=status
|
|
846
928
|
cofluxd up [flags] 幂等:首次装+起,已装则按当前 settings.json 重装服务并重启
|
|
929
|
+
cofluxd up --key <密钥> 用 Coflux 添加设备里生成的一次性密钥直接接入账号(不打印授权链接;已接入则不做改动)
|
|
847
930
|
cofluxd status 服务器/登记(含"等待授权")/服务/连接状态
|
|
848
931
|
cofluxd doctor 中心网络 + gateway bind/grant/loopback + daemon 状态分层自检
|
|
849
932
|
cofluxd update 下载新二进制(不重启;supervisor 有变化时提示用 restart 应用)
|
|
@@ -853,14 +936,14 @@ const HELP = `cofluxd —— coflux daemon 管理
|
|
|
853
936
|
cofluxd down 停止
|
|
854
937
|
cofluxd uninstall [--purge] 卸载(--purge 连二进制/配置/凭证一并删)
|
|
855
938
|
|
|
856
|
-
up flags: --server <ws://.../daemon> --name <名> --shell <路径>
|
|
939
|
+
up flags: --server <ws://.../daemon> --name <名> --shell <路径> --key <cf_join_…>(一次性接入密钥,1 小时内有效)
|
|
857
940
|
通用: --version <vX|latest>(不传时 up 沿用已有二进制,update 默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
|
|
858
941
|
配置都在 ~/.coflux/settings.json(serverUrl/deviceName/shell),daemon 直接读;改后重跑 cofluxd up 生效。`;
|
|
859
942
|
|
|
860
943
|
const { values, positionals } = parseArgs({
|
|
861
944
|
allowPositionals: true,
|
|
862
945
|
options: {
|
|
863
|
-
server: { type: "string" }, name: { type: "string" }, shell: { type: "string" },
|
|
946
|
+
server: { type: "string" }, name: { type: "string" }, shell: { type: "string" }, key: { type: "string" },
|
|
864
947
|
version: { type: "string" }, "bin-dir": { type: "string" },
|
|
865
948
|
"no-start": { type: "boolean", default: false }, purge: { type: "boolean", default: false },
|
|
866
949
|
follow: { type: "boolean", short: "f", default: false }, help: { type: "boolean", short: "h", default: false },
|