@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
|
@@ -1,43 +1,212 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
2
4
|
import { lockSync } from "@cjhyy/code-shell-core/internal";
|
|
3
5
|
/** One control process per directory; heartbeat leases recover after a crash. */
|
|
4
6
|
export function acquireProjectControllerLock(directory) {
|
|
5
|
-
|
|
7
|
+
directory = resolve(directory);
|
|
8
|
+
const original = fs.lstatSync(directory);
|
|
6
9
|
if (!original.isDirectory() || original.isSymbolicLink())
|
|
7
10
|
throw new Error("Unsafe project control directory.");
|
|
11
|
+
const lockPath = `${directory}.lock`;
|
|
12
|
+
let identity;
|
|
13
|
+
let candidate;
|
|
14
|
+
let owner;
|
|
15
|
+
let candidateOwner;
|
|
16
|
+
let mtime;
|
|
8
17
|
let compromised = false;
|
|
9
18
|
let released = false;
|
|
19
|
+
const sameDirectory = (actual, expected) => actual.isDirectory() &&
|
|
20
|
+
!actual.isSymbolicLink() &&
|
|
21
|
+
actual.dev === expected.dev &&
|
|
22
|
+
actual.ino === expected.ino;
|
|
23
|
+
const ownerFile = (actual) => actual.isFile() &&
|
|
24
|
+
!actual.isSymbolicLink() &&
|
|
25
|
+
actual.nlink === 1 &&
|
|
26
|
+
actual.size === 0 &&
|
|
27
|
+
(actual.mode & 0o077) === 0;
|
|
28
|
+
function readOwner() {
|
|
29
|
+
const entries = fs.readdirSync(lockPath);
|
|
30
|
+
if (!entries.length)
|
|
31
|
+
return; // A historical proper-lockfile owner had no marker.
|
|
32
|
+
if (entries.length !== 1 ||
|
|
33
|
+
!/^\.owner-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(entries[0]))
|
|
34
|
+
throw lost();
|
|
35
|
+
const identity = fs.lstatSync(join(lockPath, entries[0]));
|
|
36
|
+
if (!ownerFile(identity))
|
|
37
|
+
throw lost();
|
|
38
|
+
return { name: entries[0], identity };
|
|
39
|
+
}
|
|
40
|
+
function assertOwner(expected) {
|
|
41
|
+
const current = readOwner();
|
|
42
|
+
if (!current ||
|
|
43
|
+
current.name !== expected.name ||
|
|
44
|
+
current.identity.dev !== expected.identity.dev ||
|
|
45
|
+
current.identity.ino !== expected.identity.ino)
|
|
46
|
+
throw lost();
|
|
47
|
+
}
|
|
48
|
+
const lost = () => {
|
|
49
|
+
compromised = true;
|
|
50
|
+
// ENOENT terminates proper-lockfile's renewal immediately, rather than
|
|
51
|
+
// repeatedly retrying an owner which has already permanently lost custody.
|
|
52
|
+
return Object.assign(new Error("Project controller lock is no longer held."), {
|
|
53
|
+
code: "ENOENT",
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
function assertRoot() {
|
|
57
|
+
if (compromised)
|
|
58
|
+
throw lost();
|
|
59
|
+
try {
|
|
60
|
+
if (!sameDirectory(fs.lstatSync(directory), original))
|
|
61
|
+
throw lost();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw lost();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function assertOwned() {
|
|
68
|
+
assertRoot();
|
|
69
|
+
try {
|
|
70
|
+
const current = fs.lstatSync(lockPath);
|
|
71
|
+
if (!identity ||
|
|
72
|
+
!owner ||
|
|
73
|
+
!sameDirectory(current, identity) ||
|
|
74
|
+
(mtime !== undefined && current.mtimeMs !== mtime))
|
|
75
|
+
throw lost();
|
|
76
|
+
assertOwner(owner);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw lost();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// lockSync's adapter invokes these synchronous functions for acquisition,
|
|
83
|
+
// every heartbeat and exit cleanup. Guarding only callback-style fs methods
|
|
84
|
+
// would leave its renewal and release paths unprotected.
|
|
85
|
+
const guardedFs = {
|
|
86
|
+
...fs,
|
|
87
|
+
mkdirSync(path) {
|
|
88
|
+
if (path !== lockPath)
|
|
89
|
+
return fs.mkdirSync(path);
|
|
90
|
+
assertRoot();
|
|
91
|
+
fs.mkdirSync(path, { mode: 0o700 });
|
|
92
|
+
identity = fs.lstatSync(path);
|
|
93
|
+
const name = `.owner-${randomUUID()}`;
|
|
94
|
+
const marker = fs.openSync(join(path, name), fs.constants.O_CREAT |
|
|
95
|
+
fs.constants.O_EXCL |
|
|
96
|
+
fs.constants.O_WRONLY |
|
|
97
|
+
fs.constants.O_NOFOLLOW, 0o600);
|
|
98
|
+
try {
|
|
99
|
+
owner = { name, identity: fs.fstatSync(marker) };
|
|
100
|
+
if (!ownerFile(owner.identity))
|
|
101
|
+
throw lost();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
fs.closeSync(marker);
|
|
105
|
+
}
|
|
106
|
+
mtime = fs.lstatSync(path).mtimeMs;
|
|
107
|
+
assertOwned();
|
|
108
|
+
},
|
|
109
|
+
statSync(path) {
|
|
110
|
+
if (path !== lockPath)
|
|
111
|
+
return fs.statSync(path);
|
|
112
|
+
if (identity)
|
|
113
|
+
assertOwned();
|
|
114
|
+
else
|
|
115
|
+
assertRoot();
|
|
116
|
+
const current = fs.lstatSync(path);
|
|
117
|
+
if (!current.isDirectory() || current.isSymbolicLink())
|
|
118
|
+
throw lost();
|
|
119
|
+
// An existing stale lock may be removed only if it remains the exact
|
|
120
|
+
// directory whose age proper-lockfile just inspected.
|
|
121
|
+
if (!identity) {
|
|
122
|
+
candidate = current;
|
|
123
|
+
candidateOwner = readOwner();
|
|
124
|
+
}
|
|
125
|
+
return current;
|
|
126
|
+
},
|
|
127
|
+
utimesSync(path, atime, nextMtime) {
|
|
128
|
+
if (path !== lockPath)
|
|
129
|
+
return fs.utimesSync(path, atime, nextMtime);
|
|
130
|
+
assertOwned();
|
|
131
|
+
const descriptor = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
132
|
+
try {
|
|
133
|
+
if (!sameDirectory(fs.fstatSync(descriptor), identity))
|
|
134
|
+
throw lost();
|
|
135
|
+
// Renew the captured inode, never a replacement installed between the
|
|
136
|
+
// path check and the write. Recheck the public path before accepting it.
|
|
137
|
+
fs.futimesSync(descriptor, atime, nextMtime);
|
|
138
|
+
mtime = fs.fstatSync(descriptor).mtimeMs;
|
|
139
|
+
assertOwned();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw lost();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
fs.closeSync(descriptor);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
rmdirSync(path) {
|
|
149
|
+
if (path !== lockPath)
|
|
150
|
+
return fs.rmdirSync(path);
|
|
151
|
+
if (identity) {
|
|
152
|
+
assertOwned();
|
|
153
|
+
fs.unlinkSync(join(path, owner.name));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
assertRoot();
|
|
157
|
+
if (!candidate || !sameDirectory(fs.lstatSync(path), candidate))
|
|
158
|
+
throw lost();
|
|
159
|
+
if (candidateOwner) {
|
|
160
|
+
assertOwner(candidateOwner);
|
|
161
|
+
fs.unlinkSync(join(path, candidateOwner.name));
|
|
162
|
+
}
|
|
163
|
+
else if (fs.readdirSync(path).length)
|
|
164
|
+
throw lost();
|
|
165
|
+
}
|
|
166
|
+
// Only our own random marker (or the exact observed stale candidate's)
|
|
167
|
+
// was removed. If the pathname changes immediately before this syscall,
|
|
168
|
+
// an established new owner still has its different marker: ENOTEMPTY
|
|
169
|
+
// preserves it, and stale acquisition must fail rather than claim success.
|
|
170
|
+
fs.rmdirSync(path);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
10
173
|
let unlock;
|
|
11
174
|
try {
|
|
12
175
|
unlock = lockSync(directory, {
|
|
176
|
+
realpath: false,
|
|
177
|
+
lockfilePath: lockPath,
|
|
13
178
|
stale: 30000,
|
|
14
179
|
update: 10000,
|
|
15
180
|
retries: 0,
|
|
181
|
+
fs: guardedFs,
|
|
16
182
|
onCompromised: () => {
|
|
17
183
|
compromised = true;
|
|
18
184
|
},
|
|
19
185
|
});
|
|
186
|
+
assertOwned();
|
|
20
187
|
}
|
|
21
|
-
catch {
|
|
22
|
-
throw new Error(
|
|
188
|
+
catch (error) {
|
|
189
|
+
throw new Error(compromised
|
|
190
|
+
? "Project controller lock is no longer held."
|
|
191
|
+
: "Another project controller owns this data directory; stop it before starting another.", { cause: error });
|
|
23
192
|
}
|
|
24
193
|
return {
|
|
25
194
|
assertHeld() {
|
|
26
195
|
if (released || compromised)
|
|
27
196
|
throw new Error("Project controller lock is no longer held.");
|
|
28
|
-
|
|
29
|
-
if (!current.isDirectory() ||
|
|
30
|
-
current.isSymbolicLink() ||
|
|
31
|
-
current.dev !== original.dev ||
|
|
32
|
-
current.ino !== original.ino)
|
|
33
|
-
throw new Error("Project control directory changed while the controller was running.");
|
|
197
|
+
assertOwned();
|
|
34
198
|
},
|
|
35
199
|
release() {
|
|
36
200
|
if (released)
|
|
37
201
|
return;
|
|
38
202
|
released = true;
|
|
39
|
-
|
|
40
|
-
|
|
203
|
+
try {
|
|
204
|
+
assertOwned();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return; // The lost owner may never delete another owner's replacement.
|
|
208
|
+
}
|
|
209
|
+
unlock();
|
|
41
210
|
},
|
|
42
211
|
};
|
|
43
212
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ProjectControlSession } from "./control-authority.js";
|
|
2
2
|
import { ProjectRegistry, type ProjectView } from "./registry.js";
|
|
3
3
|
import type { ProjectRuntimeConnection, ProjectRuntimeProvider } from "./types.js";
|
|
4
4
|
export interface ProjectManagerOptions {
|
|
5
5
|
registry: ProjectRegistry;
|
|
6
6
|
provider: ProjectRuntimeProvider;
|
|
7
7
|
publicOrigin(): string;
|
|
8
|
-
isSessionActive(session:
|
|
8
|
+
isSessionActive(session: ProjectControlSession): boolean;
|
|
9
9
|
revokeProject(id: string): Promise<void>;
|
|
10
|
+
revalidateSession?(session: ProjectControlSession): Promise<void>;
|
|
10
11
|
maxRunning?: number;
|
|
11
12
|
/** Clock seam for Docker observations; authorization is never cached. */
|
|
12
13
|
now?: () => number;
|
|
@@ -17,20 +18,23 @@ export declare class ProjectManager {
|
|
|
17
18
|
private readonly starts;
|
|
18
19
|
private readonly stops;
|
|
19
20
|
private readonly targets;
|
|
21
|
+
private readonly stopRevisions;
|
|
20
22
|
private readonly statusChecks;
|
|
21
23
|
private closed;
|
|
22
24
|
private closing;
|
|
23
25
|
constructor(options: ProjectManagerOptions);
|
|
24
26
|
private authorize;
|
|
27
|
+
private ownedProject;
|
|
28
|
+
private authorizeContinuous;
|
|
25
29
|
reconcile(): Promise<void>;
|
|
26
|
-
list(session:
|
|
27
|
-
get(session:
|
|
28
|
-
create(session:
|
|
29
|
-
start(session:
|
|
30
|
-
stop(session:
|
|
30
|
+
list(session: ProjectControlSession): ProjectView[];
|
|
31
|
+
get(session: ProjectControlSession, id: string): ProjectView;
|
|
32
|
+
create(session: ProjectControlSession, name: unknown): ProjectView;
|
|
33
|
+
start(session: ProjectControlSession, id: string): Promise<ProjectView>;
|
|
34
|
+
stop(session: ProjectControlSession, id: string): Promise<ProjectView>;
|
|
31
35
|
private beginStop;
|
|
32
36
|
private stopProject;
|
|
33
|
-
resolveTarget(session:
|
|
37
|
+
resolveTarget(session: ProjectControlSession, id: string): Promise<ProjectRuntimeConnection>;
|
|
34
38
|
private observeRuntime;
|
|
35
39
|
/** Stop the affected runtime without relying on its already-revoked outer session. */
|
|
36
40
|
stopAfterRevocationFailure(id: string): Promise<void>;
|
|
@@ -5,6 +5,7 @@ export class ProjectManager {
|
|
|
5
5
|
starts = new Map();
|
|
6
6
|
stops = new Map();
|
|
7
7
|
targets = new Map();
|
|
8
|
+
stopRevisions = new Map();
|
|
8
9
|
statusChecks = new Map();
|
|
9
10
|
closed = false;
|
|
10
11
|
closing;
|
|
@@ -21,6 +22,16 @@ export class ProjectManager {
|
|
|
21
22
|
if (!this.options.isSessionActive(session))
|
|
22
23
|
throw new ProjectRegistryError(401, "登录已过期,请重新登录。");
|
|
23
24
|
}
|
|
25
|
+
ownedProject(session, id) {
|
|
26
|
+
return "kind" in session
|
|
27
|
+
? this.options.registry.getAccount(session.ownerId, id)
|
|
28
|
+
: this.options.registry.get(session.username, id);
|
|
29
|
+
}
|
|
30
|
+
async authorizeContinuous(session) {
|
|
31
|
+
this.authorize(session);
|
|
32
|
+
await this.options.revalidateSession?.(session);
|
|
33
|
+
this.authorize(session);
|
|
34
|
+
}
|
|
24
35
|
async reconcile() {
|
|
25
36
|
this.statusChecks.clear();
|
|
26
37
|
// Never adopt a previous controller's approvals or device credentials after a crash.
|
|
@@ -41,30 +52,47 @@ export class ProjectManager {
|
|
|
41
52
|
}
|
|
42
53
|
list(session) {
|
|
43
54
|
this.authorize(session);
|
|
44
|
-
return
|
|
55
|
+
return "kind" in session
|
|
56
|
+
? this.options.registry.listAccount(session.ownerId)
|
|
57
|
+
: this.options.registry.list(session.username);
|
|
45
58
|
}
|
|
46
59
|
get(session, id) {
|
|
47
60
|
this.authorize(session);
|
|
48
|
-
return projectView(this.
|
|
61
|
+
return projectView(this.ownedProject(session, id));
|
|
49
62
|
}
|
|
50
63
|
create(session, name) {
|
|
51
64
|
this.authorize(session);
|
|
52
|
-
return projectView(
|
|
65
|
+
return projectView("kind" in session
|
|
66
|
+
? this.options.registry.createAccount(session.ownerId, name)
|
|
67
|
+
: this.options.registry.create(session.username, name));
|
|
53
68
|
}
|
|
54
69
|
async start(session, id) {
|
|
55
70
|
this.authorize(session);
|
|
56
|
-
this.
|
|
71
|
+
this.ownedProject(session, id);
|
|
72
|
+
const revision = this.stopRevisions.get(id) ?? 0;
|
|
73
|
+
const authorizeStart = () => {
|
|
74
|
+
this.authorize(session);
|
|
75
|
+
if ((this.stopRevisions.get(id) ?? 0) !== revision)
|
|
76
|
+
throw new ProjectRegistryError(409, "项目启动已取消。");
|
|
77
|
+
};
|
|
78
|
+
if (this.options.revalidateSession)
|
|
79
|
+
await this.authorizeContinuous(session);
|
|
80
|
+
authorizeStart();
|
|
57
81
|
const stopping = this.stops.get(id);
|
|
58
82
|
if (stopping)
|
|
59
83
|
await stopping;
|
|
60
|
-
this.
|
|
84
|
+
if (this.options.revalidateSession)
|
|
85
|
+
await this.authorizeContinuous(session);
|
|
86
|
+
authorizeStart();
|
|
61
87
|
const existing = this.starts.get(id);
|
|
62
88
|
if (existing) {
|
|
63
89
|
const result = await existing;
|
|
64
|
-
this.
|
|
90
|
+
if (this.options.revalidateSession)
|
|
91
|
+
await this.authorizeContinuous(session);
|
|
92
|
+
authorizeStart();
|
|
65
93
|
return result;
|
|
66
94
|
}
|
|
67
|
-
const project = this.
|
|
95
|
+
const project = this.ownedProject(session, id);
|
|
68
96
|
if (project.status === "running") {
|
|
69
97
|
await this.resolveTarget(session, id);
|
|
70
98
|
return this.get(session, id);
|
|
@@ -84,14 +112,18 @@ export class ProjectManager {
|
|
|
84
112
|
const operation = (async () => {
|
|
85
113
|
try {
|
|
86
114
|
await this.options.revokeProject(id);
|
|
87
|
-
this.
|
|
88
|
-
|
|
115
|
+
if (this.options.revalidateSession)
|
|
116
|
+
await this.authorizeContinuous(session);
|
|
117
|
+
authorizeStart();
|
|
118
|
+
if (this.ownedProject(session, id).status !== "starting")
|
|
89
119
|
throw new ProjectRegistryError(409, "项目启动已取消。");
|
|
90
120
|
const target = await this.options.provider.ensure(record, {
|
|
91
121
|
publicOrigin: this.options.publicOrigin(),
|
|
92
122
|
});
|
|
93
|
-
this.
|
|
94
|
-
|
|
123
|
+
if (this.options.revalidateSession)
|
|
124
|
+
await this.authorizeContinuous(session);
|
|
125
|
+
authorizeStart();
|
|
126
|
+
const current = this.ownedProject(session, id);
|
|
95
127
|
if (current.status !== "starting" || current.generation !== record.generation)
|
|
96
128
|
throw new ProjectRegistryError(409, "项目启动已取消。");
|
|
97
129
|
if (target.generation !== record.generation)
|
|
@@ -109,7 +141,7 @@ export class ProjectManager {
|
|
|
109
141
|
catch {
|
|
110
142
|
stopped = false;
|
|
111
143
|
}
|
|
112
|
-
const current = this.
|
|
144
|
+
const current = this.ownedProject(session, id);
|
|
113
145
|
if (current.generation === record.generation && current.status === "starting") {
|
|
114
146
|
this.options.registry.update(id, {
|
|
115
147
|
status: stopped ? "stopped" : "error",
|
|
@@ -129,8 +161,13 @@ export class ProjectManager {
|
|
|
129
161
|
}
|
|
130
162
|
async stop(session, id) {
|
|
131
163
|
this.authorize(session);
|
|
132
|
-
this.
|
|
164
|
+
this.ownedProject(session, id);
|
|
165
|
+
this.stopRevisions.set(id, (this.stopRevisions.get(id) ?? 0) + 1);
|
|
166
|
+
if (this.options.revalidateSession)
|
|
167
|
+
await this.authorizeContinuous(session);
|
|
133
168
|
const result = await this.beginStop(id);
|
|
169
|
+
if (this.options.revalidateSession)
|
|
170
|
+
await this.authorizeContinuous(session);
|
|
134
171
|
this.authorize(session);
|
|
135
172
|
return result;
|
|
136
173
|
}
|
|
@@ -168,15 +205,19 @@ export class ProjectManager {
|
|
|
168
205
|
}
|
|
169
206
|
}
|
|
170
207
|
async resolveTarget(session, id) {
|
|
208
|
+
if (this.options.revalidateSession)
|
|
209
|
+
await this.authorizeContinuous(session);
|
|
171
210
|
this.authorize(session);
|
|
172
|
-
const record = this.
|
|
211
|
+
const record = this.ownedProject(session, id);
|
|
173
212
|
const target = this.targets.get(id);
|
|
174
213
|
if (record.status !== "running" || !target || target.generation !== record.generation)
|
|
175
214
|
throw new ProjectRegistryError(409, "项目尚未运行,请先启动项目。");
|
|
176
215
|
// The provider checks labels and the loopback binding, not just a remembered port.
|
|
177
216
|
const state = await this.observeRuntime(record, target);
|
|
217
|
+
if (this.options.revalidateSession)
|
|
218
|
+
await this.authorizeContinuous(session);
|
|
178
219
|
this.authorize(session);
|
|
179
|
-
const current = this.
|
|
220
|
+
const current = this.ownedProject(session, id);
|
|
180
221
|
if (current.status !== "running" || current.generation !== target.generation)
|
|
181
222
|
throw new ProjectRegistryError(409, "项目已停止或重新启动,请重新进入。");
|
|
182
223
|
if (state.state !== "running" || state.url !== target.url) {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { type Duplex } from "node:stream";
|
|
3
3
|
import { type HubAuth } from "../hub/auth-http.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ProjectControlAuthority, ProjectControlSession } from "./control-authority.js";
|
|
5
5
|
import type { ProjectRuntimeConnection } from "./types.js";
|
|
6
6
|
export type RuntimeTarget = ProjectRuntimeConnection;
|
|
7
7
|
export interface ProjectRuntimeProxyOptions {
|
|
8
|
-
auth: HubAuth;
|
|
9
|
-
resolveTarget(projectId: string, session:
|
|
8
|
+
auth: HubAuth | ProjectControlAuthority;
|
|
9
|
+
resolveTarget(projectId: string, session: ProjectControlSession): Promise<RuntimeTarget>;
|
|
10
10
|
publicOrigin: () => string;
|
|
11
11
|
/** Stop this runtime if its private logout cannot acknowledge revocation.
|
|
12
12
|
* Dispatched after retirement settles; may call back into revokeProject. */
|
|
@@ -86,6 +86,9 @@ export function createProjectRuntimeProxy(options) {
|
|
|
86
86
|
}
|
|
87
87
|
async function validate(lease) {
|
|
88
88
|
const session = assertLease(lease);
|
|
89
|
+
if ("revalidate" in options.auth)
|
|
90
|
+
await options.auth.revalidate(session);
|
|
91
|
+
assertLease(lease);
|
|
89
92
|
const current = checkedTarget(await options.resolveTarget(lease.projectId, session));
|
|
90
93
|
assertLease(lease);
|
|
91
94
|
if (!sameTarget(current, lease.target)) {
|
|
@@ -212,7 +215,7 @@ export function createProjectRuntimeProxy(options) {
|
|
|
212
215
|
// An opaque iframe has no cookie and Origin:null. Only its already issued
|
|
213
216
|
// random capability takes this path; another signed-in owner cannot borrow it.
|
|
214
217
|
if (req.headers.authorization !== undefined ||
|
|
215
|
-
req.headers.cookie?.includes(`${HUB_SESSION_COOKIE}=`)) {
|
|
218
|
+
req.headers.cookie?.includes(`${"cookieName" in options.auth ? options.auth.cookieName : HUB_SESSION_COOKIE}=`)) {
|
|
216
219
|
const session = await options.auth.authenticate(req);
|
|
217
220
|
if (!session || session.id !== grant.lease.sessionId)
|
|
218
221
|
throw new ProxyError(403, "Panel asset belongs to another session");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { renameSync } from "node:fs";
|
|
1
|
+
import { fsyncSync, renameSync } from "node:fs";
|
|
2
2
|
import type { ProjectRuntimeRecord } from "./types.js";
|
|
3
|
+
import { type CloudAccountBinding } from "./account-binding.js";
|
|
3
4
|
export type ProjectStatus = "stopped" | "starting" | "running" | "stopping" | "error";
|
|
4
5
|
export interface StoredProject extends ProjectRuntimeRecord {
|
|
5
6
|
name: string;
|
|
@@ -32,14 +33,20 @@ export declare class ProjectRegistry {
|
|
|
32
33
|
private readonly controllerLock;
|
|
33
34
|
private diskDigest;
|
|
34
35
|
private markerEstablished;
|
|
36
|
+
private storageFailed;
|
|
35
37
|
constructor(dataDir: string, now?: () => number, io?: {
|
|
36
38
|
rename?: typeof renameSync;
|
|
39
|
+
fsync?: typeof fsyncSync;
|
|
40
|
+
offlineAccountAdoption?: boolean;
|
|
37
41
|
});
|
|
38
42
|
/** Release the single-controller lease on shutdown or startup failure. */
|
|
39
43
|
close(): void;
|
|
40
44
|
/** Offline restore only: new Docker namespace, same project credentials for restored data volumes. */
|
|
41
45
|
renewInstallationForRestore(): void;
|
|
42
46
|
get installationId(): string;
|
|
47
|
+
get accountBinding(): CloudAccountBinding | undefined;
|
|
48
|
+
/** Offline migration only; the caller must durably preserve source evidence first. */
|
|
49
|
+
bindAccount(binding: CloudAccountBinding): void;
|
|
43
50
|
private validate;
|
|
44
51
|
private assertStorage;
|
|
45
52
|
private write;
|
|
@@ -47,6 +54,12 @@ export declare class ProjectRegistry {
|
|
|
47
54
|
list(username: string): ProjectView[];
|
|
48
55
|
get(username: string, id: string): StoredProject;
|
|
49
56
|
create(username: string, name: unknown): StoredProject;
|
|
57
|
+
listAccount(ownerId: string): ProjectView[];
|
|
58
|
+
getAccount(ownerId: string, id: string): StoredProject;
|
|
59
|
+
createAccount(ownerId: string, name: unknown): StoredProject;
|
|
60
|
+
private requireLegacy;
|
|
61
|
+
private requireAccountOwner;
|
|
62
|
+
private createForOwner;
|
|
50
63
|
update(id: string, patch: {
|
|
51
64
|
status: ProjectStatus;
|
|
52
65
|
generation?: number;
|
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants, chmodSync, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { acquireProjectControllerLock } from "./lock.js";
|
|
5
|
+
import { CLOUD_ADOPTION_PENDING, isCloudAccountBinding, } from "./account-binding.js";
|
|
5
6
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
6
7
|
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
7
8
|
const STATUSES = new Set(["stopped", "starting", "running", "stopping", "error"]);
|
|
@@ -58,6 +59,7 @@ export class ProjectRegistry {
|
|
|
58
59
|
controllerLock;
|
|
59
60
|
diskDigest;
|
|
60
61
|
markerEstablished = false;
|
|
62
|
+
storageFailed = false;
|
|
61
63
|
constructor(dataDir, now = Date.now, io = {}) {
|
|
62
64
|
this.now = now;
|
|
63
65
|
this.io = io;
|
|
@@ -72,6 +74,16 @@ export class ProjectRegistry {
|
|
|
72
74
|
this.marker = join(this.directory, "initialized");
|
|
73
75
|
this.controllerLock = acquireProjectControllerLock(this.directory);
|
|
74
76
|
try {
|
|
77
|
+
if (!this.io.offlineAccountAdoption) {
|
|
78
|
+
try {
|
|
79
|
+
lstatSync(join(this.directory, CLOUD_ADOPTION_PENDING));
|
|
80
|
+
throw new Error("Cloud account adoption is incomplete; retry its explicit offline transaction.");
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== "ENOENT")
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
75
87
|
if (existsSync(this.marker)) {
|
|
76
88
|
if (privateFileBytes(this.marker, 16).toString() !== "1\n")
|
|
77
89
|
throw new Error("Project initialization marker is corrupt.");
|
|
@@ -109,6 +121,8 @@ export class ProjectRegistry {
|
|
|
109
121
|
throw new Error("Stop all projects before restoring an installation.");
|
|
110
122
|
const next = structuredClone(this.data);
|
|
111
123
|
next.installationId = randomUUID();
|
|
124
|
+
if (next.accountBinding)
|
|
125
|
+
next.accountBinding.managementEpoch = randomUUID();
|
|
112
126
|
for (const project of next.projects) {
|
|
113
127
|
if (project.generation > 0)
|
|
114
128
|
project.generation += 1;
|
|
@@ -122,15 +136,43 @@ export class ProjectRegistry {
|
|
|
122
136
|
this.assertStorage();
|
|
123
137
|
return this.data.installationId;
|
|
124
138
|
}
|
|
139
|
+
get accountBinding() {
|
|
140
|
+
this.assertStorage();
|
|
141
|
+
return this.data.accountBinding && { ...this.data.accountBinding };
|
|
142
|
+
}
|
|
143
|
+
/** Offline migration only; the caller must durably preserve source evidence first. */
|
|
144
|
+
bindAccount(binding) {
|
|
145
|
+
this.assertStorage();
|
|
146
|
+
if (this.data.version !== 1 ||
|
|
147
|
+
this.data.accountBinding !== undefined ||
|
|
148
|
+
!isCloudAccountBinding(binding) ||
|
|
149
|
+
this.data.owners.length !== 1 ||
|
|
150
|
+
this.data.owners[0].id !== binding.ownerId ||
|
|
151
|
+
this.data.projects.some((project) => project.status !== "stopped"))
|
|
152
|
+
throw new Error("Cloud adoption requires one explicit legacy owner and stopped projects.");
|
|
153
|
+
const next = structuredClone(this.data);
|
|
154
|
+
// The real historical registry rejects version 2. Unknown metadata alone
|
|
155
|
+
// cannot prevent an old controller from continuing its username authority.
|
|
156
|
+
next.version = 2;
|
|
157
|
+
next.accountBinding = { ...binding };
|
|
158
|
+
this.write(next);
|
|
159
|
+
this.data = next;
|
|
160
|
+
}
|
|
125
161
|
validate(data = this.data) {
|
|
126
162
|
if (!data ||
|
|
127
|
-
data.version !== 1 ||
|
|
163
|
+
(data.version !== 1 && data.version !== 2) ||
|
|
128
164
|
!UUID.test(data.installationId) ||
|
|
129
165
|
!Array.isArray(data.owners) ||
|
|
130
166
|
!Array.isArray(data.projects) ||
|
|
131
167
|
data.owners.length > 128 ||
|
|
132
168
|
data.projects.length > 128)
|
|
133
169
|
throw new Error("Project registry is corrupt; restore its backup");
|
|
170
|
+
if ((data.version === 1 && data.accountBinding !== undefined) ||
|
|
171
|
+
(data.version === 2 &&
|
|
172
|
+
(!isCloudAccountBinding(data.accountBinding) ||
|
|
173
|
+
data.owners.length !== 1 ||
|
|
174
|
+
data.owners[0]?.id !== data.accountBinding.ownerId)))
|
|
175
|
+
throw new Error("Cloud account ownership is missing or corrupt; restore its backup.");
|
|
134
176
|
const owners = new Set();
|
|
135
177
|
const names = new Set();
|
|
136
178
|
for (const owner of data.owners) {
|
|
@@ -174,6 +216,8 @@ export class ProjectRegistry {
|
|
|
174
216
|
}
|
|
175
217
|
}
|
|
176
218
|
assertStorage() {
|
|
219
|
+
if (this.storageFailed)
|
|
220
|
+
throw new Error("Project registry durability failed; restart and diagnose before continuing.");
|
|
177
221
|
this.controllerLock.assertHeld();
|
|
178
222
|
if (this.markerEstablished && privateFileBytes(this.marker, 16).toString() !== "1\n")
|
|
179
223
|
throw new Error("Project initialization marker changed.");
|
|
@@ -192,15 +236,29 @@ export class ProjectRegistry {
|
|
|
192
236
|
throw new Error("Project registry is too large.");
|
|
193
237
|
const file = join(this.directory, `.registry-${randomUUID()}.tmp`);
|
|
194
238
|
let fd;
|
|
239
|
+
let committed = false;
|
|
195
240
|
try {
|
|
196
241
|
fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
197
242
|
writeFileSync(fd, bytes);
|
|
198
|
-
fsyncSync(fd);
|
|
243
|
+
(this.io.fsync ?? fsyncSync)(fd);
|
|
199
244
|
closeSync(fd);
|
|
200
245
|
fd = undefined;
|
|
201
246
|
this.assertStorage();
|
|
202
247
|
(this.io.rename ?? renameSync)(file, this.file);
|
|
248
|
+
committed = true;
|
|
203
249
|
this.diskDigest = digest(bytes);
|
|
250
|
+
const directory = openSync(this.directory, constants.O_RDONLY);
|
|
251
|
+
try {
|
|
252
|
+
(this.io.fsync ?? fsyncSync)(directory);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
closeSync(directory);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (committed)
|
|
260
|
+
this.storageFailed = true;
|
|
261
|
+
throw error;
|
|
204
262
|
}
|
|
205
263
|
finally {
|
|
206
264
|
if (fd !== undefined)
|
|
@@ -214,11 +272,13 @@ export class ProjectRegistry {
|
|
|
214
272
|
}
|
|
215
273
|
list(username) {
|
|
216
274
|
this.assertStorage();
|
|
275
|
+
this.requireLegacy();
|
|
217
276
|
const owner = this.data.owners.find((item) => item.username === username);
|
|
218
277
|
return this.data.projects.filter((item) => item.ownerId === owner?.id).map(projectView);
|
|
219
278
|
}
|
|
220
279
|
get(username, id) {
|
|
221
280
|
this.assertStorage();
|
|
281
|
+
this.requireLegacy();
|
|
222
282
|
const owner = this.data.owners.find((item) => item.username === username);
|
|
223
283
|
const project = this.data.projects.find((item) => item.id === id && item.ownerId === owner?.id);
|
|
224
284
|
if (!project)
|
|
@@ -227,6 +287,35 @@ export class ProjectRegistry {
|
|
|
227
287
|
}
|
|
228
288
|
create(username, name) {
|
|
229
289
|
this.assertStorage();
|
|
290
|
+
this.requireLegacy();
|
|
291
|
+
return this.createForOwner(username, name);
|
|
292
|
+
}
|
|
293
|
+
listAccount(ownerId) {
|
|
294
|
+
this.requireAccountOwner(ownerId);
|
|
295
|
+
return this.data.projects.filter((item) => item.ownerId === ownerId).map(projectView);
|
|
296
|
+
}
|
|
297
|
+
getAccount(ownerId, id) {
|
|
298
|
+
this.requireAccountOwner(ownerId);
|
|
299
|
+
const project = this.data.projects.find((item) => item.id === id && item.ownerId === ownerId);
|
|
300
|
+
if (!project)
|
|
301
|
+
throw new ProjectRegistryError(404, "找不到这个项目。");
|
|
302
|
+
return { ...project };
|
|
303
|
+
}
|
|
304
|
+
createAccount(ownerId, name) {
|
|
305
|
+
this.requireAccountOwner(ownerId);
|
|
306
|
+
// This username is internal legacy metadata, never the Account display name.
|
|
307
|
+
return this.createForOwner(this.data.owners[0].username, name);
|
|
308
|
+
}
|
|
309
|
+
requireLegacy() {
|
|
310
|
+
if (this.data.version !== 1)
|
|
311
|
+
throw new ProjectRegistryError(403, "此 Cloud 已采用明确的账号归属,请通过云账号管理。");
|
|
312
|
+
}
|
|
313
|
+
requireAccountOwner(ownerId) {
|
|
314
|
+
this.assertStorage();
|
|
315
|
+
if (this.data.version !== 2 || this.data.accountBinding?.ownerId !== ownerId)
|
|
316
|
+
throw new ProjectRegistryError(403, "此账号没有 Cloud 管理权限。");
|
|
317
|
+
}
|
|
318
|
+
createForOwner(username, name) {
|
|
230
319
|
if (typeof name !== "string" ||
|
|
231
320
|
!name.trim() ||
|
|
232
321
|
name.trim().length > 80 ||
|