@cjhyy/code-shell-server 0.9.36 → 0.9.38
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/dist/bin/code-shell-cloud-account.d.ts +2 -0
- package/dist/bin/code-shell-cloud-account.js +53 -0
- package/dist/index.serve.d.ts +4 -0
- package/dist/index.serve.js +3 -0
- package/dist/panels/management.js +12 -1
- package/dist/panels/types.d.ts +5 -3
- package/dist/project-runtime/account-adoption.d.ts +34 -0
- package/dist/project-runtime/account-adoption.js +280 -0
- package/dist/project-runtime/account-binding.d.ts +18 -0
- package/dist/project-runtime/account-binding.js +42 -0
- package/dist/project-runtime/backup.d.ts +10 -0
- package/dist/project-runtime/backup.js +113 -20
- package/dist/project-runtime/cloud-account-auth.d.ts +23 -0
- package/dist/project-runtime/cloud-account-auth.js +287 -0
- package/dist/project-runtime/cloud-browser-auth.d.ts +15 -0
- package/dist/project-runtime/cloud-browser-auth.js +352 -0
- package/dist/project-runtime/control-authority.d.ts +46 -0
- package/dist/project-runtime/control-authority.js +15 -0
- package/dist/project-runtime/control-server.d.ts +17 -0
- package/dist/project-runtime/control-server.js +94 -26
- package/dist/project-runtime/docker-provider.d.ts +6 -0
- package/dist/project-runtime/docker-provider.js +10 -5
- package/dist/project-runtime/lock.js +181 -12
- package/dist/project-runtime/manager.d.ts +16 -8
- package/dist/project-runtime/manager.js +89 -15
- package/dist/project-runtime/proxy.d.ts +3 -3
- package/dist/project-runtime/proxy.js +15 -5
- package/dist/project-runtime/registry.d.ts +21 -1
- package/dist/project-runtime/registry.js +224 -6
- package/dist/serve/headless-server.js +14 -0
- package/package.json +7 -6
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
|
-
import { constants, createWriteStream } from "node:fs";
|
|
3
|
+
import { closeSync, constants, createWriteStream, fsyncSync, fstatSync, lstatSync, openSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { lstat, mkdir, open, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { Transform } from "node:stream";
|
|
8
8
|
import { HubAuthStore } from "../hub/auth-store.js";
|
|
9
9
|
import { ProjectRegistry } from "./registry.js";
|
|
10
|
+
import { isCloudAccountOrigin } from "./account-binding.js";
|
|
10
11
|
const LABEL = "io.codeshell.project-runtime";
|
|
11
12
|
const FORMAT = "codeshell.project-installation-backup";
|
|
12
13
|
const MAX_CONTROL_FILE = 16 * 1024 * 1024;
|
|
13
14
|
const SHA = /^[a-f0-9]{64}$/;
|
|
14
15
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
16
|
+
const MULTI_CONTROL_FILES = ["project-control/initialized", "project-control/registry.json"];
|
|
15
17
|
const digest = (value) => ({
|
|
16
18
|
sha256: createHash("sha256").update(value).digest("hex"),
|
|
17
19
|
bytes: value.length,
|
|
@@ -256,6 +258,14 @@ async function copyControl(source, destination, records, prefix = "") {
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
}
|
|
261
|
+
async function assertMultiControlInventory(source) {
|
|
262
|
+
for (const name of await readdir(source))
|
|
263
|
+
if (!["project-control", "project-control.lock", "project-runtime-secrets"].includes(name))
|
|
264
|
+
throw new Error("Multi-account backup refuses unknown controller state files.");
|
|
265
|
+
for (const name of await readdir(join(source, "project-control")))
|
|
266
|
+
if (!["initialized", "registry.json"].includes(name))
|
|
267
|
+
throw new Error("Multi-account backup refuses unknown project control files.");
|
|
268
|
+
}
|
|
259
269
|
/** Offline, owner-only backup. It never stops projects or modifies their volumes. */
|
|
260
270
|
export async function backupProjectInstallation(options) {
|
|
261
271
|
imageId(options.helperImage);
|
|
@@ -263,15 +273,21 @@ export async function backupProjectInstallation(options) {
|
|
|
263
273
|
await directory(source);
|
|
264
274
|
// Do not initialize a fresh registry as a side effect of a mistaken backup path.
|
|
265
275
|
await regular(join(source, "project-control/registry.json"), 512 * 1024);
|
|
266
|
-
const registry = new ProjectRegistry(source
|
|
276
|
+
const registry = new ProjectRegistry(source, Date.now, {
|
|
277
|
+
multiAccountOrigin: options.multiAccountOrigin,
|
|
278
|
+
});
|
|
267
279
|
let destination;
|
|
268
280
|
try {
|
|
269
281
|
const rows = registry.all();
|
|
270
282
|
if (rows.some((row) => row.status !== "stopped"))
|
|
271
283
|
throw new Error("Stop all projects and the controller before backup.");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
284
|
+
if (registry.multiAccount)
|
|
285
|
+
await assertMultiControlInventory(source);
|
|
286
|
+
if (!registry.multiAccount) {
|
|
287
|
+
const auth = new HubAuthStore({ dataDir: source });
|
|
288
|
+
if (!auth.isInitialized())
|
|
289
|
+
throw new Error("Complete administrator setup before backing up.");
|
|
290
|
+
}
|
|
275
291
|
const installationId = registry.installationId;
|
|
276
292
|
const projects = rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }));
|
|
277
293
|
await stopped(installationId, projects);
|
|
@@ -281,7 +297,8 @@ export async function backupProjectInstallation(options) {
|
|
|
281
297
|
await mkdir(join(destination, "volumes"), { mode: 0o700 });
|
|
282
298
|
const manifest = {
|
|
283
299
|
format: FORMAT,
|
|
284
|
-
version: 1,
|
|
300
|
+
version: registry.multiAccount ? 2 : 1,
|
|
301
|
+
...(registry.multiAccount ? { multiAccountOrigin: registry.multiAccount.accountOrigin } : {}),
|
|
285
302
|
createdAt: new Date().toISOString(),
|
|
286
303
|
installationId,
|
|
287
304
|
helperImage: options.helperImage,
|
|
@@ -289,7 +306,20 @@ export async function backupProjectInstallation(options) {
|
|
|
289
306
|
control: Object.create(null),
|
|
290
307
|
volumes: [],
|
|
291
308
|
};
|
|
292
|
-
|
|
309
|
+
if (registry.multiAccount) {
|
|
310
|
+
await mkdir(join(destination, "control/project-control"), { mode: 0o700 });
|
|
311
|
+
for (const path of MULTI_CONTROL_FILES) {
|
|
312
|
+
const bytes = await controlBytes(source, path);
|
|
313
|
+
await writeFile(join(destination, "control", path), bytes, {
|
|
314
|
+
mode: 0o600,
|
|
315
|
+
flag: "wx",
|
|
316
|
+
flush: true,
|
|
317
|
+
});
|
|
318
|
+
manifest.control[path] = digest(bytes);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else
|
|
322
|
+
await copyControl(source, join(destination, "control"), manifest.control);
|
|
293
323
|
for (const project of projects) {
|
|
294
324
|
let found = 0;
|
|
295
325
|
for (const kind of ["data", "workspace"]) {
|
|
@@ -316,6 +346,8 @@ export async function backupProjectInstallation(options) {
|
|
|
316
346
|
}
|
|
317
347
|
await stopped(installationId, projects);
|
|
318
348
|
registry.all(); // Recheck registry ownership and unchanged bytes after asynchronous work.
|
|
349
|
+
if (registry.multiAccount)
|
|
350
|
+
await assertMultiControlInventory(source);
|
|
319
351
|
const manifestBytes = JSON.stringify(manifest, null, 2) + "\n";
|
|
320
352
|
if (Buffer.byteLength(manifestBytes) > 1024 * 1024)
|
|
321
353
|
throw new Error("Backup manifest exceeds its supported size.");
|
|
@@ -342,7 +374,10 @@ export async function backupProjectInstallation(options) {
|
|
|
342
374
|
}
|
|
343
375
|
function validateManifest(value) {
|
|
344
376
|
if (value?.format !== FORMAT ||
|
|
345
|
-
value.version
|
|
377
|
+
![1, 2].includes(value.version) ||
|
|
378
|
+
(value.version === 1
|
|
379
|
+
? value.multiAccountOrigin !== undefined
|
|
380
|
+
: !isCloudAccountOrigin(value.multiAccountOrigin)) ||
|
|
346
381
|
!UUID.test(value.installationId) ||
|
|
347
382
|
!Array.isArray(value.projects) ||
|
|
348
383
|
value.projects.length > 128 ||
|
|
@@ -375,12 +410,16 @@ function validateManifest(value) {
|
|
|
375
410
|
}
|
|
376
411
|
if (Object.keys(value.control).length > 4096)
|
|
377
412
|
throw new Error("Invalid control inventory.");
|
|
413
|
+
if (value.version === 2 &&
|
|
414
|
+
Object.keys(value.control).sort().join() !== MULTI_CONTROL_FILES.join())
|
|
415
|
+
throw new Error("Multi-account backup requires its exact controller inventory.");
|
|
378
416
|
for (const [path, entry] of Object.entries(value.control)) {
|
|
379
417
|
if (!path ||
|
|
380
418
|
path.includes("\\") ||
|
|
381
419
|
path.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
382
420
|
/[\0\r\n]/.test(path) ||
|
|
383
421
|
path === "restore-in-progress" ||
|
|
422
|
+
["project-runtime-secrets", "administrator-setup.txt", "environment.json"].some((obsolete) => path.startsWith(obsolete + "/")) ||
|
|
384
423
|
path.startsWith("project-control.lock/") ||
|
|
385
424
|
path === "project-control.lock" ||
|
|
386
425
|
!entry ||
|
|
@@ -404,7 +443,7 @@ function validateManifest(value) {
|
|
|
404
443
|
for (const path of [
|
|
405
444
|
"project-control/registry.json",
|
|
406
445
|
"project-control/initialized",
|
|
407
|
-
"hub/auth.json",
|
|
446
|
+
...(value.version === 1 ? ["hub/auth.json"] : []),
|
|
408
447
|
])
|
|
409
448
|
if (!Object.hasOwn(value.control, path))
|
|
410
449
|
throw new Error("Backup lacks required control data.");
|
|
@@ -418,6 +457,8 @@ export async function restoreProjectInstallation(options) {
|
|
|
418
457
|
await directory(join(source, "volumes"));
|
|
419
458
|
const manifest = JSON.parse((await regular(join(source, "manifest.json"), 1024 * 1024)).toString());
|
|
420
459
|
validateManifest(manifest);
|
|
460
|
+
if (manifest.multiAccountOrigin !== options.multiAccountOrigin)
|
|
461
|
+
throw new Error("Restore requires the explicitly configured Account origin and matching installation mode.");
|
|
421
462
|
// Verify every archive before creating a destination or Docker resource.
|
|
422
463
|
for (const volume of manifest.volumes)
|
|
423
464
|
same(await fileDigest(join(source, volume.file)), volume.digest);
|
|
@@ -425,31 +466,67 @@ export async function restoreProjectInstallation(options) {
|
|
|
425
466
|
same(digest(await controlBytes(join(source, "control"), path)), expected);
|
|
426
467
|
await docker(["image", "inspect", options.helperImage]);
|
|
427
468
|
const destination = await exclusiveDestination(source, resolve(options.destination));
|
|
469
|
+
const marker = join(destination, "restore-in-progress");
|
|
470
|
+
const directories = new Set([destination]);
|
|
471
|
+
const sync = (path) => {
|
|
472
|
+
const before = lstatSync(path);
|
|
473
|
+
if (before.isSymbolicLink() || (!before.isFile() && !before.isDirectory()))
|
|
474
|
+
throw new Error("Restore durability requires ordinary files and directories.");
|
|
475
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
476
|
+
try {
|
|
477
|
+
const current = fstatSync(fd);
|
|
478
|
+
if (current.dev !== before.dev || current.ino !== before.ino)
|
|
479
|
+
throw new Error("Restore storage changed while opening.");
|
|
480
|
+
(options.io?.fsync ?? fsyncSync)(fd);
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
closeSync(fd);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const persistMarker = (rearm = false) => {
|
|
487
|
+
try {
|
|
488
|
+
writeFileSync(marker, "1\n", { mode: 0o600, flag: "wx" });
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
if (!rearm || error.code !== "EEXIST")
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
sync(marker);
|
|
495
|
+
sync(destination);
|
|
496
|
+
sync(dirname(destination));
|
|
497
|
+
};
|
|
428
498
|
const created = [];
|
|
429
499
|
let registry;
|
|
430
500
|
try {
|
|
431
|
-
|
|
432
|
-
mode: 0o600,
|
|
433
|
-
flag: "wx",
|
|
434
|
-
flush: true,
|
|
435
|
-
});
|
|
501
|
+
persistMarker();
|
|
436
502
|
for (const [path, expected] of Object.entries(manifest.control)) {
|
|
437
503
|
const bytes = await controlBytes(join(source, "control"), path);
|
|
438
504
|
same(digest(bytes), expected);
|
|
439
505
|
await mkdir(dirname(join(destination, path)), { recursive: true, mode: 0o700 });
|
|
506
|
+
for (let parent = dirname(join(destination, path)); parent !== destination; parent = dirname(parent))
|
|
507
|
+
directories.add(parent);
|
|
440
508
|
await writeFile(join(destination, path), bytes, { mode: 0o600, flag: "wx", flush: true });
|
|
441
509
|
}
|
|
442
|
-
registry = new ProjectRegistry(destination
|
|
510
|
+
registry = new ProjectRegistry(destination, Date.now, {
|
|
511
|
+
fsync: options.io?.fsync,
|
|
512
|
+
multiAccountOrigin: options.multiAccountOrigin,
|
|
513
|
+
});
|
|
443
514
|
const rows = registry.all();
|
|
444
515
|
if (registry.installationId !== manifest.installationId ||
|
|
445
516
|
rows.some((p) => p.status !== "stopped") ||
|
|
446
517
|
JSON.stringify(rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }))) !==
|
|
447
518
|
JSON.stringify(manifest.projects))
|
|
448
519
|
throw new Error("Backup control and volume identities disagree.");
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
520
|
+
if (!registry.multiAccount) {
|
|
521
|
+
const auth = new HubAuthStore({ dataDir: destination });
|
|
522
|
+
if (!auth.isInitialized())
|
|
523
|
+
throw new Error("Backup administrator is not initialized.");
|
|
524
|
+
auth.revokeAllSessions();
|
|
525
|
+
// HubAuthStore publishes by rename. Persist the replacement and its directory
|
|
526
|
+
// before a new namespace or removal of the restore barrier can become visible.
|
|
527
|
+
sync(join(destination, "hub/auth.json"));
|
|
528
|
+
sync(join(destination, "hub"));
|
|
529
|
+
}
|
|
453
530
|
registry.renewInstallationForRestore();
|
|
454
531
|
const installationId = registry.installationId;
|
|
455
532
|
for (const volume of manifest.volumes) {
|
|
@@ -472,7 +549,12 @@ export async function restoreProjectInstallation(options) {
|
|
|
472
549
|
for (const path of ["project-runtime-secrets", "administrator-setup.txt", "environment.json"])
|
|
473
550
|
await rm(join(destination, path), { recursive: true, force: true });
|
|
474
551
|
registry.all();
|
|
475
|
-
|
|
552
|
+
// Copied file bytes were flushed individually; their new directory entries
|
|
553
|
+
// must also survive a crash before the installation is advertised as usable.
|
|
554
|
+
for (const path of [...directories].sort((a, b) => b.length - a.length))
|
|
555
|
+
sync(path);
|
|
556
|
+
await (options.io?.removeMarker ?? rm)(marker);
|
|
557
|
+
sync(destination);
|
|
476
558
|
return {
|
|
477
559
|
destination,
|
|
478
560
|
installationId,
|
|
@@ -482,6 +564,15 @@ export async function restoreProjectInstallation(options) {
|
|
|
482
564
|
};
|
|
483
565
|
}
|
|
484
566
|
catch (error) {
|
|
567
|
+
let barrierError;
|
|
568
|
+
try {
|
|
569
|
+
// In particular, unlink may have succeeded before its directory fsync
|
|
570
|
+
// failed. Re-establish and persist the barrier before any cleanup awaits.
|
|
571
|
+
persistMarker(true);
|
|
572
|
+
}
|
|
573
|
+
catch (failure) {
|
|
574
|
+
barrierError = failure;
|
|
575
|
+
}
|
|
485
576
|
// Only delete volumes created by this restore and still carrying its exact labels.
|
|
486
577
|
for (const { name, expected } of created.reverse()) {
|
|
487
578
|
try {
|
|
@@ -492,6 +583,8 @@ export async function restoreProjectInstallation(options) {
|
|
|
492
583
|
/* Preserve a busy/changed volume for operator inspection. */
|
|
493
584
|
}
|
|
494
585
|
}
|
|
586
|
+
if (barrierError)
|
|
587
|
+
throw new AggregateError([error, barrierError], `Restore failed and its durable barrier could not be confirmed; keep ${destination} offline.`, { cause: error });
|
|
495
588
|
throw error; // Keep the marked offline directory as recovery evidence; never advertise success.
|
|
496
589
|
}
|
|
497
590
|
finally {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type CloudAccountBinding } from "./account-binding.js";
|
|
2
|
+
import type { CloudAccountAuthority, CloudManagementSession, ProjectControlAuthority } from "./control-authority.js";
|
|
3
|
+
export declare const CLOUD_MANAGEMENT_AUTHORITY_VERSION = 2;
|
|
4
|
+
export declare const CLOUD_MANAGEMENT_COOKIE = "cs_cloud_management";
|
|
5
|
+
export declare function cloudManagementSessionView(session: CloudManagementSession): {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: "cloud-management";
|
|
8
|
+
audience: string;
|
|
9
|
+
installationId: string;
|
|
10
|
+
accountOrigin: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
managementEpoch: string;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
};
|
|
15
|
+
/** An explicitly bound Account controls one existing single-operator installation. */
|
|
16
|
+
export declare function createCloudAccountAuth(options: {
|
|
17
|
+
binding: CloudAccountBinding;
|
|
18
|
+
installationId: string;
|
|
19
|
+
publicOrigin: string;
|
|
20
|
+
authority: CloudAccountAuthority;
|
|
21
|
+
onRevoke(sessionId: string): void;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
}): ProjectControlAuthority;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { isCloudAccountOrigin } from "./account-binding.js";
|
|
3
|
+
import { ProjectRegistryError } from "./registry.js";
|
|
4
|
+
export const CLOUD_MANAGEMENT_AUTHORITY_VERSION = 2;
|
|
5
|
+
export const CLOUD_MANAGEMENT_COOKIE = "cs_cloud_management";
|
|
6
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
|
|
8
|
+
const hash = (token) => createHash("sha256").update(token).digest("hex");
|
|
9
|
+
export function cloudManagementSessionView(session) {
|
|
10
|
+
const { ownerId: _ownerId, ...view } = session;
|
|
11
|
+
return view;
|
|
12
|
+
}
|
|
13
|
+
function principalValid(value) {
|
|
14
|
+
return (value &&
|
|
15
|
+
Object.keys(value).sort().join() ===
|
|
16
|
+
["accountOrigin", "accountId", "sessionId", "rootSessionId", "authorityEpoch", "expiresAt"]
|
|
17
|
+
.sort()
|
|
18
|
+
.join() &&
|
|
19
|
+
isCloudAccountOrigin(value.accountOrigin) &&
|
|
20
|
+
UUID.test(value.accountId) &&
|
|
21
|
+
UUID.test(value.sessionId) &&
|
|
22
|
+
value.sessionId === value.rootSessionId &&
|
|
23
|
+
UUID.test(value.authorityEpoch) &&
|
|
24
|
+
Number.isSafeInteger(value.expiresAt));
|
|
25
|
+
}
|
|
26
|
+
/** An explicitly bound Account controls one existing single-operator installation. */
|
|
27
|
+
export function createCloudAccountAuth(options) {
|
|
28
|
+
const now = options.now ?? Date.now;
|
|
29
|
+
if (!isCloudAccountOrigin(options.publicOrigin) ||
|
|
30
|
+
options.authority.accountOrigin !== options.binding.accountOrigin ||
|
|
31
|
+
!UUID.test(options.installationId))
|
|
32
|
+
throw new Error("Cloud authority configuration does not match explicit ownership.");
|
|
33
|
+
const sessions = new Map();
|
|
34
|
+
const reading = new Set();
|
|
35
|
+
let closed = false;
|
|
36
|
+
let exchanging = 0;
|
|
37
|
+
let attempts = { until: now() + 60_000, count: 0 };
|
|
38
|
+
function revoke(entry) {
|
|
39
|
+
if (entry.revoked)
|
|
40
|
+
return;
|
|
41
|
+
entry.revoked = true;
|
|
42
|
+
options.onRevoke(entry.session.id);
|
|
43
|
+
}
|
|
44
|
+
function entryFor(session) {
|
|
45
|
+
if (closed || !("kind" in session))
|
|
46
|
+
return;
|
|
47
|
+
const entry = sessions.get(session.id);
|
|
48
|
+
if (!entry || entry.revoked || entry.session.expiresAt <= now()) {
|
|
49
|
+
if (entry)
|
|
50
|
+
revoke(entry);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Do not let a caller change owner, epoch or deadline using an existing id.
|
|
54
|
+
if (JSON.stringify(entry.session) !== JSON.stringify(session))
|
|
55
|
+
return;
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
async function revalidate(session) {
|
|
59
|
+
const entry = entryFor(session);
|
|
60
|
+
if (!entry)
|
|
61
|
+
throw new ProjectRegistryError(401, "Cloud 管理登录已失效。");
|
|
62
|
+
try {
|
|
63
|
+
const current = await options.authority.validate({ ...entry.principal }, options.installationId);
|
|
64
|
+
if (!principalValid(current) ||
|
|
65
|
+
Object.keys(current).some((key) => current[key] !==
|
|
66
|
+
entry.principal[key]) ||
|
|
67
|
+
!entryFor(session))
|
|
68
|
+
throw new Error("Cloud Account authority changed.");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
revoke(entry);
|
|
72
|
+
throw new ProjectRegistryError(401, "Cloud 管理授权已失效,请重新登录云账号。");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function requestToken(req) {
|
|
76
|
+
let authorizations = 0;
|
|
77
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2)
|
|
78
|
+
if (req.rawHeaders[i].toLowerCase() === "authorization")
|
|
79
|
+
authorizations++;
|
|
80
|
+
if (authorizations > 1)
|
|
81
|
+
return;
|
|
82
|
+
const bearer = req.headers.authorization;
|
|
83
|
+
const cookie = req.headers.cookie
|
|
84
|
+
?.split(";")
|
|
85
|
+
.map((part) => part.trim())
|
|
86
|
+
.filter((part) => part.startsWith(`${CLOUD_MANAGEMENT_COOKIE}=`));
|
|
87
|
+
if (bearer !== undefined && cookie?.length)
|
|
88
|
+
return;
|
|
89
|
+
if (bearer !== undefined)
|
|
90
|
+
return /^Bearer ([A-Za-z0-9_-]{43})$/.exec(bearer)?.[1];
|
|
91
|
+
if (cookie?.length === 1)
|
|
92
|
+
return cookie[0].slice(CLOUD_MANAGEMENT_COOKIE.length + 1);
|
|
93
|
+
}
|
|
94
|
+
function isOriginAllowed(req) {
|
|
95
|
+
let hosts = 0;
|
|
96
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2)
|
|
97
|
+
if (req.rawHeaders[i].toLowerCase() === "host")
|
|
98
|
+
hosts++;
|
|
99
|
+
if (hosts !== 1 ||
|
|
100
|
+
req.headers.host !== new URL(options.publicOrigin).host ||
|
|
101
|
+
req.headers["sec-fetch-site"] === "cross-site")
|
|
102
|
+
return false;
|
|
103
|
+
return req.headers.origin === undefined || req.headers.origin === options.publicOrigin;
|
|
104
|
+
}
|
|
105
|
+
async function authenticate(req) {
|
|
106
|
+
if (!isOriginAllowed(req))
|
|
107
|
+
return null;
|
|
108
|
+
const token = requestToken(req);
|
|
109
|
+
if (!token || !TOKEN.test(token))
|
|
110
|
+
return null;
|
|
111
|
+
const entry = [...sessions.values()].find((row) => row.hash === hash(token));
|
|
112
|
+
if (!entry || !entryFor(entry.session))
|
|
113
|
+
return null;
|
|
114
|
+
await revalidate(entry.session);
|
|
115
|
+
return { ...entry.session };
|
|
116
|
+
}
|
|
117
|
+
function json(res, status, value) {
|
|
118
|
+
res.writeHead(status, {
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
"Cache-Control": "no-store",
|
|
121
|
+
"Referrer-Policy": "no-referrer",
|
|
122
|
+
"X-Content-Type-Options": "nosniff",
|
|
123
|
+
});
|
|
124
|
+
res.end(JSON.stringify(value));
|
|
125
|
+
}
|
|
126
|
+
async function readBody(req) {
|
|
127
|
+
if (!/^application\/json(?:\s*;|$)/i.test(req.headers["content-type"] ?? ""))
|
|
128
|
+
throw new ProjectRegistryError(415, "请使用 JSON 提交。");
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let size = 0;
|
|
131
|
+
const timer = setTimeout(() => req.destroy(), 5000);
|
|
132
|
+
timer.unref();
|
|
133
|
+
reading.add(req);
|
|
134
|
+
try {
|
|
135
|
+
for await (const chunk of req) {
|
|
136
|
+
size += chunk.length;
|
|
137
|
+
if (size > 4096)
|
|
138
|
+
throw new ProjectRegistryError(413, "请求过大。");
|
|
139
|
+
chunks.push(Buffer.from(chunk));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
reading.delete(req);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
148
|
+
if (body && typeof body === "object" && !Array.isArray(body))
|
|
149
|
+
return body;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* fixed public error below */
|
|
153
|
+
}
|
|
154
|
+
throw new ProjectRegistryError(400, "请求格式无效。");
|
|
155
|
+
}
|
|
156
|
+
async function handle(req, res) {
|
|
157
|
+
const path = (req.url ?? "/").split("?", 1)[0];
|
|
158
|
+
if (!path.startsWith("/api/v1/cloud-management/") && !path.startsWith("/api/v1/auth/"))
|
|
159
|
+
return false;
|
|
160
|
+
if (!isOriginAllowed(req))
|
|
161
|
+
throw new ProjectRegistryError(403, "请求来源无效。");
|
|
162
|
+
if (closed)
|
|
163
|
+
throw new ProjectRegistryError(503, "Cloud 管理服务正在关闭。");
|
|
164
|
+
if (req.url !== path)
|
|
165
|
+
throw new ProjectRegistryError(400, "请求参数无效。");
|
|
166
|
+
if (path === "/api/v1/cloud-management/capabilities" && req.method === "GET") {
|
|
167
|
+
json(res, 200, {
|
|
168
|
+
version: CLOUD_MANAGEMENT_AUTHORITY_VERSION,
|
|
169
|
+
mode: "cloud-account",
|
|
170
|
+
cloudOrigin: options.publicOrigin,
|
|
171
|
+
installationId: options.installationId,
|
|
172
|
+
accountOrigin: options.binding.accountOrigin,
|
|
173
|
+
});
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (path === "/api/v1/cloud-management/session" && req.method === "POST") {
|
|
177
|
+
// Main sends an Account-issued target-bound single-use ticket, never its root credential.
|
|
178
|
+
if (req.headers.origin !== undefined ||
|
|
179
|
+
req.headers.cookie !== undefined ||
|
|
180
|
+
req.headers.authorization !== undefined ||
|
|
181
|
+
req.headers["sec-fetch-site"] !== undefined)
|
|
182
|
+
throw new ProjectRegistryError(403, "请从桌面 Cloud 管理器登录。");
|
|
183
|
+
if (now() >= attempts.until)
|
|
184
|
+
attempts = { until: now() + 60_000, count: 0 };
|
|
185
|
+
if (++attempts.count > 60 || exchanging >= 4)
|
|
186
|
+
throw new ProjectRegistryError(429, "Cloud 管理登录请求过多。");
|
|
187
|
+
exchanging++;
|
|
188
|
+
try {
|
|
189
|
+
const body = await readBody(req);
|
|
190
|
+
if (Object.keys(body).join() !== "ticket" ||
|
|
191
|
+
typeof body.ticket !== "string" ||
|
|
192
|
+
!TOKEN.test(body.ticket))
|
|
193
|
+
throw new ProjectRegistryError(400, "Cloud 管理票据格式无效。");
|
|
194
|
+
let principal;
|
|
195
|
+
try {
|
|
196
|
+
principal = await options.authority.redeem(body.ticket, options.installationId);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new ProjectRegistryError(401, "云账号验证失败。");
|
|
200
|
+
}
|
|
201
|
+
if (closed ||
|
|
202
|
+
!principalValid(principal) ||
|
|
203
|
+
principal.accountOrigin !== options.binding.accountOrigin ||
|
|
204
|
+
principal.accountId !== options.binding.accountId ||
|
|
205
|
+
principal.expiresAt <= now())
|
|
206
|
+
throw new ProjectRegistryError(403, "此账号没有 Cloud 管理权限。");
|
|
207
|
+
for (const [id, row] of sessions)
|
|
208
|
+
if (row.session.expiresAt + 600_000 <= now())
|
|
209
|
+
sessions.delete(id);
|
|
210
|
+
if (sessions.size >= 128)
|
|
211
|
+
throw new ProjectRegistryError(429, "Cloud 管理会话数量已达上限。");
|
|
212
|
+
const token = randomBytes(32).toString("base64url");
|
|
213
|
+
const session = {
|
|
214
|
+
id: randomUUID(),
|
|
215
|
+
kind: "cloud-management",
|
|
216
|
+
audience: options.publicOrigin,
|
|
217
|
+
installationId: options.installationId,
|
|
218
|
+
accountOrigin: principal.accountOrigin,
|
|
219
|
+
accountId: principal.accountId,
|
|
220
|
+
managementEpoch: options.binding.managementEpoch,
|
|
221
|
+
expiresAt: Math.min(now() + 600_000, principal.expiresAt),
|
|
222
|
+
ownerId: options.binding.ownerId,
|
|
223
|
+
};
|
|
224
|
+
sessions.set(session.id, {
|
|
225
|
+
hash: hash(token),
|
|
226
|
+
session,
|
|
227
|
+
principal: { ...principal },
|
|
228
|
+
revoked: false,
|
|
229
|
+
});
|
|
230
|
+
json(res, 201, { token, session: cloudManagementSessionView(session) });
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
exchanging--;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (path === "/api/v1/auth/status" && req.method === "GET") {
|
|
238
|
+
const session = await authenticate(req);
|
|
239
|
+
json(res, 200, {
|
|
240
|
+
initialized: true,
|
|
241
|
+
mode: "cloud-account",
|
|
242
|
+
authenticated: !!session,
|
|
243
|
+
...(session && "kind" in session ? { session: cloudManagementSessionView(session) } : {}),
|
|
244
|
+
});
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (["/api/v1/cloud-management/logout", "/api/v1/auth/logout"].includes(path) &&
|
|
248
|
+
req.method === "POST") {
|
|
249
|
+
const body = await readBody(req);
|
|
250
|
+
if (Object.keys(body).length)
|
|
251
|
+
throw new ProjectRegistryError(400, "退出不接受额外参数。");
|
|
252
|
+
const token = requestToken(req);
|
|
253
|
+
const entry = token &&
|
|
254
|
+
TOKEN.test(token) &&
|
|
255
|
+
[...sessions.values()].find((row) => row.hash === hash(token));
|
|
256
|
+
if (!entry)
|
|
257
|
+
throw new ProjectRegistryError(401, "Cloud 管理登录已失效。");
|
|
258
|
+
revoke(entry);
|
|
259
|
+
res.setHeader("Set-Cookie", `${CLOUD_MANAGEMENT_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${options.publicOrigin.startsWith("https:") ? "; Secure" : ""}`);
|
|
260
|
+
json(res, 200, { revoked: true });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
throw new ProjectRegistryError(403, "此 Cloud 只接受明确绑定的云账号管理。");
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
cookieName: CLOUD_MANAGEMENT_COOKIE,
|
|
267
|
+
store: {
|
|
268
|
+
listSessions: () => [...sessions.values()]
|
|
269
|
+
.filter((row) => entryFor(row.session))
|
|
270
|
+
.map((row) => ({ ...row.session })),
|
|
271
|
+
},
|
|
272
|
+
handle,
|
|
273
|
+
authenticate,
|
|
274
|
+
isOriginAllowed,
|
|
275
|
+
isActive: (session) => !!entryFor(session),
|
|
276
|
+
revalidate,
|
|
277
|
+
close() {
|
|
278
|
+
closed = true;
|
|
279
|
+
for (const request of reading)
|
|
280
|
+
request.destroy();
|
|
281
|
+
reading.clear();
|
|
282
|
+
for (const row of sessions.values())
|
|
283
|
+
revoke(row);
|
|
284
|
+
sessions.clear();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CloudAccountAuthority, ProjectControlAuthority } from "./control-authority.js";
|
|
2
|
+
/** Browser bootstrap is separate from the native, single-operator v2 API. */
|
|
3
|
+
export declare const CLOUD_BROWSER_AUTHORITY_VERSION = 1;
|
|
4
|
+
export declare const CLOUD_BROWSER_COOKIE = "cs_cloud_browser";
|
|
5
|
+
/** Every principal is independently scoped; a login never claims legacy projects. */
|
|
6
|
+
export declare function createCloudBrowserAuth(options: {
|
|
7
|
+
accountOrigin: string;
|
|
8
|
+
managementEpoch: string;
|
|
9
|
+
installationId: string;
|
|
10
|
+
publicOrigin: string;
|
|
11
|
+
authority: CloudAccountAuthority;
|
|
12
|
+
ownerForAccount(accountOrigin: string, accountId: string): string;
|
|
13
|
+
onRevoke(sessionId: string, ownerId: string): void;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}): ProjectControlAuthority;
|