@cjhyy/code-shell-server 0.9.35 → 0.9.37
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 +3 -0
- package/dist/index.serve.js +2 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +10 -1
- package/dist/mobile-remote/room-history.d.ts +20 -0
- package/dist/mobile-remote/room-history.js +327 -0
- package/dist/mobile-remote/room-manager.d.ts +11 -0
- package/dist/mobile-remote/room-manager.js +107 -11
- package/dist/panels/management.js +12 -1
- package/dist/panels/runtime-services.js +32 -7
- 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 +12 -0
- package/dist/project-runtime/account-binding.js +33 -0
- package/dist/project-runtime/backup.d.ts +7 -0
- package/dist/project-runtime/backup.js +56 -8
- 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/control-authority.d.ts +46 -0
- package/dist/project-runtime/control-authority.js +15 -0
- package/dist/project-runtime/control-server.d.ts +3 -0
- package/dist/project-runtime/control-server.js +60 -24
- package/dist/project-runtime/lock.js +181 -12
- package/dist/project-runtime/manager.d.ts +12 -8
- package/dist/project-runtime/manager.js +56 -15
- package/dist/project-runtime/proxy.d.ts +3 -3
- package/dist/project-runtime/proxy.js +4 -1
- package/dist/project-runtime/registry.d.ts +14 -1
- package/dist/project-runtime/registry.js +91 -2
- package/package.json +7 -6
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Explicit single-operator adoption; account display names never confer ownership. */
|
|
2
|
+
export interface CloudAccountBinding {
|
|
3
|
+
version: 1;
|
|
4
|
+
accountOrigin: string;
|
|
5
|
+
accountId: string;
|
|
6
|
+
ownerId: string;
|
|
7
|
+
managementEpoch: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const CLOUD_ADOPTION_PENDING = "account-adoption-pending.json";
|
|
11
|
+
export declare function isCloudAccountOrigin(value: unknown): value is string;
|
|
12
|
+
export declare function isCloudAccountBinding(value: unknown): value is CloudAccountBinding;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const CLOUD_ADOPTION_PENDING = "account-adoption-pending.json";
|
|
2
|
+
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
|
|
3
|
+
export function isCloudAccountOrigin(value) {
|
|
4
|
+
if (typeof value !== "string" || value.length > 2048)
|
|
5
|
+
return false;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
return (url.origin === value &&
|
|
9
|
+
!url.username &&
|
|
10
|
+
!url.password &&
|
|
11
|
+
(url.protocol === "https:" ||
|
|
12
|
+
(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isCloudAccountBinding(value) {
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
20
|
+
return false;
|
|
21
|
+
const row = value;
|
|
22
|
+
return (Object.keys(row).sort().join() ===
|
|
23
|
+
["version", "accountOrigin", "accountId", "ownerId", "managementEpoch", "createdAt"]
|
|
24
|
+
.sort()
|
|
25
|
+
.join() &&
|
|
26
|
+
row.version === 1 &&
|
|
27
|
+
isCloudAccountOrigin(row.accountOrigin) &&
|
|
28
|
+
UUID.test(row.accountId) &&
|
|
29
|
+
UUID.test(row.ownerId) &&
|
|
30
|
+
UUID.test(row.managementEpoch) &&
|
|
31
|
+
Number.isSafeInteger(row.createdAt) &&
|
|
32
|
+
row.createdAt >= 0);
|
|
33
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fsyncSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
1
3
|
export interface ProjectBackupOptions {
|
|
2
4
|
dataDir: string;
|
|
3
5
|
destination: string;
|
|
@@ -8,6 +10,11 @@ export interface ProjectRestoreOptions {
|
|
|
8
10
|
source: string;
|
|
9
11
|
destination: string;
|
|
10
12
|
helperImage: string;
|
|
13
|
+
/** Fault injection for offline restore durability tests. */
|
|
14
|
+
io?: {
|
|
15
|
+
fsync?: typeof fsyncSync;
|
|
16
|
+
removeMarker?: typeof rm;
|
|
17
|
+
};
|
|
11
18
|
}
|
|
12
19
|
/** Offline, owner-only backup. It never stops projects or modifies their volumes. */
|
|
13
20
|
export declare function backupProjectInstallation(options: ProjectBackupOptions): Promise<{
|
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -381,6 +381,7 @@ function validateManifest(value) {
|
|
|
381
381
|
path.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
382
382
|
/[\0\r\n]/.test(path) ||
|
|
383
383
|
path === "restore-in-progress" ||
|
|
384
|
+
["project-runtime-secrets", "administrator-setup.txt", "environment.json"].some((obsolete) => path.startsWith(obsolete + "/")) ||
|
|
384
385
|
path.startsWith("project-control.lock/") ||
|
|
385
386
|
path === "project-control.lock" ||
|
|
386
387
|
!entry ||
|
|
@@ -425,21 +426,48 @@ export async function restoreProjectInstallation(options) {
|
|
|
425
426
|
same(digest(await controlBytes(join(source, "control"), path)), expected);
|
|
426
427
|
await docker(["image", "inspect", options.helperImage]);
|
|
427
428
|
const destination = await exclusiveDestination(source, resolve(options.destination));
|
|
429
|
+
const marker = join(destination, "restore-in-progress");
|
|
430
|
+
const directories = new Set([destination]);
|
|
431
|
+
const sync = (path) => {
|
|
432
|
+
const before = lstatSync(path);
|
|
433
|
+
if (before.isSymbolicLink() || (!before.isFile() && !before.isDirectory()))
|
|
434
|
+
throw new Error("Restore durability requires ordinary files and directories.");
|
|
435
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
436
|
+
try {
|
|
437
|
+
const current = fstatSync(fd);
|
|
438
|
+
if (current.dev !== before.dev || current.ino !== before.ino)
|
|
439
|
+
throw new Error("Restore storage changed while opening.");
|
|
440
|
+
(options.io?.fsync ?? fsyncSync)(fd);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
closeSync(fd);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
const persistMarker = (rearm = false) => {
|
|
447
|
+
try {
|
|
448
|
+
writeFileSync(marker, "1\n", { mode: 0o600, flag: "wx" });
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (!rearm || error.code !== "EEXIST")
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
sync(marker);
|
|
455
|
+
sync(destination);
|
|
456
|
+
sync(dirname(destination));
|
|
457
|
+
};
|
|
428
458
|
const created = [];
|
|
429
459
|
let registry;
|
|
430
460
|
try {
|
|
431
|
-
|
|
432
|
-
mode: 0o600,
|
|
433
|
-
flag: "wx",
|
|
434
|
-
flush: true,
|
|
435
|
-
});
|
|
461
|
+
persistMarker();
|
|
436
462
|
for (const [path, expected] of Object.entries(manifest.control)) {
|
|
437
463
|
const bytes = await controlBytes(join(source, "control"), path);
|
|
438
464
|
same(digest(bytes), expected);
|
|
439
465
|
await mkdir(dirname(join(destination, path)), { recursive: true, mode: 0o700 });
|
|
466
|
+
for (let parent = dirname(join(destination, path)); parent !== destination; parent = dirname(parent))
|
|
467
|
+
directories.add(parent);
|
|
440
468
|
await writeFile(join(destination, path), bytes, { mode: 0o600, flag: "wx", flush: true });
|
|
441
469
|
}
|
|
442
|
-
registry = new ProjectRegistry(destination);
|
|
470
|
+
registry = new ProjectRegistry(destination, Date.now, { fsync: options.io?.fsync });
|
|
443
471
|
const rows = registry.all();
|
|
444
472
|
if (registry.installationId !== manifest.installationId ||
|
|
445
473
|
rows.some((p) => p.status !== "stopped") ||
|
|
@@ -450,6 +478,10 @@ export async function restoreProjectInstallation(options) {
|
|
|
450
478
|
if (!auth.isInitialized())
|
|
451
479
|
throw new Error("Backup administrator is not initialized.");
|
|
452
480
|
auth.revokeAllSessions();
|
|
481
|
+
// HubAuthStore publishes by rename. Persist the replacement and its directory
|
|
482
|
+
// before a new namespace or removal of the restore barrier can become visible.
|
|
483
|
+
sync(join(destination, "hub/auth.json"));
|
|
484
|
+
sync(join(destination, "hub"));
|
|
453
485
|
registry.renewInstallationForRestore();
|
|
454
486
|
const installationId = registry.installationId;
|
|
455
487
|
for (const volume of manifest.volumes) {
|
|
@@ -472,7 +504,12 @@ export async function restoreProjectInstallation(options) {
|
|
|
472
504
|
for (const path of ["project-runtime-secrets", "administrator-setup.txt", "environment.json"])
|
|
473
505
|
await rm(join(destination, path), { recursive: true, force: true });
|
|
474
506
|
registry.all();
|
|
475
|
-
|
|
507
|
+
// Copied file bytes were flushed individually; their new directory entries
|
|
508
|
+
// must also survive a crash before the installation is advertised as usable.
|
|
509
|
+
for (const path of [...directories].sort((a, b) => b.length - a.length))
|
|
510
|
+
sync(path);
|
|
511
|
+
await (options.io?.removeMarker ?? rm)(marker);
|
|
512
|
+
sync(destination);
|
|
476
513
|
return {
|
|
477
514
|
destination,
|
|
478
515
|
installationId,
|
|
@@ -482,6 +519,15 @@ export async function restoreProjectInstallation(options) {
|
|
|
482
519
|
};
|
|
483
520
|
}
|
|
484
521
|
catch (error) {
|
|
522
|
+
let barrierError;
|
|
523
|
+
try {
|
|
524
|
+
// In particular, unlink may have succeeded before its directory fsync
|
|
525
|
+
// failed. Re-establish and persist the barrier before any cleanup awaits.
|
|
526
|
+
persistMarker(true);
|
|
527
|
+
}
|
|
528
|
+
catch (failure) {
|
|
529
|
+
barrierError = failure;
|
|
530
|
+
}
|
|
485
531
|
// Only delete volumes created by this restore and still carrying its exact labels.
|
|
486
532
|
for (const { name, expected } of created.reverse()) {
|
|
487
533
|
try {
|
|
@@ -492,6 +538,8 @@ export async function restoreProjectInstallation(options) {
|
|
|
492
538
|
/* Preserve a busy/changed volume for operator inspection. */
|
|
493
539
|
}
|
|
494
540
|
}
|
|
541
|
+
if (barrierError)
|
|
542
|
+
throw new AggregateError([error, barrierError], `Restore failed and its durable barrier could not be confirmed; keep ${destination} offline.`, { cause: error });
|
|
495
543
|
throw error; // Keep the marked offline directory as recovery evidence; never advertise success.
|
|
496
544
|
}
|
|
497
545
|
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,46 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { HubAuth } from "../hub/auth-http.js";
|
|
3
|
+
import type { HubSession } from "../hub/auth-store.js";
|
|
4
|
+
/** Public Account identity; access/refresh credentials never enter project workers. */
|
|
5
|
+
export interface CloudAccountPrincipal {
|
|
6
|
+
accountOrigin: string;
|
|
7
|
+
accountId: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
rootSessionId: string;
|
|
10
|
+
authorityEpoch: string;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CloudManagementSession {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: "cloud-management";
|
|
16
|
+
audience: string;
|
|
17
|
+
installationId: string;
|
|
18
|
+
accountOrigin: string;
|
|
19
|
+
accountId: string;
|
|
20
|
+
managementEpoch: string;
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
/** Internal owner scope, excluded from every response. */
|
|
23
|
+
ownerId: string;
|
|
24
|
+
}
|
|
25
|
+
export type ProjectControlSession = HubSession | CloudManagementSession;
|
|
26
|
+
export interface ProjectControlAuthority {
|
|
27
|
+
bootstrapToken?: string;
|
|
28
|
+
cookieName: string;
|
|
29
|
+
store: {
|
|
30
|
+
listSessions(): ProjectControlSession[];
|
|
31
|
+
};
|
|
32
|
+
handle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
33
|
+
authenticate(req: IncomingMessage): Promise<ProjectControlSession | null>;
|
|
34
|
+
isOriginAllowed(req: IncomingMessage): boolean;
|
|
35
|
+
/** Local lifetime check, synchronous so an already revoked transport closes promptly. */
|
|
36
|
+
isActive(session: ProjectControlSession): boolean;
|
|
37
|
+
/** Continuous authority check, also called around asynchronous lifecycle work. */
|
|
38
|
+
revalidate(session: ProjectControlSession): Promise<void>;
|
|
39
|
+
close?(): void;
|
|
40
|
+
}
|
|
41
|
+
export interface CloudAccountAuthority {
|
|
42
|
+
accountOrigin: string;
|
|
43
|
+
redeem(ticket: string, installationId: string): Promise<CloudAccountPrincipal>;
|
|
44
|
+
validate(principal: CloudAccountPrincipal, installationId: string): Promise<CloudAccountPrincipal>;
|
|
45
|
+
}
|
|
46
|
+
export declare function legacyProjectAuthority(auth: HubAuth): ProjectControlAuthority;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function legacyProjectAuthority(auth) {
|
|
2
|
+
const isActive = (session) => !("kind" in session) &&
|
|
3
|
+
auth.store
|
|
4
|
+
.listSessions()
|
|
5
|
+
.some((row) => row.id === session.id && row.username === session.username);
|
|
6
|
+
return {
|
|
7
|
+
...auth,
|
|
8
|
+
cookieName: "cs_hub_session",
|
|
9
|
+
isActive,
|
|
10
|
+
async revalidate(session) {
|
|
11
|
+
if (!isActive(session))
|
|
12
|
+
throw new Error("Project control authority expired.");
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RemoteLinkConfiguration } from "@cjhyy/code-shell-core";
|
|
2
|
+
import { type CloudAccountAuthority } from "./control-authority.js";
|
|
2
3
|
import type { ProjectRuntimeProvider } from "./types.js";
|
|
3
4
|
export interface ProjectControlServerOptions {
|
|
4
5
|
host: string;
|
|
@@ -12,6 +13,8 @@ export interface ProjectControlServerOptions {
|
|
|
12
13
|
runtimeSeccompProfile?: string;
|
|
13
14
|
/** Dependency injection for lifecycle/transport integration tests. */
|
|
14
15
|
provider?: ProjectRuntimeProvider;
|
|
16
|
+
/** Trusted fixed Account-origin verifier. Requires explicit offline ownership adoption. */
|
|
17
|
+
accountAuthority?: CloudAccountAuthority;
|
|
15
18
|
}
|
|
16
19
|
/** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
|
|
17
20
|
export declare function startProjectControlServer(options: ProjectControlServerOptions): Promise<{
|