cofluxd 0.11.0 → 0.12.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 +5 -0
- package/cofluxd.mjs +216 -29
- package/package.json +3 -1
- package/release-pubkey.hex +1 -0
- package/release-trust.mjs +240 -0
package/README.md
CHANGED
|
@@ -22,6 +22,11 @@ cofluxd down # 停止
|
|
|
22
22
|
cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一并删)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
从 `cofluxd@0.12.0` 起,远端安装/更新会用 npm 包内置的 ed25519 公钥同时验证
|
|
26
|
+
supervisor 与 worker 的 version/target/size/sha256/release statement;两个文件全部通过后才替换。
|
|
27
|
+
CLI 还会取自身与 worker 的持久 release floor 较大值拒绝远端降级。`--bin-dir` 仍是本机管理员
|
|
28
|
+
显式信任本地产物的开发/救援入口。
|
|
29
|
+
|
|
25
30
|
默认连公共服务 `wss://api.coflux.dev/daemon`(自托管用 `--server` 改;已保存的地址继续生效,非默认时会有醒目提示)。
|
|
26
31
|
|
|
27
32
|
## 给 agent 用的命令
|
package/cofluxd.mjs
CHANGED
|
@@ -12,11 +12,25 @@ import dns from "node:dns/promises";
|
|
|
12
12
|
import net from "node:net";
|
|
13
13
|
import tls from "node:tls";
|
|
14
14
|
import crypto from "node:crypto";
|
|
15
|
+
import {
|
|
16
|
+
MAX_RELEASE_ARTIFACT_BYTES,
|
|
17
|
+
assertReleaseVersion,
|
|
18
|
+
compareReleaseVersions,
|
|
19
|
+
createReleasePublicKey,
|
|
20
|
+
installStagedPair,
|
|
21
|
+
parseReleaseManifestEntry,
|
|
22
|
+
verifyReleaseArtifact,
|
|
23
|
+
} from "./release-trust.mjs";
|
|
15
24
|
|
|
16
25
|
// 默认中心服务(公共 SaaS);自托管用 --server 覆盖。
|
|
17
26
|
const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
|
|
18
27
|
|
|
19
28
|
const REPO = "myWsq/coflux";
|
|
29
|
+
const RELEASE_API_BASE = (process.env.COFLUX_RELEASE_API_BASE || "https://api.github.com").replace(/\/+$/, "");
|
|
30
|
+
const RELEASE_DOWNLOAD_BASE = (
|
|
31
|
+
process.env.COFLUX_RELEASE_DOWNLOAD_BASE || `https://github.com/${REPO}/releases/download`
|
|
32
|
+
).replace(/\/+$/, "");
|
|
33
|
+
const MAX_RELEASE_METADATA_BYTES = 1024 * 1024;
|
|
20
34
|
const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
|
|
21
35
|
const BIN_DIR = join(HOME, "bin");
|
|
22
36
|
const SETTINGS = join(HOME, "settings.json"); // 用户配置(serverUrl/deviceName/shell)→ daemon 直接读;600 权限防同机其他用户窥探
|
|
@@ -28,6 +42,8 @@ const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // gateway key/ori
|
|
|
28
42
|
const FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
|
|
29
43
|
const SUP_BIN = join(BIN_DIR, "coflux-supervisor");
|
|
30
44
|
const WRK_BIN = join(BIN_DIR, "coflux-worker");
|
|
45
|
+
const CLI_RELEASE_FLOOR = join(HOME, "cofluxd.release-floor");
|
|
46
|
+
const WORKER_RELEASE_FLOOR = join(HOME, "worker.release-floor");
|
|
31
47
|
const IS_MAC = platform() === "darwin";
|
|
32
48
|
const IS_LINUX = platform() === "linux";
|
|
33
49
|
const PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.daemon.plist");
|
|
@@ -121,30 +137,113 @@ function fileSha256(path) {
|
|
|
121
137
|
|
|
122
138
|
// macOS:新落盘的二进制带 com.apple.provenance,launchd 顶层 spawn 会被 AMFI 以
|
|
123
139
|
// OS_REASON_CODESIGNING 静默杀死——即使产物是 Developer ID 签名 + 已公证(2026-07-25
|
|
124
|
-
// v0.13.0 实测仍被连杀)。本地 ad-hoc 重签使其成为"本机产物"
|
|
125
|
-
//
|
|
140
|
+
// v0.13.0 实测仍被连杀)。本地 ad-hoc 重签使其成为"本机产物"绕开该检查。
|
|
141
|
+
// 远端产物必须先完成 release statement 验签,再允许走到这里;绝不能替任意下载内容重签。
|
|
126
142
|
function resignMacBinaries(paths) {
|
|
127
143
|
if (!IS_MAC) return;
|
|
128
144
|
for (const p of paths) {
|
|
129
145
|
const r = run("codesign", ["--force", "-s", "-", p]);
|
|
130
|
-
if (r.status !== 0)
|
|
146
|
+
if (r.status !== 0) {
|
|
147
|
+
throw new Error(`macOS 本地重签失败: ${p};保留当前 daemon 二进制`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function fetchBounded(url, maxBytes, label) {
|
|
153
|
+
const res = await fetch(url, {
|
|
154
|
+
redirect: "follow",
|
|
155
|
+
signal: AbortSignal.timeout(30_000),
|
|
156
|
+
});
|
|
157
|
+
if (!res.ok) throw new Error(`${label} 下载失败(HTTP ${res.status})`);
|
|
158
|
+
const declaredLength = Number(res.headers.get("content-length"));
|
|
159
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
160
|
+
throw new Error(`${label} 超过大小上限`);
|
|
161
|
+
}
|
|
162
|
+
if (!res.body) throw new Error(`${label} 响应没有 body`);
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let total = 0;
|
|
165
|
+
for await (const chunk of res.body) {
|
|
166
|
+
const bytes = Buffer.from(chunk);
|
|
167
|
+
total += bytes.byteLength;
|
|
168
|
+
if (total > maxBytes) {
|
|
169
|
+
await res.body.cancel().catch(() => {});
|
|
170
|
+
throw new Error(`${label} 超过大小上限`);
|
|
171
|
+
}
|
|
172
|
+
chunks.push(bytes);
|
|
173
|
+
}
|
|
174
|
+
return Buffer.concat(chunks, total);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function loadReleasePublicKey() {
|
|
178
|
+
const publicKeyHex = process.env.COFLUX_WORKER_PUBKEY ||
|
|
179
|
+
fs.readFileSync(new URL("./release-pubkey.hex", import.meta.url), "utf8");
|
|
180
|
+
return createReleasePublicKey(publicKeyHex);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readReleaseFloor(path, label) {
|
|
184
|
+
let metadata;
|
|
185
|
+
try { metadata = fs.lstatSync(path); }
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error?.code === "ENOENT") return undefined;
|
|
188
|
+
throw new Error(`${label} 元数据无法读取,拒绝远端升级`);
|
|
189
|
+
}
|
|
190
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0 || metadata.size > 128) {
|
|
191
|
+
throw new Error(`${label} 不是安全且有界的普通文件,拒绝远端升级`);
|
|
131
192
|
}
|
|
193
|
+
let value;
|
|
194
|
+
try { value = fs.readFileSync(path, "utf8").trim(); }
|
|
195
|
+
catch { throw new Error(`${label} 无法读取,拒绝远端升级`); }
|
|
196
|
+
try { assertReleaseVersion(value); }
|
|
197
|
+
catch { throw new Error(`${label} 已损坏,拒绝远端升级`); }
|
|
198
|
+
return value;
|
|
132
199
|
}
|
|
133
200
|
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
201
|
+
function currentReleaseFloor() {
|
|
202
|
+
const floors = [
|
|
203
|
+
readReleaseFloor(CLI_RELEASE_FLOOR, "cofluxd.release-floor"),
|
|
204
|
+
readReleaseFloor(WORKER_RELEASE_FLOOR, "worker.release-floor"),
|
|
205
|
+
].filter(Boolean);
|
|
206
|
+
return floors.reduce((highest, candidate) => {
|
|
207
|
+
if (!highest) return candidate;
|
|
208
|
+
const order = compareReleaseVersions(candidate, highest);
|
|
209
|
+
if (order === 0 && candidate !== highest) {
|
|
210
|
+
throw new Error(`本机 release floor precedence 相同但身份冲突:${highest} / ${candidate}`);
|
|
211
|
+
}
|
|
212
|
+
return order > 0 ? candidate : highest;
|
|
213
|
+
}, undefined);
|
|
138
214
|
}
|
|
215
|
+
|
|
216
|
+
function persistCliReleaseFloor(version) {
|
|
217
|
+
const temp = join(HOME, `.cofluxd.release-floor.${process.pid}.${crypto.randomBytes(8).toString("hex")}`);
|
|
218
|
+
let file;
|
|
219
|
+
try {
|
|
220
|
+
file = fs.openSync(temp, "wx", 0o600);
|
|
221
|
+
fs.writeFileSync(file, `${version}\n`);
|
|
222
|
+
fs.fsyncSync(file);
|
|
223
|
+
fs.closeSync(file);
|
|
224
|
+
file = undefined;
|
|
225
|
+
fs.renameSync(temp, CLI_RELEASE_FLOOR);
|
|
226
|
+
const homeDir = fs.openSync(HOME, "r");
|
|
227
|
+
try { fs.fsyncSync(homeDir); }
|
|
228
|
+
finally { fs.closeSync(homeDir); }
|
|
229
|
+
} finally {
|
|
230
|
+
if (file !== undefined) {
|
|
231
|
+
try { fs.closeSync(file); } catch {}
|
|
232
|
+
}
|
|
233
|
+
try { fs.rmSync(temp, { force: true }); } catch {}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
139
237
|
// 取最新 release tag(含 prerelease;GitHub 的 /releases/latest 跳转不含 prerelease,故走 API)。
|
|
140
238
|
async function resolveLatestTag() {
|
|
141
239
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
240
|
+
const body = await fetchBounded(
|
|
241
|
+
`${RELEASE_API_BASE}/repos/${REPO}/releases?per_page=1`,
|
|
242
|
+
MAX_RELEASE_METADATA_BYTES,
|
|
243
|
+
"GitHub release 元数据",
|
|
244
|
+
);
|
|
245
|
+
const arr = JSON.parse(body.toString("utf8"));
|
|
246
|
+
return Array.isArray(arr) && typeof arr[0]?.tag_name === "string" ? arr[0].tag_name : null;
|
|
148
247
|
} catch {
|
|
149
248
|
return null;
|
|
150
249
|
}
|
|
@@ -155,13 +254,36 @@ async function resolveLatestTag() {
|
|
|
155
254
|
async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
156
255
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
157
256
|
if (binDir) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
257
|
+
const localArtifacts = ["coflux-supervisor", "coflux-worker"].map((name) => ({
|
|
258
|
+
name,
|
|
259
|
+
path: join(binDir, name),
|
|
260
|
+
}));
|
|
261
|
+
for (const artifact of localArtifacts) {
|
|
262
|
+
if (!fs.existsSync(artifact.path)) {
|
|
263
|
+
die(`本地产物缺失: ${artifact.path}(先 cargo build --release?)`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-local-install-"));
|
|
267
|
+
let localFailure;
|
|
268
|
+
try {
|
|
269
|
+
const staged = [];
|
|
270
|
+
for (const artifact of localArtifacts) {
|
|
271
|
+
const destination = join(BIN_DIR, artifact.name);
|
|
272
|
+
const source = join(stageDir, artifact.name);
|
|
273
|
+
fs.copyFileSync(artifact.path, source);
|
|
274
|
+
fs.chmodSync(source, 0o755);
|
|
275
|
+
staged.push({ source, destination });
|
|
276
|
+
}
|
|
277
|
+
resignMacBinaries(staged.map(({ source }) => source));
|
|
278
|
+
installStagedPair(staged);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
localFailure = error;
|
|
281
|
+
} finally {
|
|
282
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
if (localFailure) {
|
|
285
|
+
die(`本地 daemon 安装失败:${localFailure instanceof Error ? localFailure.message : String(localFailure)}`);
|
|
163
286
|
}
|
|
164
|
-
resignMacBinaries([SUP_BIN, WRK_BIN]);
|
|
165
287
|
console.log(`✓ 用本地二进制(${binDir})`);
|
|
166
288
|
return;
|
|
167
289
|
}
|
|
@@ -169,21 +291,86 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
169
291
|
console.log(`✓ 二进制已存在(${BIN_DIR}),跳过下载(用 cofluxd update 升级)`);
|
|
170
292
|
return;
|
|
171
293
|
}
|
|
172
|
-
const
|
|
173
|
-
let
|
|
294
|
+
const target = rustTarget();
|
|
295
|
+
let releaseVersion;
|
|
174
296
|
if (!version || version === "latest") {
|
|
175
297
|
const tag = await resolveLatestTag();
|
|
176
|
-
if (tag)
|
|
177
|
-
|
|
298
|
+
if (!tag) die("无法取得最新 release 的精确版本,拒绝无版本身份的下载");
|
|
299
|
+
releaseVersion = tag;
|
|
300
|
+
console.log(`最新版本: ${releaseVersion}`);
|
|
178
301
|
} else {
|
|
179
|
-
|
|
302
|
+
releaseVersion = version;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
assertReleaseVersion(releaseVersion);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
die(error instanceof Error ? error.message : String(error));
|
|
308
|
+
}
|
|
309
|
+
let releaseFloor;
|
|
310
|
+
try {
|
|
311
|
+
releaseFloor = currentReleaseFloor();
|
|
312
|
+
const floorOrder = releaseFloor
|
|
313
|
+
? compareReleaseVersions(releaseVersion, releaseFloor)
|
|
314
|
+
: 1;
|
|
315
|
+
if (floorOrder < 0 || (floorOrder === 0 && releaseVersion !== releaseFloor)) {
|
|
316
|
+
throw new Error(`远端 release ${releaseVersion} 不高于本机可信身份 ${releaseFloor},拒绝降级/重放`);
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
die(error instanceof Error ? error.message : String(error));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const base = `${RELEASE_DOWNLOAD_BASE}/${releaseVersion}`;
|
|
323
|
+
const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-release-install-"));
|
|
324
|
+
let failure;
|
|
325
|
+
try {
|
|
326
|
+
const manifestBytes = await fetchBounded(
|
|
327
|
+
`${base}/manifest.json`,
|
|
328
|
+
MAX_RELEASE_METADATA_BYTES,
|
|
329
|
+
"release manifest",
|
|
330
|
+
);
|
|
331
|
+
let manifest;
|
|
332
|
+
try {
|
|
333
|
+
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
334
|
+
} catch {
|
|
335
|
+
throw new Error("release manifest 不是有效 JSON");
|
|
336
|
+
}
|
|
337
|
+
const publicKey = loadReleasePublicKey();
|
|
338
|
+
const staged = [];
|
|
339
|
+
for (const component of ["supervisor", "worker"]) {
|
|
340
|
+
const entry = parseReleaseManifestEntry(manifest, component, releaseVersion, target);
|
|
341
|
+
const artifactName = `coflux-${component}-${target}`;
|
|
342
|
+
process.stdout.write(`下载并验签 ${artifactName} … `);
|
|
343
|
+
const data = await fetchBounded(
|
|
344
|
+
`${base}/${artifactName}`,
|
|
345
|
+
Math.min(entry.size, MAX_RELEASE_ARTIFACT_BYTES),
|
|
346
|
+
artifactName,
|
|
347
|
+
);
|
|
348
|
+
verifyReleaseArtifact({ component, version: releaseVersion, entry, data, publicKey });
|
|
349
|
+
const source = join(stageDir, `coflux-${component}`);
|
|
350
|
+
fs.writeFileSync(source, data, { mode: 0o755 });
|
|
351
|
+
fs.chmodSync(source, 0o755);
|
|
352
|
+
staged.push({
|
|
353
|
+
source,
|
|
354
|
+
destination: component === "supervisor" ? SUP_BIN : WRK_BIN,
|
|
355
|
+
});
|
|
356
|
+
console.log("✓");
|
|
357
|
+
}
|
|
358
|
+
// 两个远端产物均通过同一发布根的验签后,才允许做本地平台变换与替换。
|
|
359
|
+
resignMacBinaries(staged.map(({ source }) => source));
|
|
360
|
+
// floor 先于 pair 提交:若其后进程崩溃,最多是旧二进制配更高 floor;重跑同版仍允许,
|
|
361
|
+
// 但任何旧的合法 release 都不能趁窗口降级。
|
|
362
|
+
if (!releaseFloor || compareReleaseVersions(releaseVersion, releaseFloor) > 0) {
|
|
363
|
+
persistCliReleaseFloor(releaseVersion);
|
|
364
|
+
}
|
|
365
|
+
installStagedPair(staged);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
failure = error;
|
|
368
|
+
} finally {
|
|
369
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
180
370
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
await download(`${base}/${b}-${t}`, join(BIN_DIR, b));
|
|
184
|
-
console.log("✓");
|
|
371
|
+
if (failure) {
|
|
372
|
+
die(`release 安装失败:${failure instanceof Error ? failure.message : String(failure)}`);
|
|
185
373
|
}
|
|
186
|
-
resignMacBinaries([SUP_BIN, WRK_BIN]);
|
|
187
374
|
}
|
|
188
375
|
|
|
189
376
|
// 写 settings.json(daemon 直接读)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cofluxd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "coflux daemon 管理 CLI:装/起/停/升级 Rust daemon(supervisor + worker)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"cofluxd.mjs",
|
|
11
|
+
"release-pubkey.hex",
|
|
12
|
+
"release-trust.mjs",
|
|
11
13
|
"README.md",
|
|
12
14
|
"skills"
|
|
13
15
|
],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
d5605555e704b65933a82f57cb648ac96884c237353b0e47f5445f09e745d7a1
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
export const WORKER_RELEASE_STATEMENT_DOMAIN = Buffer.from(
|
|
6
|
+
"coflux-worker-release-v1\0",
|
|
7
|
+
"utf8",
|
|
8
|
+
);
|
|
9
|
+
export const SUPERVISOR_RELEASE_STATEMENT_DOMAIN = Buffer.from(
|
|
10
|
+
"coflux-supervisor-release-v1\0",
|
|
11
|
+
"utf8",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const STRICT_RELEASE_VERSION = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
15
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/i;
|
|
16
|
+
const ED25519_SIGNATURE_HEX = /^[0-9a-f]{128}$/i;
|
|
17
|
+
const ED25519_PUBLIC_KEY_HEX = /^[0-9a-f]{64}$/i;
|
|
18
|
+
export const MAX_RELEASE_ARTIFACT_BYTES = 128 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function parseReleaseVersion(version) {
|
|
21
|
+
const match = typeof version === "string" ? STRICT_RELEASE_VERSION.exec(version) : null;
|
|
22
|
+
if (!match) throw new Error(`release version 不是带 v 前缀的严格 SemVer: ${JSON.stringify(version)}`);
|
|
23
|
+
const prerelease = match[4]
|
|
24
|
+
? match[4].split(".").map((identifier) => {
|
|
25
|
+
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
|
|
26
|
+
throw new Error(`release version 的数字 prerelease 标识符含前导 0: ${JSON.stringify(version)}`);
|
|
27
|
+
}
|
|
28
|
+
return /^\d+$/.test(identifier)
|
|
29
|
+
? { numeric: true, value: BigInt(identifier) }
|
|
30
|
+
: { numeric: false, value: identifier };
|
|
31
|
+
})
|
|
32
|
+
: [];
|
|
33
|
+
return {
|
|
34
|
+
raw: version,
|
|
35
|
+
core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
|
|
36
|
+
prerelease,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 与 Rust semver crate 一致的 release tag 子集:必须带 v,数字标识符禁止前导 0。 */
|
|
41
|
+
export function assertReleaseVersion(version) {
|
|
42
|
+
parseReleaseVersion(version);
|
|
43
|
+
return version;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SemVer precedence;build metadata 不参与比较。 */
|
|
47
|
+
export function compareReleaseVersions(leftVersion, rightVersion) {
|
|
48
|
+
const left = parseReleaseVersion(leftVersion);
|
|
49
|
+
const right = parseReleaseVersion(rightVersion);
|
|
50
|
+
for (let index = 0; index < 3; index += 1) {
|
|
51
|
+
if (left.core[index] < right.core[index]) return -1;
|
|
52
|
+
if (left.core[index] > right.core[index]) return 1;
|
|
53
|
+
}
|
|
54
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
55
|
+
if (left.prerelease.length === right.prerelease.length) return 0;
|
|
56
|
+
return left.prerelease.length === 0 ? 1 : -1;
|
|
57
|
+
}
|
|
58
|
+
const common = Math.min(left.prerelease.length, right.prerelease.length);
|
|
59
|
+
for (let index = 0; index < common; index += 1) {
|
|
60
|
+
const a = left.prerelease[index];
|
|
61
|
+
const b = right.prerelease[index];
|
|
62
|
+
if (a.numeric && b.numeric) {
|
|
63
|
+
if (a.value < b.value) return -1;
|
|
64
|
+
if (a.value > b.value) return 1;
|
|
65
|
+
} else if (a.numeric !== b.numeric) {
|
|
66
|
+
return a.numeric ? -1 : 1;
|
|
67
|
+
} else {
|
|
68
|
+
if (a.value < b.value) return -1;
|
|
69
|
+
if (a.value > b.value) return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Math.sign(left.prerelease.length - right.prerelease.length);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lenPrefixed(value) {
|
|
76
|
+
const bytes = Buffer.from(value, "utf8");
|
|
77
|
+
const length = Buffer.allocUnsafe(4);
|
|
78
|
+
length.writeUInt32BE(bytes.length);
|
|
79
|
+
return [length, bytes];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function artifactReleaseStatement(domain, { version, target, sha256, size }) {
|
|
83
|
+
assertReleaseVersion(version);
|
|
84
|
+
if (typeof target !== "string" || !target || Buffer.byteLength(target) > 128) {
|
|
85
|
+
throw new Error("release target 非法");
|
|
86
|
+
}
|
|
87
|
+
if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) {
|
|
88
|
+
throw new Error("release sha256 必须是 32 字节 hex");
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > MAX_RELEASE_ARTIFACT_BYTES) {
|
|
91
|
+
throw new Error("release size 必须是有效且有界的正整数");
|
|
92
|
+
}
|
|
93
|
+
const sizeBytes = Buffer.allocUnsafe(8);
|
|
94
|
+
sizeBytes.writeBigUInt64BE(BigInt(size));
|
|
95
|
+
return Buffer.concat([
|
|
96
|
+
domain,
|
|
97
|
+
...lenPrefixed(version),
|
|
98
|
+
...lenPrefixed(target),
|
|
99
|
+
Buffer.from(sha256, "hex"),
|
|
100
|
+
sizeBytes,
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** worker 热升级沿用的 v1 transcript;不得改变 domain 或字段顺序。 */
|
|
105
|
+
export function workerReleaseStatement(metadata) {
|
|
106
|
+
return artifactReleaseStatement(WORKER_RELEASE_STATEMENT_DOMAIN, metadata);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** supervisor 安装专用 transcript;独立 domain 防止合法 worker 签名被横向移植。 */
|
|
110
|
+
export function supervisorReleaseStatement(metadata) {
|
|
111
|
+
return artifactReleaseStatement(SUPERVISOR_RELEASE_STATEMENT_DOMAIN, metadata);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createReleasePublicKey(publicKeyHex) {
|
|
115
|
+
const normalized = typeof publicKeyHex === "string" ? publicKeyHex.trim() : "";
|
|
116
|
+
if (!ED25519_PUBLIC_KEY_HEX.test(normalized)) {
|
|
117
|
+
throw new Error("发布公钥必须是 32 字节 hex");
|
|
118
|
+
}
|
|
119
|
+
return crypto.createPublicKey({
|
|
120
|
+
format: "jwk",
|
|
121
|
+
key: {
|
|
122
|
+
kty: "OKP",
|
|
123
|
+
crv: "Ed25519",
|
|
124
|
+
x: Buffer.from(normalized, "hex").toString("base64url"),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isRecord(value) {
|
|
130
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 从 schema 2 manifest 取指定 component/target。额外顶层字段允许滚动扩展;
|
|
135
|
+
* 但参与信任裁决的 version/target/size/hash/signature 全部严格校验。
|
|
136
|
+
*/
|
|
137
|
+
export function parseReleaseManifestEntry(manifest, component, version, target) {
|
|
138
|
+
assertReleaseVersion(version);
|
|
139
|
+
if (component !== "worker" && component !== "supervisor") {
|
|
140
|
+
throw new Error(`未知 release component: ${JSON.stringify(component)}`);
|
|
141
|
+
}
|
|
142
|
+
if (!isRecord(manifest) || manifest.schemaVersion !== 2 || manifest.version !== version) {
|
|
143
|
+
throw new Error("release manifest schema/version 与请求不一致");
|
|
144
|
+
}
|
|
145
|
+
const entries = manifest[component];
|
|
146
|
+
const entry = isRecord(entries) ? entries[target] : undefined;
|
|
147
|
+
if (!isRecord(entry) || entry.target !== target) {
|
|
148
|
+
throw new Error(`release manifest 缺少匹配的 ${component}/${target}`);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
typeof entry.sha256 !== "string" ||
|
|
152
|
+
!SHA256_HEX.test(entry.sha256) ||
|
|
153
|
+
!Number.isSafeInteger(entry.size) ||
|
|
154
|
+
entry.size <= 0 ||
|
|
155
|
+
entry.size > MAX_RELEASE_ARTIFACT_BYTES ||
|
|
156
|
+
typeof entry.releaseSignature !== "string" ||
|
|
157
|
+
!ED25519_SIGNATURE_HEX.test(entry.releaseSignature)
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(`release manifest 的 ${component}/${target} 元数据非法`);
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
component === "worker" &&
|
|
163
|
+
(typeof entry.signature !== "string" || !ED25519_SIGNATURE_HEX.test(entry.signature))
|
|
164
|
+
) {
|
|
165
|
+
throw new Error(`release manifest 的 worker/${target} 缺少 legacy 签名`);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
target,
|
|
169
|
+
sha256: entry.sha256.toLowerCase(),
|
|
170
|
+
size: entry.size,
|
|
171
|
+
signature: component === "worker" ? entry.signature.toLowerCase() : undefined,
|
|
172
|
+
releaseSignature: entry.releaseSignature.toLowerCase(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 校验实际 bytes 与 manifest 元数据、raw worker 签名及 component-separated release 签名。 */
|
|
177
|
+
export function verifyReleaseArtifact({ component, version, entry, data, publicKey }) {
|
|
178
|
+
if (!Buffer.isBuffer(data)) throw new Error("release 产物必须是 Buffer");
|
|
179
|
+
if (data.byteLength !== entry.size) {
|
|
180
|
+
throw new Error(`${component} 产物大小不匹配:期望 ${entry.size},实际 ${data.byteLength}`);
|
|
181
|
+
}
|
|
182
|
+
const sha256 = crypto.createHash("sha256").update(data).digest("hex");
|
|
183
|
+
if (sha256 !== entry.sha256) {
|
|
184
|
+
throw new Error(`${component} 产物 sha256 不匹配`);
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
component === "worker" &&
|
|
188
|
+
!crypto.verify(null, data, publicKey, Buffer.from(entry.signature, "hex"))
|
|
189
|
+
) {
|
|
190
|
+
throw new Error("worker 产物 legacy Ed25519 签名无效");
|
|
191
|
+
}
|
|
192
|
+
const metadata = { version, target: entry.target, sha256, size: data.byteLength };
|
|
193
|
+
const statement = component === "worker"
|
|
194
|
+
? workerReleaseStatement(metadata)
|
|
195
|
+
: supervisorReleaseStatement(metadata);
|
|
196
|
+
if (!crypto.verify(null, statement, publicKey, Buffer.from(entry.releaseSignature, "hex"))) {
|
|
197
|
+
throw new Error(`${component} 产物 release Ed25519 签名无效`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 两个已验证/本地显式信任的暂存文件一起进入替换阶段;任一 rename 失败都会恢复旧 pair。
|
|
203
|
+
* 单文件 rename 是原子的,pair 级失败用同文件系统内备份回滚,绝不把“只更新一半”当成功。
|
|
204
|
+
*/
|
|
205
|
+
export function installStagedPair(staged) {
|
|
206
|
+
if (
|
|
207
|
+
!Array.isArray(staged) ||
|
|
208
|
+
staged.length !== 2 ||
|
|
209
|
+
staged.some(({ source, destination }) =>
|
|
210
|
+
typeof source !== "string" || !source || typeof destination !== "string" || !destination)
|
|
211
|
+
) {
|
|
212
|
+
throw new Error("daemon 安装必须提供两个合法的暂存文件");
|
|
213
|
+
}
|
|
214
|
+
const installed = [];
|
|
215
|
+
const backups = [];
|
|
216
|
+
try {
|
|
217
|
+
for (const { source, destination } of staged) {
|
|
218
|
+
const backup = `${source}.previous`;
|
|
219
|
+
if (fs.existsSync(destination)) {
|
|
220
|
+
fs.renameSync(destination, backup);
|
|
221
|
+
backups.push({ backup, destination });
|
|
222
|
+
}
|
|
223
|
+
fs.renameSync(source, destination);
|
|
224
|
+
installed.push(destination);
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
for (const destination of installed.reverse()) {
|
|
228
|
+
try { fs.rmSync(destination, { force: true }); } catch {}
|
|
229
|
+
}
|
|
230
|
+
const restoreFailures = [];
|
|
231
|
+
for (const { backup, destination } of backups.reverse()) {
|
|
232
|
+
try { fs.renameSync(backup, destination); }
|
|
233
|
+
catch (restoreError) { restoreFailures.push(restoreError); }
|
|
234
|
+
}
|
|
235
|
+
if (restoreFailures.length > 0) {
|
|
236
|
+
throw new AggregateError([error, ...restoreFailures], "daemon 二进制替换失败且旧版本恢复不完整");
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|