@cjhyy/code-shell-server 0.9.36 → 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/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 +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,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<{
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { remoteLinkHostConfiguration } from "../links/remote-configuration.js";
|
|
2
2
|
import { describeEnvironment } from "../environment-identity.js";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import {
|
|
4
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { extname } from "node:path";
|
|
7
7
|
import { createHubAuth } from "../hub/auth-http.js";
|
|
8
|
+
import { createCloudAccountAuth } from "./cloud-account-auth.js";
|
|
9
|
+
import { legacyProjectAuthority } from "./control-authority.js";
|
|
8
10
|
import { resolveSafe } from "../mobile-remote/mobile-static.js";
|
|
9
11
|
import { contentTypeFor } from "../static-files.js";
|
|
10
12
|
import { createDockerProjectProvider } from "./docker-provider.js";
|
|
@@ -69,8 +71,16 @@ async function readBody(request) {
|
|
|
69
71
|
/** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
|
|
70
72
|
export async function startProjectControlServer(options) {
|
|
71
73
|
const remoteLink = remoteLinkHostConfiguration(options.remoteLink, options.publicOrigin);
|
|
72
|
-
|
|
74
|
+
// Any entry is a barrier, including dangling symlinks. Only ENOENT means absent;
|
|
75
|
+
// permission/I/O failures must abort before registry or Hub initialization.
|
|
76
|
+
try {
|
|
77
|
+
lstatSync(join(options.dataDir, "restore-in-progress"));
|
|
73
78
|
throw new Error("Cloud installation restore is incomplete; do not start this directory.");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT")
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
74
84
|
const registry = new ProjectRegistry(options.dataDir);
|
|
75
85
|
let proxy;
|
|
76
86
|
let manager;
|
|
@@ -78,26 +88,38 @@ export async function startProjectControlServer(options) {
|
|
|
78
88
|
let server;
|
|
79
89
|
let shuttingDown = false;
|
|
80
90
|
try {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
const onRevoke = (sessionId) => {
|
|
92
|
+
if (shuttingDown)
|
|
93
|
+
return;
|
|
94
|
+
void proxy?.revokeOwner(sessionId).catch(async () => {
|
|
85
95
|
if (shuttingDown)
|
|
86
96
|
return;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
// The revoked browser's transports are already closed. If its inner
|
|
98
|
+
// session cannot be revoked, stop the single administrator's runtimes.
|
|
99
|
+
try {
|
|
100
|
+
await manager?.stopAll();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
console.error("[project-control] could not confirm project termination after session revocation.");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const binding = registry.accountBinding;
|
|
108
|
+
if (!!binding !== !!options.accountAuthority || (binding && !options.publicOrigin))
|
|
109
|
+
throw new Error("Cloud account authority requires explicit matching ownership and public origin.");
|
|
110
|
+
const auth = binding
|
|
111
|
+
? createCloudAccountAuth({
|
|
112
|
+
binding,
|
|
113
|
+
installationId: registry.installationId,
|
|
114
|
+
publicOrigin: options.publicOrigin,
|
|
115
|
+
authority: options.accountAuthority,
|
|
116
|
+
onRevoke,
|
|
117
|
+
})
|
|
118
|
+
: legacyProjectAuthority(await createHubAuth({
|
|
119
|
+
dataDir: options.dataDir,
|
|
120
|
+
publicOrigin: options.publicOrigin,
|
|
121
|
+
onRevoke,
|
|
122
|
+
}));
|
|
101
123
|
let listeningOrigin = "";
|
|
102
124
|
const publicOrigin = () => options.publicOrigin ?? listeningOrigin;
|
|
103
125
|
provider =
|
|
@@ -113,9 +135,12 @@ export async function startProjectControlServer(options) {
|
|
|
113
135
|
registry,
|
|
114
136
|
provider,
|
|
115
137
|
publicOrigin,
|
|
116
|
-
isSessionActive: (session) => auth.
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
isSessionActive: (session) => auth.isActive(session),
|
|
139
|
+
...(binding
|
|
140
|
+
? {
|
|
141
|
+
revalidateSession: (session) => auth.revalidate(session),
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
119
144
|
revokeProject: async (id) => {
|
|
120
145
|
await proxy?.revokeProject(id);
|
|
121
146
|
},
|
|
@@ -172,6 +197,7 @@ export async function startProjectControlServer(options) {
|
|
|
172
197
|
throw new ProjectRegistryError(400, "项目请求参数无效。");
|
|
173
198
|
if (pathname === "/api/v1/projects" && request.method === "GET") {
|
|
174
199
|
const availability = await provider.availability();
|
|
200
|
+
await auth.revalidate(session);
|
|
175
201
|
json(response, 200, {
|
|
176
202
|
runtime: "docker",
|
|
177
203
|
...availability,
|
|
@@ -183,10 +209,11 @@ export async function startProjectControlServer(options) {
|
|
|
183
209
|
const body = await readBody(request);
|
|
184
210
|
if (Object.keys(body).some((key) => key !== "name"))
|
|
185
211
|
throw new ProjectRegistryError(400, "仅支持设置项目名称。");
|
|
212
|
+
await auth.revalidate(session);
|
|
186
213
|
json(response, 201, { project: projects.create(session, body.name) });
|
|
187
214
|
return;
|
|
188
215
|
}
|
|
189
|
-
const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop))?$/.exec(pathname);
|
|
216
|
+
const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop|open))?$/.exec(pathname);
|
|
190
217
|
if (match && request.method === "GET" && !match[2]) {
|
|
191
218
|
json(response, 200, { project: projects.get(session, match[1]) });
|
|
192
219
|
return;
|
|
@@ -195,6 +222,14 @@ export async function startProjectControlServer(options) {
|
|
|
195
222
|
const body = await readBody(request);
|
|
196
223
|
if (Object.keys(body).length)
|
|
197
224
|
throw new ProjectRegistryError(400, "启动和停止不接受额外参数。");
|
|
225
|
+
if (match[2] === "open") {
|
|
226
|
+
await projects.resolveTarget(session, match[1]);
|
|
227
|
+
json(response, 200, {
|
|
228
|
+
project: projects.get(session, match[1]),
|
|
229
|
+
url: `${publicOrigin()}/?project=${match[1]}`,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
198
233
|
const project = match[2] === "start"
|
|
199
234
|
? await projects.start(session, match[1])
|
|
200
235
|
: await projects.stop(session, match[1]);
|
|
@@ -286,6 +321,7 @@ export async function startProjectControlServer(options) {
|
|
|
286
321
|
// Revoke streams and inner sessions before stopping their servers.
|
|
287
322
|
// If logout is unreachable, confirmed runtime termination is the
|
|
288
323
|
// fallback; a real stop failure must still reject close().
|
|
324
|
+
auth.close?.();
|
|
289
325
|
await transport.close().catch(() => { });
|
|
290
326
|
await projects.close();
|
|
291
327
|
}
|