borgmcp-server 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -46
- package/SECURITY.md +12 -1
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/bootstrap.d.ts +8 -1
- package/dist/bootstrap.js +39 -6
- package/dist/bootstrap.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +176 -1
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.js +10 -1
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +5 -1
- package/dist/credentials.js +35 -3
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +2 -2
- package/dist/debug-log.js +2 -2
- package/dist/debug-log.js.map +1 -1
- package/dist/https-server.d.ts +5 -1
- package/dist/https-server.js +93 -27
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +1 -0
- package/dist/main.js.map +1 -1
- package/dist/managed-service.d.ts +20 -0
- package/dist/managed-service.js +80 -0
- package/dist/managed-service.js.map +1 -0
- package/dist/migrations.js +23 -0
- package/dist/migrations.js.map +1 -1
- package/dist/portable-credential-store.d.ts +16 -0
- package/dist/portable-credential-store.js +305 -0
- package/dist/portable-credential-store.js.map +1 -0
- package/dist/registry-artifact.d.ts +11 -0
- package/dist/registry-artifact.js +99 -0
- package/dist/registry-artifact.js.map +1 -0
- package/dist/runtime-identity.d.ts +19 -0
- package/dist/runtime-identity.js +61 -0
- package/dist/runtime-identity.js.map +1 -0
- package/dist/runtime-lifecycle.d.ts +56 -0
- package/dist/runtime-lifecycle.js +486 -0
- package/dist/runtime-lifecycle.js.map +1 -0
- package/dist/runtime-operator.d.ts +24 -0
- package/dist/runtime-operator.js +95 -0
- package/dist/runtime-operator.js.map +1 -0
- package/dist/service.d.ts +53 -5
- package/dist/service.js +326 -15
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +16 -7
- package/dist/store.js +93 -83
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/src/bootstrap.ts +46 -5
- package/src/cli.ts +171 -1
- package/src/coordination-api.ts +12 -0
- package/src/credentials.ts +36 -5
- package/src/debug-log.ts +4 -3
- package/src/https-server.ts +124 -27
- package/src/index.ts +46 -1
- package/src/main.ts +1 -0
- package/src/managed-service.ts +107 -0
- package/src/migrations.ts +23 -0
- package/src/portable-credential-store.ts +306 -0
- package/src/registry-artifact.ts +111 -0
- package/src/runtime-identity.ts +82 -0
- package/src/runtime-lifecycle.ts +567 -0
- package/src/runtime-operator.ts +124 -0
- package/src/service.ts +401 -20
- package/src/store.ts +100 -83
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ServerService } from "./service.js";
|
|
2
|
+
import { RuntimeUpdateFailure } from "./runtime-operator.js";
|
|
2
3
|
|
|
3
4
|
export interface CliIo {
|
|
4
5
|
readonly stdout: (message: string) => void;
|
|
5
6
|
readonly stderr: (message: string) => void;
|
|
6
7
|
readonly readSecret?: (prompt: string) => Promise<string>;
|
|
8
|
+
readonly isTTY?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
const usage = `Usage: borg-mcp-server <command> [options]
|
|
@@ -11,6 +13,9 @@ const usage = `Usage: borg-mcp-server <command> [options]
|
|
|
11
13
|
Commands:
|
|
12
14
|
setup [--reinitialize] Prepare an offline server installation
|
|
13
15
|
start Start the server process
|
|
16
|
+
status [--json] Report exact local runtime evidence
|
|
17
|
+
update [--json] Verify and activate the latest server artifact
|
|
18
|
+
invite Create a single-use invitation in an interactive terminal.
|
|
14
19
|
client-rotate <client-id> Rotate one client credential offline
|
|
15
20
|
client-revoke <client-id> Revoke one client and its credentials offline
|
|
16
21
|
client-grant <client-id> <cube-id> <read|write|manage> Set one offline cube grant
|
|
@@ -58,11 +63,109 @@ export async function runCli(
|
|
|
58
63
|
return 1;
|
|
59
64
|
}
|
|
60
65
|
const result = await service.setup({ reinitialize: extraArgs[0] === "--reinitialize" });
|
|
61
|
-
|
|
66
|
+
const artifactIdentity = result.artifact === undefined
|
|
67
|
+
? "borgmcp-server@unavailable"
|
|
68
|
+
: `borgmcp-server@${result.artifact.version}`;
|
|
69
|
+
const artifact = result.artifact === undefined
|
|
70
|
+
? "Artifact: unavailable"
|
|
71
|
+
: `Artifact: ${artifactIdentity} (${result.artifact.integrity})`;
|
|
72
|
+
if (io.isTTY === false) {
|
|
73
|
+
io.stdout(JSON.stringify({
|
|
74
|
+
status: "prepared",
|
|
75
|
+
artifact: artifactIdentity,
|
|
76
|
+
build_identity: result.artifact?.sourceSha ?? null,
|
|
77
|
+
owner_access: "prepared",
|
|
78
|
+
process: "stopped",
|
|
79
|
+
}));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if ("existing" in result) {
|
|
83
|
+
io.stdout([
|
|
84
|
+
"Local server is already prepared.",
|
|
85
|
+
artifact,
|
|
86
|
+
`Build identity: ${result.artifact?.sourceSha ?? "unavailable"}`,
|
|
87
|
+
"Data and identity: unchanged",
|
|
88
|
+
"No server process started.",
|
|
89
|
+
"Next: borg-mcp-server start",
|
|
90
|
+
].join("\n"));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
io.stdout([
|
|
94
|
+
"Local server setup completed.",
|
|
95
|
+
artifact,
|
|
96
|
+
"Local owner access: prepared.",
|
|
97
|
+
"No server process started.",
|
|
98
|
+
"Next: start the server, then run borg assimilate.",
|
|
99
|
+
].join("\n"));
|
|
62
100
|
return 0;
|
|
63
101
|
case "start":
|
|
64
102
|
await service.start(extraArgs);
|
|
65
103
|
return 0;
|
|
104
|
+
case "status": {
|
|
105
|
+
if (extraArgs.length > 1 || (extraArgs.length === 1 && extraArgs[0] !== "--json") ||
|
|
106
|
+
service.status === undefined) return invalidArguments(io);
|
|
107
|
+
const status = await service.status();
|
|
108
|
+
if (extraArgs[0] === "--json" || io.isTTY === false) {
|
|
109
|
+
io.stdout(JSON.stringify({
|
|
110
|
+
status: status.status,
|
|
111
|
+
artifact: status.artifact === null
|
|
112
|
+
? null
|
|
113
|
+
: `borgmcp-server@${status.artifact.version}`,
|
|
114
|
+
artifact_integrity: status.artifact?.integrity ?? null,
|
|
115
|
+
build_identity: status.buildIdentity,
|
|
116
|
+
endpoint: status.endpoint,
|
|
117
|
+
mode: status.mode,
|
|
118
|
+
data_identity: status.dataIdentity,
|
|
119
|
+
}));
|
|
120
|
+
} else {
|
|
121
|
+
io.stdout(renderRuntimeStatus(status));
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
case "update": {
|
|
126
|
+
if (extraArgs.length > 1 || (extraArgs.length === 1 && extraArgs[0] !== "--json") ||
|
|
127
|
+
service.update === undefined) return invalidArguments(io);
|
|
128
|
+
let result: Awaited<ReturnType<NonNullable<ServerService["update"]>>>;
|
|
129
|
+
try {
|
|
130
|
+
result = await service.update();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!(error instanceof RuntimeUpdateFailure)) throw error;
|
|
133
|
+
renderUpdateFailure(error, io, extraArgs[0] === "--json" || io.isTTY === false);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
if (extraArgs[0] === "--json" || io.isTTY === false) {
|
|
137
|
+
io.stdout(JSON.stringify({
|
|
138
|
+
status: result.outcome,
|
|
139
|
+
artifact: `borgmcp-server@${result.artifact.version}`,
|
|
140
|
+
artifact_integrity: result.artifact.integrity,
|
|
141
|
+
build_identity: result.runningIdentity?.source_sha ?? result.artifact.sourceSha,
|
|
142
|
+
mode: result.outcome === "updated" ? "managed" : "stopped",
|
|
143
|
+
data_identity: result.dataIdentity,
|
|
144
|
+
}));
|
|
145
|
+
} else if (result.outcome === "updated") {
|
|
146
|
+
io.stdout([
|
|
147
|
+
`Verifying borgmcp-server@${result.artifact.version}...`,
|
|
148
|
+
"Artifact verified and activated.",
|
|
149
|
+
"Restarting the verified local server...",
|
|
150
|
+
`Local server is running.`,
|
|
151
|
+
`Artifact: borgmcp-server@${result.artifact.version} (${result.artifact.integrity})`,
|
|
152
|
+
`Build identity: ${result.runningIdentity?.source_sha ?? "unavailable"}`,
|
|
153
|
+
"Data and identity: preserved",
|
|
154
|
+
"Next: borg-mcp-server status",
|
|
155
|
+
].join("\n"));
|
|
156
|
+
} else {
|
|
157
|
+
io.stdout([
|
|
158
|
+
`Verifying borgmcp-server@${result.artifact.version}...`,
|
|
159
|
+
"Artifact verified and activated.",
|
|
160
|
+
"No server process started.",
|
|
161
|
+
`Artifact: borgmcp-server@${result.artifact.version} (${result.artifact.integrity})`,
|
|
162
|
+
`Build identity: ${result.artifact.sourceSha ?? "unavailable"}`,
|
|
163
|
+
"Data and identity: preserved",
|
|
164
|
+
"Next: borg-mcp-server start",
|
|
165
|
+
].join("\n"));
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
66
169
|
case "client-rotate":
|
|
67
170
|
if (extraArgs.length !== 1 || service.rotateClient === undefined) return invalidArguments(io);
|
|
68
171
|
io.stdout(`Client credential rotated (shown once): ${await service.rotateClient(extraArgs[0]!)}`);
|
|
@@ -115,6 +218,16 @@ export async function runCli(
|
|
|
115
218
|
}
|
|
116
219
|
return 0;
|
|
117
220
|
}
|
|
221
|
+
case "invite": {
|
|
222
|
+
if (extraArgs.length !== 0 || service.invite === undefined) return invalidArguments(io);
|
|
223
|
+
if (io.isTTY !== true) {
|
|
224
|
+
io.stderr("Invitation creation requires an interactive terminal.");
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
const invitation = await service.invite();
|
|
228
|
+
io.stdout(`Invitation (single-use; shown once): ${invitation}\nShare it only with the intended recipient.`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
118
231
|
case "help":
|
|
119
232
|
case "--help":
|
|
120
233
|
case "-h":
|
|
@@ -127,6 +240,63 @@ export async function runCli(
|
|
|
127
240
|
}
|
|
128
241
|
}
|
|
129
242
|
|
|
243
|
+
function renderUpdateFailure(failure: RuntimeUpdateFailure, io: CliIo, machine: boolean): void {
|
|
244
|
+
const state = failure.code === "ARTIFACT_VERIFICATION_FAILED"
|
|
245
|
+
? "verification_failed"
|
|
246
|
+
: failure.recovery === "restored"
|
|
247
|
+
? "restored"
|
|
248
|
+
: failure.recovery === "stopped"
|
|
249
|
+
? "stopped"
|
|
250
|
+
: "recovery_failed";
|
|
251
|
+
if (machine) {
|
|
252
|
+
io.stdout(JSON.stringify({
|
|
253
|
+
status: "failed",
|
|
254
|
+
error_code: failure.code,
|
|
255
|
+
recovery: state,
|
|
256
|
+
data_identity: "preserved",
|
|
257
|
+
}));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (failure.code === "ARTIFACT_VERIFICATION_FAILED") {
|
|
261
|
+
io.stderr([
|
|
262
|
+
"Update stopped: artifact verification failed.",
|
|
263
|
+
"No activation occurred.",
|
|
264
|
+
"The last verified runtime remains available.",
|
|
265
|
+
"Next: borg-mcp-server status",
|
|
266
|
+
].join("\n"));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
io.stderr([
|
|
270
|
+
"Update stopped: activation did not complete.",
|
|
271
|
+
failure.recovery === "restored"
|
|
272
|
+
? "The last verified runtime was restored."
|
|
273
|
+
: failure.recovery === "stopped"
|
|
274
|
+
? "The server stopped safely."
|
|
275
|
+
: "Recovery did not complete; inspect server status.",
|
|
276
|
+
"Data and identity: preserved",
|
|
277
|
+
"Next: borg-mcp-server status",
|
|
278
|
+
].join("\n"));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderRuntimeStatus(status: Awaited<ReturnType<NonNullable<ServerService["status"]>>>): string {
|
|
282
|
+
const heading = status.status === "running"
|
|
283
|
+
? status.buildIdentity === null
|
|
284
|
+
? "Local server is reachable, but its running build identity is unavailable."
|
|
285
|
+
: "Local server is running."
|
|
286
|
+
: "Local server is stopped.";
|
|
287
|
+
const artifact = status.artifact === null
|
|
288
|
+
? "Artifact: unavailable"
|
|
289
|
+
: `Artifact: borgmcp-server@${status.artifact.version} (${status.artifact.integrity})`;
|
|
290
|
+
return [
|
|
291
|
+
heading,
|
|
292
|
+
artifact,
|
|
293
|
+
`Build identity: ${status.buildIdentity ?? "unavailable"}`,
|
|
294
|
+
`Endpoint: ${status.endpoint ?? "unavailable"}`,
|
|
295
|
+
`Mode: ${status.mode}`,
|
|
296
|
+
`Data and identity: ${status.dataIdentity}`,
|
|
297
|
+
].join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
130
300
|
function parseClientInviteArguments(
|
|
131
301
|
args: readonly string[],
|
|
132
302
|
): { readonly cubeSelector?: string; readonly access?: "read" | "write" | "manage" } | undefined {
|
package/src/coordination-api.ts
CHANGED
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
import type { CredentialAuthority } from "./credentials.js";
|
|
22
22
|
import {
|
|
23
23
|
CursorExpiredError,
|
|
24
|
+
AttachDroneEvictedError,
|
|
25
|
+
AttachSessionExpiredError,
|
|
24
26
|
AttachSessionRejectedError,
|
|
27
|
+
AttachSessionRevokedError,
|
|
25
28
|
AccessDeniedError,
|
|
26
29
|
CreateCubeConflictError,
|
|
27
30
|
DefaultRoleRequiredError,
|
|
@@ -132,6 +135,15 @@ export class CoordinationApi {
|
|
|
132
135
|
? failure(426, error.code, "Unsupported protocol version.", requestId)
|
|
133
136
|
: failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
134
137
|
}
|
|
138
|
+
if (error instanceof AttachDroneEvictedError) {
|
|
139
|
+
return failure(410, ErrorCode.DRONE_EVICTED, error.message, requestId);
|
|
140
|
+
}
|
|
141
|
+
if (error instanceof AttachSessionExpiredError) {
|
|
142
|
+
return failure(401, ErrorCode.AUTH_EXPIRED, error.message, requestId);
|
|
143
|
+
}
|
|
144
|
+
if (error instanceof AttachSessionRevokedError) {
|
|
145
|
+
return failure(401, ErrorCode.SESSION_REVOKED, error.message, requestId);
|
|
146
|
+
}
|
|
135
147
|
if (error instanceof AttachSessionRejectedError) {
|
|
136
148
|
return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
|
|
137
149
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -43,6 +43,9 @@ export interface CubeInvitationResult extends InvitationCubeScope {
|
|
|
43
43
|
|
|
44
44
|
export type SeatAttachResponse = SeatAttachRecord;
|
|
45
45
|
|
|
46
|
+
export const DRONE_SESSION_TTL_MS = 86_400_000;
|
|
47
|
+
export const DRONE_SESSION_RENEWAL_WINDOW_MS = DRONE_SESSION_TTL_MS / 2;
|
|
48
|
+
|
|
46
49
|
export class CredentialDigester {
|
|
47
50
|
readonly #key: Buffer;
|
|
48
51
|
|
|
@@ -124,6 +127,22 @@ export class LiveCredentialRegistry {
|
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
renew(identity: string, expiresInMs: number): void {
|
|
131
|
+
const sessions = this.#sessions.get(identity);
|
|
132
|
+
if (sessions === undefined) return;
|
|
133
|
+
for (const controller of [...sessions]) {
|
|
134
|
+
const timer = this.#timers.get(controller);
|
|
135
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
136
|
+
if (expiresInMs <= 0) {
|
|
137
|
+
this.#expire(controller);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const renewedTimer = setTimeout(() => this.#expire(controller), expiresInMs);
|
|
141
|
+
renewedTimer.unref();
|
|
142
|
+
this.#timers.set(controller, renewedTimer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
activeSessionCount(clientId: string): number {
|
|
128
147
|
return this.#sessions.get(clientId)?.size ?? 0;
|
|
129
148
|
}
|
|
@@ -191,6 +210,13 @@ export class CredentialAuthority {
|
|
|
191
210
|
return this.#createInvitation("client", ttlMs);
|
|
192
211
|
}
|
|
193
212
|
|
|
213
|
+
createInvitationForOwnerCredential(clientCredential: string, ttlMs: number): string | null {
|
|
214
|
+
const principal = this.authenticate(`Bearer ${clientCredential}`);
|
|
215
|
+
if (principal?.kind !== "client" ||
|
|
216
|
+
!this.#store.clientHasServerCapability(principal.id, "create_cube")) return null;
|
|
217
|
+
return this.#createInvitation("client", ttlMs);
|
|
218
|
+
}
|
|
219
|
+
|
|
194
220
|
createCubeInvitation(
|
|
195
221
|
recoveryCredential: string,
|
|
196
222
|
cubeSelector: { readonly kind: "id" | "name"; readonly value: string },
|
|
@@ -287,7 +313,7 @@ export class CredentialAuthority {
|
|
|
287
313
|
|
|
288
314
|
authenticateStatus(
|
|
289
315
|
authorization: string | undefined,
|
|
290
|
-
): Principal | "missing" | "invalid" | "revoked" | "evicted" {
|
|
316
|
+
): Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" {
|
|
291
317
|
if (authorization === undefined) return "missing";
|
|
292
318
|
const secret = bearerSecret(authorization);
|
|
293
319
|
const clientDigest = safeDigest(this.#digester, secret, "client");
|
|
@@ -304,9 +330,9 @@ export class CredentialAuthority {
|
|
|
304
330
|
}
|
|
305
331
|
if (droneValid) {
|
|
306
332
|
if (drone!.evictedAt !== null) return "evicted";
|
|
307
|
-
if (drone!.revokedAt != null
|
|
308
|
-
|
|
309
|
-
|
|
333
|
+
if (drone!.revokedAt != null) return "revoked";
|
|
334
|
+
if (drone!.takenOver) return "rejected";
|
|
335
|
+
if (drone!.expiresAt <= this.#clock().toISOString()) return "expired";
|
|
310
336
|
return droneSessionPrincipal({
|
|
311
337
|
id: drone!.sessionId,
|
|
312
338
|
clientId: drone!.clientId,
|
|
@@ -326,6 +352,7 @@ export class CredentialAuthority {
|
|
|
326
352
|
readonly priorDroneId?: string;
|
|
327
353
|
},
|
|
328
354
|
): SeatAttachResponse {
|
|
355
|
+
const now = this.#clock();
|
|
329
356
|
const record = store.attachSeat({
|
|
330
357
|
cubeId: request.cubeId,
|
|
331
358
|
roleId: request.roleId,
|
|
@@ -334,8 +361,12 @@ export class CredentialAuthority {
|
|
|
334
361
|
sessionId: randomUUID(),
|
|
335
362
|
credentialId: randomUUID(),
|
|
336
363
|
credentialDigest: this.#digester.digest(request.sessionCredential, "drone-session"),
|
|
337
|
-
expiresAt: new Date(
|
|
364
|
+
expiresAt: new Date(now.getTime() + DRONE_SESSION_TTL_MS).toISOString(),
|
|
365
|
+
renewIfExpiresAtOrBefore: new Date(
|
|
366
|
+
now.getTime() + DRONE_SESSION_RENEWAL_WINDOW_MS,
|
|
367
|
+
).toISOString(),
|
|
338
368
|
});
|
|
369
|
+
this.#registry.renew(record.sessionId, new Date(record.expiresAt).getTime() - now.getTime());
|
|
339
370
|
if (record.result === "created") {
|
|
340
371
|
this.#debugLogger.emit({
|
|
341
372
|
event: "credential",
|
package/src/debug-log.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Principal } from "./principal.js";
|
|
|
3
3
|
export type DebugRoute =
|
|
4
4
|
| "health"
|
|
5
5
|
| "protocol"
|
|
6
|
+
| "runtime"
|
|
6
7
|
| "enrollment_exchange"
|
|
7
8
|
| "client_attach"
|
|
8
9
|
| "cubes"
|
|
@@ -21,7 +22,7 @@ export type DebugRoute =
|
|
|
21
22
|
export type DebugEvent =
|
|
22
23
|
| { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
|
|
23
24
|
| { readonly event: "lifecycle"; readonly action: "listening" | "stopped" }
|
|
24
|
-
| { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
|
|
25
|
+
| { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
|
|
25
26
|
| { readonly event: "activity_append"; readonly cubeId: string; readonly entryId: string; readonly principal: Principal; readonly droneId: string | null; readonly visibility: "broadcast" | "direct"; readonly recipientDroneIds: readonly string[] }
|
|
26
27
|
| { readonly event: "cursor_replay"; readonly mode: "page" | "sse"; readonly cubeId: string; readonly cursorId: string | null; readonly returnedCount: number; readonly behindBy: number; readonly truncated: boolean }
|
|
27
28
|
| { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
|
|
@@ -158,8 +159,8 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
161
|
-
const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
|
|
162
|
+
const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "runtime", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
|
|
162
163
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
|
|
163
|
-
const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "evicted", "accepted"] as const;
|
|
164
|
+
const AUTH_RESULTS = ["not_required", "missing", "invalid", "expired", "revoked", "evicted", "rejected", "accepted"] as const;
|
|
164
165
|
const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
|
|
165
166
|
const CREDENTIAL_ACTIONS = ["invitation_created", "enrollment_accepted", "enrollment_rejected", "session_created", "session_revoked", "client_rotated", "client_revoked"] as const;
|
package/src/https-server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
|
|
|
17
17
|
import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
|
|
18
18
|
import type { Principal } from "./principal.js";
|
|
19
19
|
import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
|
|
20
|
+
import { RUNTIME_INFO_PATH, type RuntimeBuildIdentity } from "./runtime-identity.js";
|
|
20
21
|
|
|
21
22
|
export interface ServiceLimits {
|
|
22
23
|
readonly maxConnections: number;
|
|
@@ -39,7 +40,7 @@ export interface ServiceLimits {
|
|
|
39
40
|
|
|
40
41
|
export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
41
42
|
maxConnections: 100,
|
|
42
|
-
maxConnectionsPerAddress:
|
|
43
|
+
maxConnectionsPerAddress: 30,
|
|
43
44
|
maxRequestsPerWindow: 120,
|
|
44
45
|
maxRequestsPerAddressWindow: 600,
|
|
45
46
|
maxRequestsGlobalWindow: 5_000,
|
|
@@ -48,7 +49,7 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
|
48
49
|
maxStreamsPerCredential: 8,
|
|
49
50
|
maxHeaderBytes: 16_384,
|
|
50
51
|
maxRequestBodyBytes: 65_536,
|
|
51
|
-
maxRequestsPerSocket:
|
|
52
|
+
maxRequestsPerSocket: 0,
|
|
52
53
|
requestTimeoutMs: 15_000,
|
|
53
54
|
tlsHandshakeTimeoutMs: 10_000,
|
|
54
55
|
headersTimeoutMs: 10_000,
|
|
@@ -63,9 +64,10 @@ export interface RequestHandlerContext {
|
|
|
63
64
|
readonly authorizeCoordination?: (
|
|
64
65
|
authorization: string | undefined,
|
|
65
66
|
signal: AbortSignal,
|
|
66
|
-
) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
|
|
67
|
+
) => Promise<Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected">;
|
|
67
68
|
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
68
69
|
readonly debugLogger: DebugLogger;
|
|
70
|
+
readonly runtimeIdentity?: RuntimeBuildIdentity;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export interface HttpsServerOptions {
|
|
@@ -80,8 +82,10 @@ export interface HttpsServerOptions {
|
|
|
80
82
|
readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
|
|
81
83
|
readonly limits?: ServiceLimits;
|
|
82
84
|
readonly debugLogger?: DebugLogger;
|
|
85
|
+
readonly runtimeIdentity?: RuntimeBuildIdentity;
|
|
83
86
|
readonly testHooks?: {
|
|
84
87
|
readonly identifyRemoteAddress?: (socket: Socket) => string;
|
|
88
|
+
readonly identifyConnectionAddress?: (socket: Socket) => string;
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -100,6 +104,15 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
|
|
|
100
104
|
validateLimits(limits);
|
|
101
105
|
validateTlsCertificate(options.tls.cert, bind.host, bind.mode, options.tls.ca);
|
|
102
106
|
const handlerContext = createRequestHandlerContext(options);
|
|
107
|
+
const identifyRemoteAddress = options.testHooks?.identifyRemoteAddress ??
|
|
108
|
+
((socket: Socket) => socket.remoteAddress ?? "unknown");
|
|
109
|
+
const identifyConnectionAddress = options.testHooks?.identifyConnectionAddress ??
|
|
110
|
+
((socket: Socket) => socket.remoteAddress ?? "unknown");
|
|
111
|
+
const addressConnectionLimiter = new AddressConnectionLimiter(
|
|
112
|
+
limits.maxConnectionsPerAddress,
|
|
113
|
+
limits.maxConnections,
|
|
114
|
+
identifyConnectionAddress,
|
|
115
|
+
);
|
|
103
116
|
|
|
104
117
|
const server = createServer(
|
|
105
118
|
{
|
|
@@ -112,11 +125,19 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
|
|
|
112
125
|
headersTimeout: limits.headersTimeoutMs,
|
|
113
126
|
keepAliveTimeout: limits.keepAliveTimeoutMs,
|
|
114
127
|
},
|
|
115
|
-
createRequestListener(
|
|
128
|
+
createRequestListener(
|
|
129
|
+
handlerContext,
|
|
130
|
+
limits,
|
|
131
|
+
identifyRemoteAddress,
|
|
132
|
+
(socket) => addressConnectionLimiter.isRejected(socket),
|
|
133
|
+
),
|
|
116
134
|
);
|
|
117
135
|
|
|
118
|
-
const acceptedSockets = applyServerLimits(server, limits);
|
|
119
|
-
server.on("secureConnection", (socket) =>
|
|
136
|
+
const acceptedSockets = applyServerLimits(server, limits, addressConnectionLimiter);
|
|
137
|
+
server.on("secureConnection", (socket) => {
|
|
138
|
+
socket.disableRenegotiation();
|
|
139
|
+
addressConnectionLimiter.admit(socket);
|
|
140
|
+
});
|
|
120
141
|
server.on("tlsClientError", (_error, socket) => {
|
|
121
142
|
handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
|
|
122
143
|
socket.destroy();
|
|
@@ -165,6 +186,7 @@ export function createRequestHandlerContext(
|
|
|
165
186
|
...(options.handleCoordination === undefined
|
|
166
187
|
? {}
|
|
167
188
|
: { handleCoordination: options.handleCoordination }),
|
|
189
|
+
...(options.runtimeIdentity === undefined ? {} : { runtimeIdentity: options.runtimeIdentity }),
|
|
168
190
|
debugLogger: options.debugLogger ?? disabledDebugLogger,
|
|
169
191
|
});
|
|
170
192
|
}
|
|
@@ -173,6 +195,7 @@ function createRequestListener(
|
|
|
173
195
|
context: RequestHandlerContext,
|
|
174
196
|
limits: ServiceLimits,
|
|
175
197
|
identifyRemoteAddress: (socket: Socket) => string = (socket) => socket.remoteAddress ?? "unknown",
|
|
198
|
+
isConnectionRejected: (socket: Socket) => boolean = () => false,
|
|
176
199
|
): (request: IncomingMessage, response: ServerResponse) => void {
|
|
177
200
|
const admissionLimiter = new PreAuthAdmissionLimiter(limits);
|
|
178
201
|
const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
|
|
@@ -204,6 +227,11 @@ function createRequestListener(
|
|
|
204
227
|
};
|
|
205
228
|
response.once("finish", emitDebug);
|
|
206
229
|
response.once("close", emitDebug);
|
|
230
|
+
if (isConnectionRejected(request.socket)) {
|
|
231
|
+
request.resume();
|
|
232
|
+
sendRateLimited(response, 1);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
207
235
|
const controller = new AbortController();
|
|
208
236
|
response.once("close", () => controller.abort());
|
|
209
237
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -318,7 +346,7 @@ async function handleRequest(
|
|
|
318
346
|
return;
|
|
319
347
|
}
|
|
320
348
|
|
|
321
|
-
if (
|
|
349
|
+
if (isAuthenticatedPath(path) && context.authorizeCoordination !== undefined) {
|
|
322
350
|
let decoded: unknown;
|
|
323
351
|
if (requestBody.length === 0) {
|
|
324
352
|
decoded = undefined;
|
|
@@ -330,12 +358,7 @@ async function handleRequest(
|
|
|
330
358
|
}
|
|
331
359
|
}
|
|
332
360
|
const authorization = request.headers.authorization;
|
|
333
|
-
const
|
|
334
|
-
if (authorizeCoordination === undefined) {
|
|
335
|
-
sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination authentication is unavailable."), true);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
const authentication = await authorizeCoordination(authorization, signal);
|
|
361
|
+
const authentication = await context.authorizeCoordination(authorization, signal);
|
|
339
362
|
trace.authentication = typeof authentication === "string" ? authentication : "accepted";
|
|
340
363
|
if (signal.aborted) return;
|
|
341
364
|
if (typeof authentication === "string") {
|
|
@@ -343,16 +366,32 @@ async function handleRequest(
|
|
|
343
366
|
sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
|
|
344
367
|
return;
|
|
345
368
|
}
|
|
346
|
-
const code = authentication === "
|
|
347
|
-
?
|
|
369
|
+
const code = authentication === "expired"
|
|
370
|
+
? ErrorCode.AUTH_EXPIRED
|
|
371
|
+
: authentication === "revoked" ? ErrorCode.SESSION_REVOKED
|
|
372
|
+
: authentication === "rejected" ? ErrorCode.SESSION_REJECTED
|
|
348
373
|
: authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
|
|
349
374
|
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
350
375
|
return;
|
|
351
376
|
}
|
|
352
377
|
trace.principal = authentication;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
378
|
+
if (path === RUNTIME_INFO_PATH) {
|
|
379
|
+
if (request.method !== "GET" || requestBody.length !== 0) {
|
|
380
|
+
sendEmpty(response, request.method === "GET" ? 400 : 405, true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (context.runtimeIdentity === undefined) {
|
|
384
|
+
sendEmpty(response, 404);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
sendJson(response, 200, context.runtimeIdentity);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (context.handleCoordination === undefined) {
|
|
391
|
+
sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination handling is unavailable."), true);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const clientIdentity = credentialRateLimitIdentity(authentication, request.method, path);
|
|
356
395
|
const credentialRetry = credentialRateLimiter.consume(clientIdentity);
|
|
357
396
|
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
358
397
|
const query = parseCoordinationQuery(request.url, path);
|
|
@@ -393,7 +432,7 @@ async function handleRequest(
|
|
|
393
432
|
interface RequestTrace {
|
|
394
433
|
readonly route: DebugRoute;
|
|
395
434
|
readonly method: string;
|
|
396
|
-
authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
|
|
435
|
+
authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted";
|
|
397
436
|
principal?: Principal;
|
|
398
437
|
}
|
|
399
438
|
|
|
@@ -407,6 +446,7 @@ function debugRoute(rawUrl: string | undefined): DebugRoute {
|
|
|
407
446
|
if (path === null) return "unknown";
|
|
408
447
|
if (path === HEALTH_PATH) return "health";
|
|
409
448
|
if (path === PROTOCOL_INFO_PATH) return "protocol";
|
|
449
|
+
if (path === RUNTIME_INFO_PATH) return "runtime";
|
|
410
450
|
if (path === ENROLLMENT_EXCHANGE_PATH) return "enrollment_exchange";
|
|
411
451
|
if (path === ATTACH_PATH) return "client_attach";
|
|
412
452
|
if (path === "/api/cubes") return "cubes";
|
|
@@ -592,7 +632,28 @@ function isCoordinationPath(path: string | null): path is string {
|
|
|
592
632
|
path?.startsWith("/api/cubes/") === true;
|
|
593
633
|
}
|
|
594
634
|
|
|
595
|
-
function
|
|
635
|
+
function isAuthenticatedPath(path: string | null): path is string {
|
|
636
|
+
return path === RUNTIME_INFO_PATH || isCoordinationPath(path);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function credentialRateLimitIdentity(
|
|
640
|
+
principal: Principal,
|
|
641
|
+
method: string | undefined,
|
|
642
|
+
path: string,
|
|
643
|
+
): string {
|
|
644
|
+
const routineRead = (method === "GET" && !path.endsWith("/stream")) ||
|
|
645
|
+
(method === "PUT" && (path.endsWith("/logs") || path.endsWith("/decisions")));
|
|
646
|
+
if (principal.kind === "drone-session" && routineRead) {
|
|
647
|
+
return `drone-session:${principal.id}`;
|
|
648
|
+
}
|
|
649
|
+
return `client:${principal.kind === "drone-session" ? principal.clientId : principal.id}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function applyServerLimits(
|
|
653
|
+
server: HttpsServer,
|
|
654
|
+
limits: ServiceLimits,
|
|
655
|
+
addressConnectionLimiter: AddressConnectionLimiter,
|
|
656
|
+
): Set<Socket> {
|
|
596
657
|
server.maxConnections = limits.maxConnections;
|
|
597
658
|
server.maxRequestsPerSocket = limits.maxRequestsPerSocket;
|
|
598
659
|
server.requestTimeout = limits.requestTimeoutMs;
|
|
@@ -602,27 +663,63 @@ function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Sock
|
|
|
602
663
|
Math.min(limits.handlerTimeoutMs * 2, 2_147_483_647),
|
|
603
664
|
(socket) => socket.destroy(),
|
|
604
665
|
);
|
|
605
|
-
const addressConnections = new ConcurrentQuota(limits.maxConnectionsPerAddress);
|
|
606
666
|
const acceptedSockets = new Set<Socket>();
|
|
607
667
|
server.on("connection", (socket) => {
|
|
608
668
|
const tracked = socket as Socket;
|
|
609
669
|
acceptedSockets.add(tracked);
|
|
610
670
|
tracked.once("close", () => acceptedSockets.delete(tracked));
|
|
611
|
-
|
|
612
|
-
|
|
671
|
+
addressConnectionLimiter.admitRaw(tracked);
|
|
672
|
+
});
|
|
673
|
+
return acceptedSockets;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
class AddressConnectionLimiter {
|
|
677
|
+
readonly #rawConnections: ConcurrentQuota;
|
|
678
|
+
readonly #connections: ConcurrentQuota;
|
|
679
|
+
readonly #rejected = new WeakSet<Socket>();
|
|
680
|
+
readonly #identifyRemoteAddress: (socket: Socket) => string;
|
|
681
|
+
|
|
682
|
+
constructor(
|
|
683
|
+
limit: number,
|
|
684
|
+
globalLimit: number,
|
|
685
|
+
identifyRemoteAddress: (socket: Socket) => string,
|
|
686
|
+
) {
|
|
687
|
+
// Keep exactly one per-address raw socket available beyond normal secure
|
|
688
|
+
// admission so it can receive a controlled HTTP 429 after TLS. All later
|
|
689
|
+
// raw sockets are rejected before they can consume the global pool.
|
|
690
|
+
this.#rawConnections = new ConcurrentQuota(Math.min(limit + 1, globalLimit));
|
|
691
|
+
this.#connections = new ConcurrentQuota(limit);
|
|
692
|
+
this.#identifyRemoteAddress = identifyRemoteAddress;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
admitRaw(socket: Socket): void {
|
|
696
|
+
const release = this.#rawConnections.acquire(this.#identifyRemoteAddress(socket));
|
|
613
697
|
if (release === null) {
|
|
614
698
|
socket.destroy();
|
|
615
699
|
return;
|
|
616
700
|
}
|
|
617
701
|
socket.once("close", release);
|
|
618
|
-
}
|
|
619
|
-
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
admit(socket: Socket): void {
|
|
705
|
+
const release = this.#connections.acquire(this.#identifyRemoteAddress(socket));
|
|
706
|
+
if (release === null) {
|
|
707
|
+
this.#rejected.add(socket);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
socket.once("close", release);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
isRejected(socket: Socket): boolean {
|
|
714
|
+
return this.#rejected.has(socket);
|
|
715
|
+
}
|
|
620
716
|
}
|
|
621
717
|
|
|
622
718
|
function validateLimits(limits: ServiceLimits): void {
|
|
623
719
|
for (const [name, value] of Object.entries(limits)) {
|
|
624
|
-
if (!Number.isSafeInteger(value) || value
|
|
625
|
-
|
|
720
|
+
if (!Number.isSafeInteger(value) || value < 0 || (value === 0 && name !== "maxRequestsPerSocket")) {
|
|
721
|
+
const range = name === "maxRequestsPerSocket" ? "non-negative" : "positive";
|
|
722
|
+
throw new Error(`${name} must be a ${range} safe integer.`);
|
|
626
723
|
}
|
|
627
724
|
}
|
|
628
725
|
if (limits.headersTimeoutMs > limits.requestTimeoutMs) {
|